Skip to content

Commit

Permalink
feat: Support GraphQL Subscriptions in Apollo Client using SSE links (#…
Browse files Browse the repository at this point in the history
…9009)

This PR adds support for GraphQL SSE (Server Sent Events) in both the
Redwood GraphQL Server and the Apollo Client web side.

GraphQL SSE has two options: distinct connections mode and single
connection mode. Yoga supports distinct connection mode out of the box
and is the one configured in the PR. There is a known "gotcha" with
distinct mode:

* [Maximum open connections
limit](https://developer.mozilla.org/en-US/docs/Web/HTTP/Connection_management_in_HTTP_1.x)
(when not using http/2)
* But we can work to support http2 in Fastify and also see how deploy
providers handle http2

See also:
https://github.com/enisdenjo/graphql-sse/blob/master/PROTOCOL.md#distinct-connections-mode

Note: I have testing setting up single connection mode but this
requires:

* two graphql endpoints/servers at "graphql" for non subscriptions and
"graphql/stream" for subs
* some extra termination code as seen here:
https://github.com/dotansimha/graphql-yoga/blob/main/examples/graphql-sse/src/app.ts

We can revisit single connection mode configuration if in beta testing
we see the distinct connection issue being significant. At least we now
know how to configure and setup both modes.

The PR:

* Adds the graphql sse plugin to yoga if subscriptions are enabled
* Adds a new SSELink to Apollo client following
https://the-guild.dev/graphql/sse/recipes#with-apollo
* In Apollo client, it needs to now use the SSELink for subs and the
HTTPLink for other operations, so "directional link composition" is
employed. See:
https://www.apollographql.com/docs/react/api/link/introduction/#directional-composition
* This is now the "terminatingLink" and the client uses one or the other
depending on the operation
* Fixes out-of-date realtime templates that now need to use the realtime
package
  • Loading branch information
dthyresson authored and jtoar committed Oct 8, 2023
1 parent 71888e3 commit 20a1fd1
Show file tree
Hide file tree
Showing 12 changed files with 244 additions and 190 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// api/src/services/auctions/auctions.ts
import type { LiveQueryStorageMechanism } from '@redwoodjs/graphql-server'
import type { LiveQueryStorageMechanism } from '@redwoodjs/realtime'

import { logger } from 'src/lib/logger'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import gql from 'graphql-tag'

import type { PubSub } from '@redwoodjs/graphql-server'
import type { PubSub } from '@redwoodjs/realtime'

import { logger } from 'src/lib/logger'

Expand Down
82 changes: 55 additions & 27 deletions packages/fastify/src/graphql.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import fastifyUrlData from '@fastify/url-data'
import type { FastifyInstance, HookHandlerDoneFunction } from 'fastify'
import type {
FastifyInstance,
HTTPMethods,
HookHandlerDoneFunction,
FastifyReply,
FastifyRequest,
} from 'fastify'
import fastifyRawBody from 'fastify-raw-body'
import type { Plugin } from 'graphql-yoga'

Expand Down Expand Up @@ -36,6 +42,14 @@ export async function redwoodFastifyGraphQLServer(
await fastify.register(fastifyRawBody)

try {
const method = ['GET', 'POST', 'OPTIONS'] as HTTPMethods[]

// TODO: This should be refactored to only be defined once and it might not live here
// Ensure that each request has a unique global context
fastify.addHook('onRequest', (_req, _reply, done) => {
getAsyncStoreInstance().run(new Map<string, GlobalContext>(), done)
})

// Here we can add any plugins that we want to use with GraphQL Yoga Server
// that we do not want to add the the GraphQLHandler in the graphql-server
// graphql function.
Expand All @@ -48,40 +62,54 @@ export async function redwoodFastifyGraphQLServer(
options.extraPlugins || []
originalExtraPlugins.push(useRedwoodRealtime(options.realtime))
options.extraPlugins = originalExtraPlugins

// uses for SSE single connection mode with the `/graphql/stream` endpoint
if (options.realtime.subscriptions) {
method.push('PUT')
}
}

const { yoga } = createGraphQLYoga(options)

// TODO: This should be refactored to only be defined once and it might not live here
// Ensure that each request has a unique global context
fastify.addHook('onRequest', (_req, _reply, done) => {
getAsyncStoreInstance().run(new Map<string, GlobalContext>(), done)
})
const graphQLYogaHandler = async (
req: FastifyRequest,
reply: FastifyReply
) => {
const response = await yoga.handleNodeRequest(req, {
req,
reply,
event: transformToRedwoodGraphQLContextEvent(req),
requestContext: {},
})

for (const [name, value] of response.headers) {
reply.header(name, value)
}

reply.status(response.status)
reply.send(response.body)

return reply
}

const routePaths = ['', '/health', '/readiness', '/stream']

fastify.route({
url: yoga.graphqlEndpoint,
method: ['GET', 'POST', 'OPTIONS'],
handler: async (req, reply) => {
const response = await yoga.handleNodeRequest(req, {
req,
reply,
event: transformToRedwoodGraphQLContextEvent(req),
requestContext: {},
})

for (const [name, value] of response.headers) {
reply.header(name, value)
}

reply.status(response.status)
reply.send(response.body)

return reply
},
routePaths.forEach((routePath) => {
fastify.route({
url: `${yoga.graphqlEndpoint}${routePath}`,
method,
handler: async (req, reply) => await graphQLYogaHandler(req, reply),
})
})

fastify.ready(() => {
console.log(`GraphQL Yoga Server endpoint at ${yoga.graphqlEndpoint}`)
console.info(`GraphQL Yoga Server endpoint at ${yoga.graphqlEndpoint}`)
console.info(
`GraphQL Yoga Server Health Check endpoint at ${yoga.graphqlEndpoint}/health`
)
console.info(
`GraphQL Yoga Server Readiness endpoint at ${yoga.graphqlEndpoint}/readiness`
)
})

done()
Expand Down
2 changes: 1 addition & 1 deletion packages/graphql-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"graphql": "16.8.1",
"graphql-scalars": "1.22.2",
"graphql-tag": "2.12.6",
"graphql-yoga": "4.0.2",
"graphql-yoga": "4.0.4",
"lodash": "4.17.21",
"uuid": "9.0.0"
},
Expand Down
13 changes: 0 additions & 13 deletions packages/graphql-server/src/createGraphQLYoga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,19 +180,6 @@ export const createGraphQLYoga = ({
// so can process any data added to results and extensions
plugins.push(useRedwoodLogger(loggerConfig))

logger.debug(
{
healthCheckId,
allowedOperations,
defaultAllowedOperations,
allowIntrospection,
defaultError,
disableIntrospection,
allowGraphiQL,
graphiQLEndpoint,
},
'GraphiQL and Introspection Config'
)
const yoga = createYoga({
id: healthCheckId,
landingPage: isDevEnv,
Expand Down
1 change: 1 addition & 0 deletions packages/realtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@envelop/live-query": "6.0.0",
"@graphql-tools/schema": "10.0.0",
"@graphql-tools/utils": "10.0.1",
"@graphql-yoga/plugin-graphql-sse": "2.0.4",
"@graphql-yoga/redis-event-target": "2.0.0",
"@graphql-yoga/subscription": "4.0.0",
"@n1ru4l/graphql-live-query": "0.10.0",
Expand Down
4 changes: 4 additions & 0 deletions packages/realtime/src/graphql/plugins/useRedwoodRealtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Plugin } from '@envelop/core'
import { useLiveQuery } from '@envelop/live-query'
import { mergeSchemas } from '@graphql-tools/schema'
import { astFromDirective } from '@graphql-tools/utils'
import { useGraphQLSSE } from '@graphql-yoga/plugin-graphql-sse'
import { createRedisEventTarget } from '@graphql-yoga/redis-event-target'
import type { CreateRedisEventTargetArgs } from '@graphql-yoga/redis-event-target'
import type { PubSub } from '@graphql-yoga/subscription'
Expand Down Expand Up @@ -228,6 +229,9 @@ export const useRedwoodRealtime = (options: RedwoodRealtimeOptions): Plugin => {
if (liveQueriesEnabled) {
addPlugin(liveQueryPlugin)
}
if (subscriptionsEnabled) {
addPlugin(useGraphQLSSE() as Plugin<object>)
}
},
onContextBuilding() {
return ({ extendContext }) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"fastify-raw-body": "4.2.2",
"graphql": "16.8.1",
"graphql-scalars": "1.22.2",
"graphql-yoga": "3.9.1",
"graphql-yoga": "4.0.4",
"jsonwebtoken": "9.0.0",
"lodash": "4.17.21",
"mailparser": "^3.6.5",
Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@redwoodjs/auth": "6.3.1",
"core-js": "3.32.2",
"graphql": "16.8.1",
"graphql-sse": "2.2.1",
"graphql-tag": "2.12.6",
"react-helmet-async": "1.3.0",
"react-hot-toast": "2.4.1",
Expand Down
34 changes: 30 additions & 4 deletions packages/web/src/apollo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
} from '@apollo/client'
import * as apolloClient from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { getMainDefinition } from '@apollo/client/utilities'
import { print } from 'graphql/language/printer'

// Note: Importing directly from `apollo/client` doesn't work properly in Storybook.
Expand All @@ -30,13 +31,15 @@ import {
} from '../components/FetchConfigProvider'
import { GraphQLHooksProvider } from '../components/GraphQLHooksProvider'

import { SSELink } from './sseLink'

export type ApolloClientCacheConfig = apolloClient.InMemoryCacheConfig

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

export type RedwoodApolloLink<
Name extends RedwoodApolloLinkName,
Expand All @@ -50,7 +53,10 @@ export type RedwoodApolloLinks = [
RedwoodApolloLink<'withToken'>,
RedwoodApolloLink<'authMiddleware'>,
RedwoodApolloLink<'updateDataApolloLink'>,
RedwoodApolloLink<'httpLink', apolloClient.HttpLink>
RedwoodApolloLink<
'terminatingLink',
apolloClient.ApolloLink | apolloClient.HttpLink
>
]

export type RedwoodApolloLinkFactory = (
Expand Down Expand Up @@ -185,12 +191,32 @@ const ApolloProviderWithFetchConfig: React.FunctionComponent<{
// 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.
// Our terminating link needs to be smart enough to handle subscriptions, and if the GraphQL query
// is subscription it needs to use the SSELink (server sent events link).
const terminatingLink = apolloClient.split(
({ query }) => {
const definition = getMainDefinition(query)

return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
)
},
new SSELink({
url: uri,
auth: { authProviderType, tokenFn: getToken },
httpLinkConfig,
headers,
}),
httpLink
)

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

let link = redwoodApolloLink
Expand Down
109 changes: 109 additions & 0 deletions packages/web/src/apollo/sseLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { HttpOptions } from '@apollo/client'
import {
ApolloLink,
Operation,
FetchResult,
Observable,
} from '@apollo/client/core'
import { print } from 'graphql'
import { createClient, ClientOptions, Client } from 'graphql-sse'
interface SSELinkOptions extends Partial<ClientOptions> {
url: string
auth: { authProviderType: string; tokenFn: () => Promise<null | string> }
httpLinkConfig?: HttpOptions
headers?: Record<string, string>
}

const mapCredentialsHeader = (
httpLinkCredentials?: string
): 'omit' | 'same-origin' | 'include' | undefined => {
if (!httpLinkCredentials) {
return undefined
}
switch (httpLinkCredentials) {
case 'omit':
case 'same-origin':
case 'include':
return httpLinkCredentials
default:
return undefined
}
}

const mapReferrerPolicyHeader = (
referrerPolicy?: string
):
| 'no-referrer'
| 'no-referrer-when-downgrade'
| 'same-origin'
| 'origin'
| 'strict-origin'
| 'origin-when-cross-origin'
| 'strict-origin-when-cross-origin'
| 'unsafe-url'
| undefined => {
if (!referrerPolicy) {
return undefined
}
switch (referrerPolicy) {
case 'no-referrer':
case 'no-referrer-when-downgrade':
case 'same-origin':
case 'origin':
case 'strict-origin':
case 'origin-when-cross-origin':
case 'strict-origin-when-cross-origin':
case 'unsafe-url':
return referrerPolicy
default:
return undefined
}
}

/**
* GraphQL over Server-Sent Events (SSE) spec link for Apollo Client
*/
export class SSELink extends ApolloLink {
private client: Client

constructor(options: SSELinkOptions) {
super()

const { url, auth, headers, httpLinkConfig } = options
const { credentials, referrer, referrerPolicy } =
httpLinkConfig?.headers || {}

this.client = createClient({
url,
headers: async () => {
const token = await auth.tokenFn()

// Only add auth headers when there's a token. `token` is `null` when `!isAuthenticated`.
if (!token) {
return { ...headers }
}
return {
Authorization: `Bearer ${token}`,
'auth-provider': auth.authProviderType,
...headers,
}
},
credentials: mapCredentialsHeader(credentials),
referrer,
referrerPolicy: mapReferrerPolicyHeader(referrerPolicy),
})
}

public request(operation: Operation): Observable<FetchResult> {
return new Observable((sink) => {
return this.client.subscribe<FetchResult>(
{ ...operation, query: print(operation.query) },
{
next: sink.next.bind(sink),
complete: sink.complete.bind(sink),
error: sink.error.bind(sink),
}
)
})
}
}
Loading

0 comments on commit 20a1fd1

Please sign in to comment.