msw
Version:
1,488 lines (1,299 loc) • 43.9 kB
text/typescript
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])
}
}