graphql-ws
repository·master·Indexed 23 days ago
https://github.com/enisdenjo/graphql-wsA zero-dependency, lazy, and simple implementation of the GraphQL over WebSocket Protocol, providing both server and client capabilities for handling GraphQL subscriptions. It implements the `graphql-transport-ws` sub-protocol for bidirectional communication, featuring a lifecycle of ConnectionInit, ConnectionAck, and operation-based messaging (Subscribe, Next, Error, Complete). The client includes support for automatic reconnection, keep-alive pings, and consumption via `subscribe()` or `iterate()`.
What's inside graphql-ws
- The library implements a specific transport protocol. To understand the exact transport intricacies, message formats, and lifecycle used by the library, refer to the PROTOCOL.md file.
Understand the GraphQL over WebSocket Protocol
masterThe
graphql-transport-wssub-protocol enables bidirectional communication between a client and a server over a WebSocket. It distinguishes between the Socket (the physical WebSocket channel) and the Connection (a logical session established within that socket).Key communication rules:
- Sub-protocol: Must use
graphql-transport-ws. - Message Format: All messages are JSON structures stringified before transmission.
- Identification: Messages for operations must include a unique
idfield. This allows multiple operations to be active simultaneously and their messages to be interleaved. - Termination: The client closes the socket with
1000: Normal Closure. The server may close the socket at any time to signal fatal errors.
- Sub-protocol: Must use
Handle custom authentication on the server
masterTo implement authentication, use the
onConnectandonSubscribehooks in themakeServerconfiguration. You can access the connection context (including the HTTP request) via thectx.extrafield. If authentication fails, you can throw an error or returnfalsefromonConnectto close the connection with a4403: Forbiddencode. For more granular control, you can usectx.extra.socket.close()withinonSubscribe.import { CloseCode, makeServer } from 'graphql-ws'; import { WebSocketServer } from 'ws'; import { validate } from './my-auth'; import { schema } from './my-graphql-schema'; interface Extra { readonly request: http.IncomingMessage; } const gqlServer = makeServer<Extra>({ schema, onConnect: async (ctx) => { await handleAuth(ctx.extra.request); }, onSubscribe: async (ctx) => { await handleAuth(ctx.extra.request); }, }); // ... implementation of wsServer.on('connection') using gqlServer.openedIntegrate graphql-ws with Apollo Server Express
masterWhen using Apollo Server Express, you need to manage the lifecycle of both the HTTP server and the WebSocket server. Use
useServerto create the WebSocket server and capture the returneddisposefunction (often namedserverCleanup). In the Apollo Serverpluginsconfiguration, useApolloServerPluginDrainHttpServerfor the HTTP server and a customdrainServerhook to callserverCleanup.dispose()for the WebSocket server.import { createServer } from 'http'; import { ApolloServerPluginDrainHttpServer } from 'apollo-server-core'; import { ApolloServer } from 'apollo-server-express'; import express from 'express'; import { useServer } from 'graphql-ws/use/ws'; import { WebSocketServer } from 'ws'; import { schema } from './my-graphql-schema'; const app = express(); const httpServer = createServer(app); const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql', }); const serverCleanup = useServer({ schema }, wsServer); const apolloServer = new ApolloServer({ schema, plugins: [ ApolloServerPluginDrainHttpServer({ httpServer }), { async serverWillStart() { return { async drainServer() { await serverCleanup.dispose(); }, }; }, }, ], }); await apolloServer.start(); apolloServer.applyMiddleware({ app }); httpServer.listen(4000);Handle Token Expiration and Refresh on the Client
masterTo handle authentication tokens that expire, use the
connectionParamsoption increateClientas an async function. If a token is expired, you can trigger a refresh inside this function. Additionally, use theon: { connected, closed }lifecycle hooks. Inconnected, set a timeout to manually close the socket withCloseCode.Forbiddenwhen the token is expected to expire. Inclosed, if the event code isCloseCode.Forbidden, set a flag to trigger a token refresh on the next reconnection attempt.// 📺 client import { CloseCode, createClient } from 'graphql-ws'; let shouldRefreshToken = false, tokenExpiryTimeout = null; const client = createClient({ url: 'ws://server-validates.auth:4000/graphql', connectionParams: async () => { if (shouldRefreshToken) { await refreshCurrentToken(); shouldRefreshToken = false; } return { token: getCurrentToken() }; }, on: { connected: (socket) => { clearTimeout(tokenExpiryTimeout); tokenExpiryTimeout = setTimeout(() => { if (socket.readyState === WebSocket.OPEN) socket.close(CloseCode.Forbidden, 'Forbidden'); }, getCurrentTokenExpiresIn()); }, closed: (event) => { if (event.code === CloseCode.Forbidden) shouldRefreshToken = true; }, }, });Implement Persisted Queries with graphql-ws
masterTo support persisted queries, the server's
onSubscribehook should check thepayload.extensions.persistedQueryfield. If the ID exists in your queries store, return the pre-parsedExecutionArgs. If not, you can either throw an error to reject the request or allow the client to send the full query as a fallback.// 🛸 server import { ExecutionArgs, parse } from 'graphql'; import { useServer } from 'graphql-ws/use/ws'; import { WebSocketServer } from 'ws'; import { schema } from './my-graphql-schema'; type QueryID = string; const queriesStore: Record<QueryID, ExecutionArgs> = { iWantTheGreetings: { schema, document: parse('subscription Greetings { greetings }'), }, }; const wsServer = new WebSocketServer({ port: 4000, path: '/graphql' }); useServer( { onSubscribe: (_ctx, _id, payload) => { const persistedQuery = queriesStore[payload.extensions?.persistedQuery]; if (persistedQuery) { return { ...persistedQuery, variableValues: payload.variables, }; } throw new Error('404: Query Not Found'); }, }, wsServer, ); // 📺 client import { createClient } from 'graphql-ws'; const client = createClient({ url: 'ws://persisted.graphql:4000/queries', }); (async () => { await new Promise((resolve, reject) => { client.subscribe( { query: '', extensions: { persistedQuery: 'iWantTheGreetings' }, }, { next: () => {}, error: reject, complete: resolve, }, ); }); })();Implement subprotocol pings and pongs
masterIf your WebSocket environment does not support standard WS-level pings/pongs, you can implement them at the
graphql-wsprotocol level. UsestringifyMessagewithMessageType.Pingto send a ping. You can then use theonPongcallback in theserver.openedconfiguration to clear any termination timeouts when a pong is received.import { CloseCode, makeServer, MessageType, stringifyMessage, } from 'graphql-ws'; import { WebSocketServer } from 'ws'; const server = makeServer({ schema }); const wsServer = new WebSocketServer({ port: 4000, path: '/graphql' }); wsServer.on('connection', (socket, request) => { let pinger, pongWait; function ping() { if (socket.readyState === socket.OPEN) { socket.send(stringifyMessage({ type: MessageType.Ping })); pongWait = setTimeout(() => { clearInterval(pinger); socket.close(); }, 6_000); } } pinger = setInterval(() => ping(), 12_000); const closed = server.opened( { protocol: socket.protocol, send: (data) => socket.send(data), close: (code, reason) => socket.close(code, reason), onMessage: (cb) => socket.on('message', async (event) => { try { await cb(event.toString()); } catch (err) { socket.close(CloseCode.InternalServerError, err.message); } }), onPong: () => clearTimeout(pongWait), }, { socket, request }, ); socket.once('close', (code, reason) => { clearTimeout(pongWait); clearInterval(pinger); closed(code, reason); }); });Establish a connection using ConnectionInit and ConnectionAck
masterBefore executing operations, a client must establish a logical connection within the socket.
- Client sends
ConnectionInit: The client sends aconnection_initmessage. It can optionally include apayload(e.g., for authentication tokens). - Server responds with
ConnectionAck: If successful, the server sends aconnection_ackmessage. The client is now ready to sendSubscribemessages.
Error Cases:
- If
ConnectionInitis not received within the server'sconnectionInitWaitTimeout, the server closes the socket with4408: Connection initialisation timeout. - If multiple
ConnectionInitmessages are received simultaneously, the server closes the socket with4429: Too many initialisation requests. - If the server rejects the connection (e.g., failed auth), it should close the socket with
4403: Forbidden.
// Client -> Server interface ConnectionInitMessage { type: 'connection_init'; payload?: Record<string, unknown> | null; } // Server -> Client interface ConnectionAckMessage { type: 'connection_ack'; payload?: Record<string, unknown> | null; }- Client sends
Implement Subscription Acknowledgment
masterTo implement a pattern where the client is notified when a subscription is successfully acknowledged by the server, you can augment the
client.subscribemethod. The server usesonSubscribeto create an 'awaiter' in the context andonOperationto trigger it. The client sends a uniqueackIdin theextensionsof the subscription payload. The server then sends aMessageType.Pingcontaining thisackIdback to the client, which the client listens for to trigger a callback.// 📺 client augmented version type ClientWithSubscribeAck = Omit<Client, 'subscribe'> & { subscribe<Data = Record<string, unknown>, Extensions = unknown>( payload: SubscribePayload, sink: Sink<ExecutionResult<Data, Extensions>>, onAck: () => void, ): () => void; }; function createClientWithSubscribeAck( options: ClientOptions, ): ClientWithSubscribeAck { const client = createClient(options); const ackListeners: Record<string, () => void> = {}; client.on('ping', (_received, payload) => { const ackId = payload?.ackId; if (typeof ackId === 'string') { ackListeners[ackId]?.(); delete ackListeners[ackId]; } }); return { ...client, subscribe: (payload, sink, onAck) => { const ackId = Math.random().toString(); ackListeners[ackId] = onAck; return client.subscribe( { ...payload, extensions: { ...payload.extensions, ackId }, }, sink, ); }, }; }Subscribe to operations using Subscribe, Next, Error, and Complete
masterTo execute a GraphQL operation (query, mutation, or subscription), follow this lifecycle:
Subscribe(Client -> Server): The client sends asubscribemessage with a uniqueidand apayloadcontaining thequery, optionaloperationName,variables, andextensions.- Execution:
- If the
idis already in use, the server closes the socket with4409: Subscriber for <unique-operation-id> already exists. - If the operation is invalid or unauthorized, the server sends an
Errormessage or closes the socket with4401: Unauthorized.
- If the
- Results (
Next): For streaming operations, the server sends one or morenextmessages containing theExecutionResult. - Termination:
Error(Server -> Client): If an error occurs, the server sends anerrormessage and the operation terminates.Complete(Bidirectional):- Server -> Client: Indicates the stream has finished. If an
Errorwas sent, noCompleteis sent. - Client -> Server: Indicates the client is stopping the subscription.
- Server -> Client: Indicates the stream has finished. If an
Note: Because the connection is full-duplex, both parties should be prepared to ignore messages for IDs they consider already completed.
// Client -> Server interface SubscribeMessage { id: '<unique-operation-id>'; type: 'subscribe'; payload: { operationName?: string | null; query: string; variables?: Record<string, unknown> | null; extensions?: Record<string, unknown> | null; }; } // Server -> Client (Result) import { ExecutionResult } from 'graphql'; interface NextMessage { id: '<unique-operation-id>'; type: 'next'; payload: ExecutionResult; } // Server -> Client (Error) import { GraphQLError } from 'graphql'; interface ErrorMessage { id: '<unique-operation-id>'; type: 'error'; payload: GraphQLError[]; } // Bidirectional (Completion) interface CompleteMessage { id: '<unique-operation-id>'; type: 'complete'; }Find graphql-ws recipes for common use-cases
masterIf you are looking for specific implementation patterns, the project provides short and concise code snippets for common use-cases in the Recipes section on the official website.Install graphql-ws
masterInstall the
graphql-wspackage using npm:npm i graphql-ws