diff --git a/decisions/cross-process-request-interception.md b/decisions/cross-process-request-interception.md new file mode 100644 index 000000000..2dbd61c3e --- /dev/null +++ b/decisions/cross-process-request-interception.md @@ -0,0 +1,63 @@ +# Cross-Process Request Interception (CPRI) + +## Goal + +Allow the user to intercept the network from one process while handling it in another. The primary use case for this is to affect the server-side application requests while within the test process: + +```ts +// app.js +import { setupServer } from 'msw/node' + +// Establish the sender +const server = setupServer({ + remote: { enable: true }, +}) +server.listen() +``` + +```ts +// test/homepage.test.ts +// Establish a receiver. +const server = setupRemoteServer() + +test('...', async () => { + server.use( + http.get('https://example.com/resource', () => { + return HttpResponse.text('hello world') + }), + ) +}) +``` + +## Sender and receiver + +The remote interception consists of two parts: + +- The sender that intercepts the traffic in a remote process and sends it to the receiver to potentially handle; +- The receiver that resolves any received requests against the defined request handlers. + +> I chose to emphasize the receiver part with `setupRemoteServer()` to highlight that this "server" is not going to intercept the traffic in this process, unlike `setupServer()`. + +## Protocol + +For such an interception to be possible, we are designing a network serialization protocol to transfer requests and responses between processes. Spiritually, this isn't much different from the serialization we already perform between the client and worker threads in `setupWorker`. + +I propose we use WebSocket as the underlying transport for this protocol. This will support HTTP requests while give us a proper protocol to serialize and handle WebSocket requests as well, since those will be event-based by design (something that won't be possible to properly describe with HTTP). WebSocket does pose a slight challenge as we would have to handle request body streams properly (can we use `WebSocketStream` for that?). + +> I am open to considering other protocols, too. We might benefit even more from a custom RPC, like the one in [Cap'n Web](https://github.com/cloudflare/capnweb). May be worth looking into it for inspiration. + +## `RemoteRequestHandler` + +We use a new kind of request handler called `RemoteRequestHandler` on the _sender_ part to stall the request resolution until the receiver decides on how to handle the outgoing request. If the receiver does not handle the request, the sender proceeds with using its own request handlers for request resolution. + +> `RemoteRequestHandler` is not the best mechanism for this. While it works with the current layout of MSW, the sender process is de-facto a _source_ of network. I like the concept of [network sources](https://github.com/mswjs/msw/discussions/2488) but it's a large refactor that we should not include in the remote interception right now. What we should do, is design the current approach so it's ready for network sources as much as possible. + +## Pending tasks + +- [ ] **Outline the network serialization protocol**. How to represent requests/responses? Their body streams? (see prior work at `src/core/nhp`). How to handle binary data transferred via WebSocket? The protocol should be able to serialize and deserialize all of that. +- [ ] (Potentially) replace the current serialization logic in `setupWorker` with the designed protocol to stay consistent. +- [ ] Tackle common use case problems: + - [ ] What if the app needs the test to serve homepage (see [this](https://github.com/mswjs/msw/pull/1617#issuecomment-2331258739))? That creates a catch 22 since testing frameworks often wait for the app to respond so they know the app is ready for tests. If the app's response depends on the test now, they both will get stuck indefinitely. +- [ ] Fix the [life-cycle events order](https://github.com/mswjs/msw/pull/1617#issuecomment-2580999914). +- [ ] Ensure the WebSocket transport is secure (see [this](https://github.com/mswjs/msw/pull/1617#pullrequestreview-2937795837)). +- [ ] Add more tests for HTTP and WebSocket CPRI. diff --git a/package.json b/package.json index 549dc5ce8..6648c879a 100644 --- a/package.json +++ b/package.json @@ -238,9 +238,12 @@ "sideEffects": false, "dependencies": { "@inquirer/confirm": "^5.0.0", + "@msgpack/msgpack": "^3.1.2", "@mswjs/interceptors": "^0.40.0", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.4", + "@types/ws": "^8.18.1", + "engine.io-client": "^6.6.3", "cookie": "^1.0.2", "graphql": "^16.8.1", "headers-polyfill": "^4.0.2", @@ -249,11 +252,14 @@ "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.7.0", + "socket.io": "^4.8.1", + "socket.io-client": "^4.8.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^4.26.1", "until-async": "^3.0.2", + "ws": "^8.18.3", "yargs": "^17.7.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac9c27eed..62d7922c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@inquirer/confirm': specifier: ^5.0.0 version: 5.0.2(@types/node@18.19.28) + '@msgpack/msgpack': + specifier: ^3.1.2 + version: 3.1.2 '@mswjs/interceptors': specifier: ^0.40.0 version: 0.40.0 @@ -20,9 +23,15 @@ importers: '@types/statuses': specifier: ^2.0.4 version: 2.0.5 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 cookie: specifier: ^1.0.2 version: 1.0.2 + engine.io-client: + specifier: ^6.6.3 + version: 6.6.3 graphql: specifier: ^16.8.1 version: 16.8.2 @@ -44,6 +53,12 @@ importers: rettime: specifier: ^0.7.0 version: 0.7.0 + socket.io: + specifier: ^4.8.1 + version: 4.8.1 + socket.io-client: + specifier: ^4.8.1 + version: 4.8.1 statuses: specifier: ^2.0.2 version: 2.0.2 @@ -59,6 +74,9 @@ importers: until-async: specifier: ^3.0.2 version: 3.0.2 + ws: + specifier: ^8.18.3 + version: 8.18.3 yargs: specifier: ^17.7.2 version: 17.7.2 @@ -1065,6 +1083,10 @@ packages: resolution: {integrity: sha512-stTxvLdJ2IcGOs76AnvGYAzGvx8JvQPRxC5DW0P5zdAAnhL33noqb5LKdPt3P37BKp9FzBKZHuihQI9oVqwm0g==} engines: {node: '>=16.13'} + '@msgpack/msgpack@3.1.2': + resolution: {integrity: sha512-JEW4DEtBzfe8HvUYecLU9e6+XJnKDlUAIve8FvPzF3Kzs6Xo/KuZkZJsDH0wJXl/qEZbeeE7edxDNY3kMs39hQ==} + engines: {node: '>= 18'} + '@mswjs/interceptors@0.40.0': resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==} engines: {node: '>=18'} @@ -1344,9 +1366,6 @@ packages: '@types/conventional-commits-parser@5.0.1': resolution: {integrity: sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ==} - '@types/cookie@0.4.1': - resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} - '@types/cookies@0.9.0': resolution: {integrity: sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q==} @@ -2209,10 +2228,6 @@ packages: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} - cookie@0.4.2: - resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} - engines: {node: '>= 0.6'} - cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -2512,12 +2527,15 @@ packages: end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + engine.io-client@6.6.3: + resolution: {integrity: sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==} + engine.io-parser@5.2.2: resolution: {integrity: sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==} engines: {node: '>=10.0.0'} - engine.io@6.5.4: - resolution: {integrity: sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==} + engine.io@6.6.4: + resolution: {integrity: sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==} engines: {node: '>=10.2.0'} enhanced-resolve@5.17.1: @@ -4502,12 +4520,16 @@ packages: socket.io-adapter@2.5.4: resolution: {integrity: sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==} + socket.io-client@4.8.1: + resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==} + engines: {node: '>=10.0.0'} + socket.io-parser@4.2.4: resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} engines: {node: '>=10.0.0'} - socket.io@4.7.5: - resolution: {integrity: sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==} + socket.io@4.8.1: + resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==} engines: {node: '>=10.2.0'} sonic-boom@2.8.0: @@ -5242,8 +5264,20 @@ packages: utf-8-validate: optional: true - ws@8.18.0: - resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -5273,6 +5307,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -5465,7 +5503,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.24.1 '@babel/types': 7.24.0 - debug: 4.4.0 + debug: 4.4.3 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -5930,7 +5968,7 @@ snapshots: '@fastify/websocket@8.3.1': dependencies: fastify-plugin: 4.5.1 - ws: 8.18.0 + ws: 8.18.3 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -6129,11 +6167,13 @@ snapshots: '@miniflare/core': 2.14.4 '@miniflare/shared': 2.14.4 undici: 5.28.4 - ws: 8.18.0 + ws: 8.18.3 transitivePeerDependencies: - bufferutil - utf-8-validate + '@msgpack/msgpack@3.1.2': {} + '@mswjs/interceptors@0.40.0': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -6170,7 +6210,7 @@ snapshots: cors: 2.8.5 express: 4.19.2 outvariant: 1.4.3 - socket.io: 4.7.5 + socket.io: 4.8.1 transitivePeerDependencies: - bufferutil - supports-color @@ -6389,8 +6429,6 @@ snapshots: dependencies: '@types/node': 18.19.28 - '@types/cookie@0.4.1': {} - '@types/cookies@0.9.0': dependencies: '@types/connect': 3.4.38 @@ -7447,8 +7485,6 @@ snapshots: cookie-signature@1.2.2: {} - cookie@0.4.2: {} - cookie@0.6.0: {} cookie@0.7.1: {} @@ -7704,20 +7740,31 @@ snapshots: dependencies: once: 1.4.0 + engine.io-client@6.6.3: + dependencies: + '@socket.io/component-emitter': 3.1.0 + debug: 4.3.7 + engine.io-parser: 5.2.2 + ws: 8.17.1 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + engine.io-parser@5.2.2: {} - engine.io@6.5.4: + engine.io@6.6.4: dependencies: - '@types/cookie': 0.4.1 '@types/cors': 2.8.17 '@types/node': 18.19.28 accepts: 1.3.8 base64id: 2.0.0 - cookie: 0.4.2 + cookie: 0.7.2 cors: 2.8.5 debug: 4.3.7 engine.io-parser: 5.2.2 - ws: 8.11.0 + ws: 8.17.1 transitivePeerDependencies: - bufferutil - supports-color @@ -8854,7 +8901,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.0.0 - ws: 8.18.0 + ws: 8.18.3 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -9996,6 +10043,17 @@ snapshots: - supports-color - utf-8-validate + socket.io-client@4.8.1: + dependencies: + '@socket.io/component-emitter': 3.1.0 + debug: 4.3.7 + engine.io-client: 6.6.3 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + socket.io-parser@4.2.4: dependencies: '@socket.io/component-emitter': 3.1.0 @@ -10003,13 +10061,13 @@ snapshots: transitivePeerDependencies: - supports-color - socket.io@4.7.5: + socket.io@4.8.1: dependencies: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.5 - debug: 4.3.4 - engine.io: 6.5.4 + debug: 4.3.7 + engine.io: 6.6.4 socket.io-adapter: 2.5.4 socket.io-parser: 4.2.4 transitivePeerDependencies: @@ -10769,7 +10827,9 @@ snapshots: ws@8.11.0: {} - ws@8.18.0: {} + ws@8.17.1: {} + + ws@8.18.3: {} ws@8.18.3: {} @@ -10777,6 +10837,8 @@ snapshots: xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.1.2: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/src/browser/index.ts b/src/browser/index.ts index 0eafbe76f..ec8ea3ffe 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -1,3 +1,9 @@ -export { setupWorker } from './setupWorker/setupWorker' -export type { SetupWorker, StartOptions } from './setupWorker/glossary' -export { SetupWorkerApi } from './setupWorker/setupWorker' +// export { setupWorker } from './setupWorker/setupWorker' +// export type { SetupWorker, StartOptions } from './setupWorker/glossary' +// export { SetupWorkerApi } from './setupWorker/setupWorker' + +export { + setupWorker, + type SetupWorkerApi, + type FindWorker, +} from './setup-worker' diff --git a/src/browser/setup-worker.ts b/src/browser/setup-worker.ts new file mode 100644 index 000000000..5be378a72 --- /dev/null +++ b/src/browser/setup-worker.ts @@ -0,0 +1,101 @@ +import { WebSocketInterceptor } from '@mswjs/interceptors/WebSocket' +import type { HttpHandler, WebSocketHandler } from '~/core' +import { + defineNetwork, + type NetworkHandlersApi, + type NetworkApi, +} from '~/core/new/define-network' +import { + fromLegacyOnUnhandledRequest, + UnhandledFrameStrategy, +} from '~/core/new/on-unhandled-frame' +import { UnhandledRequestCallback } from '~/core/utils/request/onUnhandledRequest' +import { NetworkSource } from '~/core/new/sources/index' +import { ServiceWorkerSource } from './sources/service-worker-source' +import { SetupWorkerFallbackSource } from './sources/setup-worker-fallback-source' +import { supportsServiceWorker } from './utils/supports' +import { InterceptorSource } from '~/core/new/sources/interceptor-source' + +const DEFAULT_WORKER_URL = '/mockServiceWorker.js' + +export interface SetupWorkerApi extends NetworkHandlersApi { + start: (options?: SetupWorkerStartOptions) => Promise + stop: () => void +} + +interface SetupWorkerStartOptions { + quiet?: boolean + serviceWorker?: { + url?: string | URL + options?: RegistrationOptions + } + findWorker?: FindWorker + onUnhandledRequest?: UnhandledFrameStrategy | UnhandledRequestCallback +} + +export type FindWorker = ( + scriptUrl: string, + mockServiceWorkerUrl: string, +) => boolean + +/** + * 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 { + let network: NetworkApi + + return { + async start(options) { + const httpSource: NetworkSource = supportsServiceWorker() + ? new ServiceWorkerSource({ + quiet: options?.quiet, + serviceWorker: { + url: + options?.serviceWorker?.url?.toString() || DEFAULT_WORKER_URL, + options: options?.serviceWorker?.options, + }, + findWorker: options?.findWorker, + }) + : new SetupWorkerFallbackSource() + + network = defineNetwork({ + sources: [ + httpSource, + /** + * @note Get WebSocket connections from the interceptor because + * Service Workers do not intercept WebSocket connections. + */ + new InterceptorSource({ + interceptors: [new WebSocketInterceptor() as any], + }), + ], + handlers, + onUnhandledFrame: fromLegacyOnUnhandledRequest( + () => options?.onUnhandledRequest, + ), + }) + + await network.enable() + }, + stop() { + network.disable() + }, + get listHandlers() { + return network.listHandlers.bind(network) + }, + get use() { + return network.use.bind(network) + }, + get resetHandlers() { + return network.resetHandlers.bind(network) + }, + get restoreHandlers() { + return network.restoreHandlers.bind(network) + }, + } +} diff --git a/src/browser/sources/service-worker-source.ts b/src/browser/sources/service-worker-source.ts new file mode 100644 index 000000000..eee3dd9ac --- /dev/null +++ b/src/browser/sources/service-worker-source.ts @@ -0,0 +1,397 @@ +import { invariant } from 'outvariant' +import { Emitter } from 'rettime' +import { FetchResponse } from '@mswjs/interceptors' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { HttpResponse, RequestHandler } from '~/core' +import { NetworkSource } from '~/core/new/sources/index' +import { + supportsReadableStreamTransfer, + supportsServiceWorker, +} from '../utils/supports' +import { WorkerChannel, WorkerChannelEventMap } from '../utils/workerChannel' +import { getWorkerInstance } from '../setupWorker/start/utils/getWorkerInstance' +import { deserializeRequest } from '../utils/deserializeRequest' +import { toResponseInit } from '~/core/utils/toResponseInit' +import { devUtils } from '~/core/utils/internal/devUtils' +import { HttpNetworkFrame } from '~/core/new/frames/http-frame' +import type { FindWorker } from '../setup-worker' + +interface ServiceWorkerSourceOptions { + quiet?: boolean + serviceWorker: { + url: string + options?: RegistrationOptions + } + findWorker?: FindWorker +} + +type RequestEvent = Emitter.EventType< + WorkerChannel, + 'REQUEST', + WorkerChannelEventMap +> + +type ResponseEvent = Emitter.EventType< + WorkerChannel, + 'RESPONSE', + WorkerChannelEventMap +> + +export class ServiceWorkerSource extends NetworkSource { + #framesMap: Map + #stoppedAt?: number + #channel: WorkerChannel + #workerPromise: DeferredPromise + #keepAliveInterval?: number + + constructor(private readonly options: ServiceWorkerSourceOptions) { + super() + + invariant( + supportsServiceWorker(), + 'Cannot use Service Worker network source: the Service Worker API is not supported', + ) + + this.#framesMap = new Map() + this.#workerPromise = new DeferredPromise() + this.#channel = new WorkerChannel({ + worker: this.#workerPromise, + }) + } + + public async enable(): Promise { + this.#stoppedAt = undefined + + if (this.#workerPromise.state !== 'pending') { + this.#workerPromise = new DeferredPromise() + } + + // Register the worker or get the active instance. + const registration = await this.#startWorker() + const pendingInstance = registration.installing || registration.waiting + + // Wait until the worker is activated and controlling the page. + if (pendingInstance) { + const activationPromise = new DeferredPromise() + pendingInstance.addEventListener('statechange', () => { + if (pendingInstance.state === 'activated') { + activationPromise.resolve() + } + }) + await activationPromise + } + + this.#channel.postMessage('MOCK_ACTIVATE') + + // Wait for the worker to confirm its activation. + const activationPromise = new DeferredPromise() + this.#channel.once('MOCKING_ENABLED', async (event) => { + activationPromise.resolve() + + this.#printStartMessage({ + registration, + client: event.data.client, + }) + }) + await activationPromise + + return registration + } + + public async disable() { + await super.disable() + + this.#framesMap.clear() + this.#stoppedAt = Date.now() + this.#channel.removeAllListeners() + } + + async #startWorker(): Promise { + const serviceWorkerUrl = this.options.serviceWorker.url + + // Register the Service Worker and gets its reference. + const [worker, registartion] = await getWorkerInstance( + serviceWorkerUrl, + this.options.serviceWorker.options, + this.options.findWorker || this.#defaultFindWorker, + ) + + if (!worker) { + 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 +`, + serviceWorkerUrl, + ) + : 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`, + serviceWorkerUrl, + location.host, + ) + + throw new Error(missingWorkerMessage) + } + + this.#workerPromise.resolve(worker) + this.#channel.on('REQUEST', this.#onRequest.bind(this)) + this.#channel.on('RESPONSE', this.#onResponse.bind(this)) + + 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. + this.#channel.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(this.#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' }) + }) + + // Ensure the registered worker and the library worker match. + 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, + ) + + return registartion + } + + async #onRequest(event: RequestEvent): Promise { + // Passthrough any requests performed after the interception was stopped. + if (this.#stoppedAt && event.data.interceptedAt > this.#stoppedAt) { + return event.postMessage('PASSTHROUGH') + } + + const request = deserializeRequest(event.data) + + // Clone the request and cache it so the first matching handler + // will skip the cloning phase altogether. + RequestHandler.cache.set(request, request.clone()) + + // Create an HTTP frame that taps into the underlying `MessageChannel` + // for scenario handling. Service Worker source only deals with HTTP requests. + const frame = new ServiceWorkerHttpNetworkFrame({ + event, + request, + }) + this.#framesMap.set(event.data.id, frame) + + await this.push(frame) + } + + async #onResponse(event: ResponseEvent) { + const { request, response, isMockedResponse } = event.data + const frame = this.#framesMap.get(request.id) + this.#framesMap.delete(request.id) + + invariant( + frame != null, + 'Failed to handle a worker response for request "%s %s": request frame is missing', + request.method, + request.url, + ) + + const fetchRequest = deserializeRequest(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 (response.type?.includes('opaque')) { + return + } + + const fetchResponse = + response.status === 0 + ? Response.error() + : new FetchResponse(response.body, response) + + frame.events.emit( + isMockedResponse ? 'response:mocked' : 'response:bypass', + { + request: fetchRequest, + requestId: request.id, + response: fetchResponse, + }, + ) + } + + #defaultFindWorker: FindWorker = (scriptUrl, mockServiceWorkerUrl) => { + return scriptUrl === 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 + } + + #printStartMessage(args: { + registration: ServiceWorkerRegistration + client: WorkerChannelEventMap['MOCKING_ENABLED']['data']['client'] + }): void { + if (this.options.quiet) { + return + } + + const { registration, client } = args + const serviceWorkerUrl = this.options.serviceWorker.url + + 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:', serviceWorkerUrl) + + // 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() + } +} + +class ServiceWorkerHttpNetworkFrame extends HttpNetworkFrame { + #event: RequestEvent + + constructor(args: { request: Request; event: RequestEvent }) { + super({ request: args.request }) + this.#event = args.event + } + + 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.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`, + this.data.request.method, + this.data.request.url, + reason.stack ?? reason, + ) + + // 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', + }, + ), + ) + } + } + + public passthrough(): void { + this.#event.postMessage('PASSTHROUGH') + } + + async #respondWith(response: Response): Promise { + let responseBody: ReadableStream | ArrayBuffer | null + let transfer: [ReadableStream] | undefined + + const responseInit = toResponseInit(response) + + // Decide whether to transfer the stream in the environments + // that support that or exhaust the stream and send the response body + // as a buffer. + if (supportsReadableStreamTransfer()) { + responseBody = response.body + transfer = response.body ? [response.body] : undefined + } else { + responseBody = + response.body == null ? null : await response.clone().arrayBuffer() + } + + this.#event.postMessage( + 'MOCK_RESPONSE', + { + ...responseInit, + body: responseBody, + }, + transfer, + ) + } +} diff --git a/src/browser/sources/setup-worker-fallback-source.ts b/src/browser/sources/setup-worker-fallback-source.ts new file mode 100644 index 000000000..010ae9d71 --- /dev/null +++ b/src/browser/sources/setup-worker-fallback-source.ts @@ -0,0 +1,33 @@ +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { InterceptorSource } from '~/core/new/sources/interceptor-source' +import { devUtils } from '~/core/utils/internal/devUtils' + +export class SetupWorkerFallbackSource extends InterceptorSource { + constructor() { + super({ + interceptors: [new XMLHttpRequestInterceptor(), new FetchInterceptor()], + }) + } + + public async enable(): Promise { + await super.enable() + this.#printStartMessage() + } + + #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() + } +} diff --git a/src/browser/tsconfig.browser.json b/src/browser/tsconfig.browser.json index 6a44514da..b8918d2a8 100644 --- a/src/browser/tsconfig.browser.json +++ b/src/browser/tsconfig.browser.json @@ -1,9 +1,10 @@ { - "extends": "../tsconfig.src.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { + "composite": true, // 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"] + "include": ["./global.browser.d.ts", "./**/*.ts"] } diff --git a/src/core/HttpResponse.ts b/src/core/HttpResponse.ts index 8a1de4284..cc050a8b5 100644 --- a/src/core/HttpResponse.ts +++ b/src/core/HttpResponse.ts @@ -176,7 +176,7 @@ export class HttpResponse< responseInit.headers.set('Content-Length', body.byteLength.toString()) } - return new HttpResponse(body, responseInit) + return new HttpResponse(body as any, responseInit) } /** diff --git a/src/core/SetupApi.ts b/src/core/SetupApi.ts index e908e5994..af768c542 100644 --- a/src/core/SetupApi.ts +++ b/src/core/SetupApi.ts @@ -2,6 +2,7 @@ import { invariant } from 'outvariant' import { EventMap, Emitter } from 'strict-event-emitter' import { RequestHandler } from './handlers/RequestHandler' import { LifeCycleEventEmitter } from './sharedOptions' +import { isObject } from './utils/internal/isObject' import { devUtils } from './utils/internal/devUtils' import { pipeEvents } from './utils/internal/pipeEvents' import { toReadonlyArray } from './utils/internal/toReadonlyArray' @@ -51,7 +52,7 @@ export abstract class SetupApi extends Disposable { public readonly events: LifeCycleEventEmitter - constructor(...initialHandlers: Array) { + constructor(initialHandlers: Array) { super() invariant( @@ -65,7 +66,22 @@ export abstract class SetupApi extends Disposable { this.emitter = new Emitter() this.publicEmitter = new Emitter() - pipeEvents(this.emitter, this.publicEmitter) + pipeEvents(this.emitter, this.publicEmitter, (_, ...data) => { + /** + * @note Prevent forwarding of internal HTTP requests to the public emitter. + * Those requests, such as the one for remote interception handshake, must never + * surface to the developer. + * + * @fixme This isn't nice. It leaks specific event types into this generic API. + * Find a better way for this, and for life-cycle events in general. + */ + return !( + isObject(data[0]) && + 'request' in data[0] && + data[0].request instanceof Request && + data[0].request?.headers.get('accept')?.includes('msw/internal') + ) + }) this.events = this.createLifeCycleEvents() diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts index 22ea65f6e..ebceda3b7 100644 --- a/src/core/handlers/WebSocketHandler.ts +++ b/src/core/handlers/WebSocketHandler.ts @@ -13,6 +13,7 @@ import { } 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 @@ -138,6 +139,10 @@ export class WebSocketHandler { // This is what the developer adds listeners for. return this[kEmitter].emit('connection', connection) } + + public log(connection: WebSocketConnectionData): void { + attachWebSocketLogger(connection) + } } function createStopPropagationListener(handler: WebSocketHandler) { diff --git a/src/core/new/define-network.ts b/src/core/new/define-network.ts new file mode 100644 index 000000000..29c4ed5b7 --- /dev/null +++ b/src/core/new/define-network.ts @@ -0,0 +1,115 @@ +import { Emitter } from 'strict-event-emitter' +import { NetworkSource } from './sources/index' +import { + type AnyHandler, + type HandlersController, + inMemoryHandlersController, +} from './handlers-controller' +import { toReadonlyArray } from '../utils/internal/toReadonlyArray' +import { resolveNetworkFrame } from './resolve-network-frame' +import { + onUnhandledFrame, + type UnhandledFrameCallback, + type UnhandledFrameStrategy, +} from './on-unhandled-frame' +import { pipeEvents } from '../utils/internal/pipeEvents' + +export interface DefineNetworkOptions { + /** + * A list of network sources. + */ + sources: Array + + /** + * A list of the initial handlers. + */ + handlers: Array + + /** + * A custom handlers controller to use. + * Use this to customize how handlers are storeded (e.g. in memory, `AsyncLocalStorage`, etc). + */ + handlersController?: HandlersController + + /** + * A fixed strategy or a custom callback for dealing with unhandled frames. + */ + onUnhandledFrame?: UnhandledFrameStrategy | UnhandledFrameCallback + + quiet?: boolean +} + +export interface NetworkApi extends NetworkHandlersApi { + /** + * Enables the network interception. + */ + enable(): Promise + + /** + * Disables the network interception. + */ + disable(): Promise + + /** + * @fixme Infer the EventMap type from the sources passed to this network. + */ + events: Emitter +} + +export interface NetworkHandlersApi { + use(...handlers: Array): void + resetHandlers(...nextHandlers: Array): void + restoreHandlers(): void + listHandlers(): ReadonlyArray +} + +export function defineNetwork(options: DefineNetworkOptions): NetworkApi { + const source = NetworkSource.from(...options.sources) + const events = new Emitter() + + const handlersController = + options.handlersController || inMemoryHandlersController(options.handlers) + + return { + async enable() { + source.on('frame', async (event) => { + const frame = event.data + pipeEvents(frame.events, events) + + await resolveNetworkFrame(frame, handlersController.currentHandlers(), { + quiet: options?.quiet, + async unhandled() { + await onUnhandledFrame( + frame, + options.onUnhandledFrame || 'bypass', + ).catch((error) => { + frame.errorWith(error) + }) + }, + }) + }) + + await source.enable() + }, + async disable() { + await source.disable() + }, + use(...handlers) { + handlersController.use(handlers) + }, + resetHandlers(...nextHandlers) { + handlersController.reset(nextHandlers) + }, + restoreHandlers() { + for (const handler of handlersController.currentHandlers()) { + if ('isUsed' in handler) { + handler.isUsed = false + } + } + }, + listHandlers() { + return toReadonlyArray(handlersController.currentHandlers()) + }, + events, + } +} diff --git a/src/core/new/frames/base-frame.ts b/src/core/new/frames/base-frame.ts new file mode 100644 index 000000000..f7da4a1a6 --- /dev/null +++ b/src/core/new/frames/base-frame.ts @@ -0,0 +1,26 @@ +import { Emitter, type EventMap } from 'strict-event-emitter' + +export abstract class BaseNetworkFrame< + Protocol extends string, + Data, + Events extends EventMap, +> { + public events: Emitter + + constructor( + public readonly protocol: Protocol, + public readonly data: Data, + ) { + this.events = new Emitter() + } + + /** + * Error the underlying network message. + */ + public abstract errorWith(reason?: unknown): unknown + + /** + * Get a message to be used when this frame goes unhandled. + */ + public abstract getUnhandledFrameMessage(): Promise +} diff --git a/src/core/new/frames/http-frame.ts b/src/core/new/frames/http-frame.ts new file mode 100644 index 000000000..b3d53b313 --- /dev/null +++ b/src/core/new/frames/http-frame.ts @@ -0,0 +1,85 @@ +import { createRequestId } from '@mswjs/interceptors' +import { BaseNetworkFrame } from './base-frame' +import { toPublicUrl } from '../../utils/request/toPublicUrl' + +type HttpNetworkFrameEventMap = { + 'request:start': [ + args: { + request: Request + requestId: string + }, + ] + 'request:match': [ + args: { + request: Request + requestId: string + }, + ] + 'request:unhandled': [ + args: { + request: Request + requestId: string + }, + ] + 'request:end': [ + args: { + request: Request + requestId: string + }, + ] + 'response:mocked': [ + args: { + request: Request + requestId: string + response: Response + }, + ] + 'response:bypass': [ + args: { + request: Request + requestId: string + response: Response + }, + ] + unhandledException: [ + args: { + error: Error + request: Request + requestId: string + }, + ] +} + +export abstract class HttpNetworkFrame extends BaseNetworkFrame< + 'http', + { + id?: string + request: Request + }, + HttpNetworkFrameEventMap +> { + public id: string + + constructor(args: { id?: string; request: Request }) { + super('http', { request: args.request }) + this.id = args.id || createRequestId() + } + + public abstract respondWith(response?: Response): void + public abstract errorWith(reason?: unknown): void + public abstract passthrough(): void + + public async getUnhandledFrameMessage(): 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 + } +} diff --git a/src/core/new/frames/websocket-frame.ts b/src/core/new/frames/websocket-frame.ts new file mode 100644 index 000000000..16064a310 --- /dev/null +++ b/src/core/new/frames/websocket-frame.ts @@ -0,0 +1,37 @@ +import { WebSocketConnectionData } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' +import { BaseNetworkFrame } from './base-frame' + +type WebSocketNetworkFrameEventMap = { + 'websocket:connection': [ + args: { + url: URL + protocols: string | Array | undefined + }, + ] +} + +export abstract class WebSocketNetworkFrame extends BaseNetworkFrame< + 'ws', + { + connection: WebSocketConnectionData + }, + WebSocketNetworkFrameEventMap +> { + constructor(args: { connection: WebSocketConnectionData }) { + super('ws', { + connection: args.connection, + }) + } + + /** + * Establish the intercepted WebSocket connection as-is. + */ + public abstract passthrough(): unknown + + public async getUnhandledFrameMessage(): 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/new/handlers-controller.ts b/src/core/new/handlers-controller.ts new file mode 100644 index 000000000..d4ef2aa90 --- /dev/null +++ b/src/core/new/handlers-controller.ts @@ -0,0 +1,79 @@ +import { invariant } from 'outvariant' +import { RequestHandler } from '../handlers/RequestHandler' +import { WebSocketHandler } from '../handlers/WebSocketHandler' +import { devUtils } from '../utils/internal/devUtils' + +export type AnyHandler = RequestHandler | WebSocketHandler + +export type HandlersControllerFunction = + (initialHandlers: Array) => Controller + +export interface HandlersController { + currentHandlers(): Array + use(nextHandlers: Array): void + reset(nextHandlers: Array): void +} + +export function validateHandlers(handlers: Array): boolean { + return handlers.every((handler) => !Array.isArray(handler)) +} + +export function defineHandlersController< + Controller extends HandlersController = HandlersController, +>( + init: HandlersControllerFunction, +): HandlersControllerFunction { + return (initialHandlers) => { + invariant( + validateHandlers(initialHandlers), + devUtils.formatMessage( + 'Failed to create a handlers controller: invalid handlers. Did you forget to spread the handlers array?', + ), + ) + + const api = init(initialHandlers) + + return { + ...api, + use(nextHandlers) { + invariant( + validateHandlers(nextHandlers), + devUtils.formatMessage( + 'Failed to prepend runtime handlers: invalid handlers. Did you forget to spread the handlers array?', + ), + ) + + return api.use(nextHandlers) + }, + reset(nextHandlers) { + invariant( + nextHandlers.length > 0 ? validateHandlers(nextHandlers) : true, + devUtils.formatMessage( + 'Failed to replace initial handlers during reset: invalid handlers. Did you forget to spread the handlers array?', + ), + ) + + return api.reset(nextHandlers) + }, + } + } +} + +export const inMemoryHandlersController = defineHandlersController( + (initialHandlers) => { + let handlers = [...initialHandlers] + + return { + currentHandlers() { + return handlers + }, + use(nextHandlers) { + handlers.unshift(...nextHandlers) + }, + reset(nextHandlers) { + handlers = + nextHandlers.length > 0 ? [...nextHandlers] : [...initialHandlers] + }, + } + }, +) diff --git a/src/core/new/on-unhandled-frame.ts b/src/core/new/on-unhandled-frame.ts new file mode 100644 index 000000000..b68773bd3 --- /dev/null +++ b/src/core/new/on-unhandled-frame.ts @@ -0,0 +1,105 @@ +import { isCommonAssetRequest } from '../isCommonAssetRequest' +import { devUtils, InternalError } from '../utils/internal/devUtils' +import { UnhandledRequestStrategy } from '../utils/request/onUnhandledRequest' +import type { NetworkFrame } from './sources/index' + +export type UnhandledFrameStrategy = 'bypass' | 'warn' | 'error' + +export type UnhandledFrameDefaults = { + warn: () => void + error: () => void +} + +export type UnhandledFrameCallback = (args: { + frame: NetworkFrame + defaults: UnhandledFrameDefaults +}) => Promise | void + +export async function onUnhandledFrame( + frame: NetworkFrame, + strategyOrCallback: UnhandledFrameStrategy | UnhandledFrameCallback, +): Promise { + const applyStrategy = async (strategy: UnhandledFrameStrategy) => { + if (strategy === 'bypass') { + return + } + + const message = await frame.getUnhandledFrameMessage() + + switch (strategy) { + case 'warn': { + return devUtils.warn('Warning: %s', message) + } + + case 'error': { + // Print a developer-friendly error. + devUtils.error('Error: %s', message) + + return Promise.reject( + new InternalError( + devUtils.formatMessage( + 'Cannot bypass a request when using the "error" strategy for the "onUnhandledRequest" option.', + ), + ), + ) + } + + default: { + throw new InternalError( + 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 satisfies never, + ), + ) + } + } + } + + if (typeof strategyOrCallback === 'function') { + return strategyOrCallback({ + frame, + defaults: { + warn: applyStrategy.bind(null, 'warn'), + error: applyStrategy.bind(null, 'warn'), + }, + }) + } + + // Ignore static assets, framework/bundler requests, modules served via HTTP. + if (frame.protocol === 'http' && isCommonAssetRequest(frame.data.request)) { + return + } + + await applyStrategy(strategyOrCallback) +} + +export function fromLegacyOnUnhandledRequest( + getLegacyValue: () => UnhandledRequestStrategy | undefined, +): UnhandledFrameCallback { + return ({ frame, defaults }) => { + const legacyOnUnhandledRequestStrategy = getLegacyValue() + + if (legacyOnUnhandledRequestStrategy === undefined) { + return + } + + if (typeof legacyOnUnhandledRequestStrategy === 'function') { + const request = + frame.protocol === 'http' + ? frame.data.request + : new Request(frame.data.connection.client.url, { + headers: { + connection: 'upgrade', + upgrade: 'websocket', + }, + }) + + return legacyOnUnhandledRequestStrategy(request, { + warning: defaults.warn, + error: defaults.error, + }) + } + + return onUnhandledFrame(frame, legacyOnUnhandledRequestStrategy) + } +} diff --git a/src/core/new/resolve-network-frame.ts b/src/core/new/resolve-network-frame.ts new file mode 100644 index 000000000..71dfb9346 --- /dev/null +++ b/src/core/new/resolve-network-frame.ts @@ -0,0 +1,173 @@ +import { until } from 'until-async' +import { isHandlerKind } from '../utils/internal/isHandlerKind' +import type { AnyHandler } from './handlers-controller' +import { NetworkFrame } from './sources/index' +import { + isPassthroughResponse, + shouldBypassRequest, +} from '../utils/internal/requestUtils' +import { executeHandlers } from '../utils/executeHandlers' +import { storeResponseCookies } from '../utils/request/storeResponseCookies' +import { HttpNetworkFrame } from './frames/http-frame' +import { WebSocketNetworkFrame } from './frames/websocket-frame' + +export interface ResolveNetworkFlow { + quiet?: boolean + skip?(): void + handled?(args: { frame: NetworkFrame; handler: AnyHandler }): void + unhandled?(): void +} + +/** + * Resolve a network frame against the given list of handlers. + * @param frame A network frame. + * @param handlers A list of handlers. + * @returns A boolean indicating whether this frame has been handled. + */ +export async function resolveNetworkFrame( + frame: NetworkFrame, + handlers: Array, + flow: ResolveNetworkFlow, +): Promise { + if (frame.protocol === 'http') { + return resolveHttpNetworkFrame(frame, handlers, flow) + } + + if (frame.protocol === 'ws') { + return resolveWebSocketNetworkFrame(frame, handlers, flow) + } + + throw new Error( + // @ts-expect-error Runtime error. + `Failed to resolve a network frame: unsupported protocol "${frame.protocol}"`, + ) +} + +async function resolveHttpNetworkFrame( + frame: HttpNetworkFrame, + handlers: Array, + flow: ResolveNetworkFlow, +): Promise { + const requestId = frame.id + const request = frame.data.request + const requestCloneForLogs = flow.quiet ? null : request.clone() + + frame.events.emit('request:start', { request, requestId }) + + // Perform requests wrapped in "bypass()" as-is. + if (shouldBypassRequest(request)) { + frame.passthrough() + return flow.skip?.() + } + + const requestHandlers = handlers.filter(isHandlerKind('RequestHandler')) + + const [lookupError, lookupResult] = await until(() => { + return executeHandlers({ + request, + requestId, + handlers: requestHandlers, + resolutionContext: {}, + }) + }) + + if (lookupError) { + // Allow developers to react to unhandled exceptions in request handlers. + frame.events.emit('unhandledException', { + error: lookupError, + request, + requestId, + }) + frame.errorWith(lookupError) + return flow.skip?.() + } + + // If the handler lookup returned nothing, no request handler was found + // matching this request. Report the request as unhandled. + if (!lookupResult) { + frame.events.emit('request:unhandled', { request, requestId }) + frame.events.emit('request:end', { request, requestId }) + frame.passthrough() + return flow.unhandled?.() + } + + const { response, handler, parsedResult } = lookupResult + + // When the handled request returned no mocked response, warn the developer, + // as it may be an oversight on their part. Perform the request as-is. + if (!response) { + frame.events.emit('request:end', { request, requestId }) + frame.passthrough() + return flow.skip?.() + } + + // Perform the request as-is when the developer explicitly returned `passthrough()`. + // This produces no warning as the request was handled. + if (isPassthroughResponse(response)) { + frame.events.emit('request:end', { request, requestId }) + frame.passthrough() + return flow.skip?.() + } + + // Store all the received response cookies in the cookie jar. + await storeResponseCookies(request, response) + + frame.events.emit('request:match', { request, requestId }) + + frame.respondWith(response.clone()) + + frame.events.emit('request:end', { request, requestId }) + + if (!flow.quiet) { + // Log mocked responses. Use the Network tab to observe the original network. + handler.log({ + request: requestCloneForLogs!, + response, + parsedResult, + }) + } + + return flow.handled?.({ + frame, + handler, + }) +} + +async function resolveWebSocketNetworkFrame( + frame: WebSocketNetworkFrame, + handlers: Array, + flow: ResolveNetworkFlow, +): Promise { + const { connection } = frame.data + const eventHandlers = handlers.filter(isHandlerKind('EventHandler')) + + frame.events.emit('websocket:connection', { + url: connection.client.url, + protocols: connection.info.protocols, + }) + + if (eventHandlers.length > 0) { + await Promise.all( + eventHandlers.map(async (handler) => { + // Foward the connection data to every WebSocket handler. + // This is equivalent to dispatching the connection event + // onto multiple listeners. + const matches = await handler.run(connection) + + if (matches) { + // Invoke the callback for each matched event handler. + flow?.handled?.({ frame, handler }) + + if (!flow?.quiet) { + handler.log(connection) + } + } + }), + ) + + return + } + + flow?.unhandled?.() + frame.passthrough() +} diff --git a/src/core/new/sources/index.ts b/src/core/new/sources/index.ts new file mode 100644 index 000000000..0179ce508 --- /dev/null +++ b/src/core/new/sources/index.ts @@ -0,0 +1,74 @@ +import { type DefaultEventMap, Emitter, TypedEvent } from 'rettime' +import type { HttpNetworkFrame } from '../frames/http-frame' +import type { WebSocketNetworkFrame } from '../frames/websocket-frame' + +export type NetworkFrame = HttpNetworkFrame | WebSocketNetworkFrame + +interface NetworkSourceEventMap extends DefaultEventMap { + frame: TypedEvent +} + +export abstract class NetworkSource { + /** + * Combines multiple network sources into one. + * @param sources A list of network sources. + */ + static from(...sources: Array): NetworkSource { + return sources.length > 1 ? new BatchNetworkSource(sources) : sources[0] + } + + protected emitter: Emitter + + public on: Emitter['on'] + + constructor() { + this.emitter = new Emitter() + this.on = this.emitter.on.bind(this.emitter) + } + + /** + * Enable this source and start the network interception. + */ + public abstract enable(): Promise + + /** + * Push a new network frame to the underlying handlers. + * @returns {Promise} A Promise that resolves when the handlers + * are done handling this frame. + */ + public async push(frame: NetworkFrame): Promise { + await this.emitter.emitAsPromise(new TypedEvent('frame', { data: frame })) + } + + /** + * Disable this source and stop the network interception. + */ + public async disable(): Promise { + this.emitter.removeAllListeners() + } +} + +class BatchNetworkSource extends NetworkSource { + constructor(private readonly sources: Array) { + super() + + this.on = (...args: any[]): any => { + for (const source of sources) { + source.on(args[0], args[1]) + } + } + } + + public async enable(): Promise { + await Promise.all(this.sources.map((source) => source.enable())) + } + + public async push(frame: NetworkFrame): Promise { + await Promise.all(this.sources.map((source) => source.push(frame))) + } + + public async disable(): Promise { + await Promise.all(this.sources.map((source) => source.disable())) + await super.disable() + } +} diff --git a/src/core/new/sources/interceptor-source.ts b/src/core/new/sources/interceptor-source.ts new file mode 100644 index 000000000..373c69101 --- /dev/null +++ b/src/core/new/sources/interceptor-source.ts @@ -0,0 +1,189 @@ +import { + BatchInterceptor, + Interceptor, + HttpRequestEventMap, + RequestController, +} from '@mswjs/interceptors' +import type { + WebSocketConnectionData, + WebSocketEventMap, +} from '@mswjs/interceptors/WebSocket' +import { NetworkSource } from './index' +import { HttpNetworkFrame } from '../frames/http-frame' +import { WebSocketNetworkFrame } from '../frames/websocket-frame' +import { deleteRequestPassthroughHeader } from '../../utils/internal/requestUtils' +import { InternalError } from '../../utils/internal/devUtils' + +interface InterceptorSourceOptions { + interceptors: Array> +} + +/** + * Create a network source from the given interceptors. + */ +export class InterceptorSource extends NetworkSource { + #interceptor: BatchInterceptor< + Array>, + HttpRequestEventMap | WebSocketEventMap + > + #httpFrames: Map + + constructor(options: InterceptorSourceOptions) { + super() + + this.#interceptor = new BatchInterceptor({ + name: 'interceptor-source', + interceptors: options.interceptors, + }) + this.#httpFrames = new Map() + } + + public async enable(): Promise { + this.#interceptor.apply() + + /** + * @fixme Incorrect disciminated union when merging multiple + * events map in `BatchInterceptor` (results in `Listener`). + */ + this.#interceptor + .on('request', this.#onRequest.bind(this) as any) + .on('response', this.#onResponse.bind(this) as any) + + this.#interceptor.on( + 'connection', + this.#onWebSocketConnection.bind(this) as any, + ) + } + + public async disable(): Promise { + await super.disable() + this.#httpFrames.clear() + this.#interceptor.dispose() + } + + async #onRequest({ + requestId, + request, + controller, + }: HttpRequestEventMap['request'][0]) { + const httpFrame = new InterceptorHttpNetworkFrame({ + id: requestId, + request, + controller, + }) + + this.#httpFrames.set(requestId, httpFrame) + + httpFrame.events.on('unhandledException', ({ error }) => { + // Throw the errors intended for the developer as-is. + // Those must not be coerced into 500 responses. + if (error instanceof InternalError) { + throw error + } + }) + + await this.push(httpFrame) + } + + #onResponse({ + requestId, + request, + response, + isMockedResponse, + }: HttpRequestEventMap['response'][0]): void { + const httpFrame = this.#httpFrames.get(requestId) + this.#httpFrames.delete(requestId) + + if (!httpFrame) { + return + } + + httpFrame.events.emit( + isMockedResponse ? 'response:mocked' : 'response:bypass', + { + requestId, + request, + response, + }, + ) + } + + async #onWebSocketConnection(connection: WebSocketEventMap['connection'][0]) { + const webSocketFrame = new InterceptorWebSocketNetworkFrame({ + connection, + }) + + await this.push(webSocketFrame) + } +} + +class InterceptorHttpNetworkFrame extends HttpNetworkFrame { + #controller: RequestController + + constructor(args: { + id: string + request: Request + controller: RequestController + }) { + super({ + id: args.id, + request: args.request, + }) + + this.#controller = args.controller + } + + 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 as any) + } + + throw reason + } + + public passthrough(): void { + deleteRequestPassthroughHeader(this.data.request) + return + } +} + +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/passthrough.test.ts b/src/core/passthrough.test.ts index 5a7a5ee21..51df077cd 100644 --- a/src/core/passthrough.test.ts +++ b/src/core/passthrough.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { passthrough } from './passthrough' -it('creates a 302 response with the intention header', () => { +it('creates a 302 response with the correct request intention header', () => { const response = passthrough() expect(response).toBeInstanceOf(Response) diff --git a/src/core/passthrough.ts b/src/core/passthrough.ts index 7d28f83f6..a6acb133c 100644 --- a/src/core/passthrough.ts +++ b/src/core/passthrough.ts @@ -1,3 +1,7 @@ +import { + REQUEST_INTENTION_HEADER_NAME, + RequestIntention, +} from './utils/internal/requestUtils' import type { HttpResponse } from './HttpResponse' /** @@ -19,7 +23,7 @@ export function passthrough(): HttpResponse { status: 302, statusText: 'Passthrough', headers: { - 'x-msw-intention': 'passthrough', + [REQUEST_INTENTION_HEADER_NAME]: RequestIntention.passthrough, }, }) as HttpResponse } diff --git a/src/core/rpc/events.ts b/src/core/rpc/events.ts new file mode 100644 index 000000000..6a4c9371d --- /dev/null +++ b/src/core/rpc/events.ts @@ -0,0 +1,18 @@ +import type { + SerializedRequest, + SerializedResponse, +} from './packets/http-packet' + +export type StreamEventMap = { + 'stream:chunk': (chunk: Uint8Array | undefined) => void + 'stream:error': (reason?: unknown) => void + 'stream:end': () => void +} + +export type NetworkSessionEventMap = StreamEventMap & { + request: (request: SerializedRequest) => void +} + +export type RpcServerEventMap = StreamEventMap & { + response: (response: SerializedResponse | undefined) => void +} diff --git a/src/core/rpc/handlers/remote-request-handler.ts b/src/core/rpc/handlers/remote-request-handler.ts new file mode 100644 index 000000000..af82975be --- /dev/null +++ b/src/core/rpc/handlers/remote-request-handler.ts @@ -0,0 +1,84 @@ +import { + RequestHandler, + type RequestHandlerDefaultInfo, +} from '../../handlers/RequestHandler' +import type { ResponseResolutionContext } from '../../utils/executeHandlers' +import { NetworkHttpTransport } from '../transports/http-transport' + +interface RemoteRequestHandlerParsedResult { + response: Response | undefined +} + +type RemoteRequestHandlerResolverExtras = { + response: Response | undefined +} + +export class RemoteRequestHandler extends RequestHandler< + RequestHandlerDefaultInfo, + RemoteRequestHandlerParsedResult, + RemoteRequestHandlerResolverExtras +> { + #transport: NetworkHttpTransport + + constructor(args: { port: number }) { + super({ + info: { + header: 'RemoteRequestHandler', + }, + resolver({ response }: RemoteRequestHandlerResolverExtras) { + return response + }, + }) + + this.#transport = new NetworkHttpTransport({ + port: args.port, + }) + } + + async parse(args: { + request: Request + resolutionContext?: ResponseResolutionContext + }): Promise { + const response = await this.#transport + .handleRequest({ request: args.request }) + .catch(() => undefined) + + const parsedResult = await super.parse(args) + + if (response != null) { + parsedResult.response = response + } + + return parsedResult + } + + predicate(args: { + request: Request + parsedResult: RemoteRequestHandlerParsedResult + resolutionContext?: ResponseResolutionContext + }): boolean | Promise { + // This handler is considered matching if the remote process + // decided to handle the intercepted request. + return args.parsedResult.response != null + } + + protected extendResolverArgs(args: { + request: Request + parsedResult: RemoteRequestHandlerParsedResult + }): RemoteRequestHandlerResolverExtras { + const resolverArgs = super.extendResolverArgs(args) + resolverArgs.response = args.parsedResult.response + return resolverArgs + } + + log(_args: { + request: Request + response: Response + parsedResult: RemoteRequestHandlerParsedResult + }): void { + /** + * @note Skip logging. This is an internal request handler. + */ + return + } +} diff --git a/src/core/rpc/handlers/remote-websocket-handler.ts b/src/core/rpc/handlers/remote-websocket-handler.ts new file mode 100644 index 000000000..096a48069 --- /dev/null +++ b/src/core/rpc/handlers/remote-websocket-handler.ts @@ -0,0 +1,45 @@ +import { until } from 'until-async' +import { + WebSocketHandler, + type WebSocketHandlerConnection, + type WebSocketResolutionContext, +} from '../../handlers/WebSocketHandler' +import { NetworkSession } from '../session' +import { NetworkWebSocketTransport } from '../transports/websocket-transport' + +export class RemoteWebSocketHandler extends WebSocketHandler { + #transport: NetworkWebSocketTransport + + constructor(args: { session: NetworkSession }) { + super(/.*/) + + this.#transport = new NetworkWebSocketTransport({ + session: args.session, + }) + } + + public async run( + connection: Omit, + resolutionContext?: WebSocketResolutionContext, + ): Promise { + const [error, remoteConnection] = await until(() => { + return this.#transport.send({ + clientUrl: connection.client.url, + resolutionContext, + }) + }) + + /** + * @todo Check if rejecting with no reason still falls through this check. + */ + if (error) { + return false + } + + /** + * @todo How will this work if THIS process has a WS client emitting events + * and THAT process has event listeners? Should we serialize those events now? + */ + return this.connect(remoteConnection) + } +} diff --git a/src/core/rpc/packets/http-packet.ts b/src/core/rpc/packets/http-packet.ts new file mode 100644 index 000000000..59b826c91 --- /dev/null +++ b/src/core/rpc/packets/http-packet.ts @@ -0,0 +1,101 @@ +import type { Socket } from 'socket.io-client' +import { DeferredPromise } from '@open-draft/deferred-promise' +import type { NetworkPacket } from '.' +import type { SessionSocket } from '../session' +import { emitReadableStream, WebSocketReadableStreamSource } from '../utils' +import type { StreamEventMap } from '../events' + +export interface SerializedRequest { + method: string + url: string + headers: Array<[string, string]> + hasBodyStream: boolean +} + +export interface SerializedResponse { + status: number + statusText: string + headers: Array<[string, string]> + hasBodyStream: boolean +} + +export class HttpPacket implements NetworkPacket { + constructor(private readonly request: Request) {} + + async send(socket: SessionSocket): Promise { + const intentionPromise = new DeferredPromise() + const serializedRequest = serializeHttpRequest(this.request) + + socket.emit('request', serializedRequest) + + if (this.request.body != null) { + emitReadableStream(this.request.body, socket) + } + + socket.once('response', (serializedResponse) => { + const response = + serializedResponse != null + ? deserializeHttpResponse(serializedResponse, socket) + : undefined + + intentionPromise.resolve(response) + }) + + return intentionPromise + } +} + +export function serializeHttpRequest(request: Request): SerializedRequest { + return { + method: request.method, + url: request.url, + headers: Array.from(request.headers), + hasBodyStream: request.body != null, + } +} + +/** + * Creates a Fetch API `Request` out of the serialized request frame + * and a potential body stream emitted over the given `socket` instance. + */ +export function deserializeHttpRequest( + serializedRequest: SerializedRequest, + socket: Socket, +): Request { + const { method, url, headers, hasBodyStream } = serializedRequest + + return new Request(url, { + method, + headers, + body: hasBodyStream + ? new ReadableStream(new WebSocketReadableStreamSource(socket)) + : null, + }) +} + +export function serializeHttpResponse(response: Response): SerializedResponse { + return { + status: response.status, + statusText: response.statusText, + headers: Array.from(response.headers), + hasBodyStream: response.body != null, + } +} + +export function deserializeHttpResponse( + serializedResponse: SerializedResponse, + socket: SessionSocket, +): Response { + const { status, statusText, headers, hasBodyStream } = serializedResponse + + return new Response( + hasBodyStream + ? new ReadableStream(new WebSocketReadableStreamSource(socket)) + : null, + { + status, + statusText, + headers, + }, + ) +} diff --git a/src/core/rpc/packets/index.ts b/src/core/rpc/packets/index.ts new file mode 100644 index 000000000..4564f727c --- /dev/null +++ b/src/core/rpc/packets/index.ts @@ -0,0 +1,5 @@ +import type { SessionSocket } from '../session' + +export interface NetworkPacket { + send(socket: SessionSocket): Promise +} diff --git a/src/core/rpc/packets/websocket-packet.ts b/src/core/rpc/packets/websocket-packet.ts new file mode 100644 index 000000000..f63f54dc3 --- /dev/null +++ b/src/core/rpc/packets/websocket-packet.ts @@ -0,0 +1,19 @@ +import type { NetworkPacket } from '.' +import type { WebSocketResolutionContext } from '../../handlers/WebSocketHandler' +import type { SessionSocket } from '../session' + +export class WebSocketPacket implements NetworkPacket { + constructor( + private readonly args: { + url: string + resolutionContext?: WebSocketResolutionContext + }, + ) {} + + async send(socket: SessionSocket): Promise { + // 1. Create a frame that describe this WS connection. + // 2. Send it over the `ws`. + // 3. (?) Return the response? + socket.send('...TODO...') + } +} diff --git a/src/core/rpc/server.ts b/src/core/rpc/server.ts new file mode 100644 index 000000000..7e62db2ae --- /dev/null +++ b/src/core/rpc/server.ts @@ -0,0 +1,83 @@ +import * as http from 'node:http' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { Server } from 'socket.io' +import type { Socket } from 'socket.io-client' +import { Emitter, TypedEvent } from 'rettime' +import type { NetworkSessionEventMap, StreamEventMap } from './events' +import { + deserializeHttpRequest, + serializeHttpResponse, +} from './packets/http-packet' +import { emitReadableStream } from './utils' + +type RpcServerPublicEventMap = { + request: TypedEvent<{ request: Request }, Response> +} + +export class RpcServer extends Emitter { + #server: Server + + constructor() { + super() + + const httpServer = http.createServer() + + this.#server = new Server() + this.#server.attach(httpServer) + + this.#server.on('connection', (client) => { + client.on('request', async (serializedRequest) => { + const request = deserializeHttpRequest( + serializedRequest, + client as unknown as Socket, + ) + + /** @todo Notify the consumer there's been a request! */ + const results = await this.emitAsPromise( + new TypedEvent('request', { + data: { + request, + }, + }), + ) + + const response = new Response('hello world') + + // client.emit('response', serializeHttpResponse(response)) + + if (response.body != null) { + emitReadableStream( + response.body, + client as unknown as Socket, + ) + } + }) + }) + } + + public async listen(port: number): Promise { + const listenPromise = new DeferredPromise() + const { httpServer } = this.#server + + httpServer + .listen(port, () => listenPromise.resolve()) + .once('error', (error) => listenPromise.reject(error)) + + return listenPromise + } + + public async close(): Promise { + const closePromise = new DeferredPromise() + + this.#server.disconnectSockets() + this.#server.close((error) => { + if (error) { + closePromise.reject(error) + } else { + closePromise.resolve() + } + }) + + return closePromise + } +} diff --git a/src/core/rpc/session.ts b/src/core/rpc/session.ts new file mode 100644 index 000000000..d768001c3 --- /dev/null +++ b/src/core/rpc/session.ts @@ -0,0 +1,54 @@ +import { DeferredPromise } from '@open-draft/deferred-promise' +import { io, Socket } from 'socket.io-client' +import { WebSocket as WebSocketTransport } from 'engine.io-client' +import { NetworkSessionEventMap, RpcServerEventMap } from './events' + +export type SessionSocket = Socket + +/** + * Creates a new network session that transports can use + * to transfer packets. The session only provisions the underlying + * connection but does not implement sending. Sending is implemented + * by individual packets as they are closer to the protocol specifics. + */ +export class NetworkSession { + #port: number + #connected: DeferredPromise + + constructor(args: { port: number }) { + this.#port = args.port + this.#connected = new DeferredPromise() + } + + public getSocket(): Promise { + return this.#connected + } + + public async connect(): Promise { + const ws = io(`ws://localhost:${this.#port}`, { + autoConnect: true, + transports: [WebSocketTransport], + extraHeaders: { + accept: 'msw/passthrough', + }, + }) + + ws.io.on('open', () => this.#connected.resolve(ws)) + ws.io.on('error', (error) => this.#connected.reject(error)) + + await this.#connected + } + + public async close(): Promise { + const closePromise = new DeferredPromise() + const ws = await this.#connected + + ws.once('disconnect', () => { + closePromise.resolve() + }) + + ws.disconnect() + + return closePromise + } +} diff --git a/src/core/rpc/transports/http-transport.ts b/src/core/rpc/transports/http-transport.ts new file mode 100644 index 000000000..032175fe8 --- /dev/null +++ b/src/core/rpc/transports/http-transport.ts @@ -0,0 +1,26 @@ +import { HttpPacket } from '../packets/http-packet' +import { NetworkSession } from '../session' + +/** + * A cross-process transport that can handle outgoing requests + * using the provided session connected to the remote. + */ +export class NetworkHttpTransport { + #port: number + + constructor(args: { port: number }) { + this.#port = args.port + } + + public async handleRequest(args: { + request: Request + }): Promise { + const session = new NetworkSession({ + port: this.#port, + }) + const packet = new HttpPacket(args.request) + const socket = await session.getSocket() + + return await packet.send(socket).catch(() => undefined) + } +} diff --git a/src/core/rpc/transports/websocket-transport.ts b/src/core/rpc/transports/websocket-transport.ts new file mode 100644 index 000000000..d6153f7a5 --- /dev/null +++ b/src/core/rpc/transports/websocket-transport.ts @@ -0,0 +1,40 @@ +import { DeferredPromise } from '@open-draft/deferred-promise' +import type { + WebSocketHandlerConnection, + WebSocketResolutionContext, +} from '../../handlers/WebSocketHandler' +import { WebSocketPacket } from '../packets/websocket-packet' +import type { NetworkSession } from '../session' + +export class NetworkWebSocketTransport { + #session: NetworkSession + + constructor(args: { session: NetworkSession }) { + this.#session = args.session + } + + public async send(args: { + clientUrl: URL + resolutionContext?: WebSocketResolutionContext + }): Promise { + const promise = new DeferredPromise() + const packet = new WebSocketPacket({ + url: args.clientUrl.toString(), + resolutionContext: args.resolutionContext, + }) + + // const [intent, client] = await packet.send(this.#session).catch((error) => { + // promise.reject(error) + // }) + + /** + * @todo Resolve with a Connection object that will + * route any events from HERE to the REMOTE and back. + * + * @todo If the remote does NOT handle this connection, + * reject the promise. + */ + + return promise + } +} diff --git a/src/core/rpc/utils.ts b/src/core/rpc/utils.ts new file mode 100644 index 000000000..55acc9c58 --- /dev/null +++ b/src/core/rpc/utils.ts @@ -0,0 +1,47 @@ +import { type Socket } from 'socket.io-client' +import { type StreamEventMap } from './events' + +/** + * Transfers the `ReadableStream` over the given socket. + */ +export async function emitReadableStream( + stream: ReadableStream, + socket: Socket, +): Promise { + const reader = stream.getReader() + + try { + while (true) { + const { value, done } = await reader.read() + + if (done) { + socket.emit('stream:end') + break + } + + socket.emit('stream:chunk', value) + } + } catch (error) { + socket.emit('stream:error', error) + } finally { + reader.releaseLock() + } +} + +/** + * A `ReadableStream` source that pulls the stream chunks + * from the given `WebSocket` connection. + * + * @example + * new ReadableStream(new WebSocketReadableStreamSource(socket)) + */ +export class WebSocketReadableStreamSource implements UnderlyingSource { + constructor(private readonly socket: Socket) {} + + public start(controller: ReadableStreamDefaultController) { + this.socket + .on('stream:chunk', (chunk) => controller.enqueue(chunk)) + .once('stream:error', (reason) => controller.error(reason)) + .once('stream:end', () => controller.close()) + } +} diff --git a/src/core/utils/handleRequest.ts b/src/core/utils/handleRequest.ts index 675559641..71f61a783 100644 --- a/src/core/utils/handleRequest.ts +++ b/src/core/utils/handleRequest.ts @@ -6,6 +6,10 @@ import type { RequestHandler } from '../handlers/RequestHandler' import { HandlersExecutionResult, executeHandlers } from './executeHandlers' import { onUnhandledRequest } from './request/onUnhandledRequest' import { storeResponseCookies } from './request/storeResponseCookies' +import { + shouldBypassRequest, + isPassthroughResponse, +} from './internal/requestUtils' export interface HandleRequestOptions { /** @@ -46,8 +50,8 @@ export async function handleRequest( ): Promise { emitter.emit('request:start', { request, requestId }) - // Perform requests wrapped in "bypass()" as-is. - if (request.headers.get('accept')?.includes('msw/passthrough')) { + // Perform bypassed requests (i.e. wrapped in "bypass()") as-is. + if (shouldBypassRequest(request)) { emitter.emit('request:end', { request, requestId }) handleRequestOptions?.onPassthroughResponse?.(request) return @@ -93,12 +97,9 @@ export async function handleRequest( return } - // Perform the request as-is when the developer explicitly returned "req.passthrough()". + // Perform the request as-is when the developer explicitly returned `passthrough()`. // This produces no warning as the request was handled. - if ( - response.status === 302 && - response.headers.get('x-msw-intention') === 'passthrough' - ) { + if (isPassthroughResponse(response)) { emitter.emit('request:end', { request, requestId }) handleRequestOptions?.onPassthroughResponse?.(request) return diff --git a/src/core/utils/internal/pipeEvents.ts b/src/core/utils/internal/pipeEvents.ts index 43b57cd4e..0efdbe1e5 100644 --- a/src/core/utils/internal/pipeEvents.ts +++ b/src/core/utils/internal/pipeEvents.ts @@ -6,6 +6,10 @@ import { Emitter, EventMap } from 'strict-event-emitter' export function pipeEvents( source: Emitter, destination: Emitter, + filterEvent: ( + event: E, + ...data: Events[E] + ) => boolean = () => true, ): void { const rawEmit: typeof source.emit & { _isPiped?: boolean } = source.emit @@ -15,7 +19,10 @@ export function pipeEvents( const sourceEmit: typeof source.emit & { _isPiped?: boolean } = function sourceEmit(this: typeof source, event, ...data) { - destination.emit(event, ...data) + if (filterEvent(event, ...data)) { + destination.emit(event, ...data) + } + return rawEmit.call(this, event, ...data) } diff --git a/src/core/utils/internal/requestUtils.ts b/src/core/utils/internal/requestUtils.ts new file mode 100644 index 000000000..a15f762f3 --- /dev/null +++ b/src/core/utils/internal/requestUtils.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') +} + +/** + * 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') + } + } +} + +export function isPassthroughResponse(response: Response): boolean { + return ( + response.status === 302 && + response.headers.get(REQUEST_INTENTION_HEADER_NAME) === + RequestIntention.passthrough + ) +} diff --git a/src/core/utils/internal/reversibleProxy.ts b/src/core/utils/internal/reversibleProxy.ts new file mode 100644 index 000000000..af72288e2 --- /dev/null +++ b/src/core/utils/internal/reversibleProxy.ts @@ -0,0 +1,19 @@ +export interface ReversibleProxy { + proxy: T + reverse: () => void +} + +export function reversibleProxy( + target: T, + handler: ProxyHandler, +): ReversibleProxy { + const original = target + const proxy = new Proxy(target, handler) + + return { + proxy, + reverse() { + target = original + }, + } +} diff --git a/src/core/utils/logging/serializeRequest.ts b/src/core/utils/logging/serializeRequest.ts index a2c2afd01..607fcb7e1 100644 --- a/src/core/utils/logging/serializeRequest.ts +++ b/src/core/utils/logging/serializeRequest.ts @@ -11,13 +11,10 @@ export interface LoggedRequest { export async function serializeRequest( request: Request, ): Promise { - const requestClone = request.clone() - const requestText = await requestClone.text() - return { url: new URL(request.url), method: request.method, headers: Object.fromEntries(request.headers.entries()), - body: requestText, + body: await request.clone().text(), } } diff --git a/src/node/SetupServerApi.ts b/src/node/SetupServerApi.ts index e8c83cd14..171bc2a7a 100644 --- a/src/node/SetupServerApi.ts +++ b/src/node/SetupServerApi.ts @@ -5,11 +5,12 @@ 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 { ListenOptions, SetupServer } from './glossary' import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' -import type { SetupServer } from './glossary' import { SetupServerCommonApi } from './SetupServerCommonApi' +import { RemoteRequestHandler } from '~/core/rpc/handlers/remote-request-handler' -const store = new AsyncLocalStorage() +const handlersStorage = new AsyncLocalStorage() type RequestHandlersContext = { initialHandlers: Array @@ -21,15 +22,29 @@ type RequestHandlersContext = { * to prevent the request handlers list from being a shared state * across multiple tests. */ -class AsyncHandlersController implements HandlersController { +export class AsyncHandlersController implements HandlersController { + private storage: AsyncLocalStorage private rootContext: RequestHandlersContext - constructor(initialHandlers: Array) { - this.rootContext = { initialHandlers, handlers: [] } + constructor(args: { + storage: AsyncLocalStorage + initialHandlers: Array + }) { + this.storage = args.storage + this.rootContext = { + initialHandlers: args.initialHandlers, + handlers: [], + } } get context(): RequestHandlersContext { - return store.getStore() || this.rootContext + const store = this.storage.getStore() + + if (store) { + return store + } + + return this.rootContext } public prepend(runtimeHandlers: Array) { @@ -48,6 +63,7 @@ class AsyncHandlersController implements HandlersController { return handlers.concat(initialHandlers) } } + export class SetupServerApi extends SetupServerCommonApi implements SetupServer @@ -62,14 +78,17 @@ export class SetupServerApi ) { super(interceptors, handlers) - this.handlersController = new AsyncHandlersController(handlers) + this.handlersController = new AsyncHandlersController({ + storage: handlersStorage, + initialHandlers: handlers, + }) } public boundary, R>( callback: (...args: Args) => R, ): (...args: Args) => R { return (...args: Args): R => { - return store.run( + return handlersStorage.run( { initialHandlers: this.handlersController.currentHandlers(), handlers: [], @@ -82,6 +101,29 @@ export class SetupServerApi public close(): void { super.close() - store.disable() + handlersStorage.disable() + } + + public listen(options?: Partial): void { + super.listen(options) + + // Support the remote interception mode. + if (this.resolvedOptions.remote?.enabled) { + const remoteHttpHandler = new RemoteRequestHandler({ + port: this.resolvedOptions.remote.port, + }) + + this.handlersController.currentHandlers = new Proxy( + this.handlersController.currentHandlers, + { + apply(target, thisArg, argArray) { + return [ + remoteHttpHandler, + ...Reflect.apply(target, thisArg, argArray), + ] + }, + }, + ) + } } } diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts index c5581ffbc..3455214f3 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -17,12 +17,13 @@ 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 type { ListenOptions, SetupServerCommon } from './glossary' import { handleWebSocketEvent } from '~/core/ws/handleWebSocketEvent' import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' import { isHandlerKind } from '~/core/utils/internal/isHandlerKind' +import { deleteRequestPassthroughHeader } from '~/core/utils/internal/requestUtils' -const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { +export const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { onUnhandledRequest: 'warn', } @@ -30,33 +31,45 @@ export class SetupServerCommonApi extends SetupApi implements SetupServerCommon { - protected readonly interceptor: BatchInterceptor< + protected interceptor: BatchInterceptor< Array>, HttpRequestEventMap > - private resolvedOptions: RequiredDeep + protected resolvedOptions: RequiredDeep constructor( interceptors: Array>, handlers: Array, ) { - super(...handlers) + super(handlers) this.interceptor = new BatchInterceptor({ name: 'setup-server', interceptors, }) - this.resolvedOptions = {} as RequiredDeep + this.resolvedOptions = {} as RequiredDeep + } + + protected async beforeRequest( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + args: { + requestId: string + request: Request + }, + ): Promise { + return Promise.resolve() } /** * Subscribe to all requests that are using the interceptor object */ - private init(): void { + protected init(): void { this.interceptor.on( 'request', async ({ request, requestId, controller }) => { + await this.beforeRequest({ requestId, request }) + const response = await handleRequest( request, requestId, @@ -67,25 +80,7 @@ export class SetupServerCommonApi 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') - } - } + deleteRequestPassthroughHeader(request) }, }, ) @@ -132,11 +127,11 @@ export class SetupServerCommonApi }) } - public listen(options: Partial = {}): void { + public listen(options: Partial = {}): void { this.resolvedOptions = mergeRight( DEFAULT_LISTEN_OPTIONS, options, - ) as RequiredDeep + ) as RequiredDeep // Apply the interceptor when starting the server. // Attach the event listeners to the interceptor here diff --git a/src/node/async-handler-controller.ts b/src/node/async-handler-controller.ts new file mode 100644 index 000000000..365d7d313 --- /dev/null +++ b/src/node/async-handler-controller.ts @@ -0,0 +1,46 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { + HandlersController, + AnyHandler, + defineHandlersController, +} from '~/core/new/handlers-controller' + +export interface AsyncHandlersController extends HandlersController { + context: AsyncLocalStorage +} + +export interface AsyncHandlersControllerContext { + initialHandlers: Array + handlers: Array +} + +export const asyncHandlersController = + defineHandlersController((initialHandlers) => { + const context = new AsyncLocalStorage() + + const fallbackContext: AsyncHandlersControllerContext = { + initialHandlers: [...initialHandlers], + handlers: [], + } + + const getContext = () => { + return context.getStore() || fallbackContext + } + + return { + context, + currentHandlers() { + const { initialHandlers, handlers } = getContext() + return [...handlers, ...initialHandlers] + }, + use(nextHandlers) { + getContext().handlers.unshift(...nextHandlers) + }, + reset(nextHandlers) { + const context = getContext() + context.handlers = [] + context.initialHandlers = + nextHandlers.length > 0 ? nextHandlers : context.initialHandlers + }, + } + }) diff --git a/src/node/glossary.ts b/src/node/glossary.ts index 7f52c9f91..b16b5e1dd 100644 --- a/src/node/glossary.ts +++ b/src/node/glossary.ts @@ -7,13 +7,28 @@ import type { SharedOptions, } from '~/core/sharedOptions' +export interface ListenOptions extends SharedOptions { + /** + * Enable remote request resolution. + * + * With `remote` set to `true`, all the outgoing requests in this process + * will be forwarded to a remote process where `setupRemoteServer` was + * created to handle. If the remote process hasn't handled the request, + * it will be handled by whichever request handlers you have in this process. + */ + remote?: { + enabled: boolean + port: number + } +} + export interface SetupServerCommon { /** * Starts requests interception based on the previously provided request handlers. * * @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. diff --git a/src/node/index.ts b/src/node/index.ts index d9b2ea46c..c3fdc9bf1 100644 --- a/src/node/index.ts +++ b/src/node/index.ts @@ -1,3 +1,5 @@ -export type { SetupServer } from './glossary' +export type { SetupServer, ListenOptions } from './glossary' export { SetupServerApi } from './SetupServerApi' -export { setupServer } from './setupServer' + +export { setupServer } from './setup-server' +export { setupRemoteServer } from './setup-remote-server' diff --git a/src/node/remote-interceptor.ts b/src/node/remote-interceptor.ts new file mode 100644 index 000000000..adffd102c --- /dev/null +++ b/src/node/remote-interceptor.ts @@ -0,0 +1,83 @@ +import * as http from 'node:http' +import type { WebSocketEventMap } from '@mswjs/interceptors/WebSocket' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { Server as WebSocketServer } from 'socket.io' +import { + createRequestId, + Interceptor, + RequestController, + type HttpRequestEventMap, +} from '@mswjs/interceptors' +import { deserializeHttpRequest } from '~/core/rpc/packets/http-packet' + +interface RemoteInterceptorOptions { + port: number +} + +export class RemoteInterceptor extends Interceptor< + HttpRequestEventMap | WebSocketEventMap +> { + static symbol = Symbol.for('remote-interceptor') + + #httpServer: http.Server + #server: WebSocketServer + + constructor(protected options: RemoteInterceptorOptions) { + super(RemoteInterceptor.symbol) + + this.#httpServer = http.createServer() + this.#server = new WebSocketServer() + this.#server.attachApp(this.#httpServer) + + this.#server.on('connection', (socket) => { + socket.on('http', async (serializedRequest) => { + const requestId = createRequestId() + const request = deserializeHttpRequest(serializedRequest, socket) + /** + * @todo Would be nice if `RequestController` allowed implementing + * your own `.respondWith()` and `.errorWith()` kind of like + * `ReadableStreamUnderlyingSource`. + */ + const controller = new RequestController(request) + + /** @todo emit as promise and handle the result? */ + // - asyncEmit isn't public + // - would be nice to use `rettime` in Interceptors. + this.emitter.emit('request', { + request, + requestId, + controller, + }) + }) + + /** @todo socket.on('websocket') */ + // this.emitter.emit('connection', connection) + }) + } + + protected async setup(): Promise { + const listenPromise = new DeferredPromise() + + this.#httpServer + .listen(this.options.port, () => listenPromise.resolve()) + .once('error', (error) => listenPromise.reject(error)) + + return listenPromise + } + + async dispose(): Promise { + super.dispose() + + const closePromise = new DeferredPromise() + + this.#server.close((error) => { + if (error) { + closePromise.reject(error) + } else { + closePromise.resolve() + } + }) + + await closePromise + } +} diff --git a/src/node/setup-remote-server.ts b/src/node/setup-remote-server.ts new file mode 100644 index 000000000..24c7eef9b --- /dev/null +++ b/src/node/setup-remote-server.ts @@ -0,0 +1,77 @@ +import { type AnyHandler } from '~/core/new/handlers-controller' +import type { SharedOptions } from '~/core/sharedOptions' +import { + defineNetwork, + NetworkHandlersApi, + type NetworkApi, +} from '~/core/new/define-network' +import { asyncHandlersController } from './async-handler-controller' +import { RemoteProcessSource } from './remote-process-source' + +export interface SetupRemoteServerListenOptions extends SharedOptions { + port: number +} + +interface SetupRemoteServer extends NetworkHandlersApi { + listen(options: SetupRemoteServerListenOptions): Promise + close(): Promise + boundary, R>( + callback: (...args: Args) => R, + ): (...args: Args) => R +} + +/** + * Enables request interception in Node.js, handling requests coming + * from a remote process. + * + * @see {@link https://mswjs.io/docs/api/setup-remote-server `setupRemoteServer()` API reference} + */ +export function setupRemoteServer( + ...handlers: Array +): SetupRemoteServer { + let network: NetworkApi + const handlersController = asyncHandlersController(handlers) + + return { + async listen(options) { + network = defineNetwork({ + sources: [ + new RemoteProcessSource({ + port: options.port, + }), + ], + handlers, + handlersController, + }) + + await network.enable() + }, + async close() { + await network?.disable() + }, + boundary(callback) { + return (...args) => { + return handlersController.context.run( + { + initialHandlers: handlersController.currentHandlers(), + handlers: [], + }, + callback, + ...args, + ) + } + }, + use(...handlers) { + return network.use(...handlers) + }, + resetHandlers(...nextHandlers) { + return network.resetHandlers(...nextHandlers) + }, + restoreHandlers() { + return network.restoreHandlers() + }, + listHandlers() { + return network.listHandlers() + }, + } +} diff --git a/src/node/setup-server-common.ts b/src/node/setup-server-common.ts new file mode 100644 index 000000000..857da7c26 --- /dev/null +++ b/src/node/setup-server-common.ts @@ -0,0 +1,54 @@ +import type { Interceptor } from '@mswjs/interceptors' +import { defineNetwork, type NetworkApi } from '~/core/new/define-network' +import type { + AnyHandler, + HandlersController, +} from '~/core/new/handlers-controller' +import { InterceptorSource } from '~/core/new/sources/interceptor-source' +import type { SetupServerCommon } from './glossary' + +export class SetupServerCommonApi implements SetupServerCommon { + protected network: NetworkApi + + public events: SetupServerCommon['events'] + public use: SetupServerCommon['use'] + public resetHandlers: SetupServerCommon['resetHandlers'] + public restoreHandlers: SetupServerCommon['restoreHandlers'] + public listHandlers: SetupServerCommon['listHandlers'] + + constructor( + interceptors: Array> = [], + handlers: Array, + handlersController?: HandlersController, + ) { + this.network = defineNetwork({ + sources: [ + new InterceptorSource({ + interceptors, + }), + ], + handlers, + handlersController, + }) + + /** + * @fixme This expects a readonly emitter (subset of methods). + */ + this.events = this.network.events as any + this.use = this.network.use.bind(this.network) + this.resetHandlers = this.network.resetHandlers.bind(this.network) + this.restoreHandlers = this.network.restoreHandlers.bind(this.network) + this.listHandlers = this.network.listHandlers.bind(this.network) + } + + public listen( + // eslint-disable-next-line + ...args: Array + ): void { + this.network.enable() + } + + 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..755288732 --- /dev/null +++ b/src/node/setup-server.ts @@ -0,0 +1,129 @@ +import { type Interceptor } from '@mswjs/interceptors' +import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest' +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { WebSocketInterceptor } from '@mswjs/interceptors/WebSocket' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { type AnyHandler } from '~/core/new/handlers-controller' +import { RemoteRequestHandler } from '~/core/rpc/handlers/remote-request-handler' +import { + ReversibleProxy, + reversibleProxy, +} from '~/core/utils/internal/reversibleProxy' +import { + type AsyncHandlersController, + asyncHandlersController, +} from './async-handler-controller' +import type { ListenOptions, SetupServer, SetupServerCommon } from './glossary' +import { HandlersController } from '~/core/SetupApi' +import { defineNetwork, NetworkApi } from '~/core/new/define-network' +import { InterceptorSource } from '~/core/new/sources/interceptor-source' +import { fromLegacyOnUnhandledRequest } from '~/core/new/on-unhandled-frame' + +/** + * 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): SetupServerApi { + return new SetupServerApi(handlers, [ + new ClientRequestInterceptor(), + new XMLHttpRequestInterceptor(), + new FetchInterceptor(), + new WebSocketInterceptor() as any, + ]) +} + +export class SetupServerApi implements SetupServer { + public events: SetupServerCommon['events'] + public use: SetupServerCommon['use'] + public resetHandlers: SetupServerCommon['resetHandlers'] + public restoreHandlers: SetupServerCommon['restoreHandlers'] + public listHandlers: SetupServerCommon['listHandlers'] + + #network: NetworkApi + #handlersController: AsyncHandlersController + #currentHandlersProxy?: ReversibleProxy + #listenOptions?: Partial + + constructor( + handlers: Array, + interceptors: Array> = [], + ) { + this.#handlersController = asyncHandlersController(handlers) + + this.#network = defineNetwork({ + quiet: true, + sources: [ + new InterceptorSource({ + interceptors, + }), + ], + handlers, + handlersController: this.#handlersController, + onUnhandledFrame: fromLegacyOnUnhandledRequest(() => { + return this.#listenOptions?.onUnhandledRequest || 'warn' + }), + }) + + /** + * @fixme This expects a readonly emitter (subset of methods). + */ + this.events = this.#network.events as any + + /** + * @fixme Remove this method drilling in the future. + * Drop the `SetupServerApi` class altogether and implement `setupServer` + * as a simple function that can reference the network methods directly. + */ + this.use = this.#network.use.bind(this.#network) + this.resetHandlers = this.#network.resetHandlers.bind(this.#network) + this.restoreHandlers = this.#network.restoreHandlers.bind(this.#network) + this.listHandlers = this.#network.listHandlers.bind(this.#network) + } + + public listen(options?: Partial): void { + this.#listenOptions = options + this.#network.enable() + + if (options?.remote?.enabled) { + const remoteRequestHandler = new RemoteRequestHandler({ + port: options.remote.port, + }) + + this.#currentHandlersProxy = reversibleProxy( + this.#handlersController.currentHandlers, + { + apply(target, thisArg, argArray) { + return [ + remoteRequestHandler, + ...Reflect.apply(target, thisArg, argArray), + ] + }, + }, + ) + + this.#handlersController.currentHandlers = + this.#currentHandlersProxy.proxy + } + } + + public boundary, R>( + callback: (...args: Args) => R, + ): (...args: Args) => R { + return (...args: Args): R => { + return this.#handlersController.context.run( + { + initialHandlers: this.#handlersController.currentHandlers(), + handlers: [], + }, + callback, + ...args, + ) + } + } + + public close(): void { + this.#network.disable() + this.#currentHandlersProxy?.reverse() + } +} 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.build.json b/src/tsconfig.core.build.json index 0852a1d01..06edbfc21 100644 --- a/src/tsconfig.core.build.json +++ b/src/tsconfig.core.build.json @@ -1,5 +1,5 @@ { - "extends": "./tsconfig.src.json", + "extends": "./tsconfig.core.json", "compilerOptions": { "composite": false } diff --git a/src/tsconfig.core.json b/src/tsconfig.core.json new file mode 100644 index 000000000..11cc1e95c --- /dev/null +++ b/src/tsconfig.core.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["../global.d.ts", "./core"], + "references": [{ "path": "./tsconfig.node.json" }], + "exclude": ["**/*.test.ts"], + "compilerOptions": { + "composite": true + } +} diff --git a/src/tsconfig.node.json b/src/tsconfig.node.json index c98a860ae..062f2b23f 100644 --- a/src/tsconfig.node.json +++ b/src/tsconfig.node.json @@ -1,8 +1,15 @@ { - "extends": "./tsconfig.src.json", + "extends": "../tsconfig.base.json", + "include": ["../global.d.ts", "./node", "./native"], + "references": [{ "path": "./tsconfig.core.json" }], + "exclude": ["**/*.test.ts"], "compilerOptions": { - "types": ["node"] - }, - "include": ["./node", "./native"], - "exclude": ["**/*.test.ts"] + "composite": true, + "types": ["node"], + "baseUrl": "./", + "paths": { + "~/core": ["core"], + "~/core/*": ["core/*"] + } + } } diff --git a/src/tsconfig.src.json b/src/tsconfig.src.json deleted file mode 100644 index 87ad65fc8..000000000 --- a/src/tsconfig.src.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - // Common configuration for everything - // living in the "src" directory. - "extends": "../tsconfig.base.json", - "compilerOptions": { - "composite": true - }, - "include": ["../global.d.ts", "./**/*.ts"], - "exclude": ["./**/*.test.ts"] -} diff --git a/test/browser/rest-api/context.mocks.ts b/test/browser/rest-api/context.mocks.ts deleted file mode 100644 index 5e4009baf..000000000 --- a/test/browser/rest-api/context.mocks.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { http, HttpResponse, delay } from 'msw' -import { setupWorker } from 'msw/browser' - -const worker = setupWorker( - http.get('https://test.mswjs.io/', async () => { - await delay(2000) - return HttpResponse.json( - { mocked: true }, - { - status: 201, - statusText: 'Yahoo!', - headers: { - Accept: 'foo/bar', - 'Custom-Header': 'arbitrary-value', - }, - }, - ) - }), -) - -worker.start() diff --git a/test/browser/rest-api/context.test.ts b/test/browser/rest-api/context.test.ts deleted file mode 100644 index 327b392f4..000000000 --- a/test/browser/rest-api/context.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { test, expect } from '../playwright.extend' - -test('composes various context utilities into a valid mocked response', async ({ - loadExample, - fetch, -}) => { - await loadExample(new URL('./context.mocks.ts', import.meta.url)) - - const res = await fetch('https://test.mswjs.io/') - const headers = await res.allHeaders() - const body = await res.json() - - expect(res.status()).toEqual(201) - expect(res.statusText()).toEqual('Yahoo!') - expect(res.fromServiceWorker()).toBe(true) - expect(headers).toHaveProperty('content-type', 'application/json') - expect(headers).toHaveProperty('accept', 'foo/bar') - expect(headers).toHaveProperty('custom-header', 'arbitrary-value') - expect(body).toEqual({ - mocked: true, - }) -}) diff --git a/test/browser/rest-api/response/throw-response.test.ts b/test/browser/rest-api/response/throw-response.test.ts index 4af7ca1ff..2f2ee4d83 100644 --- a/test/browser/rest-api/response/throw-response.test.ts +++ b/test/browser/rest-api/response/throw-response.test.ts @@ -8,7 +8,7 @@ test('supports throwing a plain Response in a response resolver', async ({ const response = await fetch('/throw/plain') expect(response.status()).toBe(200) - expect(await response.text()).toBe('hello world') + await expect(response.text()).resolves.toBe('hello world') }) test('supports throwing an HttpResponse in a response resolver', async ({ @@ -20,7 +20,7 @@ test('supports throwing an HttpResponse in a response resolver', async ({ const response = await fetch('/throw/http-response') expect(response.status()).toBe(200) expect(await response.headerValue('Content-Type')).toBe('text/plain') - expect(await response.text()).toBe('hello world') + await expect(response.text()).resolves.toBe('hello world') }) test('supports throwing an error response in a response resolver', async ({ @@ -32,7 +32,7 @@ test('supports throwing an error response in a response resolver', async ({ const errorResponse = await fetch('/throw/error') expect(errorResponse.status()).toBe(400) expect(await errorResponse.headerValue('Content-Type')).toBe('text/plain') - expect(await errorResponse.text()).toBe('invalid input') + await expect(errorResponse.text()).resolves.toBe('invalid input') }) test('supports throwing a network error in a response resolver', async ({ @@ -62,11 +62,11 @@ test('supports middleware-style responses', async ({ loadExample, fetch }) => { const response = await fetch('/middleware?id=1') expect(response.status()).toBe(200) - expect(await response.text()).toBe('ok') + await expect(response.text()).resolves.toBe('ok') const errorResponse = await fetch('/middleware') expect(errorResponse.status()).toBe(400) - expect(await errorResponse.text()).toBe('must have id') + await expect(errorResponse.text()).resolves.toBe('must have id') }) test('throws a non-Response error as-is', async ({ loadExample, fetch }) => { @@ -77,7 +77,7 @@ test('throws a non-Response error as-is', async ({ loadExample, fetch }) => { const networkError = await fetch('/throw/non-response-error') expect(networkError.status()).toBe(500) - expect(await networkError.json()).toEqual({ + await expect(networkError.json()).resolves.toEqual({ name: 'Error', message: 'Oops!', stack: expect.any(String), diff --git a/test/node/msw-api/setup-remote-server/life-cycle-event-forwarding.node.test.ts b/test/node/msw-api/setup-remote-server/life-cycle-event-forwarding.node.test.ts new file mode 100644 index 000000000..ce34989da --- /dev/null +++ b/test/node/msw-api/setup-remote-server/life-cycle-event-forwarding.node.test.ts @@ -0,0 +1,121 @@ +// @vitest-environment node +import { http, HttpResponse } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { HttpServer } from '@open-draft/test-server/http' +import { spyOnLifeCycleEvents } from '../../utils' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() + +const httpServer = new HttpServer((app) => { + app.get('/greeting', (req, res) => { + res.send('hello') + }) +}) + +beforeAll(async () => { + await remote.listen() + await httpServer.listen() +}) + +afterAll(async () => { + await remote.close() + await httpServer.close() +}) + +it( + 'emits correct events for the request handled in the test process', + remote.boundary(async () => { + remote.use( + http.get('https://example.com/resource', () => { + return HttpResponse.json({ mocked: true }) + }), + ) + + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + const { listener, requestIdPromise } = spyOnLifeCycleEvents(remote) + + const response = await fetch(new URL('/resource', testApp.url)) + const requestId = await requestIdPromise + + // Must respond with the mocked response defined in the test. + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.json()).resolves.toEqual({ mocked: true }) + + // Must forward the life-cycle events to the test process. + await vi.waitFor(() => { + expect(listener.mock.calls).toEqual([ + [`[request:start] GET https://example.com/resource ${requestId}`], + [`[request:match] GET https://example.com/resource ${requestId}`], + [`[request:end] GET https://example.com/resource ${requestId}`], + [ + `[response:mocked] GET https://example.com/resource ${requestId} 200 {"mocked":true}`, + ], + ]) + }) + }), +) + +it( + 'emits correct events for the request handled in the remote process', + remote.boundary(async () => { + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + const { listener, requestIdPromise } = spyOnLifeCycleEvents(remote) + + const response = await fetch(new URL('/resource', testApp.url)) + const requestId = await requestIdPromise + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.json()).resolves.toEqual([1, 2, 3]) + + await vi.waitFor(() => { + expect(listener.mock.calls).toEqual([ + [`[request:start] GET https://example.com/resource ${requestId}`], + [`[request:match] GET https://example.com/resource ${requestId}`], + [`[request:end] GET https://example.com/resource ${requestId}`], + [ + `[response:mocked] GET https://example.com/resource ${requestId} 200 [1,2,3]`, + ], + ]) + }) + }), +) + +it( + 'emits correct events for the request unhandled by either parties', + remote.boundary(async () => { + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + const { listener, requestIdPromise } = spyOnLifeCycleEvents(remote) + + const resourceUrl = httpServer.http.url('/greeting') + // Request a special route in the running app that performs a proxy request + // to the resource specified in the "Location" request header. + const response = await fetch(new URL('/proxy', testApp.url), { + headers: { + location: resourceUrl, + }, + }) + const requestId = await requestIdPromise + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(await response.text()).toEqual('hello') + + await vi.waitFor(() => { + expect(listener.mock.calls).toEqual([ + [`[request:start] GET ${resourceUrl} ${requestId}`], + [`[request:unhandled] GET ${resourceUrl} ${requestId}`], + [`[request:end] GET ${resourceUrl} ${requestId}`], + [`[response:bypass] GET ${resourceUrl} ${requestId} 200 hello`], + ]) + }) + }), +) diff --git a/test/node/msw-api/setup-remote-server/on-unhandled-request-bypass.test.ts b/test/node/msw-api/setup-remote-server/on-unhandled-request-bypass.test.ts new file mode 100644 index 000000000..a4e6460f0 --- /dev/null +++ b/test/node/msw-api/setup-remote-server/on-unhandled-request-bypass.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment node +import { http } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() + +beforeAll(async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + await remote.listen({ + onUnhandledRequest: 'bypass', + }) +}) + +afterEach(() => { + remote.resetHandlers() +}) + +afterAll(async () => { + vi.restoreAllMocks() + await remote.close() +}) + +it( + 'does not error on the request not handled here and there', + remote.boundary(async () => { + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + { + onUnhandledRequest: 'bypass', + }, + ) + + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/unhandled', + }, + }) + + const unhandledErrorPromise = vi.waitFor(() => { + expect(console.error).toHaveBeenCalledWith(`\ +[MSW] Error: intercepted a request without a matching request handler: + + • GET http://localhost/unhandled + +If you still wish to intercept this unhandled request, please create a request handler for it. +Read more: https://mswjs.io/docs/getting-started/mocks`) + }) + + await expect(unhandledErrorPromise).rejects.toThrow() + expect(console.error).not.toHaveBeenCalled() + }), +) + +it( + 'does not error on the request handled here', + remote.boundary(async () => { + remote.use( + http.get('http://localhost/handled', () => { + return new Response('handled') + }), + ) + + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + { + onUnhandledRequest: 'bypass', + }, + ) + + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/handled', + }, + }) + + const unhandledErrorPromise = vi.waitFor(() => { + expect(console.error).toHaveBeenCalledWith(`\ +[MSW] Error: intercepted a request without a matching request handler: + +• GET http://localhost/handled + +If you still wish to intercept this unhandled request, please create a request handler for it. +Read more: https://mswjs.io/docs/getting-started/mocks`) + }) + + await expect(unhandledErrorPromise).rejects.toThrow() + expect(console.error).not.toHaveBeenCalled() + }), +) + +it( + 'does not error on the request handled there', + remote.boundary(async () => { + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + { + onUnhandledRequest: 'bypass', + }, + ) + + await fetch(new URL('/resource', testApp.url)) + + const unhandledErrorPromise = vi.waitFor(() => { + expect(console.error).toHaveBeenCalledWith(`\ +[MSW] Error: intercepted a request without a matching request handler: + +• GET https://example.com/resource + +If you still wish to intercept this unhandled request, please create a request handler for it. +Read more: https://mswjs.io/docs/getting-started/mocks`) + }) + + await expect(unhandledErrorPromise).rejects.toThrow() + expect(console.error).not.toHaveBeenCalled() + }), +) diff --git a/test/node/msw-api/setup-remote-server/on-unhandled-request-callback.test.ts b/test/node/msw-api/setup-remote-server/on-unhandled-request-callback.test.ts new file mode 100644 index 000000000..d2ba6a35a --- /dev/null +++ b/test/node/msw-api/setup-remote-server/on-unhandled-request-callback.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment node +import { http } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() +const onUnhandledRequestCallback = vi.fn() + +beforeAll(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await remote.listen({ + onUnhandledRequest: onUnhandledRequestCallback, + }) +}) + +afterEach(() => { + vi.clearAllMocks() + remote.resetHandlers() +}) + +afterAll(async () => { + vi.restoreAllMocks() + await remote.close() +}) + +it( + 'calls the custom callback on the request not handled here and there', + remote.boundary(async () => { + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + { + onUnhandledRequest: 'bypass', + }, + ) + + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/unhandled', + }, + }) + + await vi.waitFor(() => { + expect(onUnhandledRequestCallback).toHaveBeenCalledOnce() + }) + expect(console.warn).not.toHaveBeenCalled() + }), +) + +it( + 'does not call the custom callback on the request handled here', + remote.boundary(async () => { + remote.use( + http.get('http://localhost/handled', () => { + return new Response('handled') + }), + ) + + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + { + onUnhandledRequest: 'bypass', + }, + ) + + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/handled', + }, + }) + + const unhandledCallbackPromise = vi.waitFor(() => { + expect(onUnhandledRequestCallback).toHaveBeenCalledOnce() + }) + + await expect(unhandledCallbackPromise).rejects.toThrow() + expect(console.warn).not.toHaveBeenCalled() + }), +) + +it( + 'does not call the custom callback on the request handled there', + remote.boundary(async () => { + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + { + onUnhandledRequest: 'bypass', + }, + ) + + await fetch(new URL('/resource', testApp.url)) + + const unhandledCallbackPromise = vi.waitFor(() => { + expect(onUnhandledRequestCallback).toHaveBeenCalledOnce() + }) + + await expect(unhandledCallbackPromise).rejects.toThrow() + expect(console.warn).not.toHaveBeenCalled() + }), +) diff --git a/test/node/msw-api/setup-remote-server/on-unhandled-request-default.test.ts b/test/node/msw-api/setup-remote-server/on-unhandled-request-default.test.ts new file mode 100644 index 000000000..0f8a59232 --- /dev/null +++ b/test/node/msw-api/setup-remote-server/on-unhandled-request-default.test.ts @@ -0,0 +1,115 @@ +// @vitest-environment node +import { http } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() + +beforeAll(async () => { + /** + * @note Console warnings from the app's context are forwarded + * as `console.error`. Ignore those for this test. + */ + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await remote.listen() +}) + +afterEach(() => { + vi.clearAllMocks() + remote.resetHandlers() +}) + +afterAll(async () => { + vi.restoreAllMocks() + await remote.close() +}) + +it( + 'warns on requests not handled by either party be default', + remote.boundary(async () => { + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + // Hit a special endpoint that will perform a request to "Location" + // in the application's context. Neither party handles this request. + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/unhandled', + }, + }) + + // Awaiting the unhandled life-cycle event from the app process takes time. + await vi.waitFor(() => { + // Must print a warning since nobody has handled the request. + expect(console.warn).toHaveBeenCalledWith(`\ +[MSW] Warning: intercepted a request without a matching request handler: + + • GET http://localhost/unhandled + +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`) + }) + }), +) + +it( + 'does not warn on the request handled here', + remote.boundary(async () => { + remote.use( + http.get('http://localhost/handled', () => { + return new Response('handled') + }), + ) + + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + // Hit a special endpoint that will perform a request to "Location" + // in the application's context. Neither party handles this request. + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/handled', + }, + }) + + const unhandledWarningPromise = vi.waitFor(() => { + expect(console.warn).toHaveBeenCalledWith(`\ +[MSW] Warning: intercepted a request without a matching request handler: + +• GET http://localhost/handled + +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`) + }) + + await expect(unhandledWarningPromise).rejects.toThrow() + expect(console.warn).not.toHaveBeenCalled() + }), +) + +it( + 'does not warn on the request not handled here but handled there', + remote.boundary(async () => { + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + await fetch(new URL('/resource', testApp.url)) + + const unhandledWarningPromise = vi.waitFor(() => { + expect(console.warn).toHaveBeenCalledWith(`\ +[MSW] Warning: intercepted a request without a matching request handler: + +• GET https://example.com/resource + +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`) + }) + + await expect(unhandledWarningPromise).rejects.toThrow() + expect(console.warn).not.toHaveBeenCalled() + }), +) diff --git a/test/node/msw-api/setup-remote-server/on-unhandled-request-error.test.ts b/test/node/msw-api/setup-remote-server/on-unhandled-request-error.test.ts new file mode 100644 index 000000000..fc06492f7 --- /dev/null +++ b/test/node/msw-api/setup-remote-server/on-unhandled-request-error.test.ts @@ -0,0 +1,106 @@ +// @vitest-environment node +import { http } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() + +beforeAll(async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + await remote.listen({ + onUnhandledRequest: 'error', + }) +}) + +afterEach(() => { + vi.clearAllMocks() + remote.resetHandlers() +}) + +afterAll(async () => { + vi.restoreAllMocks() + await remote.close() +}) + +it( + 'errors on the request not handled here and there', + remote.boundary(async () => { + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/unhandled', + }, + }) + + await vi.waitFor(() => { + expect(console.error).toHaveBeenCalledWith(`\ +[MSW] Error: intercepted a request without a matching request handler: + + • GET http://localhost/unhandled + +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`) + }) + }), +) + +it( + 'does not error on the request handled here', + remote.boundary(async () => { + remote.use( + http.get('http://localhost/handled', () => { + return new Response('handled') + }), + ) + + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/handled', + }, + }) + + const unhandledErrorPromise = vi.waitFor(() => { + expect(console.error).toHaveBeenCalledWith(`\ +[MSW] Error: intercepted a request without a matching request handler: + +• GET http://localhost/handled + +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`) + }) + + await expect(unhandledErrorPromise).rejects.toThrow() + expect(console.error).not.toHaveBeenCalled() + }), +) + +it( + 'does not error on the request handled there', + remote.boundary(async () => { + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + await fetch(new URL('/resource', testApp.url)) + + const unhandledErrorPromise = vi.waitFor(() => { + expect(console.error).toHaveBeenCalledWith(`\ +[MSW] Error: intercepted a request without a matching request handler: + +• GET https://example.com/resource + +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`) + }) + + await expect(unhandledErrorPromise).rejects.toThrow() + expect(console.error).not.toHaveBeenCalled() + }), +) diff --git a/test/node/msw-api/setup-remote-server/on-unhandled-request-warn.test.ts b/test/node/msw-api/setup-remote-server/on-unhandled-request-warn.test.ts new file mode 100644 index 000000000..3cc58143a --- /dev/null +++ b/test/node/msw-api/setup-remote-server/on-unhandled-request-warn.test.ts @@ -0,0 +1,111 @@ +// @vitest-environment node +import { http } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() + +beforeAll(async () => { + /** + * @note Console warnings from the app's context are forwarded + * as `console.error`. Ignore those for this test. + */ + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await remote.listen({ + onUnhandledRequest: 'warn', + }) +}) + +afterEach(() => { + vi.clearAllMocks() + remote.resetHandlers() +}) + +afterAll(async () => { + vi.restoreAllMocks() + await remote.close() +}) + +it( + 'warns on the request not handled here and there', + remote.boundary(async () => { + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/unhandled', + }, + }) + + await vi.waitFor(() => { + expect(console.warn).toHaveBeenCalledWith(`\ +[MSW] Warning: intercepted a request without a matching request handler: + + • GET http://localhost/unhandled + +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`) + }) + }), +) + +it( + 'does not warn on the request handled here', + remote.boundary(async () => { + remote.use( + http.get('http://localhost/handled', () => { + return new Response('handled') + }), + ) + + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/handled', + }, + }) + + const unhandledWarningPromise = vi.waitFor(() => { + expect(console.warn).toHaveBeenCalledWith(`\ +[MSW] Warning: intercepted a request without a matching request handler: + +• GET http://localhost/handled + +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`) + }) + + await expect(unhandledWarningPromise).rejects.toThrow() + expect(console.warn).not.toHaveBeenCalled() + }), +) + +it( + 'does not warn on the request handled there', + remote.boundary(async () => { + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + await fetch(new URL('/resource', testApp.url)) + + const unhandledWarningPromise = vi.waitFor(() => { + expect(console.warn).toHaveBeenCalledWith(`\ +[MSW] Warning: intercepted a request without a matching request handler: + +• GET https://example.com/resource + +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`) + }) + + await expect(unhandledWarningPromise).rejects.toThrow() + expect(console.warn).not.toHaveBeenCalled() + }), +) diff --git a/test/node/msw-api/setup-remote-server/remote-boundary.test.ts b/test/node/msw-api/setup-remote-server/remote-boundary.test.ts new file mode 100644 index 000000000..137e4b7e3 --- /dev/null +++ b/test/node/msw-api/setup-remote-server/remote-boundary.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment node +import { HttpResponse, http } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() + +beforeAll(async () => { + await remote.listen() +}) + +afterEach(() => { + remote.resetHandlers() +}) + +afterAll(async () => { + await remote.close() +}) + +it.concurrent( + 'uses initial handlers if the boundary has no overrides', + remote.boundary(async () => { + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + + const json = await response.json() + expect(json).toEqual([1, 2, 3]) + }), +) + +it.concurrent( + 'uses runtime request handlers declared in the boundary', + remote.boundary(async () => { + remote.use( + http.get('https://example.com/resource', () => { + return HttpResponse.json({ mocked: true }) + }), + ) + + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + + const json = await response.json() + expect(json).toEqual({ mocked: true }) + }), +) diff --git a/test/node/msw-api/setup-remote-server/response.body.test.ts b/test/node/msw-api/setup-remote-server/response.body.test.ts new file mode 100644 index 000000000..452cb07a1 --- /dev/null +++ b/test/node/msw-api/setup-remote-server/response.body.test.ts @@ -0,0 +1,149 @@ +// @vitest-environment node +import { http, HttpResponse } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() + +beforeAll(async () => { + await remote.listen() +}) + +afterAll(async () => { + await remote.close() +}) + +it( + 'supports responding to a remote request with text', + remote.boundary(async () => { + remote.use( + http.get('https://example.com/resource', () => { + return HttpResponse.text('hello world') + }), + ) + + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.text()).resolves.toBe('hello world') + }), +) + +it( + 'supports responding to a remote request with JSON', + remote.boundary(async () => { + remote.use( + http.get('https://example.com/resource', () => { + return HttpResponse.json({ hello: 'world' }) + }), + ) + + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ hello: 'world' }) + }), +) + +it( + 'supports responding to a remote request with ArrayBuffer', + remote.boundary(async () => { + remote.use( + http.get('https://example.com/resource', () => { + return HttpResponse.arrayBuffer( + new TextEncoder().encode('hello world').buffer, + ) + }), + ) + + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + const response = await fetch(new URL('/resource', testApp.url)) + const buffer = await response.arrayBuffer() + + expect(response.status).toBe(200) + expect(new TextDecoder().decode(buffer)).toBe('hello world') + }), +) + +it( + 'supports responding to a remote request with Blob', + remote.boundary(async () => { + remote.use( + http.get('https://example.com/resource', () => { + return new Response(new Blob(['hello world'])) + }), + ) + + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + await expect(response.blob()).resolves.toEqual(new Blob(['hello world'])) + }), +) + +it( + 'supports responding to a remote request with FormData', + remote.boundary(async () => { + remote.use( + http.get('https://example.com/resource', () => { + const formData = new FormData() + formData.append('hello', 'world') + return HttpResponse.formData(formData) + }), + ) + + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + + await expect(response.text()).resolves.toMatch( + /^------formdata-undici-\d{12}\r\nContent-Disposition: form-data; name="hello"\r\n\r\nworld\r\n------formdata-undici-\d{12}--$/, + ) + }), +) + +it( + 'supports responding to a remote request with ReadableStream', + remote.boundary(async () => { + const encoder = new TextEncoder() + remote.use( + http.get('https://example.com/resource', () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('hello')) + controller.enqueue(encoder.encode(' ')) + controller.enqueue(encoder.encode('world')) + controller.close() + }, + }) + return new Response(stream, { + headers: { 'Content-Type': 'text/plain' }, + }) + }), + ) + + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('hello world') + }), +) diff --git a/test/node/msw-api/setup-remote-server/use.app.js b/test/node/msw-api/setup-remote-server/use.app.js new file mode 100644 index 000000000..36dd5e123 --- /dev/null +++ b/test/node/msw-api/setup-remote-server/use.app.js @@ -0,0 +1,70 @@ +import { Readable } from 'node:stream' +import express from 'express' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +const { SETUP_SERVER_LISTEN_OPTIONS } = process.env + +// Enable API mocking as usual. +const server = setupServer( + http.get('https://example.com/resource', () => { + return HttpResponse.json([1, 2, 3]) + }), +) + +server.listen({ + ...(SETUP_SERVER_LISTEN_OPTIONS + ? JSON.parse(SETUP_SERVER_LISTEN_OPTIONS) + : {}), + remote: { + enabled: true, + }, +}) + +// Spawn a Node.js application. +const app = express() + +app.get('/resource', async (req, res) => { + const response = await fetch('https://example.com/resource') + res.writeHead(response.status, response.statusText) + Readable.fromWeb(response.body).pipe(res) +}) + +app.use('/proxy', async (req, res) => { + const response = await fetch(req.header('location'), { + method: req.method, + headers: req.headers, + }) + res.writeHead(response.status, response.statusText) + + if (response.body) { + const reader = response.body.getReader() + reader.read().then(function processResult(result) { + if (result.done) { + res.end() + return + } + + res.write(Buffer.from(result.value)) + reader.read().then(processResult) + }) + } else { + res.end() + } +}) + +const httpServer = app.listen(0, () => { + if (!process.send) { + throw new Error( + 'Failed to start a test Node.js app: not spawned as a child process of the test', + ) + } + + const address = httpServer.address() + + if (typeof address === 'string') { + return process.send(address) + } + + process.send(new URL(`http://localhost:${address.port}`).href) +}) diff --git a/test/node/msw-api/setup-remote-server/use.node.test.ts b/test/node/msw-api/setup-remote-server/use.node.test.ts new file mode 100644 index 000000000..4f59d3f01 --- /dev/null +++ b/test/node/msw-api/setup-remote-server/use.node.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment node +import { HttpResponse, http } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() + +beforeAll(async () => { + await remote.listen() +}) + +afterAll(async () => { + await remote.close() +}) + +it( + 'returns a mocked response defined in the app by default', + remote.boundary(async () => { + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + + const json = await response.json() + expect(json).toEqual([1, 2, 3]) + }), +) + +it( + 'returns a mocked response from the matching runtime request handler', + remote.boundary(async () => { + remote.use( + http.get('https://example.com/resource', () => { + return HttpResponse.json({ mocked: true }) + }), + ) + + await using testApp = await spawnTestApp( + new URL('./use.app.js', import.meta.url), + ) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + + const json = await response.json() + expect(json).toEqual({ mocked: true }) + }), +) diff --git a/test/node/msw-api/setup-remote-server/utils.ts b/test/node/msw-api/setup-remote-server/utils.ts new file mode 100644 index 000000000..2359a5b62 --- /dev/null +++ b/test/node/msw-api/setup-remote-server/utils.ts @@ -0,0 +1,86 @@ +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { invariant } from 'outvariant' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { type ListenOptions, getRemoteEnvironment } from 'msw/node' + +export async function spawnTestApp( + appUrl: URL, + listenOptions: Partial = {}, +) { + let url: string | undefined + const spawnPromise = new DeferredPromise().then((resolvedUrl) => { + url = resolvedUrl + }) + + const io = spawn('node', [fileURLToPath(appUrl.href)], { + // Establish an IPC between the test and the test app. + // This IPC is not required for the remote interception to work. + // This IPC is required for the test app to be spawned at a random port + // and be able to communicate the port back to the test. + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + env: { + ...process.env, + ...getRemoteEnvironment(), + SETUP_SERVER_LISTEN_OPTIONS: JSON.stringify(listenOptions), + }, + }) + + io.stdout?.on('data', (data) => console.log(data.toString())) + io.stderr?.on('data', (data) => console.error(data.toString())) + + io.on('message', (message) => { + try { + const url = new URL(message.toString()) + spawnPromise.resolve(url.href) + } catch (error) { + return + } + }) + .on('error', (error) => spawnPromise.reject(error)) + .on('exit', (code) => { + if (code !== 0) { + spawnPromise.reject( + new Error(`Failed to spawn a test Node app (exit code: ${code})`), + ) + } + }) + + await Promise.race([ + spawnPromise, + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('Failed to spawn a test Node app within timeout')) + }, 5000) + }), + ]) + + return { + get url() { + invariant( + url, + 'Failed to return the URL for the test Node app: the app is not running. Did you forget to call ".spawn()"?', + ) + + return url + }, + + async [Symbol.asyncDispose]() { + if (io.exitCode !== null) { + return Promise.resolve() + } + + const closePromise = new DeferredPromise() + + io.send('SIGTERM', (error) => { + if (error) { + closePromise.reject(error) + } else { + closePromise.resolve() + } + }) + + await closePromise + }, + } +} diff --git a/test/node/msw-api/setup-server/input-validation.node.test.ts b/test/node/msw-api/setup-server/input-validation.node.test.ts index 5d3fd23e6..206148b58 100644 --- a/test/node/msw-api/setup-server/input-validation.node.test.ts +++ b/test/node/msw-api/setup-server/input-validation.node.test.ts @@ -12,6 +12,6 @@ test('throws an error given an Array of request handlers to "setupServer"', () = } expect(createServer).toThrow( - `[MSW] Failed to apply given request handlers: invalid input. Did you forget to spread the request handlers Array?`, + `[MSW] Failed to create a handlers controller: invalid handlers. Did you forget to spread the handlers array?`, ) }) diff --git a/test/node/msw-api/setup-server/life-cycle-events/ignore-internal-requests.test.ts b/test/node/msw-api/setup-server/life-cycle-events/ignore-internal-requests.test.ts new file mode 100644 index 000000000..c9341310b --- /dev/null +++ b/test/node/msw-api/setup-server/life-cycle-events/ignore-internal-requests.test.ts @@ -0,0 +1,34 @@ +// @vitest-environment node +import { setupServer } from 'msw/node' +import { spyOnLifeCycleEvents } from '../../../utils' + +const server = setupServer() + +beforeAll(() => { + // Mock the environment variables required for the remote interception to work. + vi.stubEnv('MSW_REMOTE_SERVER_URL', 'http://localhost/noop') + vi.stubEnv('MSW_REMOTE_BOUNDARY_ID', 'abc-123') + + server.listen({ + // Enable remote interception to trigger internal requests. + // The connection is meant to fail here. + remote: { + enabled: true, + }, + }) +}) + +afterEach(() => { + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +it('does not emit life-cycle events for internal requests', async () => { + const { listener } = spyOnLifeCycleEvents(server) + + // Must emit no life-cycle events for internal requests. + expect(listener).not.toHaveBeenCalled() +}) diff --git a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/callback-throws.node.test.ts b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/callback-throws.node.test.ts index 507587b04..0841c79fc 100644 --- a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/callback-throws.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/callback-throws.node.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { setupServer } from 'msw/node' import { HttpResponse, http } from 'msw' @@ -10,18 +8,13 @@ const server = setupServer( }), ) -beforeAll(() => +beforeAll(() => { server.listen({ onUnhandledRequest(request) { - /** - * @fixme @todo For some reason, the exception from the "onUnhandledRequest" - * callback doesn't propagate to the intercepted request but instead is thrown - * in this test's context. - */ throw new Error(`Custom error for ${request.method} ${request.url}`) }, - }), -) + }) +}) afterAll(() => { server.close() @@ -30,9 +23,9 @@ afterAll(() => { test('handles exceptions in "onUnhandledRequest" callback as 500 responses', async () => { const response = await fetch('https://example.com') - expect(response.status).toBe(500) - expect(response.statusText).toBe('Unhandled Exception') - expect(await response.json()).toEqual({ + expect.soft(response.status).toBe(500) + expect.soft(response.statusText).toBe('Unhandled Exception') + await expect.soft(response.json()).resolves.toEqual({ name: 'Error', message: 'Custom error for GET https://example.com/', stack: expect.any(String), diff --git a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/default.node.test.ts b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/default.node.test.ts index 031184414..36da7245b 100644 --- a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/default.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/default.node.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { setupServer } from 'msw/node' import { HttpResponse, http } from 'msw' @@ -17,7 +15,7 @@ beforeAll(() => { }) afterEach(() => { - vi.resetAllMocks() + vi.clearAllMocks() }) afterAll(() => { @@ -40,12 +38,3 @@ it('warns on unhandled requests by default', async () => { 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`) }) - -it('does not warn on unhandled "file://" requests', async () => { - // This request is expected to fail: - // Fetching non-existing file URL. - await fetch('file:///file/does/not/exist').catch(() => void 0) - - expect(console.error).not.toBeCalled() - expect(console.warn).not.toBeCalled() -}) diff --git a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/error.node.test.ts b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/error.node.test.ts index b234da0ab..7ca65cfcf 100644 --- a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/error.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/error.node.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { HttpServer } from '@open-draft/test-server/http' import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' diff --git a/test/node/msw-api/setup-server/use.node.test.ts b/test/node/msw-api/setup-server/use.node.test.ts index 22dcc189c..ed75559ec 100644 --- a/test/node/msw-api/setup-server/use.node.test.ts +++ b/test/node/msw-api/setup-server/use.node.test.ts @@ -134,6 +134,6 @@ test('throws if provided the invalid handlers array', async () => { [http.get('*', () => new Response())], ), ).toThrow( - '[MSW] Failed to call "use()" with the given request handlers: invalid input. Did you forget to spread the array of request handlers?', + '[MSW] Failed to prepend runtime handlers: invalid handlers. Did you forget to spread the handlers array?', ) }) diff --git a/test/node/regressions/many-request-handlers-jsdom.test.ts b/test/node/regressions/many-request-handlers-jsdom.test.ts index 1a46268da..e0044182c 100644 --- a/test/node/regressions/many-request-handlers-jsdom.test.ts +++ b/test/node/regressions/many-request-handlers-jsdom.test.ts @@ -20,13 +20,14 @@ const processErrorSpy = vi.spyOn(process.stderr, 'write') const NUMBER_OF_REQUEST_HANDLERS = 100 beforeAll(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => void 0) await httpServer.listen() server.listen() }) afterEach(() => { - server.resetHandlers() vi.clearAllMocks() + server.resetHandlers() }) afterAll(async () => { @@ -58,7 +59,7 @@ describe('http handlers', () => { body: 'request-body-', }, ).then((response) => response.text()) - // Each clone is a new AbortSignal listener which needs to be registered + expect(requestCloneSpy).toHaveBeenCalledTimes(1) expect(httpResponse).toBe(`request-body-${NUMBER_OF_REQUEST_HANDLERS - 1}`) expect(processErrorSpy).not.toHaveBeenCalled() diff --git a/test/node/rest-api/request/body/body-text.node.test.ts b/test/node/rest-api/request/body/body-text.node.test.ts index 34cba4a31..cfd7c0c2e 100644 --- a/test/node/rest-api/request/body/body-text.node.test.ts +++ b/test/node/rest-api/request/body/body-text.node.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' import { encodeBuffer } from '@mswjs/interceptors' @@ -20,61 +18,56 @@ afterAll(() => { }) test('reads plain text request body as text', async () => { - const res = await fetch('http://localhost/resource', { + const response = await fetch('http://localhost/resource', { method: 'POST', headers: { 'Content-Type': 'text/plain', }, body: 'hello-world', }) - const body = await res.text() - expect(res.status).toBe(200) - expect(body).toBe('hello-world') + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('hello-world') }) test('reads json request body as text', async () => { - const res = await fetch('http://localhost/resource', { + const response = await fetch('http://localhost/resource', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ firstName: 'John' }), }) - const body = await res.text() - expect(res.status).toBe(200) - expect(body).toBe(`{"firstName":"John"}`) + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe(`{"firstName":"John"}`) }) test('reads array buffer request body as text', async () => { - const res = await fetch('http://localhost/resource', { + const response = await fetch('http://localhost/resource', { method: 'POST', body: encodeBuffer('hello-world'), }) - const body = await res.text() - expect(res.status).toBe(200) - expect(body).toBe('hello-world') + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('hello-world') }) test('reads null request body as empty text', async () => { - const res = await fetch('http://localhost/resource', { + const response = await fetch('http://localhost/resource', { method: 'POST', body: null as any, }) - const body = await res.text() - expect(res.status).toBe(200) - expect(body).toBe('') + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('') }) test('reads undefined request body as empty text', async () => { - const res = await fetch('http://localhost/resource', { + const response = await fetch('http://localhost/resource', { method: 'POST', }) - const body = await res.text() - expect(res.status).toBe(200) - expect(body).toBe('') + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('') }) diff --git a/test/node/rest-api/response/throw-response.node.test.ts b/test/node/rest-api/response/throw-response.node.test.ts index b2ac3197f..9b9028a5d 100644 --- a/test/node/rest-api/response/throw-response.node.test.ts +++ b/test/node/rest-api/response/throw-response.node.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' @@ -20,60 +18,62 @@ afterAll(() => { it('supports throwing a plain Response in a response resolver', async () => { server.use( - http.get('https://example.com/', () => { + http.get('http://localhost/resource', () => { // You can throw a Response instance in a response resolver // to short-circuit its execution and respond "early". throw new Response('hello world') }), ) - const response = await fetch('https://example.com') + const response = await fetch('http://localhost/resource') expect(response.status).toBe(200) - expect(await response.text()).toBe('hello world') + await expect(response.text()).resolves.toBe('hello world') }) it('supports throwing an HttpResponse instance in a response resolver', async () => { server.use( - http.get('https://example.com/', () => { + http.get('http://localhost/resource', () => { throw HttpResponse.text('hello world') }), ) - const response = await fetch('https://example.com') + const response = await fetch('http://localhost/resource') expect(response.status).toBe(200) expect(response.headers.get('Content-Type')).toBe('text/plain') - expect(await response.text()).toBe('hello world') + await expect(response.text()).resolves.toBe('hello world') }) it('supports throwing an error response in a response resolver', async () => { server.use( - http.get('https://example.com/', () => { + http.get('http://localhost/resource', () => { throw HttpResponse.text('not found', { status: 400 }) }), ) - const response = await fetch('https://example.com') + const response = await fetch('http://localhost/resource') expect(response.status).toBe(400) expect(response.headers.get('Content-Type')).toBe('text/plain') - expect(await response.text()).toBe('not found') + await expect(response.text()).resolves.toBe('not found') }) it('supports throwing a network error in a response resolver', async () => { server.use( - http.get('https://example.com/', () => { + http.get('http://localhost/resource', () => { throw HttpResponse.error() }), ) - await expect(fetch('https://example.com')).rejects.toThrow('Failed to fetch') + await expect(fetch('http://localhost/resource')).rejects.toThrow( + 'Failed to fetch', + ) }) it('supports middleware-style responses', async () => { server.use( - http.get('https://example.com/', ({ request }) => { + http.get('http://localhost/resource', ({ request }) => { const url = new URL(request.url) if (!url.searchParams.has('id')) { @@ -84,29 +84,29 @@ it('supports middleware-style responses', async () => { }), ) - const response = await fetch('https://example.com/?id=1') + const response = await fetch('http://localhost/resource?id=1') expect(response.status).toBe(200) expect(response.headers.get('Content-Type')).toBe('text/plain') - expect(await response.text()).toBe('ok') + await expect(response.text()).resolves.toBe('ok') - const errorResponse = await fetch('https://example.com/') + const errorResponse = await fetch('http://localhost/resource') expect(errorResponse.status).toBe(400) expect(errorResponse.headers.get('Content-Type')).toBe('text/plain') - expect(await errorResponse.text()).toBe('must have id') + await expect(errorResponse.text()).resolves.toBe('must have id') }) -it('handles non-response errors as 500 error responses', async () => { +it('coerces non-response errors into 500 error responses', async () => { server.use( - http.get('https://example.com/', () => { + http.get('http://localhost/resource', () => { throw new Error('Custom error') }), ) - const response = await fetch('https://example.com') + const response = await fetch('http://localhost/resource') expect(response.status).toBe(500) expect(response.statusText).toBe('Unhandled Exception') - expect(await response.json()).toEqual({ + await expect(response.json()).resolves.toEqual({ name: 'Error', message: 'Custom error', stack: expect.any(String), diff --git a/test/node/utils.ts b/test/node/utils.ts new file mode 100644 index 000000000..84c64843c --- /dev/null +++ b/test/node/utils.ts @@ -0,0 +1,58 @@ +import { DeferredPromise } from '@open-draft/deferred-promise' +import { vi, afterEach } from 'vitest' +import { LifeCycleEventsMap, SetupApi } from 'msw' + +export function spyOnLifeCycleEvents(api: SetupApi) { + const listener = vi.fn() + const requestIdPromise = new DeferredPromise() + + afterEach(() => listener.mockReset()) + + api.events + .on('request:start', ({ request, requestId }) => { + if (request.headers.has('upgrade')) { + return + } + + requestIdPromise.resolve(requestId) + listener(`[request:start] ${request.method} ${request.url} ${requestId}`) + }) + .on('request:match', ({ request, requestId }) => { + listener(`[request:match] ${request.method} ${request.url} ${requestId}`) + }) + .on('request:unhandled', ({ request, requestId }) => { + listener( + `[request:unhandled] ${request.method} ${request.url} ${requestId}`, + ) + }) + .on('request:end', ({ request, requestId }) => { + if (request.headers.has('upgrade')) { + return + } + + listener(`[request:end] ${request.method} ${request.url} ${requestId}`) + }) + .on('response:mocked', async ({ response, request, requestId }) => { + listener( + `[response:mocked] ${request.method} ${request.url} ${requestId} ${ + response.status + } ${await response.clone().text()}`, + ) + }) + .on('response:bypass', async ({ response, request, requestId }) => { + if (request.headers.has('upgrade')) { + return + } + + listener( + `[response:bypass] ${request.method} ${request.url} ${requestId} ${ + response.status + } ${await response.clone().text()}`, + ) + }) + + return { + listener, + requestIdPromise, + } +} diff --git a/test/node/ws-api/on-unhandled-request/error.test.ts b/test/node/ws-api/on-unhandled-request/error.test.ts index 9c4837d2f..cfe416971 100644 --- a/test/node/ws-api/on-unhandled-request/error.test.ts +++ b/test/node/ws-api/on-unhandled-request/error.test.ts @@ -39,12 +39,12 @@ it( expect(console.error).toHaveBeenCalledWith( `\ -[MSW] Error: intercepted a request without a matching request handler: +[MSW] Error: intercepted a WebSocket connection without a matching event handler: - • GET wss://localhost:4321/ + • wss://localhost:4321/ -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`, +If you still wish to intercept this unhandled connection, please create an event handler for it. +Read more: https://mswjs.io/docs/websocket`, ) expect(errorListener).toHaveBeenCalledOnce() diff --git a/test/node/ws-api/on-unhandled-request/warn.test.ts b/test/node/ws-api/on-unhandled-request/warn.test.ts index 401b66d34..fb8b52e89 100644 --- a/test/node/ws-api/on-unhandled-request/warn.test.ts +++ b/test/node/ws-api/on-unhandled-request/warn.test.ts @@ -34,12 +34,12 @@ it( expect(console.warn).toHaveBeenCalledWith( `\ -[MSW] Warning: intercepted a request without a matching request handler: +[MSW] Warning: intercepted a WebSocket connection without a matching event handler: - • GET wss://localhost:4321/ + • wss://localhost:4321/ -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`, +If you still wish to intercept this unhandled connection, please create an event handler for it. +Read more: https://mswjs.io/docs/websocket`, ) }), ) diff --git a/test/support/utils.ts b/test/support/utils.ts index ec800beaa..7642ead3f 100644 --- a/test/support/utils.ts +++ b/test/support/utils.ts @@ -1,6 +1,6 @@ -import url from 'node:url' -import path from 'node:path' -import { ClientRequest, IncomingMessage } from 'http' +import * as url from 'node:url' +import * as path from 'node:path' +import { ClientRequest, IncomingMessage } from 'node:http' export function sleep(duration: number) { return new Promise((resolve) => { diff --git a/tsconfig.test.unit.json b/tsconfig.test.unit.json index abea30c95..ba0d5c8fa 100644 --- a/tsconfig.test.unit.json +++ b/tsconfig.test.unit.json @@ -9,9 +9,5 @@ "types": ["vitest/globals"] }, "include": ["./src/**/*.test.ts", "./test/support"], - "references": [ - { - "path": "./src/tsconfig.src.json" - } - ] + "references": [{ "path": "./src/tsconfig.node.json" }] } diff --git a/tsup.config.ts b/tsup.config.ts index 76fe8e8d6..b20a3babf 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -42,7 +42,7 @@ const coreConfig: Options = { name: 'core', platform: 'neutral', entry: glob.sync('./src/core/**/*.ts', { - ignore: '**/*.test.ts', + ignore: ['**/__*/**/*', '**/*.test.ts'], }), external: [ecosystemDependencies], noExternal: ['cookie'],