Skip to content

Commit

Permalink
exp(streaming): Allow SSR with experimental apollo client (#9038)
Browse files Browse the repository at this point in the history
  • Loading branch information
dac09 committed Aug 16, 2023
1 parent 865c908 commit c89e136
Show file tree
Hide file tree
Showing 12 changed files with 533 additions and 14 deletions.
1 change: 1 addition & 0 deletions packages/vite/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ declare global {
RWJS_API_URL: string
RWJS_EXP_STREAMING_SSR: boolean
RWJS_EXP_RSC: boolean
RWJS_EXP_SSR_GRAPHQL_ENDPOINT: string

__REDWOOD__APP_TITLE: string
}
Expand Down
13 changes: 13 additions & 0 deletions packages/vite/src/streaming/registerGlobals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ export const registerFwGlobals = () => {
rwConfig.experimental.streamingSsr &&
rwConfig.experimental.streamingSsr.enabled,
RWJS_EXP_RSC: rwConfig.experimental?.rsc?.enabled,
RWJS_EXP_SSR_GRAPHQL_ENDPOINT: (() => {
const apiPath =
rwConfig.web.apiGraphQLUrl ?? rwConfig.web.apiUrl + '/graphql'

// If its an absolute url, use as is
if (/^[a-zA-Z][a-zA-Z\d+\-.]*?:/.test(apiPath)) {
return apiPath
} else {
return (
'http://' + rwConfig.api.host + ':' + rwConfig.api.port + '/graphql'
)
}
})(),
}

globalThis.RWJS_DEBUG_ENV = {
Expand Down
13 changes: 7 additions & 6 deletions packages/vite/src/streaming/streamHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,6 @@ export function reactRenderToStream({
// This is effectively a transformer stream
const intermediateStream = createServerInjectionStream({
outputStream: res,
onFinal: () => {
res.end()
},
injectionState,
})

Expand Down Expand Up @@ -91,11 +88,9 @@ export function reactRenderToStream({
}
function createServerInjectionStream({
outputStream,
onFinal,
injectionState,
}: {
outputStream: Writable
onFinal: () => void
injectionState: Set<RenderCallback>
}) {
return new Writable({
Expand Down Expand Up @@ -140,7 +135,13 @@ function createServerInjectionStream({
)

outputStream.write(elementsAtTheEnd)
onFinal()

// This will find all the elements added by PortalHead during a server render, and move them into <head>
outputStream.write(
"<script>document.querySelectorAll('body [data-rwjs-head]').forEach((el)=>{el.removeAttribute('data-rwjs-head');document.head.appendChild(el);});</script>"
)

outputStream.end()
},
})
}
2 changes: 2 additions & 0 deletions packages/web/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ declare global {

__REDWOOD__APP_TITLE: string
__REDWOOD__APOLLO_STATE: NormalizedCacheObject

RWJS_EXP_SSR_GRAPHQL_ENDPOINT: string
}

var RWJS_DEBUG_ENV: {
Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"ts-toolbelt": "9.6.0"
},
"devDependencies": {
"@apollo/experimental-nextjs-app-support": "0.4.1",
"@babel/cli": "7.22.9",
"@babel/core": "7.22.9",
"@testing-library/jest-dom": "5.16.5",
Expand Down
312 changes: 312 additions & 0 deletions packages/web/src/apollo/suspense.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
/***
*
* This is a lift and shift of the original ApolloProvider
* but with suspense specific bits. Look for @MARK to find bits I've changed
*
* Done this way, to avoid making changes breaking on main.
*/

import type {
ApolloCache,
ApolloClientOptions,
setLogVerbosity,
} from '@apollo/client'
import * as apolloClient from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import {
ApolloNextAppProvider,
NextSSRApolloClient,
NextSSRInMemoryCache,
useSuspenseQuery,
} from '@apollo/experimental-nextjs-app-support/ssr'
import { print } from 'graphql/language/printer'

// Note: Importing directly from `apollo/client` doesn't work properly in Storybook.
const {
ApolloLink,
HttpLink,
useSubscription,
useMutation,
setLogVerbosity: apolloSetLogVerbosity,
} = apolloClient

import { UseAuth, useNoAuth } from '@redwoodjs/auth'
import './typeOverride'

import {
FetchConfigProvider,
useFetchConfig,
} from '../components/FetchConfigProvider'
import { GraphQLHooksProvider } from '../components/GraphQLHooksProvider'

export type ApolloClientCacheConfig = apolloClient.InMemoryCacheConfig

export type RedwoodApolloLinkName =
| 'withToken'
| 'authMiddleware'
| 'updateDataApolloLink'
| 'httpLink'

export type RedwoodApolloLink<
Name extends RedwoodApolloLinkName,
Link extends apolloClient.ApolloLink = apolloClient.ApolloLink
> = {
name: Name
link: Link
}

export type RedwoodApolloLinks = [
RedwoodApolloLink<'withToken'>,
RedwoodApolloLink<'authMiddleware'>,
RedwoodApolloLink<'updateDataApolloLink'>,
RedwoodApolloLink<'httpLink', apolloClient.HttpLink>
]

export type RedwoodApolloLinkFactory = (
links: RedwoodApolloLinks
) => apolloClient.ApolloLink

export type GraphQLClientConfigProp = Omit<
ApolloClientOptions<unknown>,
'cache' | 'link'
> & {
cache?: ApolloCache<unknown>
/**
* Configuration for Apollo Client's `InMemoryCache`.
* See https://www.apollographql.com/docs/react/caching/cache-configuration/.
*/
cacheConfig?: ApolloClientCacheConfig
/**
* Configuration for the terminating `HttpLink`.
* See https://www.apollographql.com/docs/react/api/link/apollo-link-http/#httplink-constructor-options.
*
* For example, you can use this prop to set the credentials policy so that cookies can be sent to other domains:
*
* ```js
* <RedwoodApolloProvider graphQLClientConfig={{
* httpLinkConfig: { credentials: 'include' }
* }}>
* ```
*/
httpLinkConfig?: apolloClient.HttpOptions
/**
* Extend or overwrite `RedwoodApolloProvider`'s Apollo Link.
*
* To overwrite Redwood's Apollo Link, just provide your own `ApolloLink`.
*
* To extend Redwood's Apollo Link, provide a function—it'll get passed an array of Redwood's Apollo Links
* which are objects with a name and link property:
*
* ```js
* const link = (redwoodApolloLinks) => {
* const consoleLink = new ApolloLink((operation, forward) => {
* console.log(operation.operationName)
* return forward(operation)
* })
*
* return ApolloLink.from([consoleLink, ...redwoodApolloLinks.map(({ link }) => link)])
* }
* ```
*
* If you do this, there's a few things you should keep in mind:
* - your function should return a single link (e.g., using `ApolloLink.from`; see https://www.apollographql.com/docs/react/api/link/introduction/#additive-composition)
* - the `HttpLink` should come last (https://www.apollographql.com/docs/react/api/link/introduction/#the-terminating-link)
*/
link?: apolloClient.ApolloLink | RedwoodApolloLinkFactory
}

const ApolloProviderWithFetchConfig: React.FunctionComponent<{
config: Omit<GraphQLClientConfigProp, 'cacheConfig' | 'cache'> & {
cache: ApolloCache<unknown>
}
useAuth?: UseAuth
logLevel: ReturnType<typeof setLogVerbosity>
children: React.ReactNode
}> = ({ config, children, useAuth = useNoAuth, logLevel }) => {
// Should they run into it, this helps users with the "Cannot render cell; GraphQL success but data is null" error.
// See https://github.com/redwoodjs/redwood/issues/2473.
apolloSetLogVerbosity(logLevel)

// Here we're using Apollo Link to customize Apollo Client's data flow.
// Although we're sending conventional HTTP-based requests and could just pass `uri` instead of `link`,
// we need to fetch a new token on every request, making middleware a good fit for this.
//
// See https://www.apollographql.com/docs/react/api/link/introduction.
const { getToken, type: authProviderType } = useAuth()

// `updateDataApolloLink` keeps track of the most recent req/res data so they can be passed to
// any errors passed up to an error boundary.
const data = {
mostRecentRequest: undefined,
mostRecentResponse: undefined,
} as any

const updateDataApolloLink = new ApolloLink((operation, forward) => {
const { operationName, query, variables } = operation

data.mostRecentRequest = {}
data.mostRecentRequest.operationName = operationName
data.mostRecentRequest.operationKind = query?.kind.toString()
data.mostRecentRequest.variables = variables
data.mostRecentRequest.query = query && print(operation.query)

return forward(operation).map((result) => {
data.mostRecentResponse = result

return result
})
})

const withToken = setContext(async () => {
const token = await getToken()

return { token }
})

const { headers, uri } = useFetchConfig()

const getGraphqlUrl = () => {
// @NOTE: This comes from packages/vite/src/streaming/registerGlobals.ts
// this needs to be an absolute url, as relative urls cannot be used in SSR
// @TODO (STREAMING): Should this be a new config value in Redwood.toml?
// How do we know what the absolute url is in production?
// Possible solution: https://www.apollographql.com/docs/react/api/link/apollo-link-schema/

return typeof window === 'undefined'
? RWJS_ENV.RWJS_EXP_SSR_GRAPHQL_ENDPOINT
: uri
}

const authMiddleware = new ApolloLink((operation, forward) => {
const { token } = operation.getContext()

// Only add auth headers when there's a token. `token` is `null` when `!isAuthenticated`.
const authHeaders = token
? {
'auth-provider': authProviderType,
authorization: `Bearer ${token}`,
}
: {}

operation.setContext(() => ({
headers: {
...operation.getContext().headers,
...headers,
// Duped auth headers, because we may remove the `FetchConfigProvider` at a later date.
...authHeaders,
},
}))

return forward(operation)
})

const { httpLinkConfig, link: redwoodApolloLink, ...rest } = config ?? {}

// A terminating link. Apollo Client uses this to send GraphQL operations to a server over HTTP.
// See https://www.apollographql.com/docs/react/api/link/introduction/#the-terminating-link.
const httpLink = new HttpLink({ uri, ...httpLinkConfig })

// The order here is important. The last link *must* be a terminating link like HttpLink.
const redwoodApolloLinks: RedwoodApolloLinks = [
{ name: 'withToken', link: withToken },
{ name: 'authMiddleware', link: authMiddleware },
{ name: 'updateDataApolloLink', link: updateDataApolloLink },
{ name: 'httpLink', link: httpLink },
]

let link = redwoodApolloLink

link ??= ApolloLink.from(redwoodApolloLinks.map((l) => l.link))

if (typeof link === 'function') {
link = link(redwoodApolloLinks)
}

const extendErrorAndRethrow = (error: any, _errorInfo: React.ErrorInfo) => {
error['mostRecentRequest'] = data.mostRecentRequest
error['mostRecentResponse'] = data.mostRecentResponse
throw error
}

function makeClient() {
const httpLink = new HttpLink({
// @MARK: we have to construct the absoltue url for SSR
uri: getGraphqlUrl(),
// you can disable result caching here if you want to
// (this does not work if you are rendering your page with `export const dynamic = "force-static"`)
fetchOptions: { cache: 'no-store' },
})

// @MARK use special Apollo client
return new NextSSRApolloClient({
link: httpLink,
...rest,
})
}

return (
<ApolloNextAppProvider makeClient={makeClient}>
<ErrorBoundary onError={extendErrorAndRethrow}>{children}</ErrorBoundary>
</ApolloNextAppProvider>
)
}

type ComponentDidCatch = React.ComponentLifecycle<any, any>['componentDidCatch']

interface ErrorBoundaryProps {
error?: unknown
onError: NonNullable<ComponentDidCatch>
children: React.ReactNode
}

class ErrorBoundary extends React.Component<ErrorBoundaryProps> {
componentDidCatch(...args: Parameters<NonNullable<ComponentDidCatch>>) {
this.setState({})
this.props.onError(...args)
}

render() {
return this.props.children
}
}

export const RedwoodApolloProvider: React.FunctionComponent<{
graphQLClientConfig?: GraphQLClientConfigProp
useAuth?: UseAuth
logLevel?: ReturnType<typeof setLogVerbosity>
children: React.ReactNode
}> = ({
graphQLClientConfig,
useAuth = useNoAuth,
logLevel = 'debug',
children,
}) => {
// Since Apollo Client gets re-instantiated on auth changes,
// we have to instantiate `InMemoryCache` here, so that it doesn't get wiped.
const { cacheConfig, ...config } = graphQLClientConfig ?? {}

// @MARK we need this special cache
const cache = new NextSSRInMemoryCache(cacheConfig).restore(
globalThis?.__REDWOOD__APOLLO_STATE ?? {}
)

return (
<FetchConfigProvider useAuth={useAuth}>
<ApolloProviderWithFetchConfig
// This order so that the user can still completely overwrite the cache.
config={{ cache, ...config }}
useAuth={useAuth}
logLevel={logLevel}
>
<GraphQLHooksProvider
// @MARK 👇 swapped useQuery for useSuspense query here
useQuery={useSuspenseQuery}
useMutation={useMutation}
useSubscription={useSubscription}
>
{children}
</GraphQLHooksProvider>
</ApolloProviderWithFetchConfig>
</FetchConfigProvider>
)
}
Loading

0 comments on commit c89e136

Please sign in to comment.