UNPKG

msw

Version:

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

584 lines (574 loc) • 24.4 kB
import { m as devUtils } from "../_chunks/cookie-store.js"; import { t as RequestHandler } from "../_chunks/request-handler.js"; import { t as isObject } from "../_chunks/is-object.js"; import { t as HttpResponse } from "../_chunks/http-response.js"; import { i as ResponseEvent, r as HttpNetworkFrame, s as defineNetwork, t as InterceptorSource, u as NetworkSource } from "../_chunks/interceptor-source.js"; import { isNodeProcess } from "is-node-process"; import { invariant } from "outvariant"; import { FetchRequest, FetchResponse } from "@mswjs/interceptors"; import { Emitter, TypedEvent } from "rettime"; import { WebSocketInterceptor } from "@mswjs/interceptors/WebSocket"; import { until } from "until-async"; import { XMLHttpRequestInterceptor } from "@mswjs/interceptors/XMLHttpRequest"; import { FetchInterceptor } from "@mswjs/interceptors/fetch"; //#region src/browser/utils/supports.ts /** * Checks if the Service Worker API is supproted and available * in the current browsing context. */ function supportsServiceWorker() { return typeof navigator !== "undefined" && "serviceWorker" in navigator && typeof location !== "undefined" && location.protocol !== "file:"; } /** * Returns a boolean indicating whether the current browser * supports `ReadableStream` as a `Transferable` when posting * messages. */ function supportsReadableStreamTransfer() { try { const stream = new ReadableStream({ start: (controller) => controller.close() }); new MessageChannel().port1.postMessage(stream, [stream]); return true; } catch { return false; } } //#endregion //#region src/core/utils/to-response-init.ts function toResponseInit(response) { return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers.entries()) }; } //#endregion //#region src/browser/utils/get-absolute-worker-url.ts /** * Returns an absolute Service Worker URL based on the given * relative URL (known during the registration). */ function getAbsoluteWorkerUrl(workerUrl) { return new URL(workerUrl, location.href).href; } //#endregion //#region src/browser/utils/get-worker-by-registration.ts /** * Attempts to resolve a Service Worker instance from a given registration, * regardless of its state (active, installing, waiting). */ function getWorkerByRegistration(registration, absoluteWorkerUrl, findWorker) { return [ registration.active, registration.installing, registration.waiting ].filter((state) => { return state != null; }).find((worker) => { return findWorker(worker.scriptURL, absoluteWorkerUrl); }) || null; } //#endregion //#region src/browser/utils/get-worker-instance.ts /** * Returns an active Service Worker instance. * When not found, registers a new Service Worker. */ const getWorkerInstance = async (url, options = {}, findWorker) => { const absoluteWorkerUrl = getAbsoluteWorkerUrl(url); const mockRegistrations = await navigator.serviceWorker.getRegistrations().then((registrations) => registrations.filter((registration) => getWorkerByRegistration(registration, absoluteWorkerUrl, findWorker))); if (!navigator.serviceWorker.controller && mockRegistrations.length > 0) location.reload(); const [existingRegistration] = mockRegistrations; if (existingRegistration) { existingRegistration.update(); return [getWorkerByRegistration(existingRegistration, absoluteWorkerUrl, findWorker), existingRegistration]; } const [registrationError, registrationResult] = await until(async () => { const registration = await navigator.serviceWorker.register(url, options); return [getWorkerByRegistration(registration, absoluteWorkerUrl, findWorker), registration]; }); if (registrationError) { if (registrationError.message.includes("(404)")) { const scopeUrl = new URL(options?.scope || "/", location.href); throw new Error(devUtils.formatMessage(`\ Failed to register a Service Worker for scope ('${scopeUrl.href}') with script ('${absoluteWorkerUrl}'): Service Worker script does not exist at the given path. Did you forget to run "npx msw init <PUBLIC_DIR>"? Learn more about creating the Service Worker script: https://mswjs.io/docs/cli/init`)); } throw new Error(devUtils.formatMessage("Failed to register the Service Worker:\n\n%s", registrationError.message)); } return registrationResult; }; //#endregion //#region src/browser/utils/worker-channel.ts const SUPPORTS_SERVICE_WORKER = supportsServiceWorker(); var WorkerEvent = class extends TypedEvent { #workerEvent; constructor(workerEvent) { const type = workerEvent.data.type; const data = workerEvent.data.payload; /** * @note This is the only place we're mapping { type, payload } * message structure of the worker. The client references the * payload via `event.data`. */ super(type, { data }); this.#workerEvent = workerEvent; } get ports() { return this.#workerEvent.ports; } /** * Reply directly to this event using its `MessagePort`. */ postMessage(type, ...rest) { this.#workerEvent.ports[0].postMessage({ type, data: rest[0] }, { transfer: rest[1] }); } }; var WorkerChannel = class extends Emitter { #getWorker; #controller; constructor(options) { super(); invariant(SUPPORTS_SERVICE_WORKER, "Failed to open a WorkerChannel: Service Worker is not supported in this environment."); this.#getWorker = options.getWorker; this.#controller = new AbortController(); navigator.serviceWorker.addEventListener("message", async (event) => { const worker = await this.#getWorker(); if (event.source != null && event.source !== worker) return; if (event.data && isObject(event.data) && "type" in event.data) this.emit(new WorkerEvent(event)); }, { signal: this.#controller.signal }); } /** * Send data to the Service Worker controlling this client. * This triggers the `message` event listener on ServiceWorkerGlobalScope. */ postMessage(type) { invariant(SUPPORTS_SERVICE_WORKER, "Failed to post message on a WorkerChannel: the Service Worker API is unavailable in this environment. This is likely an issue with MSW. Please report it on GitHub: https://github.com/mswjs/msw/issues"); this.#getWorker().then((worker) => { worker.postMessage(type); }); } /** * Terminal teardown. Removes the `navigator.serviceWorker` message listener * and all emitter subscriptions. The channel is not usable afterwards. */ terminate() { this.#controller.abort(); this.removeAllListeners(); } }; //#endregion //#region src/browser/utils/deserialize-request.ts /** * Converts a given request received from the Service Worker * into a Fetch `Request` instance. */ function deserializeRequest(serializedRequest) { return new FetchRequest(serializedRequest.url, { ...serializedRequest }); } //#endregion //#region src/browser/utils/validate-worker-scope.ts /** * Print a warning if the given Service Worker registration has a scope * outside of the current page's location. That is to help with debugging * issues caused by the incorrectly registered Service Worker. */ function validateWorkerScope(registration) { if (!location.href.startsWith(registration.scope)) devUtils.warn(`Cannot intercept requests on this page because it's outside of the worker's scope ("${registration.scope}"). If you wish to mock API requests on this page, you must resolve this scope issue. - (Recommended) Register the worker at the root level ("/") of your application. - Set the "Service-Worker-Allowed" response header to allow out-of-scope workers.`); } //#endregion //#region src/browser/utils/should-invalidate-worker.ts function shouldInvalidateWorker(prevOptions, nextOptions) { return prevOptions.findWorker !== nextOptions.findWorker || prevOptions.serviceWorker.url !== nextOptions.serviceWorker.url || JSON.stringify(prevOptions.serviceWorker.options) !== JSON.stringify(nextOptions.serviceWorker.options); } //#endregion //#region src/browser/sources/service-worker-source.ts var ServiceWorkerSource = class ServiceWorkerSource extends NetworkSource { static #current; /** * Create a new Service Worker source or reuse an existing one. * These sources act as a singleton and only get recreated if the options change. */ static async from(options) { if (ServiceWorkerSource.#current == null) ServiceWorkerSource.#current = new ServiceWorkerSource(options); else if (shouldInvalidateWorker(ServiceWorkerSource.#current.#options, options)) { await ServiceWorkerSource.#current.terminate(); ServiceWorkerSource.#current = new ServiceWorkerSource(options); } return ServiceWorkerSource.#current; } #options; /** * @note We cannot use `WeakMap` here as request/response * identity cannot be preserved through the client-worker channel. */ #frames; #channel; #listenerController; #clientPromise; #keepAliveInterval; workerPromise; #workerResolvers; #workerState; constructor(options) { super(); invariant(supportsServiceWorker(), "Failed to use Service Worker as the network source: the Service Worker API is not supported in this environment"); this.#options = options; this.#frames = /* @__PURE__ */ new Map(); this.#workerResolvers = Promise.withResolvers(); this.workerPromise = this.#workerResolvers.promise; this.#workerState = "pending"; this.#channel = new WorkerChannel({ getWorker: () => this.workerPromise.then(([worker]) => worker) }); } async enable() { this.#channel.removeAllListeners(); this.#frames.clear(); this.#listenerController = new AbortController(); const [worker, registration] = await this.#startWorker(); if (worker.state !== "activated") { const controller = new AbortController(); const activationPromise = Promise.withResolvers(); activationPromise.promise.then(() => controller.abort()); worker.addEventListener("statechange", () => { if (worker.state === "activated") activationPromise.resolve(); }, { signal: controller.signal }); await activationPromise.promise; } this.#channel.postMessage("MOCK_ACTIVATE"); const clientConfirmationPromise = Promise.withResolvers(); this.#clientPromise = clientConfirmationPromise.promise; this.#channel.once("MOCKING_ENABLED", (event) => { clientConfirmationPromise.resolve(event.data.client); }); await clientConfirmationPromise.promise; if (!this.#options.quiet) this.#printStartMessage(); return registration; } async disable() { /** * @note Do NOT call `super.disable()` because it removes any "frame" listeners * from this network source, effectively turning it off. The Service Worker source * is a bit special since it might process in-flight requests that have been performed * after it's been disabled. */ this.#listenerController?.abort(); this.#listenerController = void 0; const closedPromise = Promise.withResolvers(); this.#channel.once("CLIENT_CLOSED", () => closedPromise.resolve()); this.#channel.postMessage("CLIENT_CLOSE"); await closedPromise.promise; /** * @note Do NOT reset `workerPromise` here. The channel must continue to * resolve the currently registered SW so that any in-flight requests the * worker forwards after `stop()` can be answered with `PASSTHROUGH` by * `#handleRequest`. `#startWorker` swaps in a fresh deferred on re-enable. */ if (!this.#options.quiet) this.#printStopMessage(); } /** * Terminal teardown. Unregisters the Service Worker, tears down the channel, * and clears timers. Called when the singleton is being replaced with one * that has different options. The instance is not usable afterwards. */ async terminate() { if (this.#keepAliveInterval != null) { clearInterval(this.#keepAliveInterval); this.#keepAliveInterval = void 0; } this.#frames.clear(); this.#channel.terminate(); this.#listenerController?.abort(); this.#listenerController = void 0; if (this.#workerState === "fulfilled") { const [, registration] = await this.workerPromise; await registration.unregister(); } if (ServiceWorkerSource.#current === this) ServiceWorkerSource.#current = void 0; } async #startWorker() { if (this.#keepAliveInterval) clearInterval(this.#keepAliveInterval); const workerUrl = this.#options.serviceWorker.url; const [worker, registration] = await getWorkerInstance(workerUrl, this.#options.serviceWorker.options, this.#options.findWorker || this.#defaultFindWorker); if (worker == null) { const missingWorkerMessage = this.#options?.findWorker ? devUtils.formatMessage(`Failed to locate the Service Worker registration using a custom "findWorker" predicate. Please ensure that the custom predicate properly locates the Service Worker registration at "%s". More details: https://mswjs.io/docs/api/setup-worker/start#findworker `, workerUrl) : devUtils.formatMessage(`Failed to locate the Service Worker registration. This most likely means that the worker script URL "%s" cannot resolve against the actual public hostname (%s). This may happen if your application runs behind a proxy, or has a dynamic hostname. Please consider using a custom "serviceWorker.url" option to point to the actual worker script location, or a custom "findWorker" option to resolve the Service Worker registration manually. More details: https://mswjs.io/docs/api/setup-worker/start`, workerUrl, location.host); throw new Error(missingWorkerMessage); } if (this.#workerState === "pending") { this.#workerResolvers.resolve([worker, registration]); this.#workerState = "fulfilled"; } else { /** * @note Re-enable after `stop()`: the previous `workerPromise` is already * fulfilled and cannot be resolved again. Swap in a pre-resolved one so * `getWorker()` sees the new worker instance immediately. */ this.#workerResolvers = Promise.withResolvers(); this.#workerResolvers.resolve([worker, registration]); this.workerPromise = this.#workerResolvers.promise; } this.#channel.on("REQUEST", this.#handleRequest.bind(this)); this.#channel.on("RESPONSE", this.#handleResponse.bind(this)); this.#channel.on("REQUEST_ERROR", this.#handleRequestError.bind(this)); window.addEventListener("pagehide", (event) => { if (event.persisted) return; if (worker.state !== "redundant") this.#channel.postMessage("CLIENT_CLOSE"); clearInterval(this.#keepAliveInterval); window.postMessage({ type: "msw/worker:stop" }); }, { signal: this.#listenerController?.signal }); await this.#checkWorkerIntegrity().catch((error) => { devUtils.error("Error while checking the worker script integrity. Please report this on GitHub (https://github.com/mswjs/msw/issues) and include the original error below."); console.error(error); }); this.#keepAliveInterval = window.setInterval(() => { this.#channel.postMessage("KEEPALIVE_REQUEST"); }, 5e3); if (!this.#options.quiet) validateWorkerScope(registration); return [worker, registration]; } async #handleRequest(event) { const request = deserializeRequest(event.data); RequestHandler.cache.set(request, request.clone()); const frame = new ServiceWorkerHttpNetworkFrame({ event, request }); this.#frames.set(event.data.id, frame); await this.queue(frame); } async #handleResponse(event) { const { request, response, isMockedResponse } = event.data; const frame = this.#frames.get(request.id); /** * CORS requests with `mode: "no-cors"` result in "opaque" responses. * That kind of responses cannot be manipulated in JavaScript due * to the security considerations. * @see https://fetch.spec.whatwg.org/#concept-filtered-response-opaque * @see https://github.com/mswjs/msw/issues/529 */ if (response.type?.includes("opaque")) { this.#frames.delete(request.id); frame?.events.removeAllListeners(); return; } this.#frames.delete(request.id); /** * @note A request frame will be missing in case of passthrough after the worker is stopped. * Creating a frame is costly so it's better to handle it as an edge case here. */ if (frame == null) return; const fetchRequest = deserializeRequest(request); const fetchResponse = response.status === 0 ? Response.error() : new FetchResponse( /** * Responses may be streams here, but when we create a response object * with null-body status codes, like 204, 205, 304 Response will * throw when passed a non-null body, so ensure it's null here * for those codes */ FetchResponse.isResponseWithBody(response.status) ? response.body : null, { ...response, /** * Set response URL if it's not set already. * @see https://github.com/mswjs/msw/issues/2030 * @see https://developer.mozilla.org/en-US/docs/Web/API/Response/url */ url: request.url } ); try { frame.events.emit(new ResponseEvent(isMockedResponse ? "response:mocked" : "response:bypass", { requestId: frame.data.id, request: fetchRequest, response: fetchResponse, isMockedResponse })); } finally { frame.events.removeAllListeners(); } } /** * @note A request can settle without ever producing a response * (e.g. a passthrough request failing with a network error). * The worker never sends the "RESPONSE" message for such requests, * so the frame must be released here to prevent it from being retained. * No "response:*" event is emitted, consistent with the Node.js source. * @see https://github.com/mswjs/msw/issues/2792 */ #handleRequestError(event) { const { request } = event.data; const frame = this.#frames.get(request.id); this.#frames.delete(request.id); frame?.events.removeAllListeners(); } #defaultFindWorker = (workerUrl, mockServiceWorkerUrl) => { return workerUrl === mockServiceWorkerUrl; }; async #checkWorkerIntegrity() { const integrityCheckPromise = Promise.withResolvers(); this.#channel.postMessage("INTEGRITY_CHECK_REQUEST"); this.#channel.once("INTEGRITY_CHECK_RESPONSE", (event) => { const { checksum, packageVersion } = event.data; if (checksum !== "5cd5cf8b54c3a90f82960cedcd637772") devUtils.warn(`The currently registered Service Worker has been generated by a different version of MSW (${packageVersion}) and may not be fully compatible with the installed version. It's recommended you update your worker script by running this command: \u2022 npx msw init <PUBLIC_DIR> You can also automate this process and make the worker script update automatically upon the library installations. Read more: https://mswjs.io/docs/cli/init.`); integrityCheckPromise.resolve(); }); return integrityCheckPromise.promise; } async #printStartMessage() { if (this.#workerState === "rejected") return; invariant(this.#clientPromise != null, "[ServiceWorkerSource] Failed to print a start message: client confirmation not received"); const client = await this.#clientPromise; const [worker, registration] = await this.workerPromise; console.groupCollapsed(`%c${devUtils.formatMessage("Mocking enabled.")}`, "color:orangered;font-weight:bold;"); console.log("%cDocumentation: %chttps://mswjs.io/docs", "font-weight:bold", "font-weight:normal"); console.log("Found an issue? https://github.com/mswjs/msw/issues"); console.log("Worker script URL:", worker.scriptURL); console.log("Worker scope:", registration.scope); if (client) console.log("Client ID: %s (%s)", client.id, client.frameType); console.groupEnd(); } #printStopMessage() { console.log(`%c${devUtils.formatMessage("Mocking disabled.")}`, "color:orangered;font-weight:bold;"); } }; var ServiceWorkerHttpNetworkFrame = class extends HttpNetworkFrame { #event; constructor(options) { super({ request: options.request }); this.#event = options.event; } passthrough() { this.#event.postMessage("PASSTHROUGH", { request: { headers: Array.from(this.data.request.headers) } }); } respondWith(response) { if (response) this.#respondWith(response); } errorWith(reason) { if (reason instanceof Response) return this.respondWith(reason); devUtils.warn(`Uncaught exception in the request handler for "%s %s". This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/http/mocking-responses/error-responses`, this.data.request.method, this.data.request.url); const error = reason instanceof Error ? reason : new Error(reason?.toString() || "Request failure"); this.respondWith(HttpResponse.json({ name: error.name, message: error.message, stack: error.stack }, { status: 500, statusText: "Request Handler Error" })); } async #respondWith(response) { let responseBody; let transfer; const responseInit = toResponseInit(response); if (supportsReadableStreamTransfer()) { responseBody = response.body; transfer = response.body == null ? void 0 : [response.body]; } else responseBody = response.body == null ? null : await response.clone().arrayBuffer(); this.#event.postMessage("MOCK_RESPONSE", { ...responseInit, body: responseBody }, transfer); } }; //#endregion //#region src/browser/sources/fallback-http-source.ts var FallbackHttpSource = class extends InterceptorSource { options; constructor(options) { super({ interceptors: [new XMLHttpRequestInterceptor(), new FetchInterceptor()] }); this.options = options; } enable() { super.enable(); if (!this.options.quiet) this.#printStartMessage(); } disable() { super.disable(); if (!this.options.quiet) this.#printStopMessage(); } #printStartMessage() { console.groupCollapsed(`%c${devUtils.formatMessage("Mocking enabled (fallback mode).")}`, "color:orangered;font-weight:bold;"); console.log("%cDocumentation: %chttps://mswjs.io/docs", "font-weight:bold", "font-weight:normal"); console.log("Found an issue? https://github.com/mswjs/msw/issues"); console.groupEnd(); } #printStopMessage() { console.log(`%c${devUtils.formatMessage("Mocking disabled.")}`, "color:orangered;font-weight:bold;"); } }; //#endregion //#region src/browser/setup-worker.ts const DEFAULT_WORKER_URL = "/mockServiceWorker.js"; /** * Sets up a requests interception in the browser with the given request handlers. * @param {Array<AnyHandler>} handlers List of request handlers. * * @see {@link https://mswjs.io/docs/api/setup-worker `setupWorker()` API reference} */ function setupWorker(...handlers) { invariant(!isNodeProcess(), devUtils.formatMessage("Failed to execute `setupWorker` in a non-browser environment")); const network = defineNetwork({ sources: [], handlers }); return { get readyState() { return network.readyState; }, async start(options) { invariant(network.readyState === 0, devUtils.formatMessage("Failed to call \"worker.start()\": the worker is already started. Remove the redundant \"worker.start()\" call.")); const httpSource = supportsServiceWorker() ? await ServiceWorkerSource.from({ serviceWorker: { url: options?.serviceWorker?.url?.toString() || DEFAULT_WORKER_URL, options: options?.serviceWorker?.options }, findWorker: options?.findWorker, quiet: options?.quiet }) : new FallbackHttpSource({ quiet: options?.quiet }); network.configure({ sources: [httpSource, new InterceptorSource({ interceptors: [new WebSocketInterceptor()] })], onUnhandledFrame: options?.onUnhandledFrame ?? "warn", context: { quiet: options?.quiet } }); await network.enable(); if (httpSource instanceof ServiceWorkerSource) { const [, registration] = await httpSource.workerPromise; return registration; } }, async stop() { await network.disable(); window.postMessage({ type: "msw/worker:stop" }); }, events: network.events, use: network.use.bind(network), resetHandlers: network.resetHandlers.bind(network), restoreHandlers: network.restoreHandlers.bind(network), listHandlers: network.listHandlers.bind(network) }; } //#endregion //#region src/browser/default-network-options.ts function createDefaultNetworkOptions(workerUrl = "/mockServiceWorker.js") { return { sources: [supportsServiceWorker() ? new ServiceWorkerSource({ serviceWorker: { url: workerUrl } }) : new FallbackHttpSource({}), new InterceptorSource({ interceptors: [new WebSocketInterceptor()] })], onUnhandledFrame: "warn" }; } const defaultNetworkOptions = createDefaultNetworkOptions(); //#endregion export { createDefaultNetworkOptions, defaultNetworkOptions, setupWorker }; //# sourceMappingURL=index.js.map