UNPKG

msw

Version:

The industry standard for API mocking in JavaScript.

1,244 lines (1,242 loc) • 45.5 kB
import { a as matchRequestUrl, h as devUtils, n as jsonParse, r as toPublicUrl, s as getTimestamp } from "../_chunks/cookie-store.js"; import { t as RequestHandler } from "../_chunks/request-handler.js"; import { a as serializeRequest, i as serializeResponse, o as getStatusCodeColor, r as getAllRequestCookies } from "../_chunks/http-handler.js"; import { t as http } from "../_chunks/http.js"; import { a as WebSocketHandler, s as kConnect, t as attachSiblingHandlers } from "../_chunks/attach-sibling-handlers.js"; import { t as colors } from "../_chunks/colors.js"; import { n as webSocketUpgrade } from "../_chunks/websocket-upgrade.js"; import { n as kDefaultContentType } from "../_chunks/symbols.js"; import { invariant } from "outvariant"; import { createRequestId, resolveWebSocketUrl } from "@mswjs/interceptors"; import { Emitter, TypedEvent } from "rettime"; import { OperationTypeNode, parse } from "graphql"; import { stringToHeaders } from "headers-polyfill"; //#region src/core/utils/internal/parse-multipart-data.ts function parseContentHeaders(headersString) { const headers = stringToHeaders(headersString); const contentType = headers.get("content-type") || "text/plain"; const disposition = headers.get("content-disposition"); if (!disposition) throw new Error("\"Content-Disposition\" header is required."); const directives = disposition.split(";").reduce((acc, chunk) => { const [name, ...rest] = chunk.trim().split("="); acc[name] = rest.join("="); return acc; }, {}); return { name: directives.name?.slice(1, -1), filename: directives.filename?.slice(1, -1), contentType }; } /** * Parses a given string as a multipart/form-data. * Does not throw an exception on an invalid multipart string. */ function parseMultipartData(data, headers) { const contentType = headers?.get("content-type"); if (!contentType) return; const [, ...directives] = contentType.split(/; */); const boundary = directives.filter((d) => d.startsWith("boundary=")).map((s) => s.replace(/^boundary=/, ""))[0]; if (!boundary) return; const boundaryRegExp = new RegExp(`--+${boundary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`); const fields = data.split(boundaryRegExp).filter((chunk) => chunk.startsWith("\r\n") && chunk.endsWith("\r\n")).map((chunk) => chunk.trimStart().replace(/\r\n$/, "")); if (!fields.length) return; const parsedBody = {}; try { for (const field of fields) { const [contentHeaders, ...rest] = field.split("\r\n\r\n"); const contentBody = rest.join("\r\n\r\n"); const { contentType, filename, name } = parseContentHeaders(contentHeaders); const value = filename === void 0 ? contentBody : new File([contentBody], filename, { type: contentType }); const parsedValue = parsedBody[name]; if (parsedValue === void 0) parsedBody[name] = value; else if (Array.isArray(parsedValue)) parsedBody[name] = [...parsedValue, value]; else parsedBody[name] = [parsedValue, value]; } return parsedBody; } catch { return; } } //#endregion //#region src/graphql/parse-graphql-request.ts function parseDocumentNode(node, operationName) { const operationDefs = node.definitions.filter((definition) => { return definition.kind === "OperationDefinition"; }); /** * @note A document may bundle multiple operations (e.g. the ones * emitted by GraphQL Code Generator). Honor the requested operation * name, and resolve nothing if the document has no such operation. * Falling back to the first operation would silently resolve one the * client never asked for. */ const operationDef = operationName ? operationDefs.find((definition) => { return definition.name?.value === operationName; }) : operationDefs[0]; return { operationType: operationDef?.operation, /** * @note Echo the requested operation name even when the document * has no such operation. It makes the "unhandled operation" warnings * name the operation the client actually asked for. */ operationName: operationDef?.name?.value || operationName || void 0 }; } async function parseQuery(query, operationName) { try { return parseDocumentNode(parse(query), operationName); } catch (error) { return error; } } function extractMultipartVariables(variables, map, files) { const operations = { variables }; for (const [key, pathArray] of Object.entries(map)) { if (!(key in files)) throw new Error(`Given files do not have a key '${key}' .`); for (const dotPath of pathArray) { const [lastPath, ...reversedPaths] = dotPath.split(".").reverse(); const paths = reversedPaths.reverse(); let target = operations; for (const path of paths) { if (!(path in target)) throw new Error(`Property '${path}' is not in operations.`); target = target[path]; } target[lastPath] = files[key]; } } return operations.variables; } async function getGraphQLInput(request) { switch (request.method) { case "GET": { const url = new URL(request.url); const query = url.searchParams.get("query"); const variables = url.searchParams.get("variables") || ""; return { query, variables: jsonParse(variables), operationName: url.searchParams.get("operationName") }; } case "POST": { const requestClone = request.clone(); if (request.headers.get("content-type")?.includes("multipart/form-data")) { const responseJson = parseMultipartData(await requestClone.text(), request.headers); if (!responseJson) return null; const { operations, map, ...files } = responseJson; const parsedOperations = jsonParse(operations) || {}; if (!parsedOperations.query) return null; const parsedMap = jsonParse(map || "") || {}; const variables = parsedOperations.variables ? extractMultipartVariables(parsedOperations.variables, parsedMap, files) : {}; return { query: parsedOperations.query, variables, operationName: parsedOperations.operationName }; } const requestJson = await requestClone.json().catch(() => null); if (requestJson?.query) { const { query, variables, operationName } = requestJson; return { query, variables, operationName }; } return null; } default: return null; } } /** * Determines if a given request can be considered a GraphQL request. * Does not parse the query and does not guarantee its validity. */ async function parseGraphQLRequest(request) { const input = await getGraphQLInput(request); if (!input || !input.query) return; const { query, variables, operationName } = input; const parsedResult = await parseQuery(query, operationName); if (parsedResult instanceof Error) { const requestPublicUrl = toPublicUrl(request.url); throw new Error(devUtils.formatMessage("Failed to intercept a GraphQL request to \"%s %s\": cannot parse query. See the error message from the parser below.\n\n%s", request.method, requestPublicUrl, parsedResult.message)); } return { query: input.query, operationType: parsedResult.operationType, operationName: parsedResult.operationName, variables }; } //#endregion //#region src/core/utils/request/get-all-accepted-mime-types.ts /** * Returns all accepted mime types, ordered by precedence as defined * in [RFC 7231 Section 5.3.2](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2). * * Precedence rules (highest to lowest): * 1. Quality value (`q` parameter, default 1). * 2. Specificity: `type/subtype` > `type/*` > `*\/*`. * 3. Number of media type parameters (more = more specific). * * Types with `q=0` are excluded (explicitly not acceptable). */ function getAllAcceptedMimeTypes(acceptHeader) { if (acceptHeader == null) return []; const accepted = []; for (const part of acceptHeader.split(",")) { const [type, ...params] = part.split(";").map((v) => v.trim()); let quality = 1; let parameterCount = 0; for (const param of params) { const [key, value] = param.split("=").map((v) => v.trim()); if (key === "q") quality = Number(value); else parameterCount++; } if (quality === 0) continue; const [mediaType, mediaSubtype] = type.split("/"); const specificity = mediaType === "*" ? 0 : mediaSubtype === "*" ? 1 : 2; accepted.push({ type, quality, specificity, parameterCount }); } if (!accepted.length) return []; return accepted.sort((left, right) => { if (right.quality !== left.quality) return right.quality - left.quality; if (right.specificity !== left.specificity) return right.specificity - left.specificity; return right.parameterCount - left.parameterCount; }).map((entry) => entry.type); } //#endregion //#region src/graphql/graphql-handler.ts function isDocumentNode(value) { if (value == null) return false; return typeof value === "object" && "kind" in value && "definitions" in value; } function isDocumentTypeDecoration(value) { return value instanceof String; } var GraphQLHandler = class GraphQLHandler extends RequestHandler { endpoint; static parsedRequestCache = /* @__PURE__ */ new WeakMap(); static #parseOperationName(predicate, operationType) { const getOperationName = (node) => { invariant(node.operationType === operationType, "Failed to create a GraphQL handler: provided a DocumentNode with a mismatched operation type (expected \"%s\" but got \"%s\").", operationType, node.operationType); invariant(node.operationName, "Failed to create a GraphQL handler: provided a DocumentNode without operation name"); return node.operationName; }; if (isDocumentNode(predicate)) return getOperationName(parseDocumentNode(predicate)); if (isDocumentTypeDecoration(predicate)) { const documentNode = parse(predicate.toString()); invariant(isDocumentNode(documentNode), "Failed to create a GraphQL handler: given TypedDocumentString (%s) does not produce a valid DocumentNode", predicate); return getOperationName(parseDocumentNode(documentNode)); } return predicate; } /** * Creates a GraphQL handler information object from the given * operation predicate. Normalizes `DocumentNode` and typed document * string predicates to plain operation names. */ static parseGraphQLRequestInfo(args) { const operationName = GraphQLHandler.#parseOperationName(args.predicate, args.operationType); const displayOperationName = typeof operationName === "function" ? "[custom predicate]" : operationName; return { header: args.operationType === "all" ? `${args.operationType} (origin: ${args.url.toString()})` : `${args.operationType}${displayOperationName ? ` ${displayOperationName}` : ""} (origin: ${args.url.toString()})`, operationType: args.operationType, operationName }; } constructor(operationType, predicate, endpoint, resolver, options) { super({ info: GraphQLHandler.parseGraphQLRequestInfo({ operationType, predicate, url: endpoint }), resolver, options }); this.endpoint = endpoint; } /** * Parses the request body, once per request, cached across all * GraphQL handlers. This is done to avoid multiple parsing of the * request body, which each requires a clone of the request. */ async parseGraphQLRequestOrGetFromCache(request) { if (!GraphQLHandler.parsedRequestCache.has(request)) GraphQLHandler.parsedRequestCache.set(request, await parseGraphQLRequest(request).catch((error) => { console.error(error); })); return GraphQLHandler.parsedRequestCache.get(request); } async parse(args) { const match = matchRequestUrl(new URL(args.request.url), this.endpoint); const cookies = getAllRequestCookies(args.request); if (!match.matches) return { match, cookies }; const parsedResult = await this.parseGraphQLRequestOrGetFromCache(args.request); if (typeof parsedResult === "undefined") return { match, cookies }; return { match, cookies, query: parsedResult.query, operationType: parsedResult.operationType, operationName: parsedResult.operationName, variables: parsedResult.variables }; } async predicate(args) { if (args.parsedResult.operationType === void 0) return false; if (!args.parsedResult.operationName && this.info.operationType !== "all") { const publicUrl = toPublicUrl(args.request.url); devUtils.warn(`\ Failed to intercept a GraphQL request at "${args.request.method} ${publicUrl}": anonymous GraphQL operations are not supported. Consider naming this operation or using the "operation()" request handler of "graphql.link()" to intercept GraphQL requests regardless of their operation name/type. Read more: https://mswjs.io/docs/api/graphql/#graphqloperationresolver`); return false; } const hasMatchingOperationType = this.info.operationType === "all" || args.parsedResult.operationType === this.info.operationType; /** * Check if the operation name matches the outgoing GraphQL request. * @note Unlike the HTTP handler, the custom predicate functions are invoked * during predicate, not parsing, because GraphQL request parsing happens first, * and non-GraphQL requests are filtered out automatically. */ const hasMatchingOperationName = await this.matchOperationName({ request: args.request, parsedResult: args.parsedResult }); return args.parsedResult.match.matches && hasMatchingOperationType && hasMatchingOperationName; } async run(args) { const result = await super.run(args); if (result?.response == null) return result; if (!(kDefaultContentType in result.response)) return result; const acceptedMimeTypes = getAllAcceptedMimeTypes(args.request.headers.get("accept")); if (acceptedMimeTypes.length === 0) return result; const graphqlResponseIndex = acceptedMimeTypes.indexOf("application/graphql-response+json"); const jsonIndex = acceptedMimeTypes.indexOf("application/json"); /** * Use the "application/graphql-response+json" response content type * only when the client accepts it AND prefers it over "application/json" * (i.e. it appears earlier in the precedence-sorted list, or "application/json" * is not listed at all). * @see https://github.com/graphql/graphql-over-http/blob/4d1df1fb829ec2dd3ecbf3c6aa4025bd356c270d/spec/GraphQLOverHTTP.md#accept */ if (graphqlResponseIndex !== -1 && (jsonIndex === -1 || graphqlResponseIndex <= jsonIndex)) result.response.headers.set("content-type", "application/graphql-response+json"); return result; } async matchOperationName(args) { if (typeof this.info.operationName === "function") { const customPredicateResult = await this.info.operationName({ request: args.request, ...this.extendResolverArgs({ request: args.request, parsedResult: args.parsedResult }) }); /** * @note Keep the { matches } signature in case we decide to support path parameters * in GraphQL handlers. If that happens, the custom predicate would have to be moved * to the parsing phase, the same as we have for the HttpHandler, and the user will * have a possibility to return parsed path parameters from the custom predicate. */ return typeof customPredicateResult === "boolean" ? customPredicateResult : customPredicateResult.matches; } if (this.info.operationName instanceof RegExp) return this.info.operationName.test(args.parsedResult.operationName || ""); return args.parsedResult.operationName === this.info.operationName; } extendResolverArgs(args) { return { query: args.parsedResult.query || "", operationType: args.parsedResult.operationType, operationName: args.parsedResult.operationName || "", variables: args.parsedResult.variables || {}, cookies: args.parsedResult.cookies }; } async log(args) { const loggedRequest = await serializeRequest(args.request); const loggedResponse = await serializeResponse(args.response); const statusColor = getStatusCodeColor(loggedResponse.status); const requestInfo = args.parsedResult.operationName ? `${args.parsedResult.operationType} ${args.parsedResult.operationName}` : `anonymous ${args.parsedResult.operationType}`; console.groupCollapsed(devUtils.formatMessage(`${getTimestamp()} ${requestInfo} (%c${loggedResponse.status} ${loggedResponse.statusText}%c)`), `color:${statusColor}`, "color:inherit"); console.log("Request:", loggedRequest); console.log("Handler:", this); console.log("Response:", loggedResponse); console.groupEnd(); } }; //#endregion //#region src/graphql/graphql-subscription-event.ts /** * Emitted when a GraphQL subscription is established over an * intercepted WebSocket connection (i.e. matched by a subscription * handler and resolved). * * @note The event is referenced by the WebSocket frame event map so * the life-cycle event emitters derived from it (e.g. `server.events`) * are typed correctly. It lives in its own module so the emitting * `msw/graphql` module doesn't pull the entire frame graph, and the * core stays free of the `graphql` dependency (the event carries * plain data only). */ var GraphQLSubscriptionEvent = class extends TypedEvent { operationName; query; variables; request; constructor(init) { super("graphql:subscription"); this.operationName = init.operationName; this.query = init.query; this.variables = init.variables; this.request = init.request; } }; //#endregion //#region src/graphql/graphql-subscription.ts function createInitMessage(payload) { return JSON.stringify({ type: "connection_init", payload }); } function createAcknowledgeMessage() { return JSON.stringify({ type: "connection_ack" }); } function createNextMessage(args) { return JSON.stringify({ id: args.id, type: "next", payload: args.payload }); } function createErrorMessage(args) { return JSON.stringify({ id: args.id, type: "error", payload: args.payload }); } function createCompleteMessage(args) { return JSON.stringify({ id: args.id, type: "complete" }); } function createPongMessage() { return JSON.stringify({ type: "pong" }); } /** * 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) { return new Request(url, { headers: { connection: "upgrade", upgrade: "websocket" } }); } function parseGraphQLWebSocketMessage(data) { if (typeof data !== "string") return; const message = jsonParse(data); if (!message || typeof message.type !== "string") return; return message; } /** * 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) { if (connection.upstreamSession) return connection.upstreamSession; const { server } = connection; connection.upstreamSession = new Promise((resolve) => { server.addEventListener("message", (event) => { if (parseGraphQLWebSocketMessage(event.data)?.type === "connection_ack") { 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 = /* @__PURE__ */ new Map(); /** * 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) { if (protocols == null) return false; return (typeof protocols === "string" ? protocols.split(",") : protocols).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). */ var GraphQLWebSocketHandler = class extends WebSocketHandler { async run(connection, resolutionContext) { 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. */ var GraphQLSubscriptionTransportHandler = class 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). */ subscribe(connection, handler, subscriber) { this.#getOrCreateConnection(connection).subscribers.set(handler, { transport: this, subscriber }); } getConnection(clientId) { return connections.get(clientId); } async run(connection, resolutionContext) { const handlerConnection = await super.run(connection, resolutionContext); if (handlerConnection) { const transportConnection = this.#getOrCreateConnection(handlerConnection); 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). */ endSubscription(args) { 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. */ finalize(args) { const cleanups = connections.get(args.clientId)?.subscriptions.get(args.subscriptionId); if (cleanups) { cleanups.push(args.cleanup); return; } this.#exhaustCleanups([args.cleanup]); } async #endAllSubscriptions(connection) { const pendingCleanups = []; 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, subscriptionId) { 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) { const errors = []; 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. */ publish(args) { 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. */ error(args) { 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. */ complete(args) { 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()`). */ reset() { 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; } 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()`). */ dispose() { const pendingCleanups = []; for (const [clientId, connection] of connections) { for (const [handler, entry] of connection.subscribers) if (entry.transport === this) connection.subscribers.delete(handler); 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. */ log(connection) { return attachGraphQLSubscriptionLogger(connection); } [kConnect](connection) { this.#getOrCreateConnection(connection); return true; } #getOrCreateConnection(connection) { const { client } = connection; const existingConnection = connections.get(client.id); if (existingConnection) return existingConnection; const transportConnection = { client: connection.client, server: connection.server, subscribers: /* @__PURE__ */ new Map(), subscriptions: /* @__PURE__ */ new Map() }; connections.set(client.id, transportConnection); client.addEventListener("message", (event) => { this.#handleClientMessage(client.id, event.data); }); client.addEventListener("close", () => { this.#endAllSubscriptions(transportConnection); connections.delete(client.id); }); return transportConnection; } #getConnectionForSubscription(args) { 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; } return connection; } #handleClientMessage(clientId, data) { const connection = connections.get(clientId); if (!connection) return; const message = parseGraphQLWebSocketMessage(data); if (!message) return; switch (message.type) { case "connection_init": 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); } } #handleSubscribeMessage(connection, message) { let node; 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; } 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, node, message) { 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) })); } }; /** * 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. */ var GraphQLSubscriptionHandler = class extends GraphQLWebSocketHandler { info; isUsed; #operationName; #transport; #resolver; #options; constructor(args) { super(args.url); 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; } reset() { 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). */ log() { return function detachLogger() {}; } [kConnect](connection) { this.#transport.subscribe(connection, this, (args) => { return this.#handleSubscribe(connection, args); }); return true; } #handleSubscribe(connection, args) { 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({ 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) { if (this.#operationName instanceof RegExp) return this.#operationName.test(operationName); return this.#operationName === operationName; } }; /** * Representation of the intercepted GraphQL subscription. */ var GraphQLSubscription = class { id; query; variables; extensions; #message; #clientId; #transport; constructor(args) { this.id = args.message.id; this.query = args.message.payload.query; this.variables = args.message.payload.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' * } * } * }) */ publish(payload) { 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' } * }) */ async from(source) { for await (const data of source) this.publish({ data }); } /** * Terminate this subscription with the given errors. * * @example * subscription.error([{ message: 'Something went wrong' }]) */ error(errors) { this.#transport.error({ clientId: this.#clientId, subscriptionId: this.id, errors }); } /** * Marks this subscription as complete. * * @example * subscription.complete() */ complete() { 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) * }) */ passthrough() { 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 }); } }); } }; /** * Representation of a GraphQL subscription to the actual server. * You interface with this object from the client's perspective. */ var GraphQLPassthroughSubscription = class { #server; #message; #emitter; #abortController; #onTerminate; constructor(args) { this.#server = args.server; this.#message = args.message; this.#onTerminate = args.onTerminate; this.#emitter = new Emitter(); this.#abortController = new AbortController(); args.upstreamSession.then(() => { if (!this.#abortController.signal.aborted) this.#server.send(JSON.stringify(this.#message)); }); this.#server.addEventListener("message", (event) => { const message = parseGraphQLWebSocketMessage(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; } 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, ... } * }) */ addEventListener(event, listener) { 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() */ unsubscribe() { 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) { const timestamp = getTimestamp({ milliseconds: true }); if (typeof args.payload === "undefined") { 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"); 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) { 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(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})` }); } }, { signal: abortController.signal }); const originalClientSend = client.send; client.send = new Proxy(client.send, { apply: (target, thisArg, args) => { const [data] = args; const message = parseGraphQLWebSocketMessage(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})` }); } return Reflect.apply(target, thisArg, args); } }); return function detachLogger() { abortController.abort(); client.send = originalClientSend; }; } /** * 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' } } }) * }) */ function createGraphQLSubscriptionHandler(url) { const webSocketUrl = typeof url === "string" ? url.replace(/^http/, "ws") : url; const transport = new GraphQLSubscriptionTransportHandler(webSocketUrl); 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]); }; } //#endregion //#region src/graphql/graphql.ts function createScopedGraphQLHandler(operationType, url) { return (predicate, resolver, options = {}) => { return new GraphQLHandler(operationType, predicate, url, resolver, options); }; } function createGraphQLOperationHandler(url, subscriptionFactory) { /** * @note An explicitly generic function so the subscription sibling * can be created with the same `Query`/`Variables` types as the * operation resolver, without casting its resolver info. */ return (resolver, options) => { const handler = new GraphQLHandler("all", /* @__PURE__ */ new RegExp(".*"), url, resolver, options); if (!subscriptionFactory) return handler; const subscriptionCatchAllHandler = subscriptionFactory(/* @__PURE__ */ new RegExp(".*"), ({ operationName, subscription, request, finalize }) => { /** * @note Subscriptions are resolved imperatively, so the return * value of the resolver is ignored. The request describes the * WebSocket connection this subscription is multiplexed over. */ resolver({ operationName, query: subscription.query, variables: subscription.variables, cookies: getAllRequestCookies(request), request, requestId: createRequestId(), finalize }); }, options); return attachSiblingHandlers(handler, [subscriptionCatchAllHandler]); }; } /** * A namespace to intercept and mock GraphQL operations * * @example * const api = graphql.link('https://api.example.com/graphql') * api.query('GetUser', resolver) * api.mutation('DeletePost', resolver) * * @see {@link https://mswjs.io/docs/api/graphql `graphql` API reference} */ const graphql = { /** * Intercepts GraphQL operations scoped by the given URL. * * @example * const github = graphql.link('https://api.github.com/graphql') * github.query('GetRepo', resolver) * * @see {@link https://mswjs.io/docs/api/graphql#graphqllinkurl `graphql.link()` API reference} */ link(url) { /** * @note Create the subscription handler factory once per link so * the `subscription()` and `operation()` handlers share the same * underlying subscription transport (deduped by reference). */ const subscription = createGraphQLSubscriptionHandler(url); return { operation: createGraphQLOperationHandler(url, subscription), query: createScopedGraphQLHandler("query", url), mutation: createScopedGraphQLHandler("mutation", url), subscription }; } }; //#endregion export { GraphQLHandler, GraphQLPassthroughSubscription, GraphQLSubscription, GraphQLSubscriptionHandler, createGraphQLSubscriptionHandler, graphql }; //# sourceMappingURL=index.js.map