UNPKG

msw

Version:

Seamless REST/GraphQL API mocking library for browser and Node.js.

1,488 lines (1,299 loc) • 43.9 kB
import { invariant } from 'outvariant' import { Emitter, TypedEvent } from 'rettime' import { parse, OperationTypeNode, type GraphQLError } from 'graphql' import { resolveWebSocketUrl } from '@mswjs/interceptors' import type { WebSocketClientHandle, WebSocketConnectionEventData, WebSocketData, WebSocketServerHandle, } from '@mswjs/interceptors/WebSocket' import { http } from '#http/http' import { webSocketUpgrade } from '#ws/websocket-upgrade' import { WebSocketHandler, kConnect, type WebSocketHandlerConnection, type WebSocketResolutionContext, } from '#ws/websocket-handler' import { GraphQLSubscriptionEvent } from './graphql-subscription-event' import { matchRequestUrl, type Path, type PathParams, } from '#core/utils/matching/match-request-url' import type { ResponseResolverFinalizeFunction } from '#core/handlers/request-handler' import type { MaybePromise } from '#core/type-utils' import { attachSiblingHandlers } from '#core/utils/internal/attach-sibling-handlers' import { jsonParse } from '#core/utils/internal/json-parse' import { devUtils } from '#core/utils/internal/dev-utils' import { getTimestamp } from '#core/utils/logging/get-timestamp' import { toPublicUrl } from '#core/utils/request/to-public-url' import { colors } from '#core/utils/logging/colors' import { GraphQLHandler, isDocumentNode, type DocumentTypeDecoration, type GraphQLHandlerInfo, type GraphQLHandlerNameSelector, type GraphQLQuery, type GraphQLVariables, } from './graphql-handler' import { parseDocumentNode, type ParsedGraphQLQuery, } from './parse-graphql-request' /** * Messages of the `graphql-transport-ws` protocol. * @see https://github.com/graphql/graphql-over-http/blob/main/rfcs/GraphQLOverWebSocket.md */ interface GraphQLWebSocketInitMessage { type: 'connection_init' payload?: Record<string, unknown> } interface GraphQLWebSocketSubscribePayload< Variables extends GraphQLVariables = GraphQLVariables, > { operationName?: string | null query: string variables?: Variables extensions?: Record<string, unknown> } export interface GraphQLWebSocketSubscribeMessage< Variables extends GraphQLVariables = GraphQLVariables, > { type: 'subscribe' id: string payload: GraphQLWebSocketSubscribePayload<Variables> } interface GraphQLWebSocketCompleteMessage { type: 'complete' id: string } interface GraphQLWebSocketPingMessage { type: 'ping' payload?: Record<string, unknown> } interface GraphQLWebSocketPongMessage { type: 'pong' payload?: Record<string, unknown> } type GraphQLWebSocketClientMessage = | GraphQLWebSocketInitMessage | GraphQLWebSocketSubscribeMessage | GraphQLWebSocketCompleteMessage | GraphQLWebSocketPingMessage | GraphQLWebSocketPongMessage interface GraphQLWebSocketAcknowledgeMessage { type: 'connection_ack' payload?: Record<string, unknown> } interface GraphQLWebSocketNextMessage { type: 'next' id: string payload: GraphQLSubscriptionPayload } interface GraphQLWebSocketErrorMessage { type: 'error' id: string payload: ReadonlyArray<Partial<GraphQLError>> } type GraphQLWebSocketServerMessage = | GraphQLWebSocketAcknowledgeMessage | GraphQLWebSocketNextMessage | GraphQLWebSocketErrorMessage | GraphQLWebSocketCompleteMessage | GraphQLWebSocketPingMessage | GraphQLWebSocketPongMessage /** * A GraphQL execution result published to a subscription. */ export interface GraphQLSubscriptionPayload< Query extends GraphQLQuery = GraphQLQuery, > { data?: Query | null errors?: ReadonlyArray<Partial<GraphQLError>> | null extensions?: Record<string, unknown> } function createInitMessage(payload?: Record<string, unknown>): string { return JSON.stringify({ type: 'connection_init', payload, } satisfies GraphQLWebSocketInitMessage) } function createAcknowledgeMessage(): string { return JSON.stringify({ type: 'connection_ack', } satisfies GraphQLWebSocketAcknowledgeMessage) } function createNextMessage(args: { id: string payload: GraphQLSubscriptionPayload }): string { return JSON.stringify({ id: args.id, type: 'next', payload: args.payload, } satisfies GraphQLWebSocketNextMessage) } function createErrorMessage(args: { id: string payload: ReadonlyArray<Partial<GraphQLError>> }): string { return JSON.stringify({ id: args.id, type: 'error', payload: args.payload, } satisfies GraphQLWebSocketErrorMessage) } function createCompleteMessage(args: { id: string }): string { return JSON.stringify({ id: args.id, type: 'complete', } satisfies GraphQLWebSocketCompleteMessage) } function createPongMessage(): string { return JSON.stringify({ type: 'pong', } satisfies GraphQLWebSocketPongMessage) } /** * Construct a request representing the WebSocket upgrade of the given * connection. GraphQL subscriptions have no request of their own, so * this describes the connection they are multiplexed over. */ function createUpgradeRequest(url: URL): Request { return new Request(url, { headers: { connection: 'upgrade', upgrade: 'websocket', }, }) } function parseGraphQLWebSocketMessage<MessageType extends { type: string }>( data: WebSocketData, ): MessageType | undefined { if (typeof data !== 'string') { return undefined } const message = jsonParse<MessageType>(data) if (!message || typeof message.type !== 'string') { return undefined } return message } /** * A subscriber provided by a `GraphQLSubscriptionHandler` for a particular * WebSocket connection. Returns true if the handler matched the parsed * subscribe operation and resolved it. */ type GraphQLSubscriptionSubscriber = (args: { node: ParsedGraphQLQuery message: GraphQLWebSocketSubscribeMessage }) => boolean interface GraphQLSubscriptionSubscriberEntry { transport: GraphQLSubscriptionTransportHandler subscriber: GraphQLSubscriptionSubscriber } type GraphQLSubscriptionCleanup = () => MaybePromise<void> interface GraphQLSubscriptionConnection { client: WebSocketClientHandle server: WebSocketServerHandle subscribers: Map<WebSocketHandler, GraphQLSubscriptionSubscriberEntry> /** * The active subscriptions of this connection, mapped to the cleanups * scheduled for them via the resolver's `finalize()`. */ subscriptions: Map<string, Array<GraphQLSubscriptionCleanup>> events?: WebSocketResolutionContext['events'] /** * The payload of the client's `connection_init` message (i.e. the * `connectionParams` of the GraphQL client). Kept so it can be * replayed to the original server on passthrough. */ connectionParams?: Record<string, unknown> /** * Resolves once the original server has acknowledged this connection. * The upstream session is established once per connection, no matter * how many subscriptions pass through it. */ upstreamSession?: Promise<void> } /** * Connect to the original server and initialize the `graphql-transport-ws` * session for the given connection, at most once. * * @note The server connection is shared by every subscription of this * client. Initializing it more than once makes a compliant GraphQL server * close it ("Too many initialisation requests"). */ function ensureUpstreamSession( connection: GraphQLSubscriptionConnection, ): Promise<void> { if (connection.upstreamSession) { return connection.upstreamSession } const { server } = connection connection.upstreamSession = new Promise<void>((resolve) => { server.addEventListener('message', (event) => { const message = parseGraphQLWebSocketMessage<GraphQLWebSocketServerMessage>(event.data) if (message?.type === 'connection_ack') { // Prevent the original acknowledgement from being forwarded to // the client: it has already received a mocked one on connect. event.preventDefault() resolve() } }) server.addEventListener( 'open', () => { server.send(createInitMessage(connection.connectionParams)) }, { once: true }, ) server.connect() }) return connection.upstreamSession } /** * The `graphql-transport-ws` sessions of the intercepted WebSocket * connections, keyed by the client id. * * @note This registry is module-level, and not per-transport, on purpose. * Multiple `graphql.link()` calls to the same endpoint create multiple * transports, and all of them must share a single session per connection. * Otherwise, each transport binds its own protocol listeners to the same * client, which makes it receive duplicate `connection_ack`/`pong` frames * and resolves matching subscription handlers more than once. */ const connections = new Map<string, GraphQLSubscriptionConnection>() /** * The WebSocket protocol implemented by the subscription transport. * @see https://github.com/graphql/graphql-over-http/blob/main/rfcs/GraphQLOverWebSocket.md */ const GRAPHQL_WEBSOCKET_SUBPROTOCOL = 'graphql-transport-ws' function includesGraphQLProtocol( protocols: string | Array<string> | null | undefined, ): boolean { if (protocols == null) { return false } // Support both the WebSocket constructor argument (a protocol string // or a list of protocol strings) and the `Sec-WebSocket-Protocol` // request header (a comma-separated list). const requestedProtocols = typeof protocols === 'string' ? protocols.split(',') : protocols return requestedProtocols.some((protocol) => { return protocol.trim() === GRAPHQL_WEBSOCKET_SUBPROTOCOL }) } /** * A WebSocket handler that only matches connections that requested the * `graphql-transport-ws` protocol. Connections without it (e.g. * arbitrary non-GraphQL WebSockets whose URL matched a permissive * endpoint like a wildcard link) are left unhandled so they follow the * regular unhandled connection flow (warnings, passthrough). */ abstract class GraphQLWebSocketHandler extends WebSocketHandler { public async run( connection: WebSocketConnectionEventData, resolutionContext?: WebSocketResolutionContext, ): Promise<WebSocketHandlerConnection | null> { if (!includesGraphQLProtocol(connection.info.protocols)) { return null } return super.run(connection, resolutionContext) } } /** * A WebSocket handler implementing the `graphql-transport-ws` protocol * session for a single GraphQL endpoint. One transport is shared across * all subscription handlers created from the same `graphql.link()` call * (attached to each of them as a sibling handler). * * The transport owns the protocol/session concerns: connection * acknowledgement, keep-alive, the per-connection registry of active * subscriptions, and dispatching parsed `subscribe` operations to the * matching subscription handler. */ export class GraphQLSubscriptionTransportHandler extends GraphQLWebSocketHandler { /** * Register the given handler as a subscriber to the GraphQL * subscriptions on the given WebSocket connection. Subscribers are * dispatched in registration order, which follows the handlers * resolution order (runtime handlers take precedence). */ public subscribe( connection: WebSocketHandlerConnection, handler: WebSocketHandler, subscriber: GraphQLSubscriptionSubscriber, ): void { const transportConnection = this.#getOrCreateConnection(connection) transportConnection.subscribers.set(handler, { transport: this, subscriber, }) } public getConnection( clientId: string, ): GraphQLSubscriptionConnection | undefined { return connections.get(clientId) } public async run( connection: WebSocketConnectionEventData, resolutionContext?: WebSocketResolutionContext, ): Promise<WebSocketHandlerConnection | null> { const handlerConnection = await super.run(connection, resolutionContext) // Capture the network frame events reference for this connection. // The transport emits life-cycle events (e.g. "graphql:subscription") // long after the run: whenever the client sends a "subscribe" message. if (handlerConnection) { const transportConnection = this.#getOrCreateConnection(handlerConnection) // Never overwrite the events of a session shared with another // transport: only the frame that resolved it provides them. if (resolutionContext?.events) { transportConnection.events = resolutionContext.events } } return handlerConnection } /** * End the given subscription without notifying the client. Used when * the subscription has already been terminated over the wire (e.g. the * original server completed it and that frame reached the client). */ public endSubscription(args: { clientId: string subscriptionId: string }): void { const connection = connections.get(args.clientId) if (connection) { this.#endSubscription(connection, args.subscriptionId) } } /** * Schedule a cleanup to run once the given subscription ends. * * @note If the subscription has already ended by the time this is * called, the cleanup runs immediately. The resolver can no longer * affect that subscription, so there is nothing left to wait for. */ public finalize(args: { clientId: string subscriptionId: string cleanup: GraphQLSubscriptionCleanup }): void { const cleanups = connections .get(args.clientId) ?.subscriptions.get(args.subscriptionId) if (cleanups) { cleanups.push(args.cleanup) return } this.#exhaustCleanups([args.cleanup]) } async #endAllSubscriptions( connection: GraphQLSubscriptionConnection, ): Promise<void> { const pendingCleanups: Array<Promise<void>> = [] for (const subscriptionId of connection.subscriptions.keys()) { pendingCleanups.push(this.#endSubscription(connection, subscriptionId)) } await Promise.all(pendingCleanups) } /** * End the given subscription and run the cleanups scheduled for it. * * This is the single exit point for every way a subscription can end: * completed by the mock, by the client, or by the original server; * terminated with errors; dropped when the client disconnects; or * detached from its resolver when the handlers are reset. Ending an * already-ended subscription is a no-op, so the cleanups are * guaranteed to run at most once. */ #endSubscription( connection: GraphQLSubscriptionConnection, subscriptionId: string, ): Promise<void> { const cleanups = connection.subscriptions.get(subscriptionId) if (!cleanups) { return Promise.resolve() } connection.subscriptions.delete(subscriptionId) return this.#exhaustCleanups(cleanups) } /** * Run the given cleanups as LIFO, consistently with `finalize()` in * the request handlers. Cleanups are detached from the subscription * life-cycle: nothing awaits them, so this must never reject. */ async #exhaustCleanups( cleanups: Array<GraphQLSubscriptionCleanup>, ): Promise<void> { const errors: Array<Error> = [] for (let index = cleanups.length - 1; index >= 0; index--) { try { await cleanups[index]() } catch (error) { if (error instanceof Error) { errors.push(error) } } } if (errors.length > 0) { devUtils.error( 'Failed to execute the cleanup for a GraphQL subscription to "%s". Please see the original error below.', this.url.toString(), new AggregateError(errors), ) } } /** * Send a `next` message with the given payload to the subscription. */ public publish(args: { clientId: string subscriptionId: string payload: GraphQLSubscriptionPayload }): void { const connection = this.#getConnectionForSubscription({ clientId: args.clientId, subscriptionId: args.subscriptionId, intent: 'publish to', }) if (!connection) { return } connection.client.send( createNextMessage({ id: args.subscriptionId, payload: args.payload, }), ) } /** * Send a terminal `error` message to the subscription and * removes it from the registry of active subscriptions. */ public error(args: { clientId: string subscriptionId: string errors: ReadonlyArray<Partial<GraphQLError>> }): void { const connection = this.#getConnectionForSubscription({ clientId: args.clientId, subscriptionId: args.subscriptionId, intent: 'error', }) if (!connection) { return } connection.client.send( createErrorMessage({ id: args.subscriptionId, payload: args.errors, }), ) this.#endSubscription(connection, args.subscriptionId) } /** * Send a `complete` message to the subscription and removes it * from the registry of active subscriptions. */ public complete(args: { clientId: string; subscriptionId: string }): void { const connection = this.#getConnectionForSubscription({ clientId: args.clientId, subscriptionId: args.subscriptionId, intent: 'complete', }) if (!connection) { return } connection.client.send(createCompleteMessage({ id: args.subscriptionId })) this.#endSubscription(connection, args.subscriptionId) } /** * Drop this transport's subscribers and active subscriptions from the * sessions it participates in. The sessions themselves are left intact: * they are shared with the other transports of the same connection and * own the protocol listeners for as long as the client stays connected. * * @note This method is invoked automatically when the handlers * controller resets the handlers (e.g. `server.resetHandlers()`). */ public reset(): void { for (const connection of connections.values()) { let ownsConnection = false for (const [handler, entry] of connection.subscribers) { if (entry.transport === this) { connection.subscribers.delete(handler) ownsConnection = true } } // Resetting the handlers detaches the resolvers from their // subscriptions, so run their cleanups instead of dropping them. if (ownsConnection) { this.#endAllSubscriptions(connection) } } } /** * Forget the sessions of this transport, ending their subscriptions. * @note This method is invoked automatically when the network is * disabled (e.g. `server.close()`). */ public dispose(): MaybePromise<void> { const pendingCleanups: Array<Promise<void>> = [] for (const [clientId, connection] of connections) { for (const [handler, entry] of connection.subscribers) { if (entry.transport === this) { connection.subscribers.delete(handler) } } // A session is shared by all the transports of the same endpoint, // so it's only torn down once the last of them is disposed of. if (connection.subscribers.size === 0) { pendingCleanups.push(this.#endAllSubscriptions(connection)) connections.delete(clientId) } } if (pendingCleanups.length > 0) { return Promise.all(pendingCleanups).then(() => {}) } } /** * @note The transport is the sole owner of logging for GraphQL * subscription connections. It logs parsed `graphql-transport-ws` * frames instead of raw WebSocket messages. */ public log(connection: WebSocketConnectionEventData): () => void { return attachGraphQLSubscriptionLogger(connection) } protected [kConnect](connection: WebSocketHandlerConnection): boolean { this.#getOrCreateConnection(connection) return true } #getOrCreateConnection( connection: WebSocketHandlerConnection, ): GraphQLSubscriptionConnection { const { client } = connection const existingConnection = connections.get(client.id) if (existingConnection) { return existingConnection } const transportConnection: GraphQLSubscriptionConnection = { client: connection.client, server: connection.server, subscribers: new Map(), subscriptions: new Map(), } connections.set(client.id, transportConnection) // Bind the protocol listeners alongside the session that owns them. // Creating the session and binding its listeners is a single step, so // they are guaranteed to be bound exactly once per connection no matter // how many transports end up sharing this session. client.addEventListener('message', (event) => { this.#handleClientMessage(client.id, event.data) }) client.addEventListener('close', () => { // The resolvers can no longer affect any of the subscriptions // on this connection once the client disconnects. this.#endAllSubscriptions(transportConnection) connections.delete(client.id) }) return transportConnection } #getConnectionForSubscription(args: { clientId: string subscriptionId: string intent: string }): GraphQLSubscriptionConnection | undefined { const connection = connections.get(args.clientId) if (!connection || !connection.subscriptions.has(args.subscriptionId)) { devUtils.warn( 'Failed to %s the GraphQL subscription "%s": the subscription is no longer active.', args.intent, args.subscriptionId, ) return undefined } return connection } #handleClientMessage(clientId: string, data: WebSocketData): void { const connection = connections.get(clientId) if (!connection) { return } const message = parseGraphQLWebSocketMessage<GraphQLWebSocketClientMessage>(data) if (!message) { return } switch (message.type) { case 'connection_init': { // Preserve the initialization payload (e.g. the client's // credentials) so passthrough can replay it to the server. connection.connectionParams = message.payload connection.client.send(createAcknowledgeMessage()) break } case 'ping': { connection.client.send(createPongMessage()) break } case 'subscribe': { this.#handleSubscribeMessage(connection, message) break } case 'complete': { this.#endSubscription(connection, message.id) break } } } #handleSubscribeMessage( connection: GraphQLSubscriptionConnection, message: GraphQLWebSocketSubscribeMessage, ): void { let node: ParsedGraphQLQuery try { node = parseDocumentNode( parse(message.payload.query), message.payload.operationName, ) } catch (error) { devUtils.warn( 'Failed to intercept a GraphQL subscription to "%s": the subscription query is not a valid GraphQL document.\n\n%s', toPublicUrl(connection.client.url), error, ) return } if (node.operationType !== OperationTypeNode.SUBSCRIPTION) { devUtils.warn( 'Intercepted a GraphQL %s "%s" over WebSocket: only subscription operations are supported over the WebSocket transport.', node.operationType, node.operationName || '(anonymous)', ) return } // Register the subscription before dispatching it so the resolver // can publish to it synchronously. connection.subscriptions.set(message.id, []) for (const { subscriber } of connection.subscribers.values()) { if (subscriber({ node, message })) { this.#emitSubscriptionEvent(connection, node, message) return } } devUtils.warn( 'Intercepted a GraphQL subscription "%s" to "%s" that has no matching subscription handler. If you wish to mock this subscription, create a subscription handler for it.', node.operationName || '(anonymous)', toPublicUrl(connection.client.url), ) } /** * Emit the "graphql:subscription" life-cycle event on the network. * The event is emitted once the subscription has been established: * matched by a subscription handler and resolved. */ #emitSubscriptionEvent( connection: GraphQLSubscriptionConnection, node: ParsedGraphQLQuery, message: GraphQLWebSocketSubscribeMessage, ): void { // Anonymous subscriptions can never match a subscription handler. if (!connection.events || !node.operationName) { return } connection.events.emit( new GraphQLSubscriptionEvent({ operationName: node.operationName, query: message.payload.query, variables: { ...message.payload.variables }, request: createUpgradeRequest(connection.client.url), }), ) } } export type GraphQLSubscriptionName< Query extends GraphQLQuery = GraphQLQuery, Variables extends GraphQLVariables = GraphQLVariables, > = GraphQLHandlerNameSelector | DocumentTypeDecoration<Query, Variables> export interface GraphQLSubscriptionResolverInfo< Query extends GraphQLQuery = GraphQLQuery, Variables extends GraphQLVariables = GraphQLVariables, > { /** * Path parameters parsed from the WebSocket connection URL. */ params: PathParams /** * The name of the intercepted operation. */ operationName: string /** * Intercepted GraphQL subscription. */ subscription: GraphQLSubscription<Query, Variables> /** * The request that established the WebSocket connection this * subscription is multiplexed over. */ request: Request /** * Schedule a cleanup to run once this subscription ends and the * resolver can no longer affect it: it has been completed (by the mock, * the client, or the original server), terminated with errors, or the * client has disconnected. * * @example * api.subscription('OnCommentAdded', ({ subscription, finalize }) => { * const interval = setInterval(() => subscription.publish(payload), 1000) * finalize(() => clearInterval(interval)) * }) */ finalize: ResponseResolverFinalizeFunction } export type GraphQLSubscriptionResolver< Query extends GraphQLQuery = GraphQLQuery, Variables extends GraphQLVariables = GraphQLVariables, > = (info: GraphQLSubscriptionResolverInfo<Query, Variables>) => void export interface GraphQLSubscriptionHandlerOptions { /** * Mark this handler as used after its first match. * Used handlers do not match subsequent subscriptions. */ once?: boolean } /** * A WebSocket handler intercepting GraphQL subscriptions by their * operation name. Matching and resolution are delegated to it by the * subscription transport (its sibling handler) so the first matching * handler wins, respecting runtime handler overrides. */ export class GraphQLSubscriptionHandler< Query extends GraphQLQuery = GraphQLQuery, Variables extends GraphQLVariables = GraphQLVariables, > extends GraphQLWebSocketHandler { public info: GraphQLHandlerInfo public isUsed: boolean readonly #operationName: string | RegExp readonly #transport: GraphQLSubscriptionTransportHandler readonly #resolver: GraphQLSubscriptionResolver<Query, Variables> readonly #options: GraphQLSubscriptionHandlerOptions constructor(args: { url: Path operationName: GraphQLSubscriptionName<Query, Variables> transport: GraphQLSubscriptionTransportHandler resolver: GraphQLSubscriptionResolver<Query, Variables> options?: GraphQLSubscriptionHandlerOptions }) { super(args.url) // Create the same GraphQL handler info as request-based GraphQL // handlers so this handler prints nicely during introspection // (e.g. `server.listHandlers()`). This also normalizes `DocumentNode` // and typed document predicates to plain operation names. this.info = GraphQLHandler.parseGraphQLRequestInfo({ operationType: OperationTypeNode.SUBSCRIPTION, predicate: args.operationName, url: args.url, }) const { operationName } = this.info invariant( typeof operationName !== 'function' && !isDocumentNode(operationName), 'Failed to create a GraphQL subscription handler: custom predicates are not supported for subscriptions', ) this.#operationName = operationName this.#transport = args.transport this.#resolver = args.resolver this.#options = args.options || {} this.isUsed = false } public reset(): void { this.isUsed = false } /** * @note Individual subscription handlers stay silent. The subscription * transport owns the GraphQL-aware logging for the entire connection * (a logger is attached once per matching handler otherwise). */ public log(): () => void { return function detachLogger() {} } protected [kConnect](connection: WebSocketHandlerConnection): boolean { this.#transport.subscribe(connection, this, (args) => { return this.#handleSubscribe(connection, args) }) return true } #handleSubscribe( connection: WebSocketHandlerConnection, args: { node: ParsedGraphQLQuery message: GraphQLWebSocketSubscribeMessage }, ): boolean { if (this.#options.once && this.isUsed) { return false } const { operationName } = args.node if (!operationName || !this.#matchesOperationName(operationName)) { return false } this.isUsed = true const subscription = new GraphQLSubscription<Query, Variables>({ message: args.message, clientId: connection.client.id, transport: this.#transport, }) this.#resolver({ params: connection.params, operationName, subscription, request: createUpgradeRequest(connection.client.url), finalize: (cleanup) => { this.#transport.finalize({ clientId: connection.client.id, subscriptionId: subscription.id, cleanup, }) }, }) return true } #matchesOperationName(operationName: string): boolean { if (this.#operationName instanceof RegExp) { return this.#operationName.test(operationName) } return this.#operationName === operationName } } /** * Representation of the intercepted GraphQL subscription. */ export class GraphQLSubscription< Query extends GraphQLQuery = GraphQLQuery, Variables extends GraphQLVariables = GraphQLVariables, > { public id: string public query: string public variables: Variables public extensions?: Record<string, unknown> readonly #message: GraphQLWebSocketSubscribeMessage readonly #clientId: string readonly #transport: GraphQLSubscriptionTransportHandler constructor(args: { message: GraphQLWebSocketSubscribeMessage clientId: string transport: GraphQLSubscriptionTransportHandler }) { this.id = args.message.id this.query = args.message.payload.query this.variables = (args.message.payload.variables || {}) as Variables this.extensions = args.message.payload.extensions this.#message = args.message this.#clientId = args.clientId this.#transport = args.transport } /** * Publish an execution result to the subscribed client. * * @example * subscription.publish({ * data: { * postAdded: { * id: 'abc-123' * } * } * }) */ public publish(payload: GraphQLSubscriptionPayload<Query>): void { this.#transport.publish({ clientId: this.#clientId, subscriptionId: this.id, payload, }) } /** * Use the given `Iterable` or `AsyncIterable` as the source * of data for this subscription. Whenever the iterable yields a * value, it gets published to this subscription. * * @example * subscription.from(async function* () { * yield { text: 'hello world' } * }) */ public async from( source: Iterable<Query> | AsyncIterable<Query>, ): Promise<void> { for await (const data of source) { this.publish({ data }) } } /** * Terminate this subscription with the given errors. * * @example * subscription.error([{ message: 'Something went wrong' }]) */ public error(errors: ReadonlyArray<Partial<GraphQLError>>): void { this.#transport.error({ clientId: this.#clientId, subscriptionId: this.id, errors, }) } /** * Marks this subscription as complete. * * @example * subscription.complete() */ public complete(): void { this.#transport.complete({ clientId: this.#clientId, subscriptionId: this.id, }) } /** * Perform this GraphQL subscription as-is. * This establishes a connection to the actual server, replays * the intercepted subscription, and forwards the server payloads * to the GraphQL client. You can intercept, modify, or prevent * any of the original server messages. * * @example * const postAddedSubscription = subscription.passthrough() * postAddedSubscription.addEventListener('next', (event) => { * event.preventDefault() * event.data.payload.data.postAdded.id = 'mock-id' * subscription.publish(event.data.payload) * }) */ public passthrough(): GraphQLPassthroughSubscription { const connection = this.#transport.getConnection(this.#clientId) /** * @note One can only call this method inside the GraphQL subscription * handler. By that point, the WebSocket connection has been established * and intercepted so the connection reference is guaranteed. */ invariant( connection, 'Failed to passthrough the GraphQL subscription ("%s"): the underlying WebSocket connection is closed', this.query, ) return new GraphQLPassthroughSubscription({ server: connection.server, message: this.#message, upstreamSession: ensureUpstreamSession(connection), onTerminate: () => { this.#transport.endSubscription({ clientId: this.#clientId, subscriptionId: this.id, }) }, }) } } export type GraphQLPassthroughSubscriptionEventMap = { connection_ack: TypedEvent next: TypedEvent<GraphQLWebSocketNextMessage> error: TypedEvent<GraphQLWebSocketErrorMessage> complete: TypedEvent<GraphQLWebSocketCompleteMessage> } /** * Representation of a GraphQL subscription to the actual server. * You interface with this object from the client's perspective. */ export class GraphQLPassthroughSubscription { readonly #server: WebSocketServerHandle readonly #message: GraphQLWebSocketSubscribeMessage readonly #emitter: Emitter<GraphQLPassthroughSubscriptionEventMap> readonly #abortController: AbortController readonly #onTerminate: () => void constructor(args: { server: WebSocketServerHandle message: GraphQLWebSocketSubscribeMessage upstreamSession: Promise<void> onTerminate: () => void }) { this.#server = args.server this.#message = args.message this.#onTerminate = args.onTerminate this.#emitter = new Emitter() // An abort controller responsible for removing the server event // listeners once the subscription is unsubscribed. this.#abortController = new AbortController() // Replay this subscription once the shared upstream session is // established, so the server can authorize this client first. args.upstreamSession.then(() => { if (!this.#abortController.signal.aborted) { this.#server.send(JSON.stringify(this.#message)) } }) this.#server.addEventListener( 'message', (event) => { const message = parseGraphQLWebSocketMessage<GraphQLWebSocketServerMessage>( event.data, ) if (!message) { return } switch (message.type) { case 'connection_ack': { event.preventDefault() this.#emitter.emit(new TypedEvent('connection_ack')) break } case 'next': { if (message.id !== this.#message.id) { break } const nextEvent = new TypedEvent('next', { data: message }) this.#emitter.emit(nextEvent) if (nextEvent.defaultPrevented) { event.preventDefault() } break } case 'error': { if (message.id !== this.#message.id) { break } const errorEvent = new TypedEvent('error', { data: message }) this.#emitter.emit(errorEvent) if (errorEvent.defaultPrevented) { event.preventDefault() break } // The original server terminated the subscription and that // frame reaches the client, so the subscription ends here. // A prevented frame means the mock took over instead. this.#onTerminate() break } case 'complete': { if (message.id !== this.#message.id) { break } const completeEvent = new TypedEvent('complete', { data: message }) this.#emitter.emit(completeEvent) if (completeEvent.defaultPrevented) { event.preventDefault() break } this.#onTerminate() break } } }, { signal: this.#abortController.signal }, ) } /** * Add an event listener to the given GraphQL subscription event. * * @example * const onPostAddedSubscription = subscription.passthrough() * onPostAddedSubscription.addEventListener('next', (event) => { * console.log(event.data) * // { id, payload, ... } * }) */ public addEventListener< EventType extends keyof GraphQLPassthroughSubscriptionEventMap & string, >( event: EventType, listener: Emitter.Listener< Emitter<GraphQLPassthroughSubscriptionEventMap>, EventType >, ): void { this.#emitter.on(event, listener, { signal: this.#abortController.signal, }) } /** * Unsubscribe from this passthrough GraphQL subscription. * This stops this subscription on the original server. * * @note Unsubscribing from the original subscription has no * effect on the intercepted `subscription` object. * * @example * const onPostAddedSubscription = subscription.passthrough() * onPostAddedSubscription.unsubscribe() */ public unsubscribe(): void { this.#abortController.abort() this.#emitter.removeAllListeners() /** * @note Complete this subscription instead of closing the server * connection. That connection is shared by every subscription of * this client, and closing it would terminate the unrelated ones. */ this.#server.send(createCompleteMessage({ id: this.#message.id })) } } function logGraphQLFrame(args: { color: string label: string payload?: unknown }): void { const timestamp = getTimestamp({ milliseconds: true }) if (typeof args.payload === 'undefined') { // eslint-disable-next-line no-console console.log( devUtils.formatMessage(`${timestamp} %c${args.label}%c`), `color:${args.color}`, 'color:inherit', ) return } console.groupCollapsed( devUtils.formatMessage(`${timestamp} %c${args.label}%c`), `color:${args.color}`, 'color:inherit', ) // eslint-disable-next-line no-console console.log(args.payload) console.groupEnd() } /** * Attach a GraphQL-aware logger to the intercepted WebSocket connection. * Unlike the raw WebSocket logger, this logger prints parsed * `graphql-transport-ws` frames relevant to the subscription. */ function attachGraphQLSubscriptionLogger( connection: WebSocketConnectionEventData, ): () => void { const { client } = connection const abortController = new AbortController() logGraphQLFrame({ color: colors.system, label: `GraphQL subscription connection ${toPublicUrl(client.url)}`, }) client.addEventListener( 'message', (event) => { const message = parseGraphQLWebSocketMessage<GraphQLWebSocketClientMessage>(event.data) if (!message) { return } switch (message.type) { case 'subscribe': { logGraphQLFrame({ color: colors.outgoing, label: `subscribe (id: ${message.id})`, payload: message.payload, }) break } case 'complete': { logGraphQLFrame({ color: colors.outgoing, label: `complete (id: ${message.id})`, }) break } } }, { signal: abortController.signal }, ) // Proxy `client.send` to log the frames published to the client // (`client.send` does not dispatch any observable events). const originalClientSend = client.send client.send = new Proxy(client.send, { apply: (target, thisArg, args) => { const [data] = args const message = parseGraphQLWebSocketMessage<GraphQLWebSocketServerMessage>(data) if (message) { switch (message.type) { case 'next': { logGraphQLFrame({ color: colors.mocked, label: `next (id: ${message.id})`, payload: message.payload, }) break } case 'error': { logGraphQLFrame({ color: colors.mocked, label: `error (id: ${message.id})`, payload: message.payload, }) break } case 'complete': { logGraphQLFrame({ color: colors.mocked, label: `complete (id: ${message.id})`, }) break } } } return Reflect.apply(target, thisArg, args) }, }) return function detachLogger() { abortController.abort() client.send = originalClientSend } } export type GraphQLSubscriptionHandlerFactory = < Query extends GraphQLQuery = GraphQLQuery, Variables extends GraphQLVariables = GraphQLVariables, >( operationName: GraphQLSubscriptionName<Query, Variables>, resolver: GraphQLSubscriptionResolver<Query, Variables>, options?: GraphQLSubscriptionHandlerOptions, ) => GraphQLSubscriptionHandler<Query, Variables> /** * Create a `subscription()` handler factory bound to the given GraphQL * endpoint. All subscription handlers created by the factory share a single * subscription transport and a single WebSocket upgrade handler, both * attached to each handler as siblings. * * @example * const subscription = createGraphQLSubscriptionHandler('https://api.example.com/graphql') * subscription('OnPostAdded', ({ subscription }) => { * subscription.publish({ data: { postAdded: { id: 'abc-123' } } }) * }) */ export function createGraphQLSubscriptionHandler( url: Path, ): GraphQLSubscriptionHandlerFactory { const webSocketUrl = typeof url === 'string' ? url.replace(/^http/, 'ws') : url const transport = new GraphQLSubscriptionTransportHandler(webSocketUrl) // The `upgrade` request handler enables WebSocket interception in Node.js. // The same handler instance is shared between all subscription handlers // of this endpoint (sibling handlers are deduped by reference). const upgradeHandler = http.get(({ request }) => { return ( request.headers.get('upgrade')?.toLowerCase() === 'websocket' && includesGraphQLProtocol(request.headers.get('sec-websocket-protocol')) && matchRequestUrl(new URL(resolveWebSocketUrl(request.url)), webSocketUrl) .matches ) }, webSocketUpgrade) return (operationName, resolver, options) => { const handler = new GraphQLSubscriptionHandler({ url: webSocketUrl, operationName, transport, resolver, options, }) return attachSiblingHandlers(handler, [transport, upgradeHandler]) } }