diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9ca01f6f..639aca987 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'pnpm' - name: Install dependencies @@ -93,7 +93,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'pnpm' - name: Restore build cache @@ -129,7 +129,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'pnpm' - name: Restore build cache @@ -195,7 +195,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'pnpm' - name: Install dependencies @@ -220,7 +220,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'pnpm' - name: Restore build cache @@ -263,7 +263,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'pnpm' - name: Restore build cache diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index d07309e78..33743229b 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Set up pnpm uses: pnpm/action-setup@v4 @@ -62,7 +62,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - uses: pnpm/action-setup@v4 with: diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml index c166314b8..eb909bd4c 100644 --- a/.github/workflows/release-preview.yml +++ b/.github/workflows/release-preview.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Set up pnpm uses: pnpm/action-setup@v4 diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index d16403597..8892f5393 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Set up pnpm uses: pnpm/action-setup@v4 diff --git a/.github/workflows/typescript-nightly.yml b/.github/workflows/typescript-nightly.yml index 48633ec5f..b921225c5 100644 --- a/.github/workflows/typescript-nightly.yml +++ b/.github/workflows/typescript-nightly.yml @@ -13,7 +13,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Get latest TypeScript version id: get-versions @@ -42,7 +42,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Set up pnpm uses: pnpm/action-setup@v4 diff --git a/.nvmrc b/.nvmrc index 9a2a0e219..53d1c14db 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20 +v22 diff --git a/config/replaceCoreImports.js b/config/replaceCoreImports.js index b86f57b55..c4dff75d4 100644 --- a/config/replaceCoreImports.js +++ b/config/replaceCoreImports.js @@ -1,5 +1,5 @@ -const CORE_ESM_IMPORT_PATTERN = /from ["'](~\/core(.*))["'](;)?/gm -const CORE_CJS_IMPORT_PATTERN = /require\(["'](~\/core(.*))["']\)(;)?/gm +const CORE_ESM_IMPORT_PATTERN = /from ["'](#core(.*))["'](;)?/gm +const CORE_CJS_IMPORT_PATTERN = /require\(["'](#core(.*))["']\)(;)?/gm function getCoreImportPattern(isEsm) { return isEsm ? CORE_ESM_IMPORT_PATTERN : CORE_CJS_IMPORT_PATTERN diff --git a/config/scripts/patch-ts.js b/config/scripts/patch-ts.js index 7c17ff477..95ada4fe3 100644 --- a/config/scripts/patch-ts.js +++ b/config/scripts/patch-ts.js @@ -29,13 +29,13 @@ async function patchTypeDefs() { if (typeDefsWithCoreImports.length === 0) { console.log( - 'Found no .d.ts modules containing the "~/core" import, skipping...', + 'Found no .d.ts modules containing the "#core" import, skipping...', ) return process.exit(0) } console.log( - 'Found %d module(s) with the "~/core" import, resolving...', + 'Found %d module(s) with the "#core" import, resolving...', typeDefsWithCoreImports.length, ) @@ -56,9 +56,9 @@ async function patchTypeDefs() { typeDefsWithCoreImports.length, ) - // Next, validate that we left no "~/core" imports unresolved. + // Next, validate that we left no "#core" imports unresolved. const result = await execAsync( - `grep "~/core" ./**/*.d.{ts,mts} -R -l || exit 0`, + `grep "#core" ./**/*.d.{ts,mts} -R -l || exit 0`, { cwd: BUILD_DIR, shell: '/bin/bash', @@ -67,7 +67,7 @@ async function patchTypeDefs() { invariant( result.stderr === '', - 'Failed to validate the .d.ts modules for the presence of the "~/core" import. See the original error below.', + 'Failed to validate the .d.ts modules for the presence of the "#core" import. See the original error below.', result.stderr, ) @@ -77,7 +77,7 @@ async function patchTypeDefs() { .filter(Boolean) console.error( - `Found .d.ts modules containing unresolved "~/core" import after the patching: + `Found .d.ts modules containing unresolved "#core" import after the patching: ${modulesWithUnresolvedImports.map((path) => ` - ${new URL(path, BUILD_DIR).pathname}`).join('\n')} `, @@ -86,7 +86,7 @@ ${modulesWithUnresolvedImports.map((path) => ` - ${new URL(path, BUILD_DIR).pat return process.exit(1) } - // Ensure that the .d.ts files compile without errors after resolving the "~/core" imports. + // Ensure that the .d.ts files compile without errors after resolving the "#core" imports. console.log('Compiling the .d.ts modules with tsc...') const tscCompilation = await execAsync( `tsc --noEmit --skipLibCheck ${typeDefsPaths.join(' ')}`, @@ -135,7 +135,7 @@ ${modulesWithUnresolvedImports.map((path) => ` - ${new URL(path, BUILD_DIR).pat } console.log( - 'The "~/core" imports resolved successfully in %d .d.ts modules! 🎉', + 'The "#core" imports resolved successfully in %d .d.ts modules! 🎉', typeDefsWithCoreImports.length, ) } diff --git a/package.json b/package.json index 3b8086a06..21790f4ef 100644 --- a/package.json +++ b/package.json @@ -159,9 +159,22 @@ "default": "./lib/core/ws.js" } }, + "./experimental": { + "import": { + "types": "./lib/core/experimental/index.d.mts", + "default": "./lib/core/experimental/index.mjs" + }, + "default": { + "types": "./lib/core/experimental/index.d.ts", + "default": "./lib/core/experimental/index.js" + } + }, "./mockServiceWorker.js": "./lib/mockServiceWorker.js", "./package.json": "./package.json" }, + "imports": { + "#core": "./src/core" + }, "bin": { "msw": "cli/index.js" }, diff --git a/src/browser/setupWorker/glossary.ts b/src/browser/glossary.ts similarity index 66% rename from src/browser/setupWorker/glossary.ts rename to src/browser/glossary.ts index 54e05cd29..f2511a563 100644 --- a/src/browser/setupWorker/glossary.ts +++ b/src/browser/glossary.ts @@ -1,33 +1,13 @@ -import { Emitter } from 'strict-event-emitter' -import type { HttpRequestEventMap, Interceptor } from '@mswjs/interceptors' -import type { DeferredPromise } from '@open-draft/deferred-promise' -import { - LifeCycleEventEmitter, - LifeCycleEventsMap, - SharedOptions, -} from '~/core/sharedOptions' -import { RequestHandler } from '~/core/handlers/RequestHandler' -import type { RequiredDeep } from '~/core/typeUtils' -import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' -import type { WorkerChannel } from '../utils/workerChannel' +import type { LifeCycleEventEmitter, SharedOptions } from '#core/sharedOptions' +import type { RequiredDeep } from '#core/typeUtils' +import type { HttpNetworkFrameEventMap } from '#core/experimental/frames/http-frame' +import type { WebSocketNetworkFrameEventMap } from '#core/experimental/frames/websocket-frame' +import { AnyHandler } from '#core/experimental/handlers-controller' export interface StringifiedResponse extends ResponseInit { body: string | ArrayBuffer | ReadableStream | null } -export type SetupWorkerInternalContext = { - isMockingEnabled: boolean - workerStoppedAt?: number - startOptions: RequiredDeep - workerPromise: DeferredPromise - registration: ServiceWorkerRegistration | undefined - getRequestHandlers: () => Array - emitter: Emitter - keepAliveInterval?: number - workerChannel: WorkerChannel - fallbackInterceptor?: Interceptor -} - export type ServiceWorkerInstanceTuple = [ ServiceWorker | null, ServiceWorkerRegistration, @@ -62,6 +42,7 @@ export interface StartOptions extends SharedOptions { * Defers any network requests until the Service Worker * instance is activated. * @default true + * @deprecated */ waitUntilReady?: boolean @@ -81,6 +62,9 @@ export type StartHandler = ( export type StopHandler = () => void +/** + * @deprecated Use the `SetupWorkerApi` type instead. + */ export interface SetupWorker { /** * Registers and activates the mock Service Worker. @@ -98,11 +82,11 @@ export interface SetupWorker { /** * Prepends given request handlers to the list of existing handlers. - * @param {RequestHandler[]} handlers List of runtime request handlers. + * @param {Array} handlers List of runtime request handlers. * * @see {@link https://mswjs.io/docs/api/setup-worker/use `worker.use()` API reference} */ - use: (...handlers: Array) => void + use: (...handlers: Array) => void /** * Marks all request handlers that respond using `res.once()` as unused. @@ -113,20 +97,18 @@ export interface SetupWorker { /** * Resets request handlers to the initial list given to the `setupWorker` call, or to the explicit next request handlers list, if given. - * @param {RequestHandler[]} nextHandlers List of the new initial request handlers. + * @param {Array} nextHandlers List of the new initial request handlers. * * @see {@link https://mswjs.io/docs/api/setup-worker/reset-handlers `worker.resetHandlers()` API reference} */ - resetHandlers: ( - ...nextHandlers: Array - ) => void + resetHandlers: (...nextHandlers: Array) => void /** * Returns a readonly list of currently active request handlers. * * @see {@link https://mswjs.io/docs/api/setup-worker/list-handlers `worker.listHandlers()` API reference} */ - listHandlers(): ReadonlyArray + listHandlers: () => ReadonlyArray /** * Life-cycle events. @@ -134,5 +116,7 @@ export interface SetupWorker { * * @see {@link https://mswjs.io/docs/api/life-cycle-events Life-cycle Events API reference} */ - events: LifeCycleEventEmitter + events: LifeCycleEventEmitter< + HttpNetworkFrameEventMap | WebSocketNetworkFrameEventMap + > } diff --git a/src/browser/index.ts b/src/browser/index.ts index 0eafbe76f..79cd66359 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -1,3 +1,2 @@ -export { setupWorker } from './setupWorker/setupWorker' -export type { SetupWorker, StartOptions } from './setupWorker/glossary' -export { SetupWorkerApi } from './setupWorker/setupWorker' +export { setupWorker, type SetupWorkerApi } from './setup-worker' +export type { SetupWorker, StartOptions } from './glossary' diff --git a/src/browser/setupWorker/setupWorker.node.test.ts b/src/browser/setup-worker.node.test.ts similarity index 69% rename from src/browser/setupWorker/setupWorker.node.test.ts rename to src/browser/setup-worker.node.test.ts index 6c3f0fb42..85135ecae 100644 --- a/src/browser/setupWorker/setupWorker.node.test.ts +++ b/src/browser/setup-worker.node.test.ts @@ -1,7 +1,5 @@ -/** - * @vitest-environment node - */ -import { setupWorker } from './setupWorker' +// @vitest-environment node +import { setupWorker } from './setup-worker' test('returns an error when run in a Node.js environment', () => { expect(setupWorker).toThrow( diff --git a/src/browser/setup-worker.ts b/src/browser/setup-worker.ts new file mode 100644 index 000000000..bb96a0121 --- /dev/null +++ b/src/browser/setup-worker.ts @@ -0,0 +1,142 @@ +import { invariant } from 'outvariant' +import { isNodeProcess } from 'is-node-process' +import { WebSocketInterceptor } from '@mswjs/interceptors/WebSocket' +import { + defineNetwork, + NetworkHandlersApi, +} from '#core/experimental/define-network' +import { type AnyHandler } from '#core/experimental/handlers-controller' +import { InterceptorSource } from '#core/experimental/sources/interceptor-source' +import { type UnhandledRequestStrategy } from '#core/utils/request/onUnhandledRequest' +import { fromLegacyOnUnhandledRequest } from '#core/experimental/compat' +import type { LifeCycleEventEmitter } from '#core/sharedOptions' +import type { HttpNetworkFrameEventMap } from '#core/experimental/frames/http-frame' +import type { WebSocketNetworkFrameEventMap } from '#core/experimental/frames/websocket-frame' +import { devUtils } from '#core/utils/internal/devUtils' +import { supportsServiceWorker } from './utils/supports' +import { ServiceWorkerSource } from './sources/service-worker-source' +import { FallbackHttpSource } from './sources/fallback-http-source' +import type { FindWorker, StartReturnType } from './glossary' + +export interface SetupWorkerApi extends NetworkHandlersApi { + start: (options?: SetupWorkerStartOptions) => StartReturnType + stop: () => void + events: LifeCycleEventEmitter< + HttpNetworkFrameEventMap | WebSocketNetworkFrameEventMap + > +} + +interface SetupWorkerStartOptions { + quiet?: boolean + serviceWorker?: { + url?: string | URL + options?: RegistrationOptions + } + findWorker?: FindWorker + onUnhandledRequest?: UnhandledRequestStrategy + + /** + * @deprecated + * Please use a proper browser integration instead. + * @see https://mswjs.io/docs/integrations/browser + */ + waitUntilReady?: boolean +} + +const DEFAULT_WORKER_URL = '/mockServiceWorker.js' + +/** + * Sets up a requests interception in the browser with the given request handlers. + * @param {Array} handlers List of request handlers. + * + * @see {@link https://mswjs.io/docs/api/setup-worker `setupWorker()` API reference} + */ +export function setupWorker(...handlers: Array): SetupWorkerApi { + invariant( + !isNodeProcess(), + devUtils.formatMessage( + 'Failed to execute `setupWorker` in a non-browser environment', + ), + ) + + const network = defineNetwork< + Array + >({ + sources: [], + handlers, + }) + + let isStarted = false + + return { + async start(options) { + if (options?.waitUntilReady != null) { + devUtils.warn( + `The "waitUntilReady" option has been deprecated. Please remove it from this "worker.start()" call. Follow the recommended Browser integration (https://mswjs.io/docs/integrations/browser) to eliminate any race conditions between the Service Worker registration and any requests made by your application on initial render.`, + ) + } + + /** + * @todo @fixme + * This is kept for backward-compatibility reasons. We don't really need this check anymore. + */ + if (isStarted) { + devUtils.warn( + 'Found a redundant "worker.start()" call. Note that starting the worker while mocking is already enabled will have no effect. Consider removing this "worker.start()" call.', + ) + return + } + + const httpSource = supportsServiceWorker() + ? new ServiceWorkerSource({ + 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() as any], + }), + ], + onUnhandledFrame: fromLegacyOnUnhandledRequest(() => { + return options?.onUnhandledRequest || 'warn' + }), + context: { + quiet: options?.quiet, + }, + }) + + await network.enable() + isStarted = true + + if (httpSource instanceof ServiceWorkerSource) { + const [, registration] = await httpSource.workerPromise + return registration + } + }, + stop() { + if (!isStarted) { + return + } + + network.disable().then(() => { + 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), + } +} diff --git a/src/browser/setupWorker/setupWorker.ts b/src/browser/setupWorker/setupWorker.ts deleted file mode 100644 index 66748eb26..000000000 --- a/src/browser/setupWorker/setupWorker.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { invariant } from 'outvariant' -import { isNodeProcess } from 'is-node-process' -import { DeferredPromise } from '@open-draft/deferred-promise' -import type { - SetupWorkerInternalContext, - StartReturnType, - StartOptions, - SetupWorker, -} from './glossary' -import { RequestHandler } from '~/core/handlers/RequestHandler' -import { DEFAULT_START_OPTIONS } from './start/utils/prepareStartHandler' -import { createStartHandler } from './start/createStartHandler' -import { devUtils } from '~/core/utils/internal/devUtils' -import { SetupApi } from '~/core/SetupApi' -import { mergeRight } from '~/core/utils/internal/mergeRight' -import type { LifeCycleEventsMap } from '~/core/sharedOptions' -import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' -import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' -import { handleWebSocketEvent } from '~/core/ws/handleWebSocketEvent' -import { attachWebSocketLogger } from '~/core/ws/utils/attachWebSocketLogger' -import { WorkerChannel } from '../utils/workerChannel' -import { createFallbackRequestListener } from './start/createFallbackRequestListener' -import { printStartMessage } from './start/utils/printStartMessage' -import { printStopMessage } from './stop/utils/printStopMessage' -import { supportsServiceWorker } from '../utils/supports' - -export class SetupWorkerApi - extends SetupApi - implements SetupWorker -{ - private context: SetupWorkerInternalContext - - constructor(...handlers: Array) { - super(...handlers) - - invariant( - !isNodeProcess(), - devUtils.formatMessage( - 'Failed to execute `setupWorker` in a non-browser environment. Consider using `setupServer` for Node.js environment instead.', - ), - ) - - this.context = this.createWorkerContext() - } - - private createWorkerContext(): SetupWorkerInternalContext { - const workerPromise = new DeferredPromise() - - return { - // Mocking is not considered enabled until the worker - // signals back the successful activation event. - isMockingEnabled: false, - startOptions: null as any, - workerPromise, - registration: undefined, - getRequestHandlers: () => { - return this.handlersController.currentHandlers() - }, - emitter: this.emitter, - workerChannel: new WorkerChannel({ - worker: workerPromise, - }), - } - } - - public async start(options: StartOptions = {}): StartReturnType { - if ('waitUntilReady' in options) { - devUtils.warn( - 'The "waitUntilReady" option has been deprecated. Please remove it from this "worker.start()" call. Follow the recommended Browser integration (https://mswjs.io/docs/integrations/browser) to eliminate any race conditions between the Service Worker registration and any requests made by your application on initial render.', - ) - } - - // Warn the developer on multiple "worker.start()" calls. - // While this will not affect the worker in any way, - // it likely indicates an issue with the developer's code. - if (this.context.isMockingEnabled) { - devUtils.warn( - `Found a redundant "worker.start()" call. Note that starting the worker while mocking is already enabled will have no effect. Consider removing this "worker.start()" call.`, - ) - return this.context.registration - } - - this.context.workerStoppedAt = undefined - - this.context.startOptions = mergeRight( - DEFAULT_START_OPTIONS, - options, - ) as SetupWorkerInternalContext['startOptions'] - - // Enable the WebSocket interception. - handleWebSocketEvent({ - getUnhandledRequestStrategy: () => { - return this.context.startOptions.onUnhandledRequest - }, - getHandlers: () => { - return this.handlersController.currentHandlers() - }, - onMockedConnection: (connection) => { - if (!this.context.startOptions.quiet) { - // Attach the logger for mocked connections since - // those won't be visible in the browser's devtools. - attachWebSocketLogger(connection) - } - }, - onPassthroughConnection() {}, - }) - webSocketInterceptor.apply() - - this.subscriptions.push(() => { - webSocketInterceptor.dispose() - }) - - // Use a fallback interception algorithm in the environments - // where the Service Worker API isn't supported. - if (!supportsServiceWorker()) { - const fallbackInterceptor = createFallbackRequestListener( - this.context, - this.context.startOptions, - ) - - this.subscriptions.push(() => { - fallbackInterceptor.dispose() - }) - - this.context.isMockingEnabled = true - - printStartMessage({ - message: 'Mocking enabled (fallback mode).', - quiet: this.context.startOptions.quiet, - }) - - return undefined - } - - const startHandler = createStartHandler(this.context) - const registration = await startHandler(this.context.startOptions, options) - - this.context.isMockingEnabled = true - - return registration - } - - public stop(): void { - super.dispose() - - if (!this.context.isMockingEnabled) { - devUtils.warn( - 'Found a redundant "worker.stop()" call. Notice that stopping the worker after it has already been stopped has no effect. Consider removing this "worker.stop()" call.', - ) - return - } - - this.context.isMockingEnabled = false - this.context.workerStoppedAt = Date.now() - this.context.emitter.removeAllListeners() - - if (supportsServiceWorker()) { - this.context.workerChannel.removeAllListeners('RESPONSE') - window.clearInterval(this.context.keepAliveInterval) - } - - // Post the internal stop message on the window - // to let any logic know when the worker has stopped. - // E.g. the WebSocket client manager needs this to know - // when to clear its in-memory clients list. - window.postMessage({ type: 'msw/worker:stop' }) - - printStopMessage({ - quiet: this.context.startOptions?.quiet, - }) - } -} - -/** - * Sets up a requests interception in the browser with the given request handlers. - * @param {RequestHandler[]} handlers List of request handlers. - * - * @see {@link https://mswjs.io/docs/api/setup-worker `setupWorker()` API reference} - */ -export function setupWorker( - ...handlers: Array -): SetupWorker { - return new SetupWorkerApi(...handlers) -} diff --git a/src/browser/setupWorker/start/createFallbackRequestListener.ts b/src/browser/setupWorker/start/createFallbackRequestListener.ts deleted file mode 100644 index 6d5b6915e..000000000 --- a/src/browser/setupWorker/start/createFallbackRequestListener.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - Interceptor, - BatchInterceptor, - HttpRequestEventMap, -} from '@mswjs/interceptors' -import { FetchInterceptor } from '@mswjs/interceptors/fetch' -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' -import { SetupWorkerInternalContext, StartOptions } from '../glossary' -import type { RequiredDeep } from '~/core/typeUtils' -import { handleRequest } from '~/core/utils/handleRequest' -import { isHandlerKind } from '~/core/utils/internal/isHandlerKind' - -export function createFallbackRequestListener( - context: SetupWorkerInternalContext, - options: RequiredDeep, -): Interceptor { - const interceptor = new BatchInterceptor({ - name: 'fallback', - interceptors: [new FetchInterceptor(), new XMLHttpRequestInterceptor()], - }) - - interceptor.on('request', async ({ request, requestId, controller }) => { - const requestCloneForLogs = request.clone() - - const response = await handleRequest( - request, - requestId, - context.getRequestHandlers().filter(isHandlerKind('RequestHandler')), - options, - context.emitter, - { - resolutionContext: { - quiet: options.quiet, - }, - onMockedResponse(_, { handler, parsedResult }) { - if (!options.quiet) { - context.emitter.once('response:mocked', ({ response }) => { - handler.log({ - request: requestCloneForLogs, - response, - parsedResult, - }) - }) - } - }, - }, - ) - - if (response) { - controller.respondWith(response) - } - }) - - interceptor.on( - 'response', - ({ response, isMockedResponse, request, requestId }) => { - context.emitter.emit( - isMockedResponse ? 'response:mocked' : 'response:bypass', - { - response, - request, - requestId, - }, - ) - }, - ) - - interceptor.apply() - - return interceptor -} diff --git a/src/browser/setupWorker/start/createRequestListener.ts b/src/browser/setupWorker/start/createRequestListener.ts deleted file mode 100644 index 9f18b5e08..000000000 --- a/src/browser/setupWorker/start/createRequestListener.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Emitter } from 'rettime' -import { StartOptions, SetupWorkerInternalContext } from '../glossary' -import { deserializeRequest } from '../../utils/deserializeRequest' -import { supportsReadableStreamTransfer } from '../../utils/supports' -import { RequestHandler } from '~/core/handlers/RequestHandler' -import { handleRequest } from '~/core/utils/handleRequest' -import { RequiredDeep } from '~/core/typeUtils' -import { devUtils } from '~/core/utils/internal/devUtils' -import { toResponseInit } from '~/core/utils/toResponseInit' -import { isHandlerKind } from '~/core/utils/internal/isHandlerKind' - -const SUPPORTS_READABLE_STREAM_TRANSFER = supportsReadableStreamTransfer() - -export const createRequestListener = ( - context: SetupWorkerInternalContext, - options: RequiredDeep, -): Emitter.ListenerType => { - return async (event) => { - // Treat any incoming requests from the worker as passthrough - // if `worker.stop()` has been called for this client. - if ( - !context.isMockingEnabled && - context.workerStoppedAt && - event.data.interceptedAt > context.workerStoppedAt - ) { - event.postMessage('PASSTHROUGH') - return - } - - const requestId = event.data.id - const request = deserializeRequest(event.data) - const requestCloneForLogs = request.clone() - - // Make this the first request clone before the - // request resolution pipeline even starts. - // Store the clone in cache so the first matching - // request handler would skip the cloning phase. - const requestClone = request.clone() - RequestHandler.cache.set(request, requestClone) - - try { - await handleRequest( - request, - requestId, - context.getRequestHandlers().filter(isHandlerKind('RequestHandler')), - options, - context.emitter, - { - resolutionContext: { - quiet: options.quiet, - }, - onPassthroughResponse() { - event.postMessage('PASSTHROUGH') - }, - async onMockedResponse(response, { handler, parsedResult }) { - // Clone the mocked response so its body could be read - // to buffer to be sent to the worker and also in the - // ".log()" method of the request handler. - const responseClone = response.clone() - const responseCloneForLogs = response.clone() - const responseInit = toResponseInit(response) - - /** - * @note Safari doesn't support transferring a "ReadableStream". - * Check that the browser supports that before sending it to the worker. - */ - if (SUPPORTS_READABLE_STREAM_TRANSFER) { - const responseStreamOrNull = response.body - - event.postMessage( - 'MOCK_RESPONSE', - { - ...responseInit, - body: responseStreamOrNull, - }, - responseStreamOrNull ? [responseStreamOrNull] : undefined, - ) - } else { - /** - * @note If we are here, this means the current environment doesn't - * support "ReadableStream" as transferable. In that case, - * attempt to read the non-empty response body as ArrayBuffer, if it's not empty. - * @see https://github.com/mswjs/msw/issues/1827 - */ - const responseBufferOrNull = - response.body === null - ? null - : await responseClone.arrayBuffer() - - event.postMessage('MOCK_RESPONSE', { - ...responseInit, - body: responseBufferOrNull, - }) - } - - if (!options.quiet) { - context.emitter.once('response:mocked', () => { - handler.log({ - request: requestCloneForLogs, - response: responseCloneForLogs, - parsedResult, - }) - }) - } - }, - }, - ) - } catch (error) { - if (error instanceof Error) { - devUtils.error( - `Uncaught exception in the request handler for "%s %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`, - request.method, - request.url, - error.stack ?? error, - ) - - // Treat all other exceptions in a request handler as unintended, - // alerting that there is a problem that needs fixing. - event.postMessage('MOCK_RESPONSE', { - status: 500, - statusText: 'Request Handler Error', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: error.name, - message: error.message, - stack: error.stack, - }), - }) - } - } - } -} diff --git a/src/browser/setupWorker/start/createResponseListener.ts b/src/browser/setupWorker/start/createResponseListener.ts deleted file mode 100644 index 8c52dd366..000000000 --- a/src/browser/setupWorker/start/createResponseListener.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { FetchResponse } from '@mswjs/interceptors' -import type { Emitter } from 'rettime' -import type { SetupWorkerInternalContext } from '../glossary' -import { deserializeRequest } from '../../utils/deserializeRequest' - -export function createResponseListener( - context: SetupWorkerInternalContext, -): Emitter.ListenerType { - return (event) => { - const responseMessage = event.data - const request = deserializeRequest(responseMessage.request) - - /** - * 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 (responseMessage.response.type?.includes('opaque')) { - return - } - - const response = - responseMessage.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(responseMessage.response.status) - ? responseMessage.response.body - : null, - { - ...responseMessage.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, - }, - ) - - context.emitter.emit( - responseMessage.isMockedResponse ? 'response:mocked' : 'response:bypass', - { - requestId: responseMessage.request.id, - request, - response, - }, - ) - } -} diff --git a/src/browser/setupWorker/start/createStartHandler.ts b/src/browser/setupWorker/start/createStartHandler.ts deleted file mode 100644 index 8ea497cb7..000000000 --- a/src/browser/setupWorker/start/createStartHandler.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { devUtils } from '~/core/utils/internal/devUtils' -import { getWorkerInstance } from './utils/getWorkerInstance' -import { enableMocking } from './utils/enableMocking' -import type { SetupWorkerInternalContext, StartHandler } from '../glossary' -import { createRequestListener } from './createRequestListener' -import { checkWorkerIntegrity } from '../../utils/checkWorkerIntegrity' -import { createResponseListener } from './createResponseListener' -import { validateWorkerScope } from './utils/validateWorkerScope' -import { DeferredPromise } from '@open-draft/deferred-promise' - -export const createStartHandler = ( - context: SetupWorkerInternalContext, -): StartHandler => { - return function start(options, customOptions) { - const startWorkerInstance = async () => { - // Remove all previously existing event listeners. - // This way none of the listeners persists between Fast refresh - // of the application's code. - context.workerChannel.removeAllListeners() - - // Handle requests signaled by the worker. - context.workerChannel.on( - 'REQUEST', - createRequestListener(context, options), - ) - - // Handle responses signaled by the worker. - context.workerChannel.on('RESPONSE', createResponseListener(context)) - - const instance = await getWorkerInstance( - options.serviceWorker.url, - options.serviceWorker.options, - options.findWorker, - ) - - const [worker, registration] = instance - - if (!worker) { - const missingWorkerMessage = customOptions?.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 -`, - options.serviceWorker.url, - ) - : 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`, - options.serviceWorker.url, - location.host, - ) - - throw new Error(missingWorkerMessage) - } - - context.workerPromise.resolve(worker) - context.registration = registration - - window.addEventListener('beforeunload', () => { - if (worker.state !== 'redundant') { - // Notify the Service Worker that this client has closed. - // Internally, it's similar to disabling the mocking, only - // client close event has a handler that self-terminates - // the Service Worker when there are no open clients. - context.workerChannel.postMessage('CLIENT_CLOSED') - } - - // Make sure we're always clearing the interval - there are reports that not doing this can - // cause memory leaks in headless browser environments. - window.clearInterval(context.keepAliveInterval) - - // Notify others about this client disconnecting. - // E.g. this will purge the in-memory WebSocket clients since - // starting the worker again will assign them new IDs. - window.postMessage({ type: 'msw/worker:stop' }) - }) - - // Check if the active Service Worker has been generated - // by the currently installed version of MSW. - await checkWorkerIntegrity(context).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) - }) - - context.keepAliveInterval = window.setInterval( - () => context.workerChannel.postMessage('KEEPALIVE_REQUEST'), - 5000, - ) - - // Warn the user when loading the page that lies outside - // of the worker's scope. - validateWorkerScope(registration, context.startOptions) - - return registration - } - - const workerRegistration = startWorkerInstance().then( - async (registration) => { - const pendingInstance = registration.installing || registration.waiting - - if (pendingInstance) { - const activationPromise = new DeferredPromise() - - pendingInstance.addEventListener('statechange', () => { - if (pendingInstance.state === 'activated') { - activationPromise.resolve() - } - }) - - // Wait until the worker is activated. - // Assume the worker is already activated if there's no pending registration - // (i.e. when reloading the page after a successful activation). - await activationPromise - } - - // Print the activation message only after the worker has been activated. - await enableMocking(context, options).catch((error) => { - devUtils.error( - 'Failed to enable mocking. Please report this on GitHub (https://github.com/mswjs/msw/issues) and include the original error below.', - ) - throw error - }) - - return registration - }, - ) - - return workerRegistration - } -} diff --git a/src/browser/setupWorker/start/utils/enableMocking.ts b/src/browser/setupWorker/start/utils/enableMocking.ts deleted file mode 100644 index 649bb1812..000000000 --- a/src/browser/setupWorker/start/utils/enableMocking.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { DeferredPromise } from '@open-draft/deferred-promise' -import type { StartOptions, SetupWorkerInternalContext } from '../../glossary' -import { printStartMessage } from './printStartMessage' - -/** - * Signals the worker to enable the interception of requests. - */ -export function enableMocking( - context: SetupWorkerInternalContext, - options: StartOptions, -): Promise { - const mockingEnabledPromise = new DeferredPromise() - - context.workerChannel.postMessage('MOCK_ACTIVATE') - context.workerChannel.once('MOCKING_ENABLED', async (event) => { - context.isMockingEnabled = true - const worker = await context.workerPromise - - printStartMessage({ - quiet: options.quiet, - workerScope: context.registration?.scope, - workerUrl: worker.scriptURL, - client: event.data.client, - }) - - mockingEnabledPromise.resolve(true) - }) - - return mockingEnabledPromise -} diff --git a/src/browser/setupWorker/start/utils/prepareStartHandler.test.ts b/src/browser/setupWorker/start/utils/prepareStartHandler.test.ts deleted file mode 100644 index f2c66e8d2..000000000 --- a/src/browser/setupWorker/start/utils/prepareStartHandler.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { SetupWorkerInternalContext, StartOptions } from '../../glossary' -import { - DEFAULT_START_OPTIONS, - resolveStartOptions, - prepareStartHandler, -} from './prepareStartHandler' - -describe('resolveStartOptions', () => { - test('returns default options given no custom start options', () => { - expect(resolveStartOptions()).toEqual(DEFAULT_START_OPTIONS) - expect(resolveStartOptions(undefined)).toEqual(DEFAULT_START_OPTIONS) - expect(resolveStartOptions({})).toEqual(DEFAULT_START_OPTIONS) - }) - - test('deeply merges the default and custom start options', () => { - expect( - resolveStartOptions({ - quiet: true, - serviceWorker: { - url: './custom.js', - }, - }), - ).toEqual({ - ...DEFAULT_START_OPTIONS, - quiet: true, - serviceWorker: { - url: './custom.js', - options: null, - }, - }) - }) -}) - -describe('prepareStartHandler', () => { - test('exposes resolved start options to the generated star handler', () => { - const createStartHandler = vi.fn() - const context: SetupWorkerInternalContext = {} as any - const startHandler = prepareStartHandler(createStartHandler, context) - expect(startHandler).toBeInstanceOf(Function) - - const initialOptions: StartOptions = { - quiet: true, - serviceWorker: { - url: './custom.js', - }, - } - const resolvedOptions = resolveStartOptions(initialOptions) - startHandler(initialOptions) - - // Calls the handler creator with both resolved and initial options. - expect(createStartHandler).toHaveBeenCalledWith( - resolvedOptions, - initialOptions, - ) - - // Sets the resolved options on the internal context. - expect(context).toHaveProperty('startOptions', resolvedOptions) - }) -}) diff --git a/src/browser/setupWorker/start/utils/prepareStartHandler.ts b/src/browser/setupWorker/start/utils/prepareStartHandler.ts deleted file mode 100644 index e98fe832c..000000000 --- a/src/browser/setupWorker/start/utils/prepareStartHandler.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { RequiredDeep } from '~/core/typeUtils' -import { mergeRight } from '~/core/utils/internal/mergeRight' -import { - SetupWorker, - SetupWorkerInternalContext, - StartHandler, - StartOptions, -} from '../../glossary' - -export const DEFAULT_START_OPTIONS: RequiredDeep = { - serviceWorker: { - url: '/mockServiceWorker.js', - options: null as any, - }, - quiet: false, - waitUntilReady: true, - onUnhandledRequest: 'warn', - findWorker(scriptURL, mockServiceWorkerUrl) { - return scriptURL === mockServiceWorkerUrl - }, -} - -/** - * Returns resolved worker start options, merging the default options - * with the given custom options. - */ -export function resolveStartOptions( - initialOptions?: StartOptions, -): RequiredDeep { - return mergeRight( - DEFAULT_START_OPTIONS, - initialOptions || {}, - ) as RequiredDeep -} - -export function prepareStartHandler( - handler: StartHandler, - context: SetupWorkerInternalContext, -): SetupWorker['start'] { - return (initialOptions) => { - context.startOptions = resolveStartOptions(initialOptions) - return handler(context.startOptions, initialOptions || {}) - } -} diff --git a/src/browser/setupWorker/start/utils/printStartMessage.test.ts b/src/browser/setupWorker/start/utils/printStartMessage.test.ts deleted file mode 100644 index 9098b1591..000000000 --- a/src/browser/setupWorker/start/utils/printStartMessage.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { printStartMessage } from './printStartMessage' - -beforeEach(() => { - vi.spyOn(console, 'groupCollapsed').mockImplementation(() => void 0) - vi.spyOn(console, 'log').mockImplementation(() => void 0) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -test('prints out a default start message into console', () => { - printStartMessage({ - workerScope: 'http://localhost:3000/', - workerUrl: 'http://localhost:3000/worker.js', - }) - - expect(console.groupCollapsed).toHaveBeenCalledWith( - '%c[MSW] Mocking enabled.', - expect.anything(), - ) - - // Includes a link to the documentation. - expect(console.log).toHaveBeenCalledWith( - '%cDocumentation: %chttps://mswjs.io/docs', - expect.anything(), - expect.anything(), - ) - - // Includes a link to the GitHub issues page. - expect(console.log).toHaveBeenCalledWith( - 'Found an issue? https://github.com/mswjs/msw/issues', - ) - - // Includes service worker scope. - expect(console.log).toHaveBeenCalledWith( - 'Worker scope:', - 'http://localhost:3000/', - ) - - // Includes service worker script location. - expect(console.log).toHaveBeenCalledWith( - 'Worker script URL:', - 'http://localhost:3000/worker.js', - ) -}) - -test('supports printing a custom start message', () => { - printStartMessage({ message: 'Custom start message' }) - - expect(console.groupCollapsed).toHaveBeenCalledWith( - '%c[MSW] Custom start message', - expect.anything(), - ) -}) - -test('does not print any messages when log level is quiet', () => { - printStartMessage({ quiet: true }) - - expect(console.groupCollapsed).not.toHaveBeenCalled() - expect(console.log).not.toHaveBeenCalled() -}) - -test('prints a worker scope in the start message', () => { - printStartMessage({ - workerScope: 'http://localhost:3000/user', - }) - - expect(console.log).toHaveBeenCalledWith( - 'Worker scope:', - 'http://localhost:3000/user', - ) -}) - -test('prints a worker script url in the start message', () => { - printStartMessage({ - workerUrl: 'http://localhost:3000/mockServiceWorker.js', - }) - - expect(console.log).toHaveBeenCalledWith( - 'Worker script URL:', - 'http://localhost:3000/mockServiceWorker.js', - ) -}) diff --git a/src/browser/setupWorker/start/utils/printStartMessage.ts b/src/browser/setupWorker/start/utils/printStartMessage.ts deleted file mode 100644 index cdfba380b..000000000 --- a/src/browser/setupWorker/start/utils/printStartMessage.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { ServiceWorkerIncomingEventsMap } from '../../glossary' -import { devUtils } from '~/core/utils/internal/devUtils' - -interface PrintStartMessageArgs { - quiet?: boolean - message?: string - workerUrl?: string - workerScope?: string - client?: ServiceWorkerIncomingEventsMap['MOCKING_ENABLED']['client'] -} - -/** - * Prints a worker activation message in the browser's console. - */ -export function printStartMessage(args: PrintStartMessageArgs = {}) { - if (args.quiet) { - return - } - - const message = args.message || 'Mocking enabled.' - - console.groupCollapsed( - `%c${devUtils.formatMessage(message)}`, - 'color:orangered;font-weight:bold;', - ) - // eslint-disable-next-line no-console - console.log( - '%cDocumentation: %chttps://mswjs.io/docs', - 'font-weight:bold', - 'font-weight:normal', - ) - // eslint-disable-next-line no-console - console.log('Found an issue? https://github.com/mswjs/msw/issues') - - if (args.workerUrl) { - // eslint-disable-next-line no-console - console.log('Worker script URL:', args.workerUrl) - } - - if (args.workerScope) { - // eslint-disable-next-line no-console - console.log('Worker scope:', args.workerScope) - } - - if (args.client) { - // eslint-disable-next-line no-console - console.log('Client ID: %s (%s)', args.client.id, args.client.frameType) - } - - console.groupEnd() -} diff --git a/src/browser/setupWorker/start/utils/validateWorkerScope.ts b/src/browser/setupWorker/start/utils/validateWorkerScope.ts deleted file mode 100644 index 0e93412c2..000000000 --- a/src/browser/setupWorker/start/utils/validateWorkerScope.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { devUtils } from '~/core/utils/internal/devUtils' -import { StartOptions } from '../../glossary' - -export function validateWorkerScope( - registration: ServiceWorkerRegistration, - options?: StartOptions, -): void { - if (!options?.quiet && !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.\ -`, - ) - } -} diff --git a/src/browser/setupWorker/stop/utils/printStopMessage.test.ts b/src/browser/setupWorker/stop/utils/printStopMessage.test.ts deleted file mode 100644 index 8e2b21416..000000000 --- a/src/browser/setupWorker/stop/utils/printStopMessage.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { printStopMessage } from './printStopMessage' - -beforeAll(() => { - vi.spyOn(global.console, 'log').mockImplementation(() => void 0) -}) - -afterEach(() => { - vi.resetAllMocks() -}) - -afterAll(() => { - vi.restoreAllMocks() -}) - -test('prints a stop message to the console', () => { - printStopMessage() - expect(console.log).toHaveBeenCalledWith( - '%c[MSW] Mocking disabled.', - 'color:orangered;font-weight:bold;', - ) -}) - -test('does not print any message when log level is quiet', () => { - printStopMessage({ quiet: true }) - expect(console.log).not.toHaveBeenCalled() -}) diff --git a/src/browser/setupWorker/stop/utils/printStopMessage.ts b/src/browser/setupWorker/stop/utils/printStopMessage.ts deleted file mode 100644 index c3c75aac3..000000000 --- a/src/browser/setupWorker/stop/utils/printStopMessage.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { devUtils } from '~/core/utils/internal/devUtils' - -export function printStopMessage(args: { quiet?: boolean } = {}): void { - if (args.quiet) { - return - } - - // eslint-disable-next-line no-console - console.log( - `%c${devUtils.formatMessage('Mocking disabled.')}`, - 'color:orangered;font-weight:bold;', - ) -} diff --git a/src/browser/sources/fallback-http-source.ts b/src/browser/sources/fallback-http-source.ts new file mode 100644 index 000000000..18e568650 --- /dev/null +++ b/src/browser/sources/fallback-http-source.ts @@ -0,0 +1,56 @@ +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { InterceptorSource } from '#core/experimental/sources/interceptor-source' +import { devUtils } from '#core/utils/internal/devUtils' + +interface FallbackHttpSourceOptions { + quiet?: boolean +} + +export class FallbackHttpSource extends InterceptorSource { + constructor(private readonly options: FallbackHttpSourceOptions) { + super({ + interceptors: [new XMLHttpRequestInterceptor(), new FetchInterceptor()], + }) + } + + public async enable(): Promise { + await super.enable() + + if (!this.options.quiet) { + this.#printStartMessage() + } + } + + public async disable(): Promise { + await super.disable() + + if (!this.options.quiet) { + this.#printStopMessage() + } + } + + #printStartMessage(): void { + console.groupCollapsed( + `%c${devUtils.formatMessage('Mocking enabled (fallback mode).')}`, + 'color:orangered;font-weight:bold;', + ) + // eslint-disable-next-line no-console + console.log( + '%cDocumentation: %chttps://mswjs.io/docs', + 'font-weight:bold', + 'font-weight:normal', + ) + // eslint-disable-next-line no-console + console.log('Found an issue? https://github.com/mswjs/msw/issues') + console.groupEnd() + } + + #printStopMessage(): void { + // eslint-disable-next-line no-console + console.log( + `%c${devUtils.formatMessage('Mocking disabled.')}`, + 'color:orangered;font-weight:bold;', + ) + } +} diff --git a/src/browser/sources/service-worker-source.ts b/src/browser/sources/service-worker-source.ts new file mode 100644 index 000000000..789fc5cd7 --- /dev/null +++ b/src/browser/sources/service-worker-source.ts @@ -0,0 +1,451 @@ +import { invariant } from 'outvariant' +import { Emitter } from 'rettime' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { FetchResponse } from '@mswjs/interceptors' +import { NetworkSource } from '#core/experimental/sources/network-source' +import { RequestHandler } from '#core/handlers/RequestHandler' +import { + HttpNetworkFrame, + ResponseEvent, +} from '#core/experimental/frames/http-frame' +import { HttpResponse } from '#core/HttpResponse' +import { toResponseInit } from '#core/utils/toResponseInit' +import { devUtils } from '#core/utils/internal/devUtils' +import { + supportsReadableStreamTransfer, + supportsServiceWorker, +} from '../utils/supports' +import { getWorkerInstance } from '../utils/get-worker-instance' +import { WorkerChannel, WorkerChannelEventMap } from '../utils/workerChannel' +import { FindWorker } from '../glossary' +import { deserializeRequest } from '../utils/deserializeRequest' +import { validateWorkerScope } from '../utils/validate-worker-scope' + +export interface ServiceWorkerSourceOptions { + quiet?: boolean + serviceWorker: { + url: string + options?: RegistrationOptions + } + findWorker?: FindWorker +} + +type WorkerChannelRequestEvent = Emitter.EventType< + WorkerChannel, + 'REQUEST', + WorkerChannelEventMap +> + +type WorkerChannelResponseEvent = Emitter.EventType< + WorkerChannel, + 'RESPONSE', + WorkerChannelEventMap +> + +type WorkerChannelClient = + WorkerChannelEventMap['MOCKING_ENABLED']['data']['client'] + +export class ServiceWorkerSource extends NetworkSource { + #frames: Map + #channel: WorkerChannel + #clientPromise?: Promise + #keepAliveInterval?: number + #stoppedAt?: number + + public workerPromise: DeferredPromise< + [ServiceWorker, ServiceWorkerRegistration] + > + + constructor(private readonly options: ServiceWorkerSourceOptions) { + super() + + invariant( + supportsServiceWorker(), + 'Failed to use Service Worker as the network source: the Service Worker API is not supported in this environment', + ) + + this.#frames = new Map() + this.workerPromise = new DeferredPromise() + this.#channel = new WorkerChannel({ + worker: this.workerPromise.then(([worker]) => worker), + }) + } + + public async enable(): Promise { + this.#stoppedAt = undefined + + if (this.workerPromise.state !== 'pending') { + devUtils.warn( + 'Found a redundant "worker.start()" call. Note that starting the worker while mocking is already enabled will have no effect. Consider removing this "worker.start()" call.', + ) + + return this.workerPromise.then(([, registration]) => registration) + } + + this.#channel.removeAllListeners() + const [worker, registration] = await this.#startWorker() + + if (worker.state !== 'activated') { + const controller = new AbortController() + const activationPromise = new DeferredPromise() + activationPromise.then(() => controller.abort()) + + worker.addEventListener( + 'statechange', + () => { + if (worker.state === 'activated') { + activationPromise.resolve() + } + }, + { signal: controller.signal }, + ) + + await activationPromise + } + + this.#channel.postMessage('MOCK_ACTIVATE') + + const clientConfirmationPromise = new DeferredPromise() + this.#clientPromise = clientConfirmationPromise + + this.#channel.once('MOCKING_ENABLED', (event) => { + clientConfirmationPromise.resolve(event.data.client) + }) + await clientConfirmationPromise + + if (!this.options.quiet) { + this.#printStartMessage() + } + + return registration + } + + public async disable(): Promise { + /** + * @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. + */ + + if (typeof this.#stoppedAt !== 'undefined') { + devUtils.warn( + `Found a redundant "worker.stop()" call. Notice that stopping the worker after it has already been stopped has no effect. Consider removing this "worker.stop()" call.`, + ) + + return + } + + this.#stoppedAt = Date.now() + this.#frames.clear() + this.workerPromise = new DeferredPromise() + + if (!this.options.quiet) { + this.#printStopMessage() + } + } + + 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) + } + + this.workerPromise.resolve([worker, registration]) + + this.#channel.on('REQUEST', this.#handleRequest.bind(this)) + this.#channel.on('RESPONSE', this.#handleResponse.bind(this)) + + window.addEventListener('beforeunload', () => { + if (worker.state !== 'redundant') { + this.#channel.postMessage('CLIENT_CLOSED') + } + + clearInterval(this.#keepAliveInterval) + + window.postMessage({ type: 'msw/worker:stop' }) + }) + + 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') + }, 5000) + + if (!this.options.quiet) { + validateWorkerScope(registration) + } + + return [worker, registration] as const + } + + async #handleRequest(event: WorkerChannelRequestEvent): Promise { + if (this.#stoppedAt && event.data.interceptedAt > this.#stoppedAt) { + return event.postMessage('PASSTHROUGH') + } + + 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: WorkerChannelResponseEvent): Promise { + const { request, response, isMockedResponse } = event.data + + /** + * 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')) { + return + } + + const frame = this.#frames.get(request.id) + 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, + }, + ) + + frame.events.emit( + new ResponseEvent( + isMockedResponse ? 'response:mocked' : 'response:bypass', + { + requestId: frame.data.id, + request: fetchRequest, + response: fetchResponse, + isMockedResponse, + }, + ), + ) + } + + #defaultFindWorker: FindWorker = (workerUrl, mockServiceWorkerUrl) => { + return workerUrl === mockServiceWorkerUrl + } + + async #checkWorkerIntegrity(): Promise { + const integrityCheckPromise = new DeferredPromise() + + this.#channel.postMessage('INTEGRITY_CHECK_REQUEST') + this.#channel.once('INTEGRITY_CHECK_RESPONSE', (event) => { + const { checksum, packageVersion } = event.data + + // Compare the response from the Service Worker and the + // global variable set during the build. + + // The integrity is validated based on the worker script's checksum + // that's derived from its minified content during the build. + // The "SERVICE_WORKER_CHECKSUM" global variable is injected by the build. + if (checksum !== SERVICE_WORKER_CHECKSUM) { + 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 + +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 + } + + async #printStartMessage() { + if (this.workerPromise.state === '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;', + ) + // eslint-disable-next-line no-console + console.log( + '%cDocumentation: %chttps://mswjs.io/docs', + 'font-weight:bold', + 'font-weight:normal', + ) + // eslint-disable-next-line no-console + console.log('Found an issue? https://github.com/mswjs/msw/issues') + + // eslint-disable-next-line no-console + console.log('Worker script URL:', worker.scriptURL) + + // eslint-disable-next-line no-console + console.log('Worker scope:', registration.scope) + + if (client) { + // eslint-disable-next-line no-console + console.log('Client ID: %s (%s)', client.id, client.frameType) + } + + console.groupEnd() + } + + #printStopMessage(): void { + // eslint-disable-next-line no-console + console.log( + `%c${devUtils.formatMessage('Mocking disabled.')}`, + 'color:orangered;font-weight:bold;', + ) + } +} + +class ServiceWorkerHttpNetworkFrame extends HttpNetworkFrame { + #event: WorkerChannelRequestEvent + + constructor(options: { event: WorkerChannelRequestEvent; request: Request }) { + super({ request: options.request }) + this.#event = options.event + } + + public passthrough(): void { + this.#event.postMessage('PASSTHROUGH') + } + + public respondWith(response?: Response): void { + if (response) { + this.#respondWith(response) + } + } + + public errorWith(reason?: unknown): void { + if (reason instanceof Response) { + return this.respondWith(reason) + } + + if (reason instanceof Error) { + 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, + ) + + // Treat exceptions during the request handling as 500 responses. + // This should alert the developer that there's a problem. + this.respondWith( + HttpResponse.json( + { + name: reason.name, + message: reason.message, + stack: reason.stack, + }, + { + status: 500, + statusText: 'Request Handler Error', + }, + ), + ) + } + } + + async #respondWith(response: Response): Promise { + let responseBody: ReadableStream | ArrayBuffer | null + let transfer: [ReadableStream] | undefined + const responseInit = toResponseInit(response) + + if (supportsReadableStreamTransfer()) { + responseBody = response.body + transfer = response.body == null ? undefined : [response.body] + } else { + responseBody = + response.body == null ? null : await response.clone().arrayBuffer() + } + + this.#event.postMessage( + 'MOCK_RESPONSE', + { + ...responseInit, + body: responseBody, + }, + transfer, + ) + } +} diff --git a/src/browser/tsconfig.browser.json b/src/browser/tsconfig.browser.json index 6a44514da..9056a5805 100644 --- a/src/browser/tsconfig.browser.json +++ b/src/browser/tsconfig.browser.json @@ -1,9 +1,14 @@ { "extends": "../tsconfig.src.json", + "include": ["../../global.d.ts", "./global.browser.d.ts", "./**/*.ts"], + "references": [ + { + "path": "../tsconfig.core.json" + } + ], "compilerOptions": { // Expose browser-specific libraries only for the // source code under the "src/browser" directory. "lib": ["DOM", "WebWorker", "DOM.Iterable"] - }, - "include": ["../../global.d.ts", "./global.browser.d.ts", "./**/*.ts"] + } } diff --git a/src/browser/utils/checkWorkerIntegrity.ts b/src/browser/utils/checkWorkerIntegrity.ts deleted file mode 100644 index 47a3bfc80..000000000 --- a/src/browser/utils/checkWorkerIntegrity.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { devUtils } from '~/core/utils/internal/devUtils' -import type { SetupWorkerInternalContext } from '../setupWorker/glossary' -import { DeferredPromise } from '@open-draft/deferred-promise' - -/** - * Check whether the registered Service Worker has been - * generated by the installed version of the library. - * Prints a warning message if the worker scripts mismatch. - */ -export function checkWorkerIntegrity( - context: SetupWorkerInternalContext, -): Promise { - const integrityCheckPromise = new DeferredPromise() - - // Request the integrity checksum from the registered worker. - context.workerChannel.postMessage('INTEGRITY_CHECK_REQUEST') - context.workerChannel.once('INTEGRITY_CHECK_RESPONSE', (event) => { - const { checksum, packageVersion } = event.data - - // Compare the response from the Service Worker and the - // global variable set during the build. - - // The integrity is validated based on the worker script's checksum - // that's derived from its minified content during the build. - // The "SERVICE_WORKER_CHECKSUM" global variable is injected by the build. - if (checksum !== SERVICE_WORKER_CHECKSUM) { - 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 - -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 -} diff --git a/src/browser/utils/deserializeRequest.ts b/src/browser/utils/deserializeRequest.ts index aae0c5c72..db82d2156 100644 --- a/src/browser/utils/deserializeRequest.ts +++ b/src/browser/utils/deserializeRequest.ts @@ -1,5 +1,5 @@ import { pruneGetRequestBody } from './pruneGetRequestBody' -import type { ServiceWorkerIncomingRequest } from '../setupWorker/glossary' +import type { ServiceWorkerIncomingRequest } from '../glossary' /** * Converts a given request received from the Service Worker diff --git a/src/browser/setupWorker/start/utils/getWorkerByRegistration.ts b/src/browser/utils/get-worker-by-registration.ts similarity index 93% rename from src/browser/setupWorker/start/utils/getWorkerByRegistration.ts rename to src/browser/utils/get-worker-by-registration.ts index 9481aa5cc..62d9141b8 100644 --- a/src/browser/setupWorker/start/utils/getWorkerByRegistration.ts +++ b/src/browser/utils/get-worker-by-registration.ts @@ -1,4 +1,4 @@ -import { FindWorker } from '../../glossary' +import type { FindWorker } from '../glossary' /** * Attempts to resolve a Service Worker instance from a given registration, @@ -14,9 +14,11 @@ export function getWorkerByRegistration( registration.installing, registration.waiting, ] + const relevantStates = allStates.filter((state): state is ServiceWorker => { return state != null }) + const worker = relevantStates.find((worker) => { return findWorker(worker.scriptURL, absoluteWorkerUrl) }) diff --git a/src/browser/setupWorker/start/utils/getWorkerInstance.ts b/src/browser/utils/get-worker-instance.ts similarity index 92% rename from src/browser/setupWorker/start/utils/getWorkerInstance.ts rename to src/browser/utils/get-worker-instance.ts index 492083009..536968ab4 100644 --- a/src/browser/setupWorker/start/utils/getWorkerInstance.ts +++ b/src/browser/utils/get-worker-instance.ts @@ -1,8 +1,8 @@ import { until } from 'until-async' -import { devUtils } from '~/core/utils/internal/devUtils' -import { getAbsoluteWorkerUrl } from '../../../utils/getAbsoluteWorkerUrl' -import { getWorkerByRegistration } from './getWorkerByRegistration' -import { ServiceWorkerInstanceTuple, FindWorker } from '../../glossary' +import { devUtils } from '#core/utils/internal/devUtils' +import { getAbsoluteWorkerUrl } from './getAbsoluteWorkerUrl' +import { getWorkerByRegistration } from './get-worker-by-registration' +import type { ServiceWorkerInstanceTuple, FindWorker } from '../glossary' /** * Returns an active Service Worker instance. diff --git a/src/browser/utils/pruneGetRequestBody.test.ts b/src/browser/utils/pruneGetRequestBody.test.ts index 46de5d4f8..aed4b42ce 100644 --- a/src/browser/utils/pruneGetRequestBody.test.ts +++ b/src/browser/utils/pruneGetRequestBody.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment jsdom - */ +// @vitest-environment jsdom import { TextEncoder } from 'util' import { pruneGetRequestBody } from './pruneGetRequestBody' diff --git a/src/browser/utils/pruneGetRequestBody.ts b/src/browser/utils/pruneGetRequestBody.ts index b17602217..2354f8059 100644 --- a/src/browser/utils/pruneGetRequestBody.ts +++ b/src/browser/utils/pruneGetRequestBody.ts @@ -1,4 +1,4 @@ -import type { ServiceWorkerIncomingRequest } from '../setupWorker/glossary' +import type { ServiceWorkerIncomingRequest } from '../glossary' type Input = Pick diff --git a/src/browser/utils/validate-worker-scope.ts b/src/browser/utils/validate-worker-scope.ts new file mode 100644 index 000000000..4a964c4db --- /dev/null +++ b/src/browser/utils/validate-worker-scope.ts @@ -0,0 +1,19 @@ +import { devUtils } from '#core/utils/internal/devUtils' + +/** + * 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. + */ +export function validateWorkerScope( + registration: ServiceWorkerRegistration, +): void { + 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.`, + ) + } +} diff --git a/src/browser/utils/workerChannel.ts b/src/browser/utils/workerChannel.ts index 88cf70935..7b39f7114 100644 --- a/src/browser/utils/workerChannel.ts +++ b/src/browser/utils/workerChannel.ts @@ -1,7 +1,7 @@ import { invariant } from 'outvariant' import { Emitter, TypedEvent } from 'rettime' -import { isObject } from '~/core/utils/internal/isObject' -import type { StringifiedResponse } from '../setupWorker/glossary' +import { isObject } from '#core/utils/internal/isObject' +import type { StringifiedResponse } from '../glossary' import { supportsServiceWorker } from '../utils/supports' export interface WorkerChannelOptions { diff --git a/src/core/SetupApi.ts b/src/core/SetupApi.ts deleted file mode 100644 index e908e5994..000000000 --- a/src/core/SetupApi.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { invariant } from 'outvariant' -import { EventMap, Emitter } from 'strict-event-emitter' -import { RequestHandler } from './handlers/RequestHandler' -import { LifeCycleEventEmitter } from './sharedOptions' -import { devUtils } from './utils/internal/devUtils' -import { pipeEvents } from './utils/internal/pipeEvents' -import { toReadonlyArray } from './utils/internal/toReadonlyArray' -import { Disposable } from './utils/internal/Disposable' -import type { WebSocketHandler } from './handlers/WebSocketHandler' - -export abstract class HandlersController { - abstract prepend( - runtimeHandlers: Array, - ): void - abstract reset(nextHandles: Array): void - abstract currentHandlers(): Array -} - -export class InMemoryHandlersController implements HandlersController { - private handlers: Array - - constructor( - private initialHandlers: Array, - ) { - this.handlers = [...initialHandlers] - } - - public prepend( - runtimeHandles: Array, - ): void { - this.handlers.unshift(...runtimeHandles) - } - - public reset(nextHandlers: Array): void { - this.handlers = - nextHandlers.length > 0 ? [...nextHandlers] : [...this.initialHandlers] - } - - public currentHandlers(): Array { - return this.handlers - } -} - -/** - * Generic class for the mock API setup. - */ -export abstract class SetupApi extends Disposable { - protected handlersController: HandlersController - protected readonly emitter: Emitter - protected readonly publicEmitter: Emitter - - public readonly events: LifeCycleEventEmitter - - constructor(...initialHandlers: Array) { - super() - - invariant( - this.validateHandlers(initialHandlers), - devUtils.formatMessage( - `Failed to apply given request handlers: invalid input. Did you forget to spread the request handlers Array?`, - ), - ) - - this.handlersController = new InMemoryHandlersController(initialHandlers) - - this.emitter = new Emitter() - this.publicEmitter = new Emitter() - pipeEvents(this.emitter, this.publicEmitter) - - this.events = this.createLifeCycleEvents() - - this.subscriptions.push(() => { - this.emitter.removeAllListeners() - this.publicEmitter.removeAllListeners() - }) - } - - private validateHandlers(handlers: ReadonlyArray): boolean { - // Guard against incorrect call signature of the setup API. - return handlers.every((handler) => !Array.isArray(handler)) - } - - public use( - ...runtimeHandlers: Array - ): void { - invariant( - this.validateHandlers(runtimeHandlers), - devUtils.formatMessage( - `Failed to call "use()" with the given request handlers: invalid input. Did you forget to spread the array of request handlers?`, - ), - ) - - this.handlersController.prepend(runtimeHandlers) - } - - public restoreHandlers(): void { - this.handlersController.currentHandlers().forEach((handler) => { - if ('isUsed' in handler) { - handler.isUsed = false - } - }) - } - - public resetHandlers( - ...nextHandlers: Array - ): void { - this.handlersController.reset(nextHandlers) - } - - public listHandlers(): ReadonlyArray { - return toReadonlyArray(this.handlersController.currentHandlers()) - } - - private createLifeCycleEvents(): LifeCycleEventEmitter { - return { - on: (...args: any[]) => { - return (this.publicEmitter.on as any)(...args) - }, - removeListener: (...args: any[]) => { - return (this.publicEmitter.removeListener as any)(...args) - }, - removeAllListeners: (...args: any[]) => { - return this.publicEmitter.removeAllListeners(...args) - }, - } - } -} diff --git a/src/core/experimental/compat.ts b/src/core/experimental/compat.ts new file mode 100644 index 000000000..46e46b1ed --- /dev/null +++ b/src/core/experimental/compat.ts @@ -0,0 +1,50 @@ +/** + * Collection of helpers for briding the compatibility between the old and the new APIs. + */ +import { invariant } from 'outvariant' +import { type UnhandledRequestStrategy } from '../utils/request/onUnhandledRequest' +import { + executeUnhandledFrameHandle, + type UnhandledFrameCallback, +} from './on-unhandled-frame' +import { HttpNetworkFrame } from './frames/http-frame' +import { WebSocketNetworkFrame } from './frames/websocket-frame' + +export function fromLegacyOnUnhandledRequest( + getLegacyValue: () => UnhandledRequestStrategy | undefined, +): UnhandledFrameCallback { + return ({ frame, defaults }) => { + const legacyOnUnhandledRequestStrategy = getLegacyValue() + + if (legacyOnUnhandledRequestStrategy == null) { + return + } + + if (typeof legacyOnUnhandledRequestStrategy === 'function') { + const request = + frame instanceof HttpNetworkFrame + ? frame.data.request + : frame instanceof WebSocketNetworkFrame + ? new Request(frame.data.connection.client.url, { + headers: { + connection: 'upgrade', + upgrade: 'websocket', + }, + }) + : null + + invariant( + request != null, + 'Failed to coerce a network frame to a legacy `onUnhandledRequest` strategy: unknown frame protocol "%s"', + frame.protocol, + ) + + return legacyOnUnhandledRequestStrategy(request, { + warning: defaults.warn, + error: defaults.error, + }) + } + + return executeUnhandledFrameHandle(frame, legacyOnUnhandledRequestStrategy) + } +} diff --git a/src/core/experimental/define-network.ts b/src/core/experimental/define-network.ts new file mode 100644 index 000000000..b2506bd92 --- /dev/null +++ b/src/core/experimental/define-network.ts @@ -0,0 +1,154 @@ +import { Emitter, type DefaultEventMap } from 'rettime' +import { + type NetworkSource, + type ExtractSourceEvents, + getHandlerKindByFrame, +} from './sources/network-source' +import { type NetworkFrameResolutionContext } from './frames/network-frame' +import { type UnhandledFrameHandle } from './on-unhandled-frame' +import { + AnyHandler, + HandlersController, + InMemoryHandlersController, +} from './handlers-controller' +import { toReadonlyArray } from '../utils/internal/toReadonlyArray' + +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never + +type MergeEventMaps>> = + UnionToIntersection> extends infer R + ? R extends Record + ? R + : DefaultEventMap + : DefaultEventMap + +export interface DefineNetworkOptions< + Sources extends Array>, +> { + /** + * List of the network sources. + * Every network source emits frames, and every frame describes how + * to handle the various network scenarios, like mocking a response, + * erroring the request, or performing it as-is. + */ + sources: Sources + /** + * List of handlers to describe the network. + */ + handlers?: Array | HandlersController + context?: NetworkFrameResolutionContext + onUnhandledFrame?: UnhandledFrameHandle +} + +export interface NetworkApi>> + extends NetworkHandlersApi { + /** + * Enable the network interception and handling. + */ + enable: () => Promise + /** + * Disable the network interception and handling. + */ + disable: () => Promise + /** + * Configure the network instance with additional options. + * The options provided in the `.configure()` call will override the same + * options in the `defineNetwork()` call. + */ + configure: (options: Partial>) => void + events: Emitter> +} + +export interface NetworkHandlersApi { + use: (...handlers: Array) => void + resetHandlers: (...handlers: Array) => void + restoreHandlers: () => void + listHandlers: () => ReadonlyArray +} + +export function defineNetwork>>( + options: DefineNetworkOptions, +): NetworkApi { + const events = new Emitter>() + + const deriveHandlersController = ( + handlers: DefineNetworkOptions['handlers'], + ) => { + return handlers instanceof HandlersController + ? handlers + : new InMemoryHandlersController(handlers || []) + } + + let resolvedOptions: DefineNetworkOptions = { + ...options, + } + + /** + * @note Create the handlers controller immediately because + * certain setup API, like `setupServer`, don't await `.enable` (`.listen`). + */ + let handlersController = deriveHandlersController(resolvedOptions.handlers) + + return { + events, + configure(options) { + if ( + options.handlers && + !Object.is(options.handlers, resolvedOptions.handlers) + ) { + handlersController = deriveHandlersController(options.handlers) + } + + resolvedOptions = { + ...resolvedOptions, + ...options, + } + }, + async enable() { + await Promise.all( + resolvedOptions.sources.map(async (source) => { + source.on('frame', async ({ frame }) => { + frame.events.on('*', (event) => events.emit(event)) + + const matchingHandlers = handlersController.getHandlersByKind( + getHandlerKindByFrame(frame), + ) + + await frame.resolve( + matchingHandlers, + resolvedOptions.onUnhandledFrame || 'warn', + resolvedOptions.context, + ) + }) + + await source.enable() + }), + ) + }, + async disable() { + await Promise.all( + resolvedOptions.sources.map((source) => source.disable()), + ) + }, + use(...handlers) { + handlersController.use(handlers) + }, + resetHandlers(...handlers) { + handlersController.reset(handlers) + }, + restoreHandlers() { + for (const handler of handlersController.currentHandlers) { + if ('isUsed' in handler) { + handler.isUsed = false + } + } + }, + listHandlers() { + return toReadonlyArray(handlersController.currentHandlers) + }, + } +} diff --git a/src/core/experimental/frames/http-frame.ts b/src/core/experimental/frames/http-frame.ts new file mode 100644 index 000000000..3960ded79 --- /dev/null +++ b/src/core/experimental/frames/http-frame.ts @@ -0,0 +1,266 @@ +import { TypedEvent } from 'rettime' +import { until } from 'until-async' +import { createRequestId } from '@mswjs/interceptors' +import { NetworkFrame, NetworkFrameResolutionContext } from './network-frame' +import { toPublicUrl } from '../../utils/request/toPublicUrl' +import { type HttpHandler } from '../../handlers/HttpHandler' +import { executeHandlers } from '../../utils/executeHandlers' +import { storeResponseCookies } from '../../utils/request/storeResponseCookies' +import { isPassthroughResponse, shouldBypassRequest } from '../request-utils' +import { devUtils } from '../../utils/internal/devUtils' +import { + executeUnhandledFrameHandle, + type UnhandledFrameHandle, +} from '../on-unhandled-frame' + +interface HttpNetworkFrameOptions { + id?: string + request: Request +} + +export class RequestEvent< + DataType extends { requestId: string; request: Request } = { + requestId: string + request: Request + }, + ReturnType = void, + EventType extends string = string, +> extends TypedEvent { + public readonly requestId: string + public readonly request: Request + + constructor(type: EventType, data: DataType) { + super(...([type, {}] as any)) + this.requestId = data.requestId + this.request = data.request + } +} + +export class ResponseEvent< + DataType extends { + requestId: string + request: Request + response: Response + } = { + requestId: string + request: Request + response: Response + }, + ReturnType = void, + EventType extends string = string, +> extends TypedEvent { + public readonly requestId: string + public readonly request: Request + public readonly response: Response + + constructor(type: EventType, data: DataType) { + super(...([type, {}] as any)) + this.requestId = data.requestId + this.request = data.request + this.response = data.response + } +} + +export class UnhandledExceptionEvent< + DataType extends { + error: Error + requestId: string + request: Request + } = { + error: Error + requestId: string + request: Request + }, + ReturnType = void, + EventType extends string = string, +> extends TypedEvent { + public readonly error: Error + public readonly requestId: string + public readonly request: Request + + constructor(type: EventType, data: DataType) { + super(...([type, {}] as any)) + this.error = data.error + this.requestId = data.requestId + this.request = data.request + } +} + +export type HttpNetworkFrameEventMap = { + 'request:start': RequestEvent + 'request:match': RequestEvent + 'request:unhandled': RequestEvent + 'request:end': RequestEvent + 'response:mocked': ResponseEvent + 'response:bypass': ResponseEvent + unhandledException: UnhandledExceptionEvent +} + +export abstract class HttpNetworkFrame extends NetworkFrame< + 'http', + { + id: string + request: Request + }, + HttpNetworkFrameEventMap +> { + constructor(options: HttpNetworkFrameOptions) { + const id = options.id || createRequestId() + super('http', { id, request: options.request }) + } + + public abstract respondWith(response?: Response): void + + public async getUnhandledMessage(): Promise { + const { request } = this.data + + const url = new URL(request.url) + const publicUrl = toPublicUrl(url) + url.search + const requestBody = + request.body == null ? null : await request.clone().text() + + const details = `\n\n \u2022 ${request.method} ${publicUrl}\n\n${requestBody ? ` \u2022 Request body: ${requestBody}\n\n` : ''}` + const message = `intercepted a request without a matching request handler:${details}If you still wish to intercept this unhandled request, please create a request handler for it.\nRead more: https://mswjs.io/docs/http/intercepting-requests` + + return message + } + + public async resolve( + handlers: Array, + onUnhandledFrame: UnhandledFrameHandle, + resolutionContext?: NetworkFrameResolutionContext, + ): Promise { + const { id: requestId, request } = this.data + const requestCloneForLogs = resolutionContext?.quiet + ? null + : request.clone() + + this.events.emit(new RequestEvent('request:start', { requestId, request })) + + // Requests wrapped in explicit `bypass(request)`. + if (shouldBypassRequest(request)) { + this.events.emit(new RequestEvent('request:end', { requestId, request })) + this.passthrough() + return null + } + + const [lookupError, lookupResult] = await until(() => { + return executeHandlers({ + requestId, + request, + handlers, + resolutionContext: { + baseUrl: resolutionContext?.baseUrl?.toString(), + quiet: resolutionContext?.quiet, + }, + }) + }) + + if (lookupError != null) { + if ( + !this.events.emit( + new UnhandledExceptionEvent('unhandledException', { + error: lookupError, + requestId, + request, + }), + ) + ) { + // Surface the error to the developer since they haven't handled it. + console.error(lookupError) + devUtils.error( + 'Encountered an unhandled exception during the handler lookup for "%s %s". Please see the original error above.', + request.method, + request.url, + ) + } + + this.errorWith(lookupError) + return null + } + + // No matching handlers. + if (lookupResult == null) { + this.events.emit( + new RequestEvent('request:unhandled', { + requestId, + request, + }), + ) + + /** + * @note The unhandled frame handle must be executed during the request resolution + * since it can influence it (e.g. error the request if the "error" startegy was used). + */ + await executeUnhandledFrameHandle(this, onUnhandledFrame).then( + () => this.passthrough(), + (error) => this.errorWith(error), + ) + + this.events.emit( + new RequestEvent('request:end', { + requestId, + request, + }), + ) + + return false + } + + const { response, handler, parsedResult } = lookupResult + + // Handlers that returned no mocked response. + if (response == null) { + this.events.emit( + new RequestEvent('request:end', { + requestId, + request, + }), + ) + + this.passthrough() + return null + } + + // Handlers that returned explicit `passthrough()`. + if (isPassthroughResponse(response)) { + this.events.emit( + new RequestEvent('request:end', { + requestId, + request, + }), + ) + + this.passthrough() + return null + } + + await storeResponseCookies(request, response) + + this.events.emit( + new RequestEvent('request:match', { + requestId, + request, + }), + ) + + this.respondWith(response.clone()) + + this.events.emit( + new RequestEvent('request:end', { + requestId, + request, + }), + ) + + if (!resolutionContext?.quiet) { + handler.log({ + request: requestCloneForLogs!, + response, + parsedResult, + }) + } + + return true + } +} diff --git a/src/core/experimental/frames/network-frame.ts b/src/core/experimental/frames/network-frame.ts new file mode 100644 index 000000000..6c93e4d99 --- /dev/null +++ b/src/core/experimental/frames/network-frame.ts @@ -0,0 +1,60 @@ +import { Emitter, type DefaultEventMap } from 'rettime' +import type { AnyHandler } from '../handlers-controller' +import type { UnhandledFrameHandle } from '../on-unhandled-frame' + +export type ExtractFrameEvents = + Frame extends NetworkFrame ? Events : never + +export interface NetworkFrameResolutionContext { + baseUrl?: string | URL + quiet?: boolean +} + +/** + * The base for the network frames. Extend this abstract class + * to implement custom network frames. + */ +export abstract class NetworkFrame< + Protocol extends string, + Data, + Events extends DefaultEventMap, +> { + public events: Emitter + + constructor( + public readonly protocol: Protocol, + public readonly data: Data, + ) { + this.events = new Emitter() + } + + /** + * Resolve the current frame against the given list of handlers. + * Optionally, use a custom resolution context to control behaviors + * like `baseUrl`. + * + * Returns `true` if the frame was handled, `false` if it wasn't, and `null` + * if its handling was skipped (e.g. the frame was bypassed). + */ + public abstract resolve( + handlers: Array, + onUnhandledFrame: UnhandledFrameHandle, + resolutionContext?: NetworkFrameResolutionContext, + ): Promise + + /** + * Perform this network frame as-is. + */ + public abstract passthrough(): void + + /** + * Error the underling network frame. + * @param reason The reason for the error. + */ + public abstract errorWith(reason?: unknown): void + + /** + * Get a message to be used when this frame is unhandled. + */ + public abstract getUnhandledMessage(): Promise +} diff --git a/src/core/experimental/frames/websocket-frame.ts b/src/core/experimental/frames/websocket-frame.ts new file mode 100644 index 000000000..85bafdcdf --- /dev/null +++ b/src/core/experimental/frames/websocket-frame.ts @@ -0,0 +1,132 @@ +import { TypedEvent } from 'rettime' +import { type WebSocketConnectionData } from '@mswjs/interceptors/WebSocket' +import { + kConnect, + kAutoConnect, + type WebSocketHandler, +} from '../../handlers/WebSocketHandler' +import { + NetworkFrame, + type NetworkFrameResolutionContext, +} from './network-frame' +import { + executeUnhandledFrameHandle, + UnhandledFrameHandle, +} from '../on-unhandled-frame' + +export interface WebSocketNetworkFrameOptions { + connection: WebSocketConnectionData +} + +export type WebSocketNetworkFrameEventMap = { + connection: WebSocketConnectionEvent +} + +class WebSocketConnectionEvent< + DataType extends { + url: URL + protocols: string | Array | undefined + } = { url: URL; protocols: string | Array | undefined }, + ReturnType = void, + EventType extends string = string, +> extends TypedEvent { + public readonly url: URL + public readonly protocols: string | Array | undefined + + constructor(type: EventType, data: DataType) { + super(...([type, {}] as any)) + this.url = data.url + this.protocols = data.protocols + } +} + +export abstract class WebSocketNetworkFrame extends NetworkFrame< + 'ws', + { + connection: WebSocketConnectionData + }, + WebSocketNetworkFrameEventMap +> { + constructor(options: WebSocketNetworkFrameOptions) { + super('ws', { + connection: options.connection, + }) + } + + public async resolve( + handlers: Array, + onUnhandledFrame: UnhandledFrameHandle, + resolutionContext?: NetworkFrameResolutionContext, + ): Promise { + const { connection } = this.data + + this.events.emit( + new WebSocketConnectionEvent('connection', { + url: connection.client.url, + protocols: connection.info.protocols, + }), + ) + + // No WebSocket handlers defined. + if (handlers.length === 0) { + await executeUnhandledFrameHandle(this, onUnhandledFrame).then( + () => this.passthrough(), + (error) => this.errorWith(error), + ) + + return false + } + + let hasMatchingHandlers = false + + for (const handler of handlers) { + const handlerConnection = await handler.run(connection, { + baseUrl: resolutionContext?.baseUrl?.toString(), + /** + * @note Do not emit the "connection" event when running the handler. + * Use the run only to get the resolved connection object. + */ + [kAutoConnect]: false, + }) + + if (!handlerConnection) { + continue + } + + hasMatchingHandlers = true + + /** + * @note Attach the WebSocket logger *before* emitting the "connection" event. + * Connection event listeners may perform actions that should be reflected in the logs + * (e.g. closing the connection immediately). If the logger is attached after the connection, + * those actions cannot be properly logged. + */ + const removeLogger = !resolutionContext?.quiet + ? handler.log(connection) + : undefined + + if (!handler[kConnect](handlerConnection)) { + removeLogger?.() + } + } + + // No matching WebSocket handlers found. + if (!hasMatchingHandlers) { + await executeUnhandledFrameHandle(this, onUnhandledFrame).then( + () => this.passthrough(), + (error) => this.errorWith(error), + ) + + return false + } + + return true + } + + public async getUnhandledMessage(): Promise { + const { connection } = this.data + const details = `\n\n \u2022 ${connection.client.url}\n\n` + + return `intercepted a WebSocket connection without a matching event handler:${details}If you still wish to intercept this unhandled connection, please create an event handler for it.\nRead more: https://mswjs.io/docs/websocket` + } +} diff --git a/src/core/experimental/handlers-controller.test.ts b/src/core/experimental/handlers-controller.test.ts new file mode 100644 index 000000000..2da78ed7f --- /dev/null +++ b/src/core/experimental/handlers-controller.test.ts @@ -0,0 +1,178 @@ +import { http } from '../http' +import { graphql } from '../graphql' +import { ws } from '../ws' +import { InMemoryHandlersController } from './handlers-controller' + +describe(InMemoryHandlersController.prototype.use, () => { + it('prepends a handler to an empty controller', () => { + const controller = new InMemoryHandlersController([]) + const httpHandler = http.get('/', () => {}) + controller.use([httpHandler]) + + expect(controller.currentHandlers).toEqual([httpHandler]) + expect(controller.getHandlersByKind('request')).toEqual([httpHandler]) + }) + + it('prepends a single handler', () => { + const httpOne = http.get('/', () => {}) + const httpTwo = http.get('/', () => {}) + + const controller = new InMemoryHandlersController([httpOne]) + controller.use([httpTwo]) + + expect(controller.currentHandlers).toEqual([httpTwo, httpOne]) + expect(controller.getHandlersByKind('request')).toEqual([httpTwo, httpOne]) + }) + + it('prepends multiple handlers', () => { + const httpOne = http.get('/', () => {}) + const httpTwo = http.get('/', () => {}) + const httpThree = http.get('/', () => {}) + + const controller = new InMemoryHandlersController([httpOne]) + + controller.use([httpTwo, httpThree]) + + expect(controller.currentHandlers).toEqual([httpTwo, httpThree, httpOne]) + expect(controller.getHandlersByKind('request')).toEqual([ + httpTwo, + httpThree, + httpOne, + ]) + }) + + it('preserves order of handlers', () => { + const httpOne = http.get('/', () => {}) + const graphqlOne = graphql.query('', () => {}) + const httpTwo = http.get('/', () => {}) + + const controller = new InMemoryHandlersController([httpOne]) + controller.use([graphqlOne, httpTwo]) + + expect(controller.currentHandlers).toEqual([graphqlOne, httpTwo, httpOne]) + }) +}) + +describe(InMemoryHandlersController.prototype.reset, () => { + it('resets to the initial handlers if called with an empty list', () => { + { + const controller = new InMemoryHandlersController([]) + controller.reset([]) + expect(controller.currentHandlers).toEqual([]) + } + + { + const httpHandler = http.get('/', () => {}) + const controller = new InMemoryHandlersController([httpHandler]) + controller.reset([]) + expect(controller.currentHandlers).toEqual([httpHandler]) + } + }) + + it('replaces the initial handlers if called with a list of handlers', () => { + const httpOne = http.get('/', () => {}) + const httpTwo = http.get('/', () => {}) + const controller = new InMemoryHandlersController([httpOne]) + controller.reset([httpTwo]) + expect(controller.currentHandlers).toEqual([httpTwo]) + }) +}) + +describe(InMemoryHandlersController.prototype.getHandlersByKind, () => { + it('returns an empty array given an empty controller', () => { + const controller = new InMemoryHandlersController([]) + expect(controller.getHandlersByKind('request')).toEqual([]) + }) + + it('returns an empty array given no handlers by the given kind', () => { + expect( + new InMemoryHandlersController([ + http.get('/', () => {}), + graphql.query('', () => {}), + ]).getHandlersByKind('websocket'), + ).toEqual([]) + + expect( + new InMemoryHandlersController([ + ws.link('*').addEventListener('connection', () => {}), + ]).getHandlersByKind('request'), + ).toEqual([]) + }) + + it('returns all handlers if they all match', () => { + const httpHandler = http.get('/', () => {}) + const graphqlHandler = graphql.query('', () => {}) + const wsHandler = ws.link('*').addEventListener('connection', () => {}) + + expect( + new InMemoryHandlersController([ + httpHandler, + graphqlHandler, + ]).getHandlersByKind('request'), + ).toEqual([httpHandler, graphqlHandler]) + + expect( + new InMemoryHandlersController([wsHandler]).getHandlersByKind( + 'websocket', + ), + ).toEqual([wsHandler]) + }) + + it('returns only the matching handlers', () => { + const httpHandler = http.get('/', () => {}) + const graphqlHandler = graphql.query('', () => {}) + const wsHandler = ws.link('*').addEventListener('connection', () => {}) + + expect( + new InMemoryHandlersController([ + httpHandler, + graphqlHandler, + wsHandler, + ]).getHandlersByKind('request'), + ).toEqual([httpHandler, graphqlHandler]) + + expect( + new InMemoryHandlersController([ + httpHandler, + graphqlHandler, + wsHandler, + ]).getHandlersByKind('websocket'), + ).toEqual([wsHandler]) + }) + + it('preserves the order of returned handlers', () => { + const httpOne = http.get('/', () => {}) + const httpTwo = http.get('/', () => {}) + const httpThree = http.get('/', () => {}) + + expect( + new InMemoryHandlersController([ + httpOne, + httpTwo, + httpThree, + ]).getHandlersByKind('request'), + ).toEqual([httpOne, httpTwo, httpThree]) + + const graphqlOne = graphql.query('', () => {}) + const graphqlTwo = graphql.query('', () => {}) + const graphqlThree = graphql.query('', () => {}) + + expect( + new InMemoryHandlersController([ + graphqlOne, + graphqlTwo, + graphqlThree, + ]).getHandlersByKind('request'), + ).toEqual([graphqlOne, graphqlTwo, graphqlThree]) + + const wsOne = ws.link('*').addEventListener('connection', () => {}) + const wsTwo = ws.link('*').addEventListener('connection', () => {}) + const wsThree = ws.link('*').addEventListener('connection', () => {}) + + expect( + new InMemoryHandlersController([wsOne, wsTwo, wsThree]).getHandlersByKind( + 'websocket', + ), + ).toEqual([wsOne, wsTwo, wsThree]) + }) +}) diff --git a/src/core/experimental/handlers-controller.ts b/src/core/experimental/handlers-controller.ts new file mode 100644 index 000000000..0176b31fc --- /dev/null +++ b/src/core/experimental/handlers-controller.ts @@ -0,0 +1,145 @@ +import { invariant } from 'outvariant' +import { type HttpHandler } from '../handlers/HttpHandler' +import { type GraphQLHandler } from '../handlers/GraphQLHandler' +import { type WebSocketHandler } from '../handlers/WebSocketHandler' +import { devUtils } from '../utils/internal/devUtils' + +export type AnyHandler = HttpHandler | GraphQLHandler | WebSocketHandler +export type HandlersMap = Partial>> + +export function groupHandlersByKind(handlers: Array): HandlersMap { + const groups: HandlersMap = {} + + /** + * @note `Object.groupBy` is not implemented in Node.js v20. + */ + for (const handler of handlers) { + ;(groups[handler.kind] ||= []).push(handler) + } + + return groups +} + +export interface HandlersControllerState { + initialHandlers: HandlersMap + handlers: HandlersMap +} + +export abstract class HandlersController { + protected getInitialState( + initialHandlers: Array, + ): HandlersControllerState { + invariant( + this.#validateHandlers(initialHandlers), + devUtils.formatMessage( + '[MSW] Failed to apply given request handlers: invalid input. Did you forget to spread the request handlers Array?', + ), + ) + + const normalizedInitialHandlers = groupHandlersByKind(initialHandlers) + + return { + initialHandlers: normalizedInitialHandlers, + handlers: { ...normalizedInitialHandlers }, + } + } + + protected abstract getState(): HandlersControllerState + protected abstract setState(nextState: Partial): void + + public get currentHandlers(): Array { + return Object.values(this.getState().handlers) + .flat() + .filter((handler) => handler != null) + } + + public getHandlersByKind(kind: AnyHandler['kind']): Array { + return this.getState().handlers[kind] || [] + } + + public use(nextHandlers: Array): void { + invariant( + this.#validateHandlers(nextHandlers), + devUtils.formatMessage( + '[MSW] Failed to call "use()" with the given request handlers: invalid input. Did you forget to spread the array of request handlers?', + ), + ) + + if (nextHandlers.length === 0) { + return + } + + const { handlers } = this.getState() + + // Iterate over next handlers and prepend them to their respective lists. + // Iterate in a reverse order to the keep the order of the runtime handlers as provided. + for (let i = nextHandlers.length - 1; i >= 0; i--) { + const handler = nextHandlers[i] + handlers[handler.kind] = handlers[handler.kind] + ? [handler, ...handlers[handler.kind]!] + : [handler] + } + } + + public reset(nextHandlers: Array): void { + invariant( + nextHandlers.length > 0 ? this.#validateHandlers(nextHandlers) : true, + devUtils.formatMessage( + 'Failed to replace initial handlers during reset: invalid handlers. Did you forget to spread the handlers array?', + ), + ) + + const { initialHandlers } = this.getState() + + if (nextHandlers.length === 0) { + this.setState({ + handlers: { ...initialHandlers }, + }) + + return + } + + const normalizedNextHandlers = groupHandlersByKind(nextHandlers) + + this.setState({ + initialHandlers: + nextHandlers.length > 0 ? normalizedNextHandlers : undefined, + handlers: { ...normalizedNextHandlers }, + }) + } + + #validateHandlers(handlers: Array): boolean { + return handlers.every((handler) => !Array.isArray(handler)) + } +} + +export class InMemoryHandlersController extends HandlersController { + #handlers: HandlersMap + #initialHandlers: HandlersMap + + constructor(initialHandlers: Array) { + super() + + const initialState = this.getInitialState(initialHandlers) + + this.#initialHandlers = initialState.initialHandlers + this.#handlers = initialState.handlers + } + + protected getState(): HandlersControllerState { + return { + initialHandlers: this.#initialHandlers, + handlers: this.#handlers, + } + } + + protected setState(nextState: Partial): void { + if (nextState.initialHandlers) { + this.#initialHandlers = nextState.initialHandlers + } + + if (nextState.handlers) { + this.#handlers = nextState.handlers + } + } +} diff --git a/src/core/experimental/index.ts b/src/core/experimental/index.ts new file mode 100644 index 000000000..4efc98ea8 --- /dev/null +++ b/src/core/experimental/index.ts @@ -0,0 +1,12 @@ +export { NetworkFrame } from './frames/network-frame' +export { + HttpNetworkFrame, + type HttpNetworkFrameEventMap, +} from './frames/http-frame' +export { + WebSocketNetworkFrame, + type WebSocketNetworkFrameEventMap, +} from './frames/websocket-frame' +export { NetworkSource } from './sources/network-source' +export { InterceptorSource } from './sources/interceptor-source' +export { defineNetwork, DefineNetworkOptions } from './define-network' diff --git a/src/core/experimental/on-unhandled-frame.test.ts b/src/core/experimental/on-unhandled-frame.test.ts new file mode 100644 index 000000000..e5514adf4 --- /dev/null +++ b/src/core/experimental/on-unhandled-frame.test.ts @@ -0,0 +1,360 @@ +// @vitest-environment node +import type { + WebSocketClientConnection, + WebSocketServerConnection, +} from '@mswjs/interceptors/WebSocket' +import { HttpNetworkFrame } from './frames/http-frame' +import { WebSocketNetworkFrame } from './frames/websocket-frame' +import { + executeUnhandledFrameHandle, + UnhandledFrameCallback, +} from './on-unhandled-frame' + +beforeAll(() => { + vi.spyOn(console, 'warn').mockImplementation(() => void 0) + vi.spyOn(console, 'error').mockImplementation(() => void 0) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +afterAll(() => { + vi.restoreAllMocks() +}) + +class TestHttpFrame extends HttpNetworkFrame { + constructor(request: Request) { + super({ request }) + } + + respondWith = () => {} + errorWith = () => {} + passthrough = () => {} +} + +class TestWebSocketFrame extends WebSocketNetworkFrame { + constructor() { + super({ + connection: { + client: { + url: new URL('wss://localhost/test'), + } as WebSocketClientConnection, + server: {} as WebSocketServerConnection, + info: { protocols: [] }, + }, + }) + } + + passthrough = () => {} + errorWith = () => {} +} + +it('does not print any warnings or errors using the "bypass" strategy', async () => { + await expect( + executeUnhandledFrameHandle( + new TestHttpFrame(new Request('http://localhost/test')), + 'bypass', + ), + ).resolves.toBeUndefined() + expect(console.warn).not.toHaveBeenCalled() + expect(console.error).not.toHaveBeenCalled() +}) + +it('prints a warning for the HTTP frame using the "warn" strategy', async () => { + await expect( + executeUnhandledFrameHandle( + new TestHttpFrame(new Request('http://localhost/test')), + 'warn', + ), + ).resolves.toBeUndefined() + + expect.soft(console.warn).toHaveBeenCalledOnce() + expect(console.warn).toHaveBeenCalledWith( + `[MSW] Warning: intercepted a request without a matching request handler: + + • GET http://localhost/test + +If you still wish to intercept this unhandled request, please create a request handler for it. +Read more: https://mswjs.io/docs/http/intercepting-requests`, + ) + expect(console.error).not.toHaveBeenCalled() +}) + +it('rejects and prints an error for the HTTP frame using the "error" strategy', async () => { + await expect( + executeUnhandledFrameHandle( + new TestHttpFrame(new Request('http://localhost/test')), + 'error', + ), + ).rejects.toThrow( + `[MSW] Cannot bypass a request when using the "error" strategy for the "onUnhandledRequest" option.`, + ) + + expect.soft(console.error).toHaveBeenCalledOnce() + expect(console.error).toHaveBeenCalledWith( + `[MSW] Error: intercepted a request without a matching request handler: + + • GET http://localhost/test + +If you still wish to intercept this unhandled request, please create a request handler for it. +Read more: https://mswjs.io/docs/http/intercepting-requests`, + ) + expect(console.warn).not.toHaveBeenCalled() +}) + +it('invokes the custom callback for the HTTP frame', async () => { + const callback = vi.fn() + const frame = new TestHttpFrame(new Request('http://localhost/test')) + + await expect( + executeUnhandledFrameHandle(frame, callback), + ).resolves.toBeUndefined() + + expect(callback).toHaveBeenCalledOnce() + expect(callback).toHaveBeenCalledWith({ + defaults: { + warn: expect.any(Function), + error: expect.any(Function), + }, + frame, + }) +}) + +it('does not print anything for common asset HTTP requests', async () => { + await expect( + executeUnhandledFrameHandle( + new TestHttpFrame(new Request('http://localhost/image.png')), + 'warn', + ), + ).resolves.toBeUndefined() + + expect(console.warn).not.toHaveBeenCalled() + expect(console.error).not.toHaveBeenCalled() + + await expect( + executeUnhandledFrameHandle( + new TestHttpFrame(new Request('http://localhost/image.png')), + 'error', + ), + ).resolves.toBeUndefined() + + expect(console.warn).not.toHaveBeenCalled() + expect(console.error).not.toHaveBeenCalled() +}) + +it('delegates common asset HTTP requests handling to the custom callback', async () => { + const callback = vi.fn() + const frame = new TestHttpFrame(new Request('http://localhost/image.png')) + + await expect( + executeUnhandledFrameHandle(frame, callback), + ).resolves.toBeUndefined() + + expect(callback).toHaveBeenCalledOnce() + expect(callback).toHaveBeenCalledWith({ + defaults: { + warn: expect.any(Function), + error: expect.any(Function), + }, + frame, + }) +}) + +it('supports printing the default warning in the custom callback for the HTTP frame', async () => { + const callback = vi.fn(({ defaults }) => { + defaults.warn() + }) + const frame = new TestHttpFrame(new Request('http://localhost/test')) + + await expect( + executeUnhandledFrameHandle(frame, callback), + ).resolves.toBeUndefined() + + expect(callback).toHaveBeenCalledOnce() + expect(callback).toHaveBeenCalledWith({ + defaults: { + warn: expect.any(Function), + error: expect.any(Function), + }, + frame, + }) + expect.soft(console.warn).toHaveBeenCalledOnce() + expect(console.warn).toHaveBeenCalledWith( + `[MSW] Warning: intercepted a request without a matching request handler: + + • GET http://localhost/test + +If you still wish to intercept this unhandled request, please create a request handler for it. +Read more: https://mswjs.io/docs/http/intercepting-requests`, + ) + expect(console.error).not.toHaveBeenCalled() +}) + +it('supports printing the default error in the custom callback for the HTTP frame', async () => { + const callback = vi.fn(({ defaults }) => { + defaults.error() + }) + const frame = new TestHttpFrame(new Request('http://localhost/test')) + + await expect( + executeUnhandledFrameHandle(frame, callback), + ).resolves.toBeUndefined() + + expect(callback).toHaveBeenCalledOnce() + expect(callback).toHaveBeenCalledWith({ + defaults: { + warn: expect.any(Function), + error: expect.any(Function), + }, + frame, + }) + expect.soft(console.error).toHaveBeenCalledOnce() + expect(console.error).toHaveBeenCalledWith( + `[MSW] Error: intercepted a request without a matching request handler: + + • GET http://localhost/test + +If you still wish to intercept this unhandled request, please create a request handler for it. +Read more: https://mswjs.io/docs/http/intercepting-requests`, + ) + expect(console.warn).not.toHaveBeenCalled() +}) + +it('throws if given an unknown strategy for the HTTP frame', async () => { + await expect( + executeUnhandledFrameHandle( + new TestHttpFrame(new Request('http://localhost/test')), + // @ts-expect-error Intentionally invalid value. + 'intentionally-invalid', + ), + ).rejects.toThrow( + `[MSW] Failed to react to an unhandled network frame: unknown strategy "intentionally-invalid". Please provide one of the supported strategies ("bypass", "warn", "error") or a custom callback function as the value of the "onUnhandledRequest" option.`, + ) +}) + +it('prints a warning for the WebSocket frame using the "warn" strategy', async () => { + await expect( + executeUnhandledFrameHandle(new TestWebSocketFrame(), 'warn'), + ).resolves.toBeUndefined() + + expect.soft(console.warn).toHaveBeenCalledOnce() + expect(console.warn).toHaveBeenCalledWith( + `[MSW] Warning: intercepted a WebSocket connection without a matching event handler: + + • wss://localhost/test + +If you still wish to intercept this unhandled connection, please create an event handler for it. +Read more: https://mswjs.io/docs/websocket`, + ) + expect(console.error).not.toHaveBeenCalled() +}) + +it('rejects and prints an error for the WebSocket frame using the "error" strategy', async () => { + await expect( + executeUnhandledFrameHandle(new TestWebSocketFrame(), 'error'), + ).rejects.toThrow( + `[MSW] Cannot bypass a request when using the "error" strategy for the "onUnhandledRequest" option.`, + ) + + expect.soft(console.error).toHaveBeenCalledOnce() + expect(console.error).toHaveBeenCalledWith( + `[MSW] Error: intercepted a WebSocket connection without a matching event handler: + + • wss://localhost/test + +If you still wish to intercept this unhandled connection, please create an event handler for it. +Read more: https://mswjs.io/docs/websocket`, + ) + expect(console.warn).not.toHaveBeenCalled() +}) + +it('invokes the custom callback for the WebSocket frame', async () => { + const callback = vi.fn() + const frame = new TestWebSocketFrame() + + await expect( + executeUnhandledFrameHandle(frame, callback), + ).resolves.toBeUndefined() + + expect(callback).toHaveBeenCalledOnce() + expect(callback).toHaveBeenCalledWith({ + defaults: { + warn: expect.any(Function), + error: expect.any(Function), + }, + frame, + }) +}) + +it('throws if given an unknown strategy for the WebSocket frame', async () => { + await expect( + executeUnhandledFrameHandle( + new TestWebSocketFrame(), + // @ts-expect-error Intentionally invalid value. + 'intentionally-invalid', + ), + ).rejects.toThrow( + `[MSW] Failed to react to an unhandled network frame: unknown strategy "intentionally-invalid". Please provide one of the supported strategies ("bypass", "warn", "error") or a custom callback function as the value of the "onUnhandledRequest" option.`, + ) +}) + +it('supports printing the default warning in the custom callback for the HTTP frame', async () => { + const callback = vi.fn(({ defaults }) => { + defaults.warn() + }) + const frame = new TestWebSocketFrame() + + await expect( + executeUnhandledFrameHandle(frame, callback), + ).resolves.toBeUndefined() + + expect(callback).toHaveBeenCalledOnce() + expect(callback).toHaveBeenCalledWith({ + defaults: { + warn: expect.any(Function), + error: expect.any(Function), + }, + frame, + }) + expect.soft(console.warn).toHaveBeenCalledOnce() + expect(console.warn).toHaveBeenCalledWith( + `[MSW] Warning: intercepted a WebSocket connection without a matching event handler: + + • wss://localhost/test + +If you still wish to intercept this unhandled connection, please create an event handler for it. +Read more: https://mswjs.io/docs/websocket`, + ) + expect(console.error).not.toHaveBeenCalled() +}) + +it('supports printing the default error in the custom callback for the HTTP frame', async () => { + const callback = vi.fn(({ defaults }) => { + defaults.error() + }) + const frame = new TestWebSocketFrame() + + await expect( + executeUnhandledFrameHandle(frame, callback), + ).resolves.toBeUndefined() + + expect(callback).toHaveBeenCalledOnce() + expect(callback).toHaveBeenCalledWith({ + defaults: { + warn: expect.any(Function), + error: expect.any(Function), + }, + frame, + }) + expect.soft(console.error).toHaveBeenCalledOnce() + expect(console.error).toHaveBeenCalledWith( + `[MSW] Error: intercepted a WebSocket connection without a matching event handler: + + • wss://localhost/test + +If you still wish to intercept this unhandled connection, please create an event handler for it. +Read more: https://mswjs.io/docs/websocket`, + ) + expect(console.warn).not.toHaveBeenCalled() +}) diff --git a/src/core/experimental/on-unhandled-frame.ts b/src/core/experimental/on-unhandled-frame.ts new file mode 100644 index 000000000..15f2e2c86 --- /dev/null +++ b/src/core/experimental/on-unhandled-frame.ts @@ -0,0 +1,110 @@ +import { invariant } from 'outvariant' +import { isCommonAssetRequest } from '../isCommonAssetRequest' +import { devUtils, InternalError } from '../utils/internal/devUtils' +import { HttpNetworkFrame } from './frames/http-frame' +import { type AnyNetworkFrame } from './sources/network-source' + +export type UnhandledFrameHandle = + | UnhandledFrameStrategy + | UnhandledFrameCallback + +export type UnhandledFrameStrategy = 'bypass' | 'warn' | 'error' + +export type UnhandledFrameCallback = (args: { + frame: AnyNetworkFrame + defaults: UnhandledFrameDefaults +}) => Promise | void + +export type UnhandledFrameDefaults = { + warn: () => void + error: () => void +} + +export async function executeUnhandledFrameHandle( + frame: AnyNetworkFrame, + handle: UnhandledFrameHandle, +): Promise { + const printStrategyMessage = async ( + strategy: UnhandledFrameStrategy, + ): Promise => { + if (strategy === 'bypass') { + return + } + + const message = await frame.getUnhandledMessage() + + switch (strategy) { + case 'warn': { + return devUtils.warn('Warning: %s', message) + } + + case 'error': { + return devUtils.error('Error: %s', message) + } + } + } + + const applyStrategy = async ( + strategy: UnhandledFrameStrategy, + ): Promise => { + invariant.as( + InternalError, + strategy === 'bypass' || strategy === 'warn' || strategy === 'error', + /** + * @fixme Rename "onUnhandledRequest" to "onUnhandledFrame" in the error message + * with the next major release. + */ + devUtils.formatMessage( + 'Failed to react to an unhandled network frame: unknown strategy "%s". Please provide one of the supported strategies ("bypass", "warn", "error") or a custom callback function as the value of the "onUnhandledRequest" option.', + strategy, + ), + ) + + if (strategy === 'bypass') { + return + } + + await printStrategyMessage(strategy) + + if (strategy === 'error') { + return Promise.reject( + new InternalError( + devUtils.formatMessage( + 'Cannot bypass a request when using the "error" strategy for the "onUnhandledRequest" option.', + ), + ), + ) + } + } + + if (typeof handle === 'function') { + return handle({ + frame, + defaults: { + warn: printStrategyMessage.bind(null, 'warn'), + /** + * @note The defaults only print the corresponding messages now. + * They do not affect the frame resolution (e.g. do not error the frame). + * That is only for backward compatibility reasons. In the future, these should + * be an alias to `applyStrategy.bind(null, 'error')` instead. + */ + error: printStrategyMessage.bind(null, 'error'), + }, + }) + } + + /** + * Ignore unhandled common HTTP assets. + * @note Calling this here applies the common assets check + * only to the scenarios when `onUnhandledFrame` was set to a predefined strategy. + * When using a custom function, you need to check for common assets manually. + */ + if ( + frame instanceof HttpNetworkFrame && + isCommonAssetRequest(frame.data.request) + ) { + return + } + + return applyStrategy(handle) +} diff --git a/src/core/experimental/request-utils.ts b/src/core/experimental/request-utils.ts new file mode 100644 index 000000000..afbdad06d --- /dev/null +++ b/src/core/experimental/request-utils.ts @@ -0,0 +1,39 @@ +export const REQUEST_INTENTION_HEADER_NAME = 'x-msw-intention' + +export enum RequestIntention { + passthrough = 'passthrough', +} + +export function shouldBypassRequest(request: Request): boolean { + return !!request.headers.get('accept')?.includes('msw/passthrough') +} + +export function isPassthroughResponse(response: Response): boolean { + return ( + response.status === 302 && + response.headers.get(REQUEST_INTENTION_HEADER_NAME) === + RequestIntention.passthrough + ) +} + +/** + * Remove the internal passthrough instruction from the request's `Accept` header. + */ +export function deleteRequestPassthroughHeader(request: Request): void { + const acceptHeader = request.headers.get('accept') + + /** + * @note Remove the internal bypass request header. + * In the browser, this is done by the worker script. + * In Node.js, it has to be done here. + */ + if (acceptHeader) { + const nextAcceptHeader = acceptHeader.replace(/(,\s+)?msw\/passthrough/, '') + + if (nextAcceptHeader) { + request.headers.set('accept', nextAcceptHeader) + } else { + request.headers.delete('accept') + } + } +} diff --git a/src/core/experimental/setup-api.ts b/src/core/experimental/setup-api.ts new file mode 100644 index 000000000..2a81c9516 --- /dev/null +++ b/src/core/experimental/setup-api.ts @@ -0,0 +1,59 @@ +import { type DefaultEventMap, Emitter } from 'rettime' +import { LifeCycleEventEmitter } from '../sharedOptions' +import { + AnyHandler, + HandlersController, + InMemoryHandlersController, +} from './handlers-controller' +import { Disposable } from '../utils/internal/Disposable' +import { toReadonlyArray } from '../utils/internal/toReadonlyArray' + +/** + * Generic class for the mock API setup. + * Preserved only for backward compatibility. + * @deprecated + */ +export abstract class SetupApi< + EventMap extends DefaultEventMap, +> extends Disposable { + protected handlersController: HandlersController + protected emitter: Emitter + protected publicEmitter: Emitter + + public readonly events: LifeCycleEventEmitter + + constructor(...initialHandlers: Array) { + super() + + this.handlersController = new InMemoryHandlersController(initialHandlers) + + this.emitter = new Emitter() + this.publicEmitter = new Emitter() + this.events = this.emitter + + this.subscriptions.push(() => { + this.emitter.removeAllListeners() + this.publicEmitter.removeAllListeners() + }) + } + + public use(...runtimeHandlers: Array): void { + this.handlersController.use(runtimeHandlers) + } + + public restoreHandlers(): void { + this.handlersController.currentHandlers.forEach((handler) => { + if ('isUsed' in handler) { + handler.isUsed = false + } + }) + } + + public resetHandlers(...nextHandlers: Array): void { + this.handlersController.reset(nextHandlers) + } + + public listHandlers(): ReadonlyArray { + return toReadonlyArray(this.handlersController.currentHandlers) + } +} diff --git a/src/core/experimental/sources/interceptor-source.ts b/src/core/experimental/sources/interceptor-source.ts new file mode 100644 index 000000000..22317e00a --- /dev/null +++ b/src/core/experimental/sources/interceptor-source.ts @@ -0,0 +1,185 @@ +import { + BatchInterceptor, + Interceptor, + RequestController, + type HttpRequestEventMap, +} from '@mswjs/interceptors' +import type { + WebSocketConnectionData, + WebSocketEventMap, +} from '@mswjs/interceptors/WebSocket' +import { NetworkSource } from './network-source' +import { InternalError } from '../../utils/internal/devUtils' +import { HttpNetworkFrame, ResponseEvent } from '../frames/http-frame' +import { WebSocketNetworkFrame } from '../frames/websocket-frame' +import { deleteRequestPassthroughHeader } from '../request-utils' + +export interface InterceptorSourceOptions { + interceptors: Array> +} + +/** + * Create a network source from the given list of interceptors. + */ +export class InterceptorSource extends NetworkSource { + #interceptor: BatchInterceptor< + InterceptorSourceOptions['interceptors'], + HttpRequestEventMap | WebSocketEventMap + > + + #frames: Map + + constructor(options: InterceptorSourceOptions) { + super() + + this.#interceptor = new BatchInterceptor({ + name: 'interceptor-source', + interceptors: options.interceptors, + }) + this.#frames = new Map() + } + + public async enable(): Promise { + this.#interceptor.apply() + + /** + * @todo @fixme BatchInterceptor infers event types but not listener types. + */ + this.#interceptor + .on('request', this.#handleRequest.bind(this) as any) + .on('response', this.#handleResponse.bind(this) as any) + .on('connection', this.#handleWebSocketConnection.bind(this) as any) + } + + public async disable(): Promise { + await super.disable() + this.#interceptor.dispose() + + /** + * @todo We can also abort any pending frames here, given we implement + * the `NetworkFrame.abort()` method. + */ + this.#frames.clear() + } + + async #handleRequest({ + requestId, + request, + controller, + }: HttpRequestEventMap['request'][0]): Promise { + const httpFrame = new InterceptorHttpNetworkFrame({ + id: requestId, + request, + controller, + }) + + this.#frames.set(requestId, httpFrame) + await this.queue(httpFrame) + } + + async #handleResponse({ + requestId, + request, + response, + isMockedResponse, + }: HttpRequestEventMap['response'][0]): Promise { + const httpFrame = this.#frames.get(requestId) + this.#frames.delete(requestId) + + if (httpFrame == null) { + return + } + + queueMicrotask(() => { + httpFrame.events.emit( + new ResponseEvent( + isMockedResponse ? 'response:mocked' : 'response:bypass', + { + requestId, + request, + response, + }, + ), + ) + }) + } + + async #handleWebSocketConnection( + connection: WebSocketEventMap['connection'][0], + ): Promise { + await this.queue( + new InterceptorWebSocketNetworkFrame({ + connection, + }), + ) + } +} + +class InterceptorHttpNetworkFrame extends HttpNetworkFrame { + #controller: RequestController + + constructor(options: { + id: string + request: Request + controller: RequestController + }) { + super({ + id: options.id, + request: options.request, + }) + + this.#controller = options.controller + } + + public passthrough(): void { + deleteRequestPassthroughHeader(this.data.request) + } + + public respondWith(response?: Response): void { + if (response) { + this.#controller.respondWith(response) + } + } + + public errorWith(reason?: unknown): void { + if (reason instanceof Response) { + return this.respondWith(reason) + } + + if (reason instanceof InternalError) { + this.#controller.errorWith(reason) + } + + throw reason + } +} + +class InterceptorWebSocketNetworkFrame extends WebSocketNetworkFrame { + constructor(args: { connection: WebSocketConnectionData }) { + super({ connection: args.connection }) + } + + public errorWith(reason?: unknown): void { + if (reason instanceof Error) { + const { client } = this.data.connection + + /** + * Use `client.errorWith(reason)` in the future. + * @see https://github.com/mswjs/interceptors/issues/747 + */ + const errorEvent = new Event('error') + + Object.defineProperty(errorEvent, 'cause', { + enumerable: true, + configurable: false, + value: reason, + }) + + client.socket.dispatchEvent(errorEvent) + } + } + + public passthrough() { + this.data.connection.server.connect() + } +} diff --git a/src/core/experimental/sources/network-source.ts b/src/core/experimental/sources/network-source.ts new file mode 100644 index 000000000..78fdc83b2 --- /dev/null +++ b/src/core/experimental/sources/network-source.ts @@ -0,0 +1,80 @@ +import { InvariantError } from 'outvariant' +import { Emitter, TypedEvent } from 'rettime' +import { + type NetworkFrame, + type ExtractFrameEvents, +} from '../frames/network-frame' +import { AnyHandler } from '../handlers-controller' + +export type AnyNetworkFrame = NetworkFrame + +class NetworkFrameEvent< + DataType = void, + ReturnType = void, + EventType extends string = string, +> extends TypedEvent { + public frame: AnyNetworkFrame + + constructor(type: string, frame: AnyNetworkFrame) { + super(...([type, {}] as any)) + this.frame = frame + } +} + +type NetworkSourceEventMap = { + frame: NetworkFrameEvent +} + +export type ExtractSourceEvents = + Source extends NetworkSource ? ExtractFrameEvents : never + +export abstract class NetworkSource< + Frame extends AnyNetworkFrame = AnyNetworkFrame, +> { + protected emitter: Emitter> + + constructor() { + this.emitter = new Emitter() + } + + public abstract enable(): Promise + + public async queue(frame: Frame): Promise { + await this.emitter.emitAsPromise( + // @ts-expect-error Trouble handling a conditional type parameter. + new NetworkFrameEvent('frame', frame), + ) + } + + public on>( + type: Type, + listener: Emitter.ListenerType, + ): void { + this.emitter.on(type, listener) + } + + public async disable(): Promise { + this.emitter.removeAllListeners() + } +} + +export function getHandlerKindByFrame( + frame: AnyNetworkFrame, +): AnyHandler['kind'] { + switch (frame.protocol) { + case 'http': { + return 'request' + } + + case 'ws': { + return 'websocket' + } + + default: { + throw new InvariantError( + 'Failed to get handler kind by frame: unknown frame protocol "%s"', + frame.protocol, + ) + } + } +} diff --git a/src/core/handlers/RequestHandler.ts b/src/core/handlers/RequestHandler.ts index bc240ebbc..400c4c691 100644 --- a/src/core/handlers/RequestHandler.ts +++ b/src/core/handlers/RequestHandler.ts @@ -11,7 +11,6 @@ import { HttpResponse, DefaultUnsafeFetchResponse, } from '../HttpResponse' -import type { HandlerKind } from './common' import type { GraphQLRequestBody } from './GraphQLHandler' export type DefaultRequestMultipartBody = Record< @@ -133,14 +132,7 @@ export abstract class RequestHandler< StrictRequest >() - private readonly __kind: HandlerKind - - public info: HandlerInfo & RequestHandlerInternalInfo - /** - * Indicates whether this request handler has been used - * (its resolver has successfully executed). - */ - public isUsed: boolean + public kind = 'request' as const protected resolver: ResponseResolver private resolverIterator?: @@ -157,6 +149,14 @@ export abstract class RequestHandler< private resolverIteratorResult?: Response | HttpResponse private options?: HandlerOptions + public info: HandlerInfo & RequestHandlerInternalInfo + + /** + * Indicates whether this request handler has been used + * (its resolver has successfully executed). + */ + public isUsed: boolean + constructor(args: RequestHandlerArgs) { this.resolver = args.resolver this.options = args.options @@ -169,7 +169,6 @@ export abstract class RequestHandler< } this.isUsed = false - this.__kind = 'RequestHandler' } /** diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts index c35277958..8edca8299 100644 --- a/src/core/handlers/WebSocketHandler.ts +++ b/src/core/handlers/WebSocketHandler.ts @@ -12,7 +12,7 @@ import { matchRequestUrl, } from '../utils/matching/matchRequestUrl' import { getCallFrame } from '../utils/internal/getCallFrame' -import type { HandlerKind } from './common' +import { attachWebSocketLogger } from '../ws/utils/attachWebSocketLogger' type WebSocketHandlerParsedResult = { match: Match @@ -31,18 +31,21 @@ export interface WebSocketHandlerConnection { export interface WebSocketResolutionContext { baseUrl?: string + [kAutoConnect]?: boolean } export const kEmitter = Symbol('kEmitter') export const kSender = Symbol('kSender') +export const kConnect = Symbol('kConnect') +export const kAutoConnect = Symbol('kAutoConnect') + const kStopPropagationPatched = Symbol('kStopPropagationPatched') const KOnStopPropagation = Symbol('KOnStopPropagation') export class WebSocketHandler { - private readonly __kind: HandlerKind - public id: string public callFrame?: string + public kind = 'websocket' as const protected [kEmitter]: Emitter @@ -51,7 +54,6 @@ export class WebSocketHandler { this[kEmitter] = new Emitter() this.callFrame = getCallFrame(new Error()) - this.__kind = 'EventHandler' } public parse(args: { @@ -95,16 +97,16 @@ export class WebSocketHandler { } public async run( - connection: Omit, + connection: WebSocketConnectionData, resolutionContext?: WebSocketResolutionContext, - ): Promise { + ): Promise { const parsedResult = this.parse({ url: connection.client.url, resolutionContext, }) if (!this.predicate({ url: connection.client.url, parsedResult })) { - return false + return null } const resolvedConnection: WebSocketHandlerConnection = { @@ -112,10 +114,18 @@ export class WebSocketHandler { params: parsedResult.match.params || {}, } - return this.connect(resolvedConnection) + if (resolutionContext?.[kAutoConnect]) { + if (this[kConnect](resolvedConnection)) { + return resolvedConnection + } + + return null + } + + return resolvedConnection } - protected connect(connection: WebSocketHandlerConnection): boolean { + protected [kConnect](connection: WebSocketHandlerConnection): boolean { // Support `event.stopPropagation()` for various client/server events. connection.client.addEventListener( 'message', @@ -143,11 +153,13 @@ export class WebSocketHandler { createStopPropagationListener(this), ) - // Emit the connection event on the handler. - // This is what the developer adds listeners for. return this[kEmitter].emit('connection', connection) } + public log(connection: WebSocketConnectionData): () => void { + return attachWebSocketLogger(connection) + } + #resolveWebSocketUrl(url: string, baseUrl?: string): string { const resolvedUrl = resolveWebSocketUrl( baseUrl diff --git a/src/core/handlers/common.ts b/src/core/handlers/common.ts deleted file mode 100644 index ef0d1018a..000000000 --- a/src/core/handlers/common.ts +++ /dev/null @@ -1 +0,0 @@ -export type HandlerKind = 'RequestHandler' | 'EventHandler' diff --git a/src/core/index.ts b/src/core/index.ts index 6a52f5d58..65c458a96 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,6 +1,6 @@ import { checkGlobals } from './utils/internal/checkGlobals' -export { SetupApi } from './SetupApi' +export { SetupApi } from './experimental/setup-api' /* HTTP handlers */ export { RequestHandler } from './handlers/RequestHandler' diff --git a/src/core/sharedOptions.ts b/src/core/sharedOptions.ts index ad7f151a2..1881cff2f 100644 --- a/src/core/sharedOptions.ts +++ b/src/core/sharedOptions.ts @@ -1,4 +1,4 @@ -import type { Emitter } from 'strict-event-emitter' +import type { Emitter, DefaultEventMap } from 'rettime' import type { UnhandledRequestStrategy } from './utils/request/onUnhandledRequest' export interface SharedOptions { @@ -13,6 +13,10 @@ export interface SharedOptions { onUnhandledRequest?: UnhandledRequestStrategy } +/** + * @deprecated + * Please use `HttpNetworkFrameEventMap` or `WebSocketNetworkFrameEventMap` instead. + */ export type LifeCycleEventsMap = { 'request:start': [ args: { @@ -61,6 +65,7 @@ export type LifeCycleEventsMap = { ] } -export type LifeCycleEventEmitter< - EventsMap extends Record, -> = Pick, 'on' | 'removeListener' | 'removeAllListeners'> +export type LifeCycleEventEmitter = Pick< + Emitter, + 'on' | 'removeListener' | 'removeAllListeners' +> diff --git a/src/core/sse.ts b/src/core/sse.ts index 89d4c0887..aa726d9d5 100644 --- a/src/core/sse.ts +++ b/src/core/sse.ts @@ -6,7 +6,7 @@ import { type HttpRequestResolverExtras, type HttpRequestParsedResult, } from './handlers/HttpHandler' -import type { ResponseResolutionContext } from '~/core/utils/executeHandlers' +import type { ResponseResolutionContext } from '#core/utils/executeHandlers' import type { Path, PathParams } from './utils/matching/matchRequestUrl' import { delay } from './delay' import { getTimestamp } from './utils/logging/getTimestamp' diff --git a/src/core/utils/cookieStore.ts b/src/core/utils/cookieStore.ts index b62a0e603..671b1fd2b 100644 --- a/src/core/utils/cookieStore.ts +++ b/src/core/utils/cookieStore.ts @@ -4,6 +4,7 @@ import { Cookie, CookieJar, MemoryCookieStore, + SerializedCookie, type MemoryCookieStoreIndex, } from 'tough-cookie' import { jsonParse } from './internal/jsonParse' @@ -76,7 +77,7 @@ class CookieStore { return } - const data = [] + const data: Array = [] const { idx } = this.#memoryStore for (const domain in idx) { diff --git a/src/core/utils/internal/isHandlerKind.test.ts b/src/core/utils/internal/isHandlerKind.test.ts index 84486fbe9..ae78f5f0a 100644 --- a/src/core/utils/internal/isHandlerKind.test.ts +++ b/src/core/utils/internal/isHandlerKind.test.ts @@ -5,14 +5,12 @@ import { WebSocketHandler } from '../../handlers/WebSocketHandler' import { isHandlerKind } from './isHandlerKind' it('returns true if expected a request handler and given a request handler', () => { - expect( - isHandlerKind('RequestHandler')(new HttpHandler('*', '*', () => {})), - ).toBe(true) + expect(isHandlerKind('request')(new HttpHandler('*', '*', () => {}))).toBe( + true, + ) expect( - isHandlerKind('RequestHandler')( - new GraphQLHandler('all', '*', '*', () => {}), - ), + isHandlerKind('request')(new GraphQLHandler('all', '*', '*', () => {})), ).toBe(true) }) @@ -25,24 +23,24 @@ it('returns true if expected a request handler and given a custom request handle log() {} } - expect(isHandlerKind('RequestHandler')(new MyHandler())).toBe(true) + expect(isHandlerKind('request')(new MyHandler())).toBe(true) }) it('returns false if expected a request handler but given event handler', () => { - expect(isHandlerKind('RequestHandler')(new WebSocketHandler('*'))).toBe(false) + expect(isHandlerKind('request')(new WebSocketHandler('*'))).toBe(false) }) it('returns false if expected a request handler but given arbitrary object', () => { - expect(isHandlerKind('RequestHandler')(undefined)).toBe(false) - expect(isHandlerKind('RequestHandler')(null)).toBe(false) - expect(isHandlerKind('RequestHandler')({})).toBe(false) - expect(isHandlerKind('RequestHandler')([])).toBe(false) - expect(isHandlerKind('RequestHandler')(123)).toBe(false) - expect(isHandlerKind('RequestHandler')('hello')).toBe(false) + expect(isHandlerKind('request')(undefined)).toBe(false) + expect(isHandlerKind('request')(null)).toBe(false) + expect(isHandlerKind('request')({})).toBe(false) + expect(isHandlerKind('request')([])).toBe(false) + expect(isHandlerKind('request')(123)).toBe(false) + expect(isHandlerKind('request')('hello')).toBe(false) }) it('returns true if expected an event handler and given an event handler', () => { - expect(isHandlerKind('EventHandler')(new WebSocketHandler('*'))).toBe(true) + expect(isHandlerKind('websocket')(new WebSocketHandler('*'))).toBe(true) }) it('returns true if expected an event handler and given a custom event handler', () => { @@ -51,14 +49,14 @@ it('returns true if expected an event handler and given a custom event handler', super('*') } } - expect(isHandlerKind('EventHandler')(new MyEventHandler())).toBe(true) + expect(isHandlerKind('websocket')(new MyEventHandler())).toBe(true) }) it('returns false if expected an event handler but given arbitrary object', () => { - expect(isHandlerKind('EventHandler')(undefined)).toBe(false) - expect(isHandlerKind('EventHandler')(null)).toBe(false) - expect(isHandlerKind('EventHandler')({})).toBe(false) - expect(isHandlerKind('EventHandler')([])).toBe(false) - expect(isHandlerKind('EventHandler')(123)).toBe(false) - expect(isHandlerKind('EventHandler')('hello')).toBe(false) + expect(isHandlerKind('websocket')(undefined)).toBe(false) + expect(isHandlerKind('websocket')(null)).toBe(false) + expect(isHandlerKind('websocket')({})).toBe(false) + expect(isHandlerKind('websocket')([])).toBe(false) + expect(isHandlerKind('websocket')(123)).toBe(false) + expect(isHandlerKind('websocket')('hello')).toBe(false) }) diff --git a/src/core/utils/internal/isHandlerKind.ts b/src/core/utils/internal/isHandlerKind.ts index d877bc847..64a9dc6c7 100644 --- a/src/core/utils/internal/isHandlerKind.ts +++ b/src/core/utils/internal/isHandlerKind.ts @@ -1,21 +1,17 @@ -import type { HandlerKind } from '../../handlers/common' +import type { AnyHandler } from '../../experimental/handlers-controller' import type { RequestHandler } from '../../handlers/RequestHandler' import type { WebSocketHandler } from '../../handlers/WebSocketHandler' +import { isObject } from './isObject' /** * A filter function that ensures that the provided argument * is a handler of the given kind. This helps differentiate * between different kinds of handlers, e.g. request and event handlers. */ -export function isHandlerKind(kind: K) { +export function isHandlerKind(kind: K) { return ( input: unknown, - ): input is K extends 'EventHandler' ? WebSocketHandler : RequestHandler => { - return ( - input != null && - typeof input === 'object' && - '__kind' in input && - input.__kind === kind - ) + ): input is K extends 'websocket' ? WebSocketHandler : RequestHandler => { + return isObject(input) && 'kind' in input && input.kind === kind } } diff --git a/src/core/utils/request/onUnhandledRequest.ts b/src/core/utils/request/onUnhandledRequest.ts index 3e1ff7080..e15987bc5 100644 --- a/src/core/utils/request/onUnhandledRequest.ts +++ b/src/core/utils/request/onUnhandledRequest.ts @@ -3,8 +3,8 @@ import { InternalError, devUtils } from '../internal/devUtils' import { isCommonAssetRequest } from '../../isCommonAssetRequest' export interface UnhandledRequestPrint { - warning(): void - error(): void + warning: () => void + error: () => void } export type UnhandledRequestCallback = ( diff --git a/src/core/ws.ts b/src/core/ws.ts index 745615951..376dbe195 100644 --- a/src/core/ws.ts +++ b/src/core/ws.ts @@ -8,7 +8,7 @@ import { kEmitter, type WebSocketHandlerEventMap, } from './handlers/WebSocketHandler' -import { Path, isPath } from './utils/matching/matchRequestUrl' +import { type Path, isPath } from './utils/matching/matchRequestUrl' import { WebSocketClientManager } from './ws/WebSocketClientManager' function isBroadcastChannelWithUnref( @@ -47,10 +47,10 @@ export type WebSocketLink = { * * @see {@link https://mswjs.io/docs/api/ws#onevent-listener `on()` API reference} */ - addEventListener( + addEventListener: ( event: EventType, listener: WebSocketEventListener, - ): WebSocketHandler + ) => WebSocketHandler /** * Broadcasts the given data to all WebSocket clients. @@ -63,7 +63,7 @@ export type WebSocketLink = { * * @see {@link https://mswjs.io/docs/api/ws#broadcastdata `broadcast()` API reference} */ - broadcast(data: WebSocketData): void + broadcast: (data: WebSocketData) => void /** * Broadcasts the given data to all WebSocket clients @@ -77,12 +77,12 @@ export type WebSocketLink = { * * @see {@link https://mswjs.io/docs/api/ws#broadcastexceptclients-data `broadcast()` API reference} */ - broadcastExcept( + broadcastExcept: ( clients: | WebSocketClientConnectionProtocol | Array, data: WebSocketData, - ): void + ) => void } /** diff --git a/src/core/ws/handleWebSocketEvent.ts b/src/core/ws/handleWebSocketEvent.ts index f67a17805..8ffd77b1f 100644 --- a/src/core/ws/handleWebSocketEvent.ts +++ b/src/core/ws/handleWebSocketEvent.ts @@ -17,7 +17,11 @@ interface HandleWebSocketEventOptions { export function handleWebSocketEvent(options: HandleWebSocketEventOptions) { webSocketInterceptor.on('connection', async (connection) => { - const handlers = options.getHandlers().filter(isHandlerKind('EventHandler')) + /** + * @todo @fixme Reference the handlers controller here and use `.getHandlersByKind`. + * That one relies on the pre-grouped handlers map and will be more performant. + */ + const handlers = options.getHandlers().filter(isHandlerKind('websocket')) // Ignore this connection if the user hasn't defined any handlers. if (handlers.length > 0) { diff --git a/src/core/ws/utils/attachWebSocketLogger.ts b/src/core/ws/utils/attachWebSocketLogger.ts index 91ecd92f5..0afba3298 100644 --- a/src/core/ws/utils/attachWebSocketLogger.ts +++ b/src/core/ws/utils/attachWebSocketLogger.ts @@ -18,8 +18,9 @@ export const colors = { export function attachWebSocketLogger( connection: WebSocketConnectionData, -): void { +): () => void { const { client, server } = connection + const controller = new AbortController() logConnectionOpen(client) @@ -30,19 +31,32 @@ export function attachWebSocketLogger( * @todo Provide the reference to the exact event handler * that called this `client.send()`. */ - client.addEventListener('message', (event) => { - logOutgoingClientMessage(event) - }) + client.addEventListener( + 'message', + (event) => { + logOutgoingClientMessage(event) + }, + { signal: controller.signal }, + ) - client.addEventListener('close', (event) => { - logConnectionClose(event) - }) + client.addEventListener( + 'close', + (event) => { + logConnectionClose(event) + }, + { signal: controller.signal }, + ) // Log client errors (connection closures due to errors). - client.socket.addEventListener('error', (event) => { - logClientError(event) - }) + client.socket.addEventListener( + 'error', + (event) => { + logClientError(event) + }, + { signal: controller.signal }, + ) + const { send: originalClientSend } = client client.send = new Proxy(client.send, { apply(target, thisArg, args) { const [data] = args @@ -75,11 +89,15 @@ export function attachWebSocketLogger( logIncomingServerMessage(event) }) }, - { once: true }, + { + once: true, + signal: controller.signal, + }, ) // Log outgoing client events initiated by the event handler. // The actual client never sent these but the handler did. + const { send: originalServerSend } = server server.send = new Proxy(server.send, { apply(target, thisArg, args) { const [data] = args @@ -102,6 +120,20 @@ export function attachWebSocketLogger( return Reflect.apply(target, thisArg, args) }, }) + + // Undo method proxies. + controller.signal.addEventListener( + 'abort', + () => { + client.send = originalClientSend + server.send = originalServerSend + }, + { once: true }, + ) + + return () => { + controller.abort() + } } /** diff --git a/src/iife/index.ts b/src/iife/index.ts index 189ebc2ec..829e2c979 100644 --- a/src/iife/index.ts +++ b/src/iife/index.ts @@ -1,2 +1,2 @@ -export * from '~/core' +export * from '#core' export * from '../browser' diff --git a/src/native/index.ts b/src/native/index.ts index 64fb526a3..9334769e7 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -1,21 +1,44 @@ +import type { Interceptor } from '@mswjs/interceptors' import { FetchInterceptor } from '@mswjs/interceptors/fetch' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' -import type { RequestHandler } from '~/core/handlers/RequestHandler' -import { SetupServerCommonApi } from '../node/SetupServerCommonApi' +import { type AnyHandler } from '#core/experimental/handlers-controller' +import { + defineNetwork, + DefineNetworkOptions, +} from '#core/experimental/define-network' +import { InterceptorSource } from '#core/experimental/sources/interceptor-source' +import { type SetupServerCommon } from '../node/glossary' +import { defineSetupServerApi } from '../node/setup-server-common' + +const defaultInterceptors: Array> = [ + new FetchInterceptor(), + new XMLHttpRequestInterceptor(), +] + +export const defaultNetworkOptions: DefineNetworkOptions<[InterceptorSource]> = + { + sources: [ + new InterceptorSource({ + interceptors: defaultInterceptors, + }), + ], + onUnhandledFrame: 'warn', + context: { + quiet: true, + }, + } /** * Sets up a requests interception in React Native with the given request handlers. - * @param {RequestHandler[]} handlers List of request handlers. + * @param {Array} handlers List of request handlers. * * @see {@link https://mswjs.io/docs/api/setup-server `setupServer()` API reference} */ -export function setupServer( - ...handlers: Array -): SetupServerCommonApi { - // Provision request interception via patching the `XMLHttpRequest` class only - // in React Native. There is no `http`/`https` modules in that environment. - return new SetupServerCommonApi( - [new FetchInterceptor(), new XMLHttpRequestInterceptor()], +export function setupServer(...handlers: Array): SetupServerCommon { + const network = defineNetwork({ + ...defaultNetworkOptions, handlers, - ) + }) + + return defineSetupServerApi(network) } diff --git a/src/node/SetupServerApi.ts b/src/node/SetupServerApi.ts deleted file mode 100644 index e8c83cd14..000000000 --- a/src/node/SetupServerApi.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { AsyncLocalStorage } from 'node:async_hooks' -import type { HttpRequestEventMap, Interceptor } from '@mswjs/interceptors' -import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest' -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' -import { FetchInterceptor } from '@mswjs/interceptors/fetch' -import { HandlersController } from '~/core/SetupApi' -import type { RequestHandler } from '~/core/handlers/RequestHandler' -import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' -import type { SetupServer } from './glossary' -import { SetupServerCommonApi } from './SetupServerCommonApi' - -const store = new AsyncLocalStorage() - -type RequestHandlersContext = { - initialHandlers: Array - handlers: Array -} - -/** - * A handlers controller that utilizes `AsyncLocalStorage` in Node.js - * to prevent the request handlers list from being a shared state - * across multiple tests. - */ -class AsyncHandlersController implements HandlersController { - private rootContext: RequestHandlersContext - - constructor(initialHandlers: Array) { - this.rootContext = { initialHandlers, handlers: [] } - } - - get context(): RequestHandlersContext { - return store.getStore() || this.rootContext - } - - public prepend(runtimeHandlers: Array) { - this.context.handlers.unshift(...runtimeHandlers) - } - - public reset(nextHandlers: Array) { - const context = this.context - context.handlers = [] - context.initialHandlers = - nextHandlers.length > 0 ? nextHandlers : context.initialHandlers - } - - public currentHandlers(): Array { - const { initialHandlers, handlers } = this.context - return handlers.concat(initialHandlers) - } -} -export class SetupServerApi - extends SetupServerCommonApi - implements SetupServer -{ - constructor( - handlers: Array, - interceptors: Array> = [ - new ClientRequestInterceptor(), - new XMLHttpRequestInterceptor(), - new FetchInterceptor(), - ], - ) { - super(interceptors, handlers) - - this.handlersController = new AsyncHandlersController(handlers) - } - - public boundary, R>( - callback: (...args: Args) => R, - ): (...args: Args) => R { - return (...args: Args): R => { - return store.run( - { - initialHandlers: this.handlersController.currentHandlers(), - handlers: [], - }, - callback, - ...args, - ) - } - } - - public close(): void { - super.close() - store.disable() - } -} diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts deleted file mode 100644 index c5581ffbc..000000000 --- a/src/node/SetupServerCommonApi.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * @note This API is extended by both "msw/node" and "msw/native" - * so be minding about the things you import! - */ -import type { RequiredDeep } from 'type-fest' -import { invariant } from 'outvariant' -import { - BatchInterceptor, - InterceptorReadyState, - type HttpRequestEventMap, - type Interceptor, -} from '@mswjs/interceptors' -import type { LifeCycleEventsMap, SharedOptions } from '~/core/sharedOptions' -import { SetupApi } from '~/core/SetupApi' -import { handleRequest } from '~/core/utils/handleRequest' -import type { RequestHandler } from '~/core/handlers/RequestHandler' -import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' -import { mergeRight } from '~/core/utils/internal/mergeRight' -import { InternalError, devUtils } from '~/core/utils/internal/devUtils' -import type { SetupServerCommon } from './glossary' -import { handleWebSocketEvent } from '~/core/ws/handleWebSocketEvent' -import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' -import { isHandlerKind } from '~/core/utils/internal/isHandlerKind' - -const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { - onUnhandledRequest: 'warn', -} - -export class SetupServerCommonApi - extends SetupApi - implements SetupServerCommon -{ - protected readonly interceptor: BatchInterceptor< - Array>, - HttpRequestEventMap - > - private resolvedOptions: RequiredDeep - - constructor( - interceptors: Array>, - handlers: Array, - ) { - super(...handlers) - - this.interceptor = new BatchInterceptor({ - name: 'setup-server', - interceptors, - }) - - this.resolvedOptions = {} as RequiredDeep - } - - /** - * Subscribe to all requests that are using the interceptor object - */ - private init(): void { - this.interceptor.on( - 'request', - async ({ request, requestId, controller }) => { - const response = await handleRequest( - request, - requestId, - this.handlersController - .currentHandlers() - .filter(isHandlerKind('RequestHandler')), - this.resolvedOptions, - this.emitter, - { - onPassthroughResponse(request) { - const acceptHeader = request.headers.get('accept') - - /** - * @note Remove the internal bypass request header. - * In the browser, this is done by the worker script. - * In Node.js, it has to be done here. - */ - if (acceptHeader) { - const nextAcceptHeader = acceptHeader.replace( - /(,\s+)?msw\/passthrough/, - '', - ) - - if (nextAcceptHeader) { - request.headers.set('accept', nextAcceptHeader) - } else { - request.headers.delete('accept') - } - } - }, - }, - ) - - if (response) { - controller.respondWith(response) - } - - return - }, - ) - - this.interceptor.on('unhandledException', ({ error }) => { - if (error instanceof InternalError) { - throw error - } - }) - - this.interceptor.on( - 'response', - ({ response, isMockedResponse, request, requestId }) => { - this.emitter.emit( - isMockedResponse ? 'response:mocked' : 'response:bypass', - { - response, - request, - requestId, - }, - ) - }, - ) - - // Preconfigure the WebSocket interception but don't enable it just yet. - // It will be enabled when the server starts. - handleWebSocketEvent({ - getUnhandledRequestStrategy: () => { - return this.resolvedOptions.onUnhandledRequest - }, - getHandlers: () => { - return this.handlersController.currentHandlers() - }, - onMockedConnection: () => {}, - onPassthroughConnection: () => {}, - }) - } - - public listen(options: Partial = {}): void { - this.resolvedOptions = mergeRight( - DEFAULT_LISTEN_OPTIONS, - options, - ) as RequiredDeep - - // Apply the interceptor when starting the server. - // Attach the event listeners to the interceptor here - // so they get re-attached whenever `.listen()` is called. - this.interceptor.apply() - this.init() - this.subscriptions.push(() => this.interceptor.dispose()) - - // Apply the WebSocket interception. - webSocketInterceptor.apply() - this.subscriptions.push(() => webSocketInterceptor.dispose()) - - // Assert that the interceptor has been applied successfully. - // Also guards us from forgetting to call "interceptor.apply()" - // as a part of the "listen" method. - invariant( - [InterceptorReadyState.APPLYING, InterceptorReadyState.APPLIED].includes( - this.interceptor.readyState, - ), - devUtils.formatMessage( - 'Failed to start "setupServer": the interceptor failed to apply. This is likely an issue with the library and you should report it at "%s".', - ), - 'https://github.com/mswjs/msw/issues/new/choose', - ) - } - - public close(): void { - this.dispose() - } -} diff --git a/src/node/async-handlers-controller.ts b/src/node/async-handlers-controller.ts new file mode 100644 index 000000000..61633a230 --- /dev/null +++ b/src/node/async-handlers-controller.ts @@ -0,0 +1,56 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { + type AnyHandler, + type HandlersMap, + HandlersController, + HandlersControllerState, +} from '#core/experimental/handlers-controller' + +export interface AsyncHandlersControllerContext { + initialHandlers: HandlersMap + handlers: HandlersMap +} + +export class AsyncHandlersController extends HandlersController { + #fallbackContext: AsyncHandlersControllerContext + + public context: AsyncLocalStorage + + constructor(initialHandlers: Array) { + super() + + const initialState = this.getInitialState(initialHandlers) + + this.context = new AsyncLocalStorage() + + this.#fallbackContext = { + initialHandlers: initialState.initialHandlers, + handlers: initialState.handlers, + } + } + + protected getState() { + const context = this.#getContext() + + return { + initialHandlers: context.initialHandlers, + handlers: context.handlers, + } + } + + protected setState(nextState: HandlersControllerState): void { + const context = this.#getContext() + + if (nextState.initialHandlers) { + context.initialHandlers = nextState.initialHandlers + } + + if (nextState.handlers) { + context.handlers = nextState.handlers + } + } + + #getContext() { + return this.context.getStore() || this.#fallbackContext + } +} diff --git a/src/node/glossary.ts b/src/node/glossary.ts index 7f52c9f91..944399d78 100644 --- a/src/node/glossary.ts +++ b/src/node/glossary.ts @@ -1,11 +1,10 @@ import type { PartialDeep } from 'type-fest' -import type { RequestHandler } from '~/core/handlers/RequestHandler' -import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' -import type { - LifeCycleEventEmitter, - LifeCycleEventsMap, - SharedOptions, -} from '~/core/sharedOptions' +import type { HttpNetworkFrameEventMap } from '#core/experimental/frames/http-frame' +import type { WebSocketNetworkFrameEventMap } from '#core/experimental/frames/websocket-frame' +import type { AnyHandler } from '#core/experimental/handlers-controller' +import type { LifeCycleEventEmitter, SharedOptions } from '#core/sharedOptions' + +export interface ListenOptions extends SharedOptions {} export interface SetupServerCommon { /** @@ -13,42 +12,42 @@ export interface SetupServerCommon { * * @see {@link https://mswjs.io/docs/api/setup-server/listen `server.listen()` API reference} */ - listen(options?: PartialDeep): void + listen: (options?: PartialDeep) => void /** * Stops requests interception by restoring all augmented modules. * * @see {@link https://mswjs.io/docs/api/setup-server/close `server.close()` API reference} */ - close(): void + close: () => void /** * Prepends given request handlers to the list of existing handlers. * * @see {@link https://mswjs.io/docs/api/setup-server/use `server.use()` API reference} */ - use(...handlers: Array): void + use: (...handlers: Array) => void /** * Marks all request handlers that respond using `res.once()` as unused. * * @see {@link https://mswjs.io/docs/api/setup-server/restore-handlers `server.restore-handlers()` API reference} */ - restoreHandlers(): void + restoreHandlers: () => void /** * Resets request handlers to the initial list given to the `setupServer` call, or to the explicit next request handlers list, if given. * * @see {@link https://mswjs.io/docs/api/setup-server/reset-handlers `server.reset-handlers()` API reference} */ - resetHandlers(...nextHandlers: Array): void + resetHandlers: (...nextHandlers: Array) => void /** * Returns a readonly list of currently active request handlers. * * @see {@link https://mswjs.io/docs/api/setup-server/list-handlers `server.listHandlers()` API reference} */ - listHandlers(): ReadonlyArray + listHandlers: () => ReadonlyArray /** * Life-cycle events. @@ -56,7 +55,9 @@ export interface SetupServerCommon { * * @see {@link https://mswjs.io/docs/api/life-cycle-events Life-cycle Events API reference} */ - events: LifeCycleEventEmitter + events: LifeCycleEventEmitter< + HttpNetworkFrameEventMap | WebSocketNetworkFrameEventMap + > } export interface SetupServer extends SetupServerCommon { @@ -68,7 +69,7 @@ export interface SetupServer extends SetupServerCommon { * * @see {@link https://mswjs.io/docs/api/setup-server/boundary `server.boundary()` API reference} */ - boundary, R>( + boundary: , R>( callback: (...args: Args) => R, - ): (...args: Args) => R + ) => (...args: Args) => R } diff --git a/src/node/index.ts b/src/node/index.ts index d9b2ea46c..e8e63f7e5 100644 --- a/src/node/index.ts +++ b/src/node/index.ts @@ -1,3 +1,7 @@ export type { SetupServer } from './glossary' -export { SetupServerApi } from './SetupServerApi' -export { setupServer } from './setupServer' +export { + setupServer, + SetupServerApi, + defaultNetworkOptions, +} from './setup-server' +export { SetupServerCommonApi } from './setup-server-common' diff --git a/src/node/setup-server-common.ts b/src/node/setup-server-common.ts new file mode 100644 index 000000000..19cd3bf8f --- /dev/null +++ b/src/node/setup-server-common.ts @@ -0,0 +1,92 @@ +import type { PartialDeep } from 'type-fest' +import { Interceptor } from '@mswjs/interceptors' +import { + type NetworkApi, + defineNetwork, +} from '#core/experimental/define-network' +import { type AnyHandler } from '#core/experimental/handlers-controller' +import { type HandlersController } from '#core/experimental/handlers-controller' +import { InterceptorSource } from '#core/experimental/sources/interceptor-source' +import { fromLegacyOnUnhandledRequest } from '#core/experimental/compat' +import type { ListenOptions, SetupServerCommon } from './glossary' + +/** + * Define the common `setupServer` API around the given network. + * This is used by both `msw/node` and `msw/native` to implement the same + * baseline setup methods, like `.use()`, `.resetHandlers()`, `.close()`, etc. + */ +export function defineSetupServerApi( + network: NetworkApi, +): SetupServerCommon { + return { + events: network.events, + listen(options) { + network.configure({ + onUnhandledFrame: fromLegacyOnUnhandledRequest(() => { + return options?.onUnhandledRequest || 'warn' + }), + }) + + network.enable() + }, + use: network.use.bind(network), + resetHandlers: network.resetHandlers.bind(network), + restoreHandlers: network.restoreHandlers.bind(network), + listHandlers: network.listHandlers.bind(network), + close() { + network.disable() + }, + } +} + +/** + * @deprecated + * Please use the `defineNetwork` API instead. + */ +export class SetupServerCommonApi implements SetupServerCommon { + protected network: NetworkApi<[InterceptorSource]> + + constructor( + interceptors: Array>, + handlers: Array | HandlersController, + ) { + this.network = defineNetwork({ + sources: [new InterceptorSource({ interceptors })], + handlers, + }) + } + + get events() { + return this.network.events + } + + public listen(options?: PartialDeep): void { + this.network.configure({ + onUnhandledFrame: fromLegacyOnUnhandledRequest(() => { + return options?.onUnhandledRequest || 'warn' + }), + }) + + this.network.enable() + } + + public use(...handlers: Array): void { + this.network.use(...handlers) + } + + public resetHandlers(...nextHandlers: Array): void { + return this.network.resetHandlers(...nextHandlers) + } + + public restoreHandlers(): void { + return this.network.restoreHandlers() + } + + public listHandlers(): ReadonlyArray { + return this.network.listHandlers() + } + + public close(): void { + this.network.disable() + } +} diff --git a/src/node/setup-server.ts b/src/node/setup-server.ts new file mode 100644 index 000000000..ba1ee39fa --- /dev/null +++ b/src/node/setup-server.ts @@ -0,0 +1,123 @@ +import type { Interceptor } from '@mswjs/interceptors' +import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { WebSocketInterceptor } from '@mswjs/interceptors/WebSocket' +import { + defineNetwork, + type DefineNetworkOptions, +} from '#core/experimental/define-network' +import { + type AnyHandler, + groupHandlersByKind, +} from '#core/experimental/handlers-controller' +import { InterceptorSource } from '#core/experimental/sources/interceptor-source' +import { SetupServer } from './glossary' +import { AsyncHandlersController } from './async-handlers-controller' +import { + defineSetupServerApi, + SetupServerCommonApi, +} from './setup-server-common' + +const defaultInterceptors: Array> = [ + new ClientRequestInterceptor(), + new XMLHttpRequestInterceptor(), + new FetchInterceptor(), + /** + * @fixme WebSocketInterceptor is in a browser-only export of Interceptors + * while the Interceptor class imported from the root module points to `lib/node`. + * An absolute madness to solve as it requires to duplicate the build config we have + * in MSW: shared core, CJS/ESM patching, .d.ts patching... + */ + new WebSocketInterceptor() as any, +] + +export const defaultNetworkOptions: DefineNetworkOptions<[InterceptorSource]> = + { + sources: [ + new InterceptorSource({ + interceptors: defaultInterceptors, + }), + ], + onUnhandledFrame: 'warn', + context: { + quiet: true, + }, + } + +/** + * Enables request interception in Node.js with the given request handlers. + * @see {@link https://mswjs.io/docs/api/setup-server `setupServer()` API reference} + */ +export function setupServer(...handlers: Array): SetupServer { + const handlersController = new AsyncHandlersController(handlers) + const network = defineNetwork({ + ...defaultNetworkOptions, + handlers: handlersController, + }) + + const commonApi = defineSetupServerApi(network) + + return { + ...commonApi, + boundary(callback) { + return (...args) => { + const normalizedInitialHandlers = groupHandlersByKind( + handlersController.currentHandlers, + ) + + return handlersController.context.run( + { + initialHandlers: normalizedInitialHandlers, + handlers: { ...normalizedInitialHandlers }, + }, + callback, + ...args, + ) + } + }, + } +} + +/** + * @deprecated + * Please use the `defineNetwork` API instead. + */ +export class SetupServerApi + extends SetupServerCommonApi + implements SetupServer +{ + #handlersController: AsyncHandlersController + + constructor( + handlers: Array, + interceptors: Array>, + ) { + const controller = new AsyncHandlersController(handlers) + super(interceptors, controller) + + const { sources: _, ...networkOptions } = defaultNetworkOptions + this.network.configure(networkOptions) + + this.#handlersController = controller + } + + public boundary, ReturnType>( + callback: (...args: Args) => ReturnType, + ): (...args: Args) => ReturnType { + return (...args: Args): ReturnType => { + const normalizedInitialHandlers = groupHandlersByKind( + this.#handlersController.currentHandlers, + ) + + return this.#handlersController.context.run( + { + initialHandlers: normalizedInitialHandlers, + handlers: { ...normalizedInitialHandlers }, + }, + callback, + ...args, + ) + } + } +} diff --git a/src/node/setupServer.ts b/src/node/setupServer.ts deleted file mode 100644 index cb2ee7ec4..000000000 --- a/src/node/setupServer.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { RequestHandler } from '~/core/handlers/RequestHandler' -import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' -import { SetupServerApi } from './SetupServerApi' - -/** - * Sets up a requests interception in Node.js with the given request handlers. - * @param {RequestHandler[]} handlers List of request handlers. - * - * @see {@link https://mswjs.io/docs/api/setup-server `setupServer()` API reference} - */ -export const setupServer = ( - ...handlers: Array -): SetupServerApi => { - return new SetupServerApi(handlers) -} diff --git a/src/tsconfig.core.json b/src/tsconfig.core.json new file mode 100644 index 000000000..dfc88cb0f --- /dev/null +++ b/src/tsconfig.core.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.src.json", + "include": ["./core/**/*.ts"], + "files": [], + "compilerOptions": { + "composite": true + } +} diff --git a/src/tsconfig.node.json b/src/tsconfig.node.json index c98a860ae..af3707338 100644 --- a/src/tsconfig.node.json +++ b/src/tsconfig.node.json @@ -1,8 +1,13 @@ { "extends": "./tsconfig.src.json", - "compilerOptions": { - "types": ["node"] - }, "include": ["./node", "./native"], - "exclude": ["**/*.test.ts"] + "exclude": ["**/*.test.ts"], + "references": [ + { + "path": "./tsconfig.src.json" + } + ], + "compilerOptions": { + "types": ["@types/node"] + } } diff --git a/src/tsconfig.src.json b/src/tsconfig.src.json index 87ad65fc8..58f2e8991 100644 --- a/src/tsconfig.src.json +++ b/src/tsconfig.src.json @@ -1,6 +1,4 @@ { - // Common configuration for everything - // living in the "src" directory. "extends": "../tsconfig.base.json", "compilerOptions": { "composite": true diff --git a/src/tsconfig.worker.json b/src/tsconfig.worker.json index 26b178ebb..d0f218d5c 100644 --- a/src/tsconfig.worker.json +++ b/src/tsconfig.worker.json @@ -1,10 +1,11 @@ { "include": ["./mockServiceWorker.js"], + "files": [], "compilerOptions": { + "composite": true, "strict": true, "allowJs": true, "checkJs": true, - "noEmit": true, "target": "esnext", "module": "esnext", "lib": ["esnext"], diff --git a/test/browser/msw-api/integrity-check.test.ts b/test/browser/msw-api/integrity-check.test.ts index 8916272e3..be9599049 100644 --- a/test/browser/msw-api/integrity-check.test.ts +++ b/test/browser/msw-api/integrity-check.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs' import { test, expect } from '../playwright.extend' import copyServiceWorker from '../../../config/copyServiceWorker' -import packageJson from '../../../package.json' assert { type: 'json' } +import packageJson from '../../../package.json' with { type: 'json' } // @ts-expect-error Importing a Javascript module. import { SERVICE_WORKER_SOURCE_PATH } from '../../../config/constants.js' diff --git a/test/browser/msw-api/setup-worker/life-cycle-events/on.test.ts b/test/browser/msw-api/setup-worker/life-cycle-events/on.test.ts index 899fefa0b..b0925a53a 100644 --- a/test/browser/msw-api/setup-worker/life-cycle-events/on.test.ts +++ b/test/browser/msw-api/setup-worker/life-cycle-events/on.test.ts @@ -1,11 +1,11 @@ -import type { SetupWorker } from 'msw/browser' +import type { SetupWorkerApi } from 'msw/browser' import { HttpServer } from '@open-draft/test-server/lib/http.js' import type { ConsoleMessages } from 'page-with' import { test, expect } from '../../../playwright.extend' declare namespace window { export const msw: { - worker: SetupWorker + worker: SetupWorkerApi } } @@ -46,7 +46,6 @@ test('emits events for a handled request and mocked response', async ({ loadExample, spyOnConsole, fetch, - waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(ON_EXAMPLE) @@ -55,11 +54,9 @@ test('emits events for a handled request and mocked response', async ({ await fetch(url) const requestId = getRequestId(consoleSpy) - await waitFor(() => { - expect(consoleSpy.get('warning')).toContainEqual( - expect.stringContaining('[response:mocked]'), - ) - }) + await expect + .poll(() => consoleSpy.get('warning')) + .toContainEqual(expect.stringContaining('[response:mocked]')) expect(consoleSpy.get('warning')).toEqual([ `[request:start] GET ${url} ${requestId}`, @@ -73,7 +70,6 @@ test('emits events for a handled request with no response', async ({ loadExample, spyOnConsole, fetch, - waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(ON_EXAMPLE) @@ -82,11 +78,9 @@ test('emits events for a handled request with no response', async ({ await fetch(url, { method: 'POST' }) const requestId = getRequestId(consoleSpy) - await waitFor(() => { - expect(consoleSpy.get('warning')).toContainEqual( - expect.stringContaining('[response:bypass]'), - ) - }) + await expect + .poll(() => consoleSpy.get('warning')) + .toContainEqual(expect.stringContaining('[response:bypass]')) expect(consoleSpy.get('warning')).toEqual([ `[request:start] POST ${url} ${requestId}`, @@ -99,7 +93,6 @@ test('emits events for an unhandled request', async ({ loadExample, spyOnConsole, fetch, - waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(ON_EXAMPLE) @@ -108,11 +101,9 @@ test('emits events for an unhandled request', async ({ await fetch(url) const requestId = getRequestId(consoleSpy) - await waitFor(() => { - expect(consoleSpy.get('warning')).toContainEqual( - expect.stringContaining('[response:bypass]'), - ) - }) + await expect + .poll(() => consoleSpy.get('warning')) + .toContainEqual(expect.stringContaining('[response:bypass]')) expect(consoleSpy.get('warning')).toEqual([ `[request:start] GET ${url} ${requestId}`, @@ -126,7 +117,6 @@ test('emits events for a passthrough request', async ({ loadExample, spyOnConsole, fetch, - waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(ON_EXAMPLE) @@ -138,20 +128,19 @@ test('emits events for a passthrough request', async ({ await fetch(url) const requestId = getRequestId(consoleSpy) - await waitFor(() => { - expect(consoleSpy.get('warning')).toEqual([ + await expect + .poll(() => consoleSpy.get('warning')) + .toEqual([ `[request:start] GET ${url} ${requestId}`, `[request:end] GET ${url} ${requestId}`, `[response:bypass] 200 ${url} passthrough-response GET ${url} ${requestId}`, ]) - }) }) test('emits events for a bypassed request', async ({ loadExample, spyOnConsole, fetch, - waitFor, page, }) => { const consoleSpy = spyOnConsole() @@ -163,9 +152,10 @@ test('emits events for a bypassed request', async ({ const url = server.http.url('/bypass') await fetch(url) - await waitFor(() => { - // First, must print the events for the original (mocked) request. - expect(consoleSpy.get('warning')).toEqual( + // First, must print the events for the original (mocked) request. + await expect + .poll(() => consoleSpy.get('warning')) + .toEqual( expect.arrayContaining([ expect.stringContaining(`[request:start] GET ${url}`), expect.stringContaining(`[request:end] GET ${url}`), @@ -175,19 +165,18 @@ test('emits events for a bypassed request', async ({ ]), ) - // Then, must also print events for the bypassed request. - expect(consoleSpy.get('warning')).toEqual( - expect.arrayContaining([ - expect.stringContaining(`[request:start] POST ${url}`), - expect.stringContaining(`[request:end] POST ${url}`), - expect.stringContaining( - `[response:bypass] 200 ${url} bypassed-response POST ${url}`, - ), - ]), - ) + // Then, must also print events for the bypassed request. + expect(consoleSpy.get('warning')).toEqual( + expect.arrayContaining([ + expect.stringContaining(`[request:start] POST ${url}`), + expect.stringContaining(`[request:end] POST ${url}`), + expect.stringContaining( + `[response:bypass] 200 ${url} bypassed-response POST ${url}`, + ), + ]), + ) - expect(pageErrors).toEqual([]) - }) + expect(pageErrors).toEqual([]) }) test('emits unhandled exceptions in the request handler', async ({ @@ -210,7 +199,6 @@ test('emits unhandled exceptions in the request handler', async ({ test('stops emitting events once the worker is stopped', async ({ loadExample, spyOnConsole, - fetch, page, }) => { const consoleSpy = spyOnConsole() @@ -219,7 +207,9 @@ test('stops emitting events once the worker is stopped', async ({ await page.evaluate(() => { return window.msw.worker.stop() }) - await fetch('/unknown-route') + + const url = server.http.url('/passthrough') + await page.evaluate((url) => fetch(url), url) expect(consoleSpy.get('warning')).toBeUndefined() }) diff --git a/test/browser/msw-api/setup-worker/scenarios/errors/internal-error.test.ts b/test/browser/msw-api/setup-worker/scenarios/errors/internal-error.test.ts index 0a4735fb2..f60c4460a 100644 --- a/test/browser/msw-api/setup-worker/scenarios/errors/internal-error.test.ts +++ b/test/browser/msw-api/setup-worker/scenarios/errors/internal-error.test.ts @@ -4,42 +4,39 @@ test('propagates the exception originating from a handled request', async ({ loadExample, spyOnConsole, fetch, - waitFor, makeUrl, }) => { const consoleSpy = spyOnConsole() await loadExample(new URL('./internal-error.mocks.ts', import.meta.url)) const endpointUrl = makeUrl('/user') - const res = await fetch(endpointUrl) + const response = await fetch(endpointUrl) // Expect the exception to be handled as a 500 error response. - expect(res.status()).toBe(500) - expect(res.statusText()).toBe('Request Handler Error') - expect(await res.json()).toEqual({ + expect.soft(response.status()).toBe(500) + expect.soft(response.statusText()).toBe('Request Handler Error') + await expect(response.json()).resolves.toEqual({ name: 'Error', message: 'Custom error message', stack: expect.stringContaining('Error: Custom error message'), }) // Expect standard request failure message from the browser. - await waitFor(() => { - expect(consoleSpy.get('error')).toEqual( + await expect + .poll(() => consoleSpy.get('error')) + .toEqual( expect.arrayContaining([ expect.stringContaining( 'Failed to load resource: the server responded with a status of 500', ), ]), ) - }) - expect(consoleSpy.get('error')).toEqual( + expect(consoleSpy.get('warning')).toEqual( expect.arrayContaining([ - expect.stringContaining(`\ -[MSW] Uncaught exception in the request handler for "GET ${endpointUrl}": - -Error: Custom error message -`), + expect.stringContaining( + `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`, + ), ]), ) }) diff --git a/test/browser/msw-api/setup-worker/scenarios/iframe-isolated-response/iframe-isolated-response.test.ts b/test/browser/msw-api/setup-worker/scenarios/iframe-isolated-response/iframe-isolated-response.test.ts index 0c5f62bb3..c76278f02 100644 --- a/test/browser/msw-api/setup-worker/scenarios/iframe-isolated-response/iframe-isolated-response.test.ts +++ b/test/browser/msw-api/setup-worker/scenarios/iframe-isolated-response/iframe-isolated-response.test.ts @@ -36,6 +36,9 @@ test('responds with different responses for the same request based on request re const frameOne = getFrameById('frame-one', page)! const frameTwo = getFrameById('frame-two', page)! + await frameOne.waitForFunction(() => window.request != null) + await frameTwo.waitForFunction(() => window.request != null) + await Promise.all([ frameOne.evaluate(() => window.request()), frameTwo.evaluate(() => window.request()), diff --git a/test/browser/msw-api/setup-worker/scenarios/iframe-isolated-response/one.html b/test/browser/msw-api/setup-worker/scenarios/iframe-isolated-response/one.html index 7f6f5aca8..a0c7dd6ef 100644 --- a/test/browser/msw-api/setup-worker/scenarios/iframe-isolated-response/one.html +++ b/test/browser/msw-api/setup-worker/scenarios/iframe-isolated-response/one.html @@ -1,25 +1,25 @@