Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

exp(streaming): Allow SSR with experimental apollo client #9038

Merged
merged 9 commits into from
Aug 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: (() => {
dthyresson marked this conversation as resolved.
Show resolved Hide resolved
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 },
dthyresson marked this conversation as resolved.
Show resolved Hide resolved
]

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
Loading