From a94fe816b222b2624e543e0113a317c92165ba9d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 3 Feb 2024 16:31:19 +0100 Subject: [PATCH 001/105] test(matchRequestUrl): add ws scheme tests --- .../utils/matching/matchRequestUrl.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/core/utils/matching/matchRequestUrl.test.ts b/src/core/utils/matching/matchRequestUrl.test.ts index 737c54594..4a5de24d6 100644 --- a/src/core/utils/matching/matchRequestUrl.test.ts +++ b/src/core/utils/matching/matchRequestUrl.test.ts @@ -61,6 +61,50 @@ describe('matchRequestUrl', () => { expect(match).toHaveProperty('matches', false) expect(match).toHaveProperty('params', {}) }) + + test('returns true for matching WebSocket URL', () => { + expect( + matchRequestUrl(new URL('ws://test.mswjs.io'), 'ws://test.mswjs.io'), + ).toEqual({ + matches: true, + params: {}, + }) + expect( + matchRequestUrl(new URL('wss://test.mswjs.io'), 'wss://test.mswjs.io'), + ).toEqual({ + matches: true, + params: {}, + }) + }) + + test('returns false for non-matching WebSocket URL', () => { + expect( + matchRequestUrl(new URL('ws://test.mswjs.io'), 'ws://foo.mswjs.io'), + ).toEqual({ + matches: false, + params: {}, + }) + expect( + matchRequestUrl(new URL('wss://test.mswjs.io'), 'wss://completely.diff'), + ).toEqual({ + matches: false, + params: {}, + }) + }) + + test('returns path parameters when matched a WebSocket URL', () => { + expect( + matchRequestUrl( + new URL('wss://test.mswjs.io'), + 'wss://:service.mswjs.io', + ), + ).toEqual({ + matches: true, + params: { + service: 'test', + }, + }) + }) }) describe('coercePath', () => { From f1a7f12a9a490aea0e0d8baa0ce4d5bf53fbd37c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 3 Feb 2024 16:31:27 +0100 Subject: [PATCH 002/105] chore(ws): draft public api --- pnpm-lock.yaml | 22 ++-------------- src/core/ws.ts | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 20 deletions(-) create mode 100644 src/core/ws.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f70a7215f..583ef3afe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,7 +73,7 @@ dependencies: '@bundled-es-modules/cookie': 2.0.0 '@bundled-es-modules/statuses': 1.0.1 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.25.15 + '@mswjs/interceptors': link:../interceptors '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -1071,18 +1071,6 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.25.15: - resolution: {integrity: sha512-s4jdyxmq1eeftfDXJ7MUiK/jlvYaU8Sr75+42hHCVBrYez0k51RHbMitKIKdmsF92Q6gwhp8Sm1MmvdA9llpcg==} - engines: {node: '>=18'} - dependencies: - '@open-draft/deferred-promise': 2.2.0 - '@open-draft/logger': 0.3.0 - '@open-draft/until': 2.1.0 - is-node-process: 1.2.0 - outvariant: 1.4.2 - strict-event-emitter: 0.5.1 - dev: false - /@nodelib/fs.scandir/2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1106,13 +1094,7 @@ packages: /@open-draft/deferred-promise/2.2.0: resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} - - /@open-draft/logger/0.3.0: - resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} - dependencies: - is-node-process: 1.2.0 - outvariant: 1.4.2 - dev: false + dev: true /@open-draft/test-server/0.4.2: resolution: {integrity: sha512-J9wbdQkPx5WKcDNtgfnXsx5ew4UJd6BZyGr89YlHeaUkOShkO2iO5QIyCCsG4qpjIvr2ZTkEYJA9ujOXXyO6Pg==} diff --git a/src/core/ws.ts b/src/core/ws.ts new file mode 100644 index 000000000..c164aefd7 --- /dev/null +++ b/src/core/ws.ts @@ -0,0 +1,71 @@ +import { WebSocketInterceptor } from '@mswjs/interceptors/WebSocket' +import { matchRequestUrl, type Path } from './utils/matching/matchRequestUrl' + +/** + * Intercept outgoing WebSocket connections to the given URL. + * @param url WebSocket server URL + */ +function createWebSocketHandler(url: Path) { + /** + * @note I think the handler should initialize the interceptor. + * This way, no WebSocket class stubs will be applied unless + * you use a single "ws" handler. Interceptors are deduped. + */ + const interceptor = new WebSocketInterceptor() + interceptor.apply() + + /** + * @todo Should this maybe live in the "setup" function + * and then emit ONE intercepted connection to MANY "ws" + * handlers? That way: + * - The order of listeners still matters (consistency). + * - The `.use()` makes sense. + * + * The challenge: only apply the interceptor when at least + * ONE "ws.link()" has been created. + */ + interceptor.on('connection', (connection) => { + const match = matchRequestUrl(connection.client.url, url) + + // For WebSocket connections that don't match the given + // URL predicate, open them as-is and forward all messages. + if (!match.matches) { + connection.server.connect() + connection.client.on('message', (event) => { + connection.server.send(event.data) + }) + return + } + + /** @todo Those that match, route to the public API */ + }) + + /** @fixme Dispose of the interceptor. */ + + return { + /** + * @fixme Don't expose these directly. The exposed interface + * must be decoupled from the interceptor to support + * "removeAllEvents" and such. + */ + on: interceptor.on.bind(interceptor), + off: interceptor.off.bind(interceptor), + removeAllListeners: interceptor.removeAllListeners.bind(interceptor), + } +} + +export const ws = { + link: createWebSocketHandler, +} + +// +// + +const chat = ws.link('wss://*.service.com') + +chat.on('connection', ({ client }) => { + client.on('message', (event) => { + console.log(event.data) + client.send('Hello from server!') + }) +}) From d5a9b4c3ba02c63dcaa32e6735226949da03d558 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 7 Feb 2024 11:16:47 +0100 Subject: [PATCH 003/105] chore: design "ws.link" api --- src/core/ws.ts | 155 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 102 insertions(+), 53 deletions(-) diff --git a/src/core/ws.ts b/src/core/ws.ts index c164aefd7..58b0afe0e 100644 --- a/src/core/ws.ts +++ b/src/core/ws.ts @@ -1,71 +1,120 @@ -import { WebSocketInterceptor } from '@mswjs/interceptors/WebSocket' -import { matchRequestUrl, type Path } from './utils/matching/matchRequestUrl' +import { Emitter } from 'strict-event-emitter' +import { + WebSocketInterceptor, + type WebSocketEventMap, + type WebSocketClientConnection, + type WebSocketServerConnection, +} from '@mswjs/interceptors/WebSocket' +import { + type Path, + matchRequestUrl, + PathParams, +} from './utils/matching/matchRequestUrl' /** - * Intercept outgoing WebSocket connections to the given URL. - * @param url WebSocket server URL + * @fixme Will each "ws" import create a NEW intereptor? + * Consider moving this away and reusing. */ -function createWebSocketHandler(url: Path) { - /** - * @note I think the handler should initialize the interceptor. - * This way, no WebSocket class stubs will be applied unless - * you use a single "ws" handler. Interceptors are deduped. - */ - const interceptor = new WebSocketInterceptor() - interceptor.apply() +const interceptor = new WebSocketInterceptor() +const emitter = new EventTarget() - /** - * @todo Should this maybe live in the "setup" function - * and then emit ONE intercepted connection to MANY "ws" - * handlers? That way: - * - The order of listeners still matters (consistency). - * - The `.use()` makes sense. - * - * The challenge: only apply the interceptor when at least - * ONE "ws.link()" has been created. - */ - interceptor.on('connection', (connection) => { - const match = matchRequestUrl(connection.client.url, url) - - // For WebSocket connections that don't match the given - // URL predicate, open them as-is and forward all messages. - if (!match.matches) { - connection.server.connect() - connection.client.on('message', (event) => { - connection.server.send(event.data) - }) - return - } - - /** @todo Those that match, route to the public API */ +interceptor.on('connection', (connection) => { + const connectionMessage = new MessageEvent('connection', { + data: connection, + cancelable: true, }) - /** @fixme Dispose of the interceptor. */ + emitter.dispatchEvent(connectionMessage) + + // If none of the "ws" handlers matched, + // establish the WebSocket connection as-is. + if (!connectionMessage.defaultPrevented) { + connection.server.connect() + connection.client.on('message', (event) => { + connection.server.send(event.data) + }) + } +}) + +/** + * Disposes of the WebSocket interceptor. + * The interceptor is a singleton instantiated in the + * "ws" API. + */ +export function disposeWebSocketInterceptor() { + interceptor.dispose() +} + +type WebSocketLinkHandlerEventMap = { + connection: [ + args: { + client: WebSocketClientConnection + server: WebSocketServerConnection + params: PathParams + }, + ] +} + +/** + * Intercepts outgoing WebSocket connections to the given URL. + * + * @example + * const chat = ws.link('wss://chat.example.com') + * chat.on('connection', (connection) => {}) + */ +function createWebSocketLinkHandler(url: Path) { + const publicEmitter = new Emitter() + + // Apply the WebSocket interceptor. + // This defers the WebSocket class patching to the first + // "ws" event handler call. Repetitive calls to the "apply" + // method have no effect. + interceptor.apply() + + emitter.addEventListener( + 'connection', + (event: MessageEvent) => { + const { client, server } = event.data + const match = matchRequestUrl(client.url, url) + + if (match.matches) { + // Preventing the default on the connection event + // will prevent the WebSocket connection from being + // established. + event.preventDefault() + publicEmitter.emit('connection', { + client, + server, + params: match.params || {}, + }) + } + }, + ) + + const { on, off, removeAllListeners } = publicEmitter return { - /** - * @fixme Don't expose these directly. The exposed interface - * must be decoupled from the interceptor to support - * "removeAllEvents" and such. - */ - on: interceptor.on.bind(interceptor), - off: interceptor.off.bind(interceptor), - removeAllListeners: interceptor.removeAllListeners.bind(interceptor), + on, + off, + removeAllListeners, } } export const ws = { - link: createWebSocketHandler, + link: createWebSocketLinkHandler, } // +// Public usage. // -const chat = ws.link('wss://*.service.com') +const chat = ws.link('wss://:roomId.service.com') -chat.on('connection', ({ client }) => { - client.on('message', (event) => { - console.log(event.data) - client.send('Hello from server!') - }) -}) +export const handlers = [ + chat.on('connection', ({ client }) => { + client.on('message', (event) => { + console.log(event.data) + client.send('Hello from server!') + }) + }), +] From 47ca629aff3c499340060f401b4fc1dfa7b25e19 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 8 Feb 2024 17:44:04 +0100 Subject: [PATCH 004/105] chore: add agnostic "Handler" class --- src/core/handlers/Handler.ts | 43 +++++++++ src/core/handlers/WebSocketHandler.ts | 95 ++++++++++++++++++++ src/core/utils/handleWebSocketEvent.ts | 31 +++++++ src/core/ws.ts | 120 ------------------------- src/core/ws/webSocketInterceptor.ts | 3 + src/core/ws/ws.ts | 19 ++++ 6 files changed, 191 insertions(+), 120 deletions(-) create mode 100644 src/core/handlers/Handler.ts create mode 100644 src/core/handlers/WebSocketHandler.ts create mode 100644 src/core/utils/handleWebSocketEvent.ts delete mode 100644 src/core/ws.ts create mode 100644 src/core/ws/webSocketInterceptor.ts create mode 100644 src/core/ws/ws.ts diff --git a/src/core/handlers/Handler.ts b/src/core/handlers/Handler.ts new file mode 100644 index 000000000..6f2bea49d --- /dev/null +++ b/src/core/handlers/Handler.ts @@ -0,0 +1,43 @@ +export type HandlerOptions = { + once?: boolean +} + +export abstract class Handler { + public isUsed: boolean + + constructor(protected readonly options: HandlerOptions = {}) { + this.isUsed = false + } + + abstract parse(args: { input: Input }): unknown + abstract predicate(args: { input: Input; parsedResult: unknown }): boolean + protected abstract handle(args: { + input: Input + parsedResult: unknown + }): Promise + + public async run(input: Input): Promise { + if (this.options?.once && this.isUsed) { + return null + } + + const parsedResult = this.parse({ input }) + const shouldHandle = this.predicate({ + input, + parsedResult, + }) + + if (!shouldHandle) { + return null + } + + const result = await this.handle({ + input, + parsedResult, + }) + + this.isUsed = true + + return result + } +} diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts new file mode 100644 index 000000000..9a96abcc6 --- /dev/null +++ b/src/core/handlers/WebSocketHandler.ts @@ -0,0 +1,95 @@ +import { Emitter } from 'strict-event-emitter' +import type { + WebSocketClientConnection, + WebSocketServerConnection, +} from '@mswjs/interceptors/WebSocket' +import { + type Match, + type Path, + type PathParams, + matchRequestUrl, +} from '../utils/matching/matchRequestUrl' +import { Handler } from './Handler' + +type WebSocketHandlerParsedResult = { + match: Match +} + +type WebSocketHandlerEventMap = { + connection: [ + args: { + client: WebSocketClientConnection + server: WebSocketServerConnection + params: PathParams + }, + ] +} + +export class WebSocketHandler extends Handler> { + public on: ( + event: K, + listener: (...args: WebSocketHandlerEventMap[K]) => void, + ) => void + + public off: ( + event: K, + listener: (...args: WebSocketHandlerEventMap[K]) => void, + ) => void + + public removeAllListeners: ( + event?: K, + ) => void + + protected emitter: Emitter + + constructor(private readonly url: Path) { + super() + this.emitter = new Emitter() + + // Forward some of the emitter API to the public API + // of the event handler. + this.on = this.emitter.on.bind(this.emitter) + this.off = this.emitter.off.bind(this.emitter) + this.removeAllListeners = this.emitter.removeAllListeners.bind(this.emitter) + } + + public parse(args: { + input: MessageEvent + }): WebSocketHandlerParsedResult { + const connection = args.input.data + const match = matchRequestUrl(connection.client.url, this.url) + + return { + match, + } + } + + public predicate(args: { + input: MessageEvent + parsedResult: WebSocketHandlerParsedResult + }): boolean { + const { match } = args.parsedResult + return match.matches + } + + protected async handle(args: { + input: MessageEvent + parsedResult: WebSocketHandlerParsedResult + }): Promise { + const connectionEvent = args.input + + // At this point, the WebSocket connection URL has matched the handler. + // Prevent the default behavior of establishing the connection as-is. + connectionEvent.preventDefault() + + const connection = connectionEvent.data + + // Emit the connection event on the handler. + // This is what the developer adds listeners for. + this.emitter.emit('connection', { + client: connection.client, + server: connection.server, + params: args.parsedResult.match.params || {}, + }) + } +} diff --git a/src/core/utils/handleWebSocketEvent.ts b/src/core/utils/handleWebSocketEvent.ts new file mode 100644 index 000000000..811dde2b1 --- /dev/null +++ b/src/core/utils/handleWebSocketEvent.ts @@ -0,0 +1,31 @@ +import { type Handler, WebSocketHandler } from '../handlers/WebSocketHandler' +import { webSocketInterceptor } from '../ws/webSocketInterceptor' + +export function handleWebSocketEvent(handlers: Array) { + webSocketInterceptor.on('connection', (connection) => { + const connectionEvent = new MessageEvent('connection', { + data: connection, + cancelable: true, + }) + + // Iterate over the handlers and forward the connection + // event to WebSocket event handlers. This is equivalent + // to dispatching that event onto multiple listeners. + for (const handler of handlers) { + if (handler instanceof WebSocketHandler) { + // Never await the run function because event handlers + // are side-effectful and don't block the event loop. + handler.run(connectionEvent) + } + } + + // If none of the "ws" handlers matched, + // establish the WebSocket connection as-is. + if (!connectionEvent.defaultPrevented) { + connection.server.connect() + connection.client.on('message', (event) => { + connection.server.send(event.data) + }) + } + }) +} diff --git a/src/core/ws.ts b/src/core/ws.ts deleted file mode 100644 index 58b0afe0e..000000000 --- a/src/core/ws.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Emitter } from 'strict-event-emitter' -import { - WebSocketInterceptor, - type WebSocketEventMap, - type WebSocketClientConnection, - type WebSocketServerConnection, -} from '@mswjs/interceptors/WebSocket' -import { - type Path, - matchRequestUrl, - PathParams, -} from './utils/matching/matchRequestUrl' - -/** - * @fixme Will each "ws" import create a NEW intereptor? - * Consider moving this away and reusing. - */ -const interceptor = new WebSocketInterceptor() -const emitter = new EventTarget() - -interceptor.on('connection', (connection) => { - const connectionMessage = new MessageEvent('connection', { - data: connection, - cancelable: true, - }) - - emitter.dispatchEvent(connectionMessage) - - // If none of the "ws" handlers matched, - // establish the WebSocket connection as-is. - if (!connectionMessage.defaultPrevented) { - connection.server.connect() - connection.client.on('message', (event) => { - connection.server.send(event.data) - }) - } -}) - -/** - * Disposes of the WebSocket interceptor. - * The interceptor is a singleton instantiated in the - * "ws" API. - */ -export function disposeWebSocketInterceptor() { - interceptor.dispose() -} - -type WebSocketLinkHandlerEventMap = { - connection: [ - args: { - client: WebSocketClientConnection - server: WebSocketServerConnection - params: PathParams - }, - ] -} - -/** - * Intercepts outgoing WebSocket connections to the given URL. - * - * @example - * const chat = ws.link('wss://chat.example.com') - * chat.on('connection', (connection) => {}) - */ -function createWebSocketLinkHandler(url: Path) { - const publicEmitter = new Emitter() - - // Apply the WebSocket interceptor. - // This defers the WebSocket class patching to the first - // "ws" event handler call. Repetitive calls to the "apply" - // method have no effect. - interceptor.apply() - - emitter.addEventListener( - 'connection', - (event: MessageEvent) => { - const { client, server } = event.data - const match = matchRequestUrl(client.url, url) - - if (match.matches) { - // Preventing the default on the connection event - // will prevent the WebSocket connection from being - // established. - event.preventDefault() - publicEmitter.emit('connection', { - client, - server, - params: match.params || {}, - }) - } - }, - ) - - const { on, off, removeAllListeners } = publicEmitter - - return { - on, - off, - removeAllListeners, - } -} - -export const ws = { - link: createWebSocketLinkHandler, -} - -// -// Public usage. -// - -const chat = ws.link('wss://:roomId.service.com') - -export const handlers = [ - chat.on('connection', ({ client }) => { - client.on('message', (event) => { - console.log(event.data) - client.send('Hello from server!') - }) - }), -] diff --git a/src/core/ws/webSocketInterceptor.ts b/src/core/ws/webSocketInterceptor.ts new file mode 100644 index 000000000..8a8b21f2d --- /dev/null +++ b/src/core/ws/webSocketInterceptor.ts @@ -0,0 +1,3 @@ +import { WebSocketInterceptor } from '@mswjs/interceptors/WebSocket' + +export const webSocketInterceptor = new WebSocketInterceptor() diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts new file mode 100644 index 000000000..c44a3e469 --- /dev/null +++ b/src/core/ws/ws.ts @@ -0,0 +1,19 @@ +import { WebSocketHandler } from '../handlers/WebSocketHandler' +import type { Path } from '../utils/matching/matchRequestUrl' +import { webSocketInterceptor } from './webSocketInterceptor' + +/** + * Intercepts outgoing WebSocket connections to the given URL. + * + * @example + * const chat = ws.link('wss://chat.example.com') + * chat.on('connection', (connection) => {}) + */ +function createWebSocketLinkHandler(url: Path) { + webSocketInterceptor.apply() + return new WebSocketHandler(url) +} + +export const ws = { + link: createWebSocketLinkHandler, +} From 4fe7989ba9463074dae5736e428f8f78fc2c53fc Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 8 Feb 2024 19:36:30 +0100 Subject: [PATCH 005/105] chore: drop non-extendable "Handler" --- src/core/handlers/Handler.ts | 43 -------------------------- src/core/handlers/WebSocketHandler.ts | 33 +++++++++++++------- src/core/utils/handleWebSocketEvent.ts | 11 ++++--- 3 files changed, 28 insertions(+), 59 deletions(-) delete mode 100644 src/core/handlers/Handler.ts diff --git a/src/core/handlers/Handler.ts b/src/core/handlers/Handler.ts deleted file mode 100644 index 6f2bea49d..000000000 --- a/src/core/handlers/Handler.ts +++ /dev/null @@ -1,43 +0,0 @@ -export type HandlerOptions = { - once?: boolean -} - -export abstract class Handler { - public isUsed: boolean - - constructor(protected readonly options: HandlerOptions = {}) { - this.isUsed = false - } - - abstract parse(args: { input: Input }): unknown - abstract predicate(args: { input: Input; parsedResult: unknown }): boolean - protected abstract handle(args: { - input: Input - parsedResult: unknown - }): Promise - - public async run(input: Input): Promise { - if (this.options?.once && this.isUsed) { - return null - } - - const parsedResult = this.parse({ input }) - const shouldHandle = this.predicate({ - input, - parsedResult, - }) - - if (!shouldHandle) { - return null - } - - const result = await this.handle({ - input, - parsedResult, - }) - - this.isUsed = true - - return result - } -} diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts index 9a96abcc6..ffb398692 100644 --- a/src/core/handlers/WebSocketHandler.ts +++ b/src/core/handlers/WebSocketHandler.ts @@ -9,7 +9,6 @@ import { type PathParams, matchRequestUrl, } from '../utils/matching/matchRequestUrl' -import { Handler } from './Handler' type WebSocketHandlerParsedResult = { match: Match @@ -25,7 +24,14 @@ type WebSocketHandlerEventMap = { ] } -export class WebSocketHandler extends Handler> { +type WebSocketHandlerIncomingEvent = MessageEvent<{ + client: WebSocketClientConnection + server: WebSocketServerConnection +}> + +export const kRun = Symbol('run') + +export class WebSocketHandler { public on: ( event: K, listener: (...args: WebSocketHandlerEventMap[K]) => void, @@ -43,7 +49,6 @@ export class WebSocketHandler extends Handler> { protected emitter: Emitter constructor(private readonly url: Path) { - super() this.emitter = new Emitter() // Forward some of the emitter API to the public API @@ -54,9 +59,9 @@ export class WebSocketHandler extends Handler> { } public parse(args: { - input: MessageEvent + event: WebSocketHandlerIncomingEvent }): WebSocketHandlerParsedResult { - const connection = args.input.data + const connection = args.event.data const match = matchRequestUrl(connection.client.url, this.url) return { @@ -65,18 +70,22 @@ export class WebSocketHandler extends Handler> { } public predicate(args: { - input: MessageEvent + event: WebSocketHandlerIncomingEvent parsedResult: WebSocketHandlerParsedResult }): boolean { const { match } = args.parsedResult return match.matches } - protected async handle(args: { - input: MessageEvent - parsedResult: WebSocketHandlerParsedResult - }): Promise { - const connectionEvent = args.input + async [kRun](args: { event: MessageEvent }): Promise { + const parsedResult = this.parse({ event: args.event }) + const shouldIntercept = this.predicate({ event: args.event, parsedResult }) + + if (!shouldIntercept) { + return + } + + const connectionEvent = args.event // At this point, the WebSocket connection URL has matched the handler. // Prevent the default behavior of establishing the connection as-is. @@ -89,7 +98,7 @@ export class WebSocketHandler extends Handler> { this.emitter.emit('connection', { client: connection.client, server: connection.server, - params: args.parsedResult.match.params || {}, + params: parsedResult.match.params || {}, }) } } diff --git a/src/core/utils/handleWebSocketEvent.ts b/src/core/utils/handleWebSocketEvent.ts index 811dde2b1..2f30e31a8 100644 --- a/src/core/utils/handleWebSocketEvent.ts +++ b/src/core/utils/handleWebSocketEvent.ts @@ -1,7 +1,10 @@ -import { type Handler, WebSocketHandler } from '../handlers/WebSocketHandler' +import { RequestHandler } from '../handlers/RequestHandler' +import { WebSocketHandler, kRun } from '../handlers/WebSocketHandler' import { webSocketInterceptor } from '../ws/webSocketInterceptor' -export function handleWebSocketEvent(handlers: Array) { +export function handleWebSocketEvent( + handlers: Array, +) { webSocketInterceptor.on('connection', (connection) => { const connectionEvent = new MessageEvent('connection', { data: connection, @@ -15,7 +18,7 @@ export function handleWebSocketEvent(handlers: Array) { if (handler instanceof WebSocketHandler) { // Never await the run function because event handlers // are side-effectful and don't block the event loop. - handler.run(connectionEvent) + handler[kRun]({ event: connectionEvent }) } } @@ -23,7 +26,7 @@ export function handleWebSocketEvent(handlers: Array) { // establish the WebSocket connection as-is. if (!connectionEvent.defaultPrevented) { connection.server.connect() - connection.client.on('message', (event) => { + connection.client.addEventListener('message', (event) => { connection.server.send(event.data) }) } From 2f9070869ff877a590efa970e0fe4d22dbad2390 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 8 Feb 2024 19:52:28 +0100 Subject: [PATCH 006/105] fix: support "WebSocketHandler" in setup apis --- src/browser/setupWorker/glossary.ts | 20 ++++++++--------- src/browser/setupWorker/setupWorker.ts | 9 +++++--- src/core/SetupApi.ts | 30 ++++++++++++++------------ src/core/utils/executeHandlers.ts | 8 +++++-- src/core/utils/handleRequest.ts | 3 +-- src/node/SetupServerApi.ts | 3 ++- src/node/glossary.ts | 14 ++++++------ src/node/setupServer.ts | 8 +++---- 8 files changed, 51 insertions(+), 44 deletions(-) diff --git a/src/browser/setupWorker/glossary.ts b/src/browser/setupWorker/glossary.ts index 4a33edee7..3124d5a66 100644 --- a/src/browser/setupWorker/glossary.ts +++ b/src/browser/setupWorker/glossary.ts @@ -5,13 +5,11 @@ import { SharedOptions, } from '~/core/sharedOptions' import { ServiceWorkerMessage } from './start/utils/createMessageChannel' -import { - RequestHandler, - RequestHandlerDefaultInfo, -} from '~/core/handlers/RequestHandler' +import { RequestHandler } from '~/core/handlers/RequestHandler' import type { HttpRequestEventMap, Interceptor } from '@mswjs/interceptors' -import { Path } from '~/core/utils/matching/matchRequestUrl' -import { RequiredDeep } from '~/core/typeUtils' +import type { Path } from '~/core/utils/matching/matchRequestUrl' +import type { RequiredDeep } from '~/core/typeUtils' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' export type ResolvedPath = Path | URL @@ -102,7 +100,7 @@ export interface SetupWorkerInternalContext { startOptions: RequiredDeep worker: ServiceWorker | null registration: ServiceWorkerRegistration | null - requestHandlers: Array + requestHandlers: Array requests: Map emitter: Emitter keepAliveInterval?: number @@ -226,7 +224,7 @@ export interface SetupWorker { * * @see {@link https://mswjs.io/docs/api/setup-worker/use `worker.use()` API reference} */ - use: (...handlers: RequestHandler[]) => void + use: (...handlers: Array) => void /** * Marks all request handlers that respond using `res.once()` as unused. @@ -241,14 +239,16 @@ export interface SetupWorker { * * @see {@link https://mswjs.io/docs/api/setup-worker/reset-handlers `worker.resetHandlers()` API reference} */ - resetHandlers: (...nextHandlers: RequestHandler[]) => void + resetHandlers: ( + ...nextHandlers: Array + ) => void /** * Returns a readonly list of currently active request handlers. * * @see {@link https://mswjs.io/docs/api/setup-worker/list-handlers `worker.listHandlers()` API reference} */ - listHandlers(): ReadonlyArray> + listHandlers(): ReadonlyArray /** * Life-cycle events. diff --git a/src/browser/setupWorker/setupWorker.ts b/src/browser/setupWorker/setupWorker.ts index f99f7b5de..4cac5e8a0 100644 --- a/src/browser/setupWorker/setupWorker.ts +++ b/src/browser/setupWorker/setupWorker.ts @@ -18,7 +18,8 @@ import { createFallbackStop } from './stop/createFallbackStop' import { devUtils } from '~/core/utils/internal/devUtils' import { SetupApi } from '~/core/SetupApi' import { mergeRight } from '~/core/utils/internal/mergeRight' -import { LifeCycleEventsMap } from '~/core/sharedOptions' +import type { LifeCycleEventsMap } from '~/core/sharedOptions' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { SetupWorker } from './glossary' import { supportsReadableStreamTransfer } from '../utils/supportsReadableStreamTransfer' @@ -37,7 +38,7 @@ export class SetupWorkerApi private stopHandler: StopHandler = null as any private listeners: Array - constructor(...handlers: Array) { + constructor(...handlers: Array) { super(...handlers) invariant( @@ -201,6 +202,8 @@ export class SetupWorkerApi * * @see {@link https://mswjs.io/docs/api/setup-worker `setupWorker()` API reference} */ -export function setupWorker(...handlers: Array): SetupWorker { +export function setupWorker( + ...handlers: Array +): SetupWorker { return new SetupWorkerApi(...handlers) } diff --git a/src/core/SetupApi.ts b/src/core/SetupApi.ts index 7dfbdb593..71c2c0c32 100644 --- a/src/core/SetupApi.ts +++ b/src/core/SetupApi.ts @@ -1,27 +1,25 @@ import { invariant } from 'outvariant' import { EventMap, Emitter } from 'strict-event-emitter' -import { - RequestHandler, - RequestHandlerDefaultInfo, -} from './handlers/RequestHandler' +import { RequestHandler } from './handlers/RequestHandler' import { LifeCycleEventEmitter } from './sharedOptions' import { devUtils } from './utils/internal/devUtils' import { pipeEvents } from './utils/internal/pipeEvents' import { toReadonlyArray } from './utils/internal/toReadonlyArray' import { Disposable } from './utils/internal/Disposable' +import type { WebSocketHandler } from './handlers/WebSocketHandler' /** * Generic class for the mock API setup. */ export abstract class SetupApi extends Disposable { - protected initialHandlers: ReadonlyArray - protected currentHandlers: Array + protected initialHandlers: ReadonlyArray + protected currentHandlers: Array protected readonly emitter: Emitter protected readonly publicEmitter: Emitter public readonly events: LifeCycleEventEmitter - constructor(...initialHandlers: Array) { + constructor(...initialHandlers: Array) { super() invariant( @@ -46,12 +44,14 @@ export abstract class SetupApi extends Disposable { }) } - private validateHandlers(handlers: ReadonlyArray): boolean { + private validateHandlers(handlers: ReadonlyArray): boolean { // Guard against incorrect call signature of the setup API. return handlers.every((handler) => !Array.isArray(handler)) } - public use(...runtimeHandlers: Array): void { + public use( + ...runtimeHandlers: Array + ): void { invariant( this.validateHandlers(runtimeHandlers), devUtils.formatMessage( @@ -64,18 +64,20 @@ export abstract class SetupApi extends Disposable { public restoreHandlers(): void { this.currentHandlers.forEach((handler) => { - handler.isUsed = false + if ('isUsed' in handler) { + handler.isUsed = false + } }) } - public resetHandlers(...nextHandlers: Array): void { + public resetHandlers( + ...nextHandlers: Array + ): void { this.currentHandlers = nextHandlers.length > 0 ? [...nextHandlers] : [...this.initialHandlers] } - public listHandlers(): ReadonlyArray< - RequestHandler - > { + public listHandlers(): ReadonlyArray { return toReadonlyArray(this.currentHandlers) } diff --git a/src/core/utils/executeHandlers.ts b/src/core/utils/executeHandlers.ts index 34e4e7894..3df00901e 100644 --- a/src/core/utils/executeHandlers.ts +++ b/src/core/utils/executeHandlers.ts @@ -1,6 +1,6 @@ import { RequestHandler, - RequestHandlerExecutionResult, + type RequestHandlerExecutionResult, } from '../handlers/RequestHandler' export interface HandlersExecutionResult { @@ -18,7 +18,7 @@ export interface ResponseResolutionContext { * Returns the execution result object containing any matching request * handler and any mocked response it returned. */ -export const executeHandlers = async >({ +export const executeHandlers = async >({ request, requestId, handlers, @@ -33,6 +33,10 @@ export const executeHandlers = async >({ let result: RequestHandlerExecutionResult | null = null for (const handler of handlers) { + if (!(handler instanceof RequestHandler)) { + continue + } + result = await handler.run({ request, requestId, resolutionContext }) // If the handler produces some result for this request, diff --git a/src/core/utils/handleRequest.ts b/src/core/utils/handleRequest.ts index 45f9ebe6f..f821410ba 100644 --- a/src/core/utils/handleRequest.ts +++ b/src/core/utils/handleRequest.ts @@ -1,6 +1,5 @@ import { until } from '@open-draft/until' import { Emitter } from 'strict-event-emitter' -import { RequestHandler } from '../handlers/RequestHandler' import { LifeCycleEventsMap, SharedOptions } from '../sharedOptions' import { RequiredDeep } from '../typeUtils' import { HandlersExecutionResult, executeHandlers } from './executeHandlers' @@ -45,7 +44,7 @@ export interface HandleRequestOptions { export async function handleRequest( request: Request, requestId: string, - handlers: Array, + handlers: Array, options: RequiredDeep, emitter: Emitter, handleRequestOptions?: HandleRequestOptions, diff --git a/src/node/SetupServerApi.ts b/src/node/SetupServerApi.ts index d820a0200..6dd18e1e0 100644 --- a/src/node/SetupServerApi.ts +++ b/src/node/SetupServerApi.ts @@ -13,6 +13,7 @@ import { handleRequest } from '~/core/utils/handleRequest' import { devUtils } from '~/core/utils/internal/devUtils' import { mergeRight } from '~/core/utils/internal/mergeRight' import { SetupServer } from './glossary' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { onUnhandledRequest: 'warn', @@ -32,7 +33,7 @@ export class SetupServerApi interceptors: Array<{ new (): Interceptor }>, - ...handlers: Array + ...handlers: Array ) { super(...handlers) diff --git a/src/node/glossary.ts b/src/node/glossary.ts index 0edda3ce8..ac6d3269e 100644 --- a/src/node/glossary.ts +++ b/src/node/glossary.ts @@ -1,9 +1,7 @@ import type { PartialDeep } from 'type-fest' -import { - RequestHandler, - RequestHandlerDefaultInfo, -} from '~/core/handlers/RequestHandler' -import { +import { RequestHandler } from '~/core/handlers/RequestHandler' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' +import type { LifeCycleEventEmitter, LifeCycleEventsMap, SharedOptions, @@ -29,7 +27,7 @@ export interface SetupServer { * * @see {@link https://mswjs.io/docs/api/setup-server/use `server.use()` API reference} */ - use(...handlers: Array): void + use(...handlers: Array): void /** * Marks all request handlers that respond using `res.once()` as unused. @@ -43,14 +41,14 @@ export interface SetupServer { * * @see {@link https://mswjs.io/docs/api/setup-server/reset-handlers `server.reset-handlers()` API reference} */ - resetHandlers(...nextHandlers: Array): void + resetHandlers(...nextHandlers: Array): void /** * Returns a readonly list of currently active request handlers. * * @see {@link https://mswjs.io/docs/api/setup-server/list-handlers `server.listHandlers()` API reference} */ - listHandlers(): ReadonlyArray> + listHandlers(): ReadonlyArray /** * Life-cycle events. diff --git a/src/node/setupServer.ts b/src/node/setupServer.ts index e72a449d3..180cd7cb8 100644 --- a/src/node/setupServer.ts +++ b/src/node/setupServer.ts @@ -1,9 +1,9 @@ import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { FetchInterceptor } from '@mswjs/interceptors/fetch' -import { RequestHandler } from '~/core/handlers/RequestHandler' +import type { RequestHandler } from '~/core/handlers/RequestHandler' import { SetupServerApi } from './SetupServerApi' -import { SetupServer } from './glossary' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' /** * Sets up a requests interception in Node.js with the given request handlers. @@ -12,8 +12,8 @@ import { SetupServer } from './glossary' * @see {@link https://mswjs.io/docs/api/setup-server `setupServer()` API reference} */ export const setupServer = ( - ...handlers: Array -): SetupServer => { + ...handlers: Array +) => { return new SetupServerApi( [ClientRequestInterceptor, XMLHttpRequestInterceptor, FetchInterceptor], ...handlers, From ad82a548f7c3e010290ed6a2e512ae48b871dc32 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 8 Feb 2024 19:56:05 +0100 Subject: [PATCH 007/105] feat: export "ws" from core --- src/core/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/index.ts b/src/core/index.ts index 6e8aa5ac9..5cfdb4b59 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -9,6 +9,9 @@ export { HttpHandler, HttpMethods } from './handlers/HttpHandler' export { graphql } from './graphql' export { GraphQLHandler } from './handlers/GraphQLHandler' +/* WebSocket */ +export { ws } from './ws/ws' + /* Utils */ export { matchRequestUrl } from './utils/matching/matchRequestUrl' export * from './utils/handleRequest' From 38f5d270491eb47dd23ceaee903c90a9ee639378 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 9 Feb 2024 12:11:45 +0100 Subject: [PATCH 008/105] fix(setupServer): invalid return type --- src/node/setupServer.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/node/setupServer.ts b/src/node/setupServer.ts index 180cd7cb8..64a2104d1 100644 --- a/src/node/setupServer.ts +++ b/src/node/setupServer.ts @@ -2,8 +2,9 @@ import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { FetchInterceptor } from '@mswjs/interceptors/fetch' import type { RequestHandler } from '~/core/handlers/RequestHandler' -import { SetupServerApi } from './SetupServerApi' import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' +import { SetupServerApi } from './SetupServerApi' +import type { SetupServer } from './glossary' /** * Sets up a requests interception in Node.js with the given request handlers. @@ -13,7 +14,7 @@ import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' */ export const setupServer = ( ...handlers: Array -) => { +): SetupServer => { return new SetupServerApi( [ClientRequestInterceptor, XMLHttpRequestInterceptor, FetchInterceptor], ...handlers, From e82d9a7137f8fedbbaab3a20b44c4a79881dc5c5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 9 Feb 2024 12:12:00 +0100 Subject: [PATCH 009/105] test(ws): add interception tests --- package.json | 5 +- src/core/handlers/WebSocketHandler.ts | 31 +---- src/core/ws/ws.ts | 24 +++- src/node/SetupServerApi.ts | 10 +- test/node/vitest.config.ts | 2 + test/node/ws-api/ws.intercept.test.ts | 109 ++++++++++++++++++ test/support/WebSocketServer.ts | 55 +++++++++ .../vitest-environment-node-websocket.ts | 20 ++++ 8 files changed, 225 insertions(+), 31 deletions(-) create mode 100644 test/node/ws-api/ws.intercept.test.ts create mode 100644 test/support/WebSocketServer.ts create mode 100644 test/support/environments/vitest-environment-node-websocket.ts diff --git a/package.json b/package.json index f23569e6f..a1b53f092 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "devDependencies": { "@commitlint/cli": "^18.4.4", "@commitlint/config-conventional": "^18.4.4", + "@fastify/websocket": "^8.3.1", "@open-draft/test-server": "^0.4.2", "@ossjs/release": "^0.8.0", "@playwright/test": "^1.40.1", @@ -143,6 +144,7 @@ "@types/glob": "^8.1.0", "@types/json-bigint": "^1.0.4", "@types/node": "18.x", + "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^5.11.0", "@typescript-eslint/parser": "^5.11.0", "@web/dev-server": "^0.1.38", @@ -158,6 +160,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "express": "^4.18.2", + "fastify": "^4.26.0", "fs-extra": "^11.2.0", "fs-teardown": "^0.3.0", "glob": "^10.3.10", @@ -174,7 +177,7 @@ "typescript": "^5.0.2", "undici": "^5.20.0", "url-loader": "^4.1.1", - "vitest": "^0.34.6", + "vitest": "^1.2.2", "vitest-environment-miniflare": "^2.14.1", "webpack": "^5.89.0", "webpack-http-server": "^0.5.0" diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts index ffb398692..be0d4e136 100644 --- a/src/core/handlers/WebSocketHandler.ts +++ b/src/core/handlers/WebSocketHandler.ts @@ -14,7 +14,7 @@ type WebSocketHandlerParsedResult = { match: Match } -type WebSocketHandlerEventMap = { +export type WebSocketHandlerEventMap = { connection: [ args: { client: WebSocketClientConnection @@ -29,33 +29,14 @@ type WebSocketHandlerIncomingEvent = MessageEvent<{ server: WebSocketServerConnection }> -export const kRun = Symbol('run') +export const kEmitter = Symbol('kEmitter') +export const kRun = Symbol('kRun') export class WebSocketHandler { - public on: ( - event: K, - listener: (...args: WebSocketHandlerEventMap[K]) => void, - ) => void - - public off: ( - event: K, - listener: (...args: WebSocketHandlerEventMap[K]) => void, - ) => void - - public removeAllListeners: ( - event?: K, - ) => void - - protected emitter: Emitter + protected [kEmitter]: Emitter constructor(private readonly url: Path) { - this.emitter = new Emitter() - - // Forward some of the emitter API to the public API - // of the event handler. - this.on = this.emitter.on.bind(this.emitter) - this.off = this.emitter.off.bind(this.emitter) - this.removeAllListeners = this.emitter.removeAllListeners.bind(this.emitter) + this[kEmitter] = new Emitter() } public parse(args: { @@ -95,7 +76,7 @@ export class WebSocketHandler { // Emit the connection event on the handler. // This is what the developer adds listeners for. - this.emitter.emit('connection', { + this[kEmitter].emit('connection', { client: connection.client, server: connection.server, params: parsedResult.match.params || {}, diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index c44a3e469..516c87d16 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -1,4 +1,8 @@ -import { WebSocketHandler } from '../handlers/WebSocketHandler' +import { + WebSocketHandler, + kEmitter, + type WebSocketHandlerEventMap, +} from '../handlers/WebSocketHandler' import type { Path } from '../utils/matching/matchRequestUrl' import { webSocketInterceptor } from './webSocketInterceptor' @@ -11,7 +15,23 @@ import { webSocketInterceptor } from './webSocketInterceptor' */ function createWebSocketLinkHandler(url: Path) { webSocketInterceptor.apply() - return new WebSocketHandler(url) + + return { + on( + event: K, + listener: (...args: WebSocketHandlerEventMap[K]) => void, + ): WebSocketHandler { + const handler = new WebSocketHandler(url) + + // The "handleWebSocketEvent" function will invoke + // the "run()" method on the WebSocketHandler. + // If the handler matches, it will emit the "connection" + // event. Attach the user-defined listener to that event. + handler[kEmitter].on(event, listener) + + return handler + }, + } } export const ws = { diff --git a/src/node/SetupServerApi.ts b/src/node/SetupServerApi.ts index 6dd18e1e0..c632c1428 100644 --- a/src/node/SetupServerApi.ts +++ b/src/node/SetupServerApi.ts @@ -7,13 +7,14 @@ import { import { invariant } from 'outvariant' import { SetupApi } from '~/core/SetupApi' import { RequestHandler } from '~/core/handlers/RequestHandler' -import { LifeCycleEventsMap, SharedOptions } from '~/core/sharedOptions' -import { RequiredDeep } from '~/core/typeUtils' +import type { LifeCycleEventsMap, SharedOptions } from '~/core/sharedOptions' +import type { RequiredDeep } from '~/core/typeUtils' import { handleRequest } from '~/core/utils/handleRequest' import { devUtils } from '~/core/utils/internal/devUtils' import { mergeRight } from '~/core/utils/internal/mergeRight' -import { SetupServer } from './glossary' import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' +import { handleWebSocketEvent } from '~/core/utils/handleWebSocketEvent' +import type { SetupServer } from './glossary' const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { onUnhandledRequest: 'warn', @@ -79,6 +80,9 @@ export class SetupServerApi ) }, ) + + // Handle outgoing WebSocket connections. + handleWebSocketEvent(this.currentHandlers) } public listen(options: Partial = {}): void { diff --git a/test/node/vitest.config.ts b/test/node/vitest.config.ts index 801c1edeb..53f3e1525 100644 --- a/test/node/vitest.config.ts +++ b/test/node/vitest.config.ts @@ -11,6 +11,8 @@ export default defineConfig({ dir: './test/node', globals: true, alias: { + 'vitest-environment-node-websocket': + './test/support/environments/vitest-environment-node-websocket', 'msw/node': path.resolve(LIB_DIR, 'node/index.mjs'), 'msw/native': path.resolve(LIB_DIR, 'native/index.mjs'), 'msw/browser': path.resolve(LIB_DIR, 'browser/index.mjs'), diff --git a/test/node/ws-api/ws.intercept.test.ts b/test/node/ws-api/ws.intercept.test.ts new file mode 100644 index 000000000..70db69c7f --- /dev/null +++ b/test/node/ws-api/ws.intercept.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' +import { waitFor } from '../../support/waitFor' + +const server = setupServer() +const wsServer = new WebSocketServer() + +const service = ws.link('ws://*') + +beforeAll(async () => { + server.listen() + await wsServer.listen() +}) + +afterEach(() => { + wsServer.closeAllClients() + wsServer.removeAllListeners() +}) + +afterAll(async () => { + server.close() + await wsServer.close() +}) + +it('intercepts outgoing client text message', async () => { + const mockMessageListener = vi.fn() + const realConnectionListener = vi.fn() + + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', mockMessageListener) + }), + ) + wsServer.on('connection', realConnectionListener) + + const ws = new WebSocket(wsServer.url) + ws.onopen = () => ws.send('hello') + + await waitFor(() => { + // Must intercept the outgoing client message event. + expect(mockMessageListener).toHaveBeenCalledTimes(1) + + const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent + expect(messageEvent.type).toBe('message') + expect(messageEvent.data).toBe('hello') + expect(messageEvent.target).toBe(ws) + + // Must not connect to the actual server by default. + expect(realConnectionListener).not.toHaveBeenCalled() + }) +}) + +it('intercepts outgoing client Blob message', async () => { + const mockMessageListener = vi.fn() + const realConnectionListener = vi.fn() + + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', mockMessageListener) + }), + ) + wsServer.on('connection', realConnectionListener) + + const ws = new WebSocket(wsServer.url) + ws.onopen = () => ws.send(new Blob(['hello'])) + + await waitFor(() => { + expect(mockMessageListener).toHaveBeenCalledTimes(1) + + const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent + expect(messageEvent.type).toBe('message') + expect(messageEvent.data.size).toBe(5) + expect(messageEvent.target).toEqual(ws) + + // Must not connect to the actual server by default. + expect(realConnectionListener).not.toHaveBeenCalled() + }) +}) + +it('intercepts outgoing client ArrayBuffer message', async () => { + const mockMessageListener = vi.fn() + const realConnectionListener = vi.fn() + + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', mockMessageListener) + }), + ) + wsServer.on('connection', realConnectionListener) + + const ws = new WebSocket(wsServer.url) + ws.onopen = () => ws.send(new TextEncoder().encode('hello')) + + await waitFor(() => { + expect(mockMessageListener).toHaveBeenCalledTimes(1) + + const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent + expect(messageEvent.type).toBe('message') + expect(messageEvent.data).toEqual(new TextEncoder().encode('hello')) + expect(messageEvent.target).toEqual(ws) + + // Must not connect to the actual server by default. + expect(realConnectionListener).not.toHaveBeenCalled() + }) +}) diff --git a/test/support/WebSocketServer.ts b/test/support/WebSocketServer.ts new file mode 100644 index 000000000..546c14168 --- /dev/null +++ b/test/support/WebSocketServer.ts @@ -0,0 +1,55 @@ +import { invariant } from 'outvariant' +import { Emitter } from 'strict-event-emitter' +import fastify, { FastifyInstance } from 'fastify' +import fastifyWebSocket, { SocketStream } from '@fastify/websocket' + +type FastifySocket = SocketStream['socket'] + +type WebSocketEventMap = { + connection: [client: FastifySocket] +} + +export class WebSocketServer extends Emitter { + private _url?: string + private app: FastifyInstance + private clients: Set + + constructor() { + super() + this.clients = new Set() + + this.app = fastify() + this.app.register(fastifyWebSocket) + this.app.register(async (fastify) => { + fastify.get('/', { websocket: true }, (connection) => { + this.clients.add(connection.socket) + this.emit('connection', connection.socket) + }) + }) + } + + get url(): string { + invariant( + this._url, + 'Failed to get "url" on WebSocketServer: server is not running. Did you forget to "await server.listen()"?', + ) + return this._url + } + + public async listen(): Promise { + const address = await this.app.listen({ port: 0 }) + const url = new URL(address) + url.protocol = url.protocol.replace(/^http/, 'ws') + this._url = url.href + } + + public closeAllClients(): void { + this.clients.forEach((client) => { + client.close() + }) + } + + public async close(): Promise { + return this.app.close() + } +} diff --git a/test/support/environments/vitest-environment-node-websocket.ts b/test/support/environments/vitest-environment-node-websocket.ts new file mode 100644 index 000000000..4fe1b93ad --- /dev/null +++ b/test/support/environments/vitest-environment-node-websocket.ts @@ -0,0 +1,20 @@ +/** + * Node.js environment superset that has a global WebSocket API. + */ +import type { Environment } from 'vitest' +import { builtinEnvironments } from 'vitest/environments' +import { WebSocket } from 'undici' + +export default { + name: 'node-with-websocket', + transformMode: 'ssr', + async setup(global, options) { + const { teardown } = await builtinEnvironments.jsdom.setup(global, options) + + Reflect.set(globalThis, 'WebSocket', WebSocket) + + return { + teardown, + } + }, +} From d795e159a7226e7de05f2012e33556112406bd9b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 14 Feb 2024 17:29:44 +0100 Subject: [PATCH 010/105] chore: update @mswjs/interceptors --- package.json | 2 +- pnpm-lock.yaml | 432 ++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 340 insertions(+), 94 deletions(-) diff --git a/package.json b/package.json index a1b53f092..4ad1cf7a9 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "@bundled-es-modules/cookie": "^2.0.0", "@bundled-es-modules/statuses": "^1.0.1", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.25.16", + "@mswjs/interceptors": "^0.26.1", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91b9528ae..97e72038b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,8 +8,9 @@ specifiers: '@bundled-es-modules/statuses': ^1.0.1 '@commitlint/cli': ^18.4.4 '@commitlint/config-conventional': ^18.4.4 + '@fastify/websocket': ^8.3.1 '@mswjs/cookies': ^1.1.0 - '@mswjs/interceptors': ^0.25.16 + '@mswjs/interceptors': ^0.26.1 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.0 @@ -22,6 +23,7 @@ specifiers: '@types/json-bigint': ^1.0.4 '@types/node': 18.x '@types/statuses': ^2.0.4 + '@types/ws': ^8.5.10 '@typescript-eslint/eslint-plugin': ^5.11.0 '@typescript-eslint/parser': ^5.11.0 '@web/dev-server': ^0.1.38 @@ -39,6 +41,7 @@ specifiers: eslint-config-prettier: ^9.1.0 eslint-plugin-prettier: ^5.1.3 express: ^4.18.2 + fastify: ^4.26.0 fs-extra: ^11.2.0 fs-teardown: ^0.3.0 glob: ^10.3.10 @@ -63,7 +66,7 @@ specifiers: typescript: ^5.0.2 undici: ^5.20.0 url-loader: ^4.1.1 - vitest: ^0.34.6 + vitest: ^1.2.2 vitest-environment-miniflare: ^2.14.1 webpack: ^5.89.0 webpack-http-server: ^0.5.0 @@ -73,7 +76,7 @@ dependencies: '@bundled-es-modules/cookie': 2.0.0 '@bundled-es-modules/statuses': 1.0.1 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.25.16 + '@mswjs/interceptors': 0.26.1 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -92,6 +95,7 @@ dependencies: devDependencies: '@commitlint/cli': 18.4.4_z5x4t2owsiiyng6cl4yau77uc4 '@commitlint/config-conventional': 18.4.4 + '@fastify/websocket': 8.3.1 '@open-draft/test-server': 0.4.2 '@ossjs/release': 0.8.0 '@playwright/test': 1.40.1 @@ -101,6 +105,7 @@ devDependencies: '@types/glob': 8.1.0 '@types/json-bigint': 1.0.4 '@types/node': 18.17.14 + '@types/ws': 8.5.10 '@typescript-eslint/eslint-plugin': 5.52.0_ct7kqyuhmchjrd4rut2lcwua2e '@typescript-eslint/parser': 5.52.0_ia2vohguagzyh4ngzoayyctqim '@web/dev-server': 0.1.38 @@ -116,6 +121,7 @@ devDependencies: eslint-config-prettier: 9.1.0_eslint@8.56.0 eslint-plugin-prettier: 5.1.3_dhjydligol7nv2ellgbhyihfk4 express: 4.18.2 + fastify: 4.26.0 fs-extra: 11.2.0 fs-teardown: 0.3.2 glob: 10.3.10 @@ -132,8 +138,8 @@ devDependencies: typescript: 5.0.2 undici: 5.23.0 url-loader: 4.1.1_webpack@5.89.0 - vitest: 0.34.6_jsdom@23.2.0 - vitest-environment-miniflare: 2.14.1_vitest@0.34.6 + vitest: 1.2.2_b4fzwn3atmkdkqrawvd5volizi + vitest-environment-miniflare: 2.14.1_vitest@1.2.2 webpack: 5.89.0_aeq7xbrtxnnsm7zl2u4axmljse webpack-http-server: 0.5.0_aeq7xbrtxnnsm7zl2u4axmljse @@ -811,6 +817,40 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /@fastify/ajv-compiler/3.5.0: + resolution: {integrity: sha512-ebbEtlI7dxXF5ziNdr05mOY8NnDiPB1XvAlLHctRt/Rc+C3LCOVW5imUVX+mhvUhnNzmPBHewUkOFgGlCxgdAA==} + dependencies: + ajv: 8.12.0 + ajv-formats: 2.1.1 + fast-uri: 2.3.0 + dev: true + + /@fastify/error/3.4.1: + resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==} + dev: true + + /@fastify/fast-json-stringify-compiler/4.3.0: + resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} + dependencies: + fast-json-stringify: 5.12.0 + dev: true + + /@fastify/merge-json-schemas/0.1.1: + resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==} + dependencies: + fast-deep-equal: 3.1.3 + dev: true + + /@fastify/websocket/8.3.1: + resolution: {integrity: sha512-hsQYHHJme/kvP3ZS4v/WMUznPBVeeQHHwAoMy1LiN6m/HuPfbdXq1MBJ4Nt8qX1YI+eVbog4MnOsU7MTozkwYA==} + dependencies: + fastify-plugin: 4.5.1 + ws: 8.16.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + /@humanwhocodes/config-array/0.11.14: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -1060,7 +1100,7 @@ packages: '@miniflare/core': 2.14.1 '@miniflare/shared': 2.14.1 undici: 5.20.0 - ws: 8.14.2 + ws: 8.16.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -1071,8 +1111,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.25.16: - resolution: {integrity: sha512-8QC8JyKztvoGAdPgyZy49c9vSHHAZjHagwl4RY9E8carULk8ym3iTaiawrT1YoLF/qb449h48f71XDPgkUSOUg==} + /@mswjs/interceptors/0.26.1: + resolution: {integrity: sha512-iK3hLdSp8153NQKU8BdnJQoa0V+tBdHZPNmmAZwLLG2GN/I64PpnbyEOT81SOPFDghpfdtTccfR1L0oEpfhxTA==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -1101,7 +1141,7 @@ packages: engines: {node: '>= 8'} dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.15.0 + fastq: 1.17.1 dev: true /@open-draft/deferred-promise/2.2.0: @@ -1480,16 +1520,6 @@ packages: '@types/node': 18.17.14 dev: true - /@types/chai-subset/1.3.4: - resolution: {integrity: sha512-CCWNXrJYSUIojZ1149ksLl3AN9cmZ5djf+yUoVVV+NuYrtydItQVlL2ZDqyC6M6O9LWRnVf8yYDxbXHO2TfQZg==} - dependencies: - '@types/chai': 4.3.9 - dev: true - - /@types/chai/4.3.9: - resolution: {integrity: sha512-69TtiDzu0bcmKQv3yg1Zx409/Kd7r0b5F1PfpYJfSHzLGtB53547V4u+9iqKYsTu/O2ai6KTb0TInNpvuQ3qmg==} - dev: true - /@types/command-line-args/5.2.1: resolution: {integrity: sha512-U2OcmS2tj36Yceu+mRuPyUV0ILfau/h5onStcSCzqTENsq0sBiAp2TmaXu1k8fY4McLcPKSYl9FRzn2hx5bI+w==} dev: true @@ -1737,6 +1767,12 @@ packages: '@types/node': 18.17.14 dev: true + /@types/ws/8.5.10: + resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} + dependencies: + '@types/node': 18.17.14 + dev: true + /@types/yargs-parser/21.0.0: resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} dev: true @@ -1881,40 +1917,41 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true - /@vitest/expect/0.34.6: - resolution: {integrity: sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw==} + /@vitest/expect/1.2.2: + resolution: {integrity: sha512-3jpcdPAD7LwHUUiT2pZTj2U82I2Tcgg2oVPvKxhn6mDI2On6tfvPQTjAI4628GUGDZrCm4Zna9iQHm5cEexOAg==} dependencies: - '@vitest/spy': 0.34.6 - '@vitest/utils': 0.34.6 + '@vitest/spy': 1.2.2 + '@vitest/utils': 1.2.2 chai: 4.3.10 dev: true - /@vitest/runner/0.34.6: - resolution: {integrity: sha512-1CUQgtJSLF47NnhN+F9X2ycxUP0kLHQ/JWvNHbeBfwW8CzEGgeskzNnHDyv1ieKTltuR6sdIHV+nmR6kPxQqzQ==} + /@vitest/runner/1.2.2: + resolution: {integrity: sha512-JctG7QZ4LSDXr5CsUweFgcpEvrcxOV1Gft7uHrvkQ+fsAVylmWQvnaAr/HDp3LAH1fztGMQZugIheTWjaGzYIg==} dependencies: - '@vitest/utils': 0.34.6 - p-limit: 4.0.0 + '@vitest/utils': 1.2.2 + p-limit: 5.0.0 pathe: 1.1.1 dev: true - /@vitest/snapshot/0.34.6: - resolution: {integrity: sha512-B3OZqYn6k4VaN011D+ve+AA4whM4QkcwcrwaKwAbyyvS/NB1hCWjFIBQxAQQSQir9/RtyAAGuq+4RJmbn2dH4w==} + /@vitest/snapshot/1.2.2: + resolution: {integrity: sha512-SmGY4saEw1+bwE1th6S/cZmPxz/Q4JWsl7LvbQIky2tKE35US4gd0Mjzqfr84/4OD0tikGWaWdMja/nWL5NIPA==} dependencies: magic-string: 0.30.5 pathe: 1.1.1 pretty-format: 29.7.0 dev: true - /@vitest/spy/0.34.6: - resolution: {integrity: sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ==} + /@vitest/spy/1.2.2: + resolution: {integrity: sha512-k9Gcahssw8d7X3pSLq3e3XEu/0L78mUkCjivUqCQeXJm9clfXR/Td8+AP+VC1O6fKPIDLcHDTAmBOINVuv6+7g==} dependencies: tinyspy: 2.2.0 dev: true - /@vitest/utils/0.34.6: - resolution: {integrity: sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A==} + /@vitest/utils/1.2.2: + resolution: {integrity: sha512-WKITBHLsBHlpjnDQahr+XK6RE7MiAsgrIkr0pGhQ9ygoxBfUeG0lUG5iLlzqjmKSlBv3+j5EGsriBzh+C3Tq9g==} dependencies: - diff-sequences: 29.4.3 + diff-sequences: 29.6.3 + estree-walker: 3.0.3 loupe: 2.3.7 pretty-format: 29.7.0 dev: true @@ -2125,6 +2162,17 @@ packages: through: 2.3.8 dev: true + /abort-controller/3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + dependencies: + event-target-shim: 5.0.1 + dev: true + + /abstract-logging/2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + dev: true + /accepts/1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -2154,6 +2202,11 @@ packages: engines: {node: '>=0.4.0'} dev: true + /acorn-walk/8.3.2: + resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} + engines: {node: '>=0.4.0'} + dev: true + /acorn/8.11.2: resolution: {integrity: sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==} engines: {node: '>=0.4.0'} @@ -2169,6 +2222,15 @@ packages: - supports-color dev: true + /ajv-formats/2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependenciesMeta: + ajv: + optional: true + dependencies: + ajv: 8.12.0 + dev: true + /ajv-keywords/3.5.2_ajv@6.12.6: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -2251,6 +2313,10 @@ packages: normalize-path: 3.0.0 picomatch: 2.3.1 + /archy/1.0.0: + resolution: {integrity: sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==} + dev: true + /arg/4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} dev: true @@ -2341,6 +2407,17 @@ packages: engines: {node: '>= 0.4'} dev: true + /avvio/8.3.0: + resolution: {integrity: sha512-VBVH0jubFr9LdFASy/vNtm5giTrnbVquWBhT0fyizuNK2rQ7e7ONU2plZQWUNqtE1EmxFEb+kbSkFRkstiaS9Q==} + dependencies: + '@fastify/error': 3.4.1 + archy: 1.0.0 + debug: 4.3.4 + fastq: 1.17.1 + transitivePeerDependencies: + - supports-color + dev: true + /axios/1.6.5: resolution: {integrity: sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==} dependencies: @@ -2643,6 +2720,13 @@ packages: base64-js: 1.5.1 ieee754: 1.2.1 + /buffer/6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: true + /builtin-modules/3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} @@ -3318,8 +3402,8 @@ packages: engines: {node: '>=8'} dev: true - /diff-sequences/29.4.3: - resolution: {integrity: sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==} + /diff-sequences/29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dev: true @@ -3731,6 +3815,12 @@ packages: resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==} dev: true + /estree-walker/3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + dependencies: + '@types/estree': 1.0.5 + dev: true + /esutils/2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -3741,6 +3831,11 @@ packages: engines: {node: '>= 0.6'} dev: true + /event-target-shim/5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + dev: true + /eventemitter3/5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} dev: true @@ -3849,6 +3944,14 @@ packages: iconv-lite: 0.4.24 tmp: 0.0.33 + /fast-content-type-parse/1.1.0: + resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==} + dev: true + + /fast-decode-uri-component/1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + dev: true + /fast-deep-equal/3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true @@ -3872,10 +3975,28 @@ packages: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} dev: true + /fast-json-stringify/5.12.0: + resolution: {integrity: sha512-7Nnm9UPa7SfHRbHVA1kJQrGXCRzB7LMlAAqHXQFkEQqueJm1V8owm0FsE/2Do55/4CcdhwiLQERaKomOnKQkyA==} + dependencies: + '@fastify/merge-json-schemas': 0.1.1 + ajv: 8.12.0 + ajv-formats: 2.1.1 + fast-deep-equal: 3.1.3 + fast-uri: 2.3.0 + json-schema-ref-resolver: 1.0.1 + rfdc: 1.3.0 + dev: true + /fast-levenshtein/2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} dev: true + /fast-querystring/1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + dependencies: + fast-decode-uri-component: 1.0.1 + dev: true + /fast-redact/3.1.2: resolution: {integrity: sha512-+0em+Iya9fKGfEQGcd62Yv6onjBmmhV1uh86XVfOU8VwAe6kaFdQCWI9s0/Nnugx5Vd9tdbZ7e6gE2tR9dzXdw==} engines: {node: '>=6'} @@ -3885,8 +4006,39 @@ packages: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} dev: true - /fastq/1.15.0: - resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} + /fast-uri/2.3.0: + resolution: {integrity: sha512-eel5UKGn369gGEWOqBShmFJWfq/xSJvsgDzgLYC845GneayWvXBf0lJCBn5qTABfewy1ZDPoaR5OZCP+kssfuw==} + dev: true + + /fastify-plugin/4.5.1: + resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} + dev: true + + /fastify/4.26.0: + resolution: {integrity: sha512-Fq/7ziWKc6pYLYLIlCRaqJqEVTIZ5tZYfcW/mDK2AQ9v/sqjGFpj0On0/7hU50kbPVjLO4de+larPA1WwPZSfw==} + dependencies: + '@fastify/ajv-compiler': 3.5.0 + '@fastify/error': 3.4.1 + '@fastify/fast-json-stringify-compiler': 4.3.0 + abstract-logging: 2.0.1 + avvio: 8.3.0 + fast-content-type-parse: 1.1.0 + fast-json-stringify: 5.12.0 + find-my-way: 8.1.0 + light-my-request: 5.11.0 + pino: 8.18.0 + process-warning: 3.0.0 + proxy-addr: 2.0.7 + rfdc: 1.3.0 + secure-json-parse: 2.7.0 + semver: 7.5.4 + toad-cache: 3.7.0 + transitivePeerDependencies: + - supports-color + dev: true + + /fastq/1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} dependencies: reusify: 1.0.4 dev: true @@ -3925,6 +4077,15 @@ packages: - supports-color dev: true + /find-my-way/8.1.0: + resolution: {integrity: sha512-41QwjCGcVTODUmLLqTMeoHeiozbMXYMAE1CKFiDyi9zVZ2Vjh0yz3MF0WQZoIb+cmzP/XlbFjlF2NtJmvZHznA==} + engines: {node: '>=14'} + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 2.0.0 + dev: true + /find-node-modules/2.1.3: resolution: {integrity: sha512-UC2I2+nx1ZuOBclWVNdcnbDR5dlrOdVb7xNjmT/lHE+LsgztWks3dG7boJ37yTS/venXw84B/mAW9uHVoC5QRg==} dependencies: @@ -4938,6 +5099,12 @@ packages: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} dev: true + /json-schema-ref-resolver/1.0.1: + resolution: {integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==} + dependencies: + fast-deep-equal: 3.1.3 + dev: true + /json-schema-traverse/0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} dev: true @@ -5073,6 +5240,14 @@ packages: type-check: 0.4.0 dev: true + /light-my-request/5.11.0: + resolution: {integrity: sha512-qkFCeloXCOMpmEdZ/MV91P8AT4fjwFXWaAFz3lUeStM8RcoM1ks4J/F8r1b3r6y/H4u3ACEJ1T+Gv5bopj7oDA==} + dependencies: + cookie: 0.5.0 + process-warning: 2.3.2 + set-cookie-parser: 2.6.0 + dev: true + /lilconfig/3.0.0: resolution: {integrity: sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==} engines: {node: '>=14'} @@ -5132,9 +5307,12 @@ packages: json5: 2.2.3 dev: true - /local-pkg/0.4.3: - resolution: {integrity: sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==} + /local-pkg/0.5.0: + resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==} engines: {node: '>=14'} + dependencies: + mlly: 1.4.2 + pkg-types: 1.0.3 dev: true /locate-path/5.0.0: @@ -5617,6 +5795,11 @@ packages: resolution: {integrity: sha512-dqaz3u44QbRXQooZLTUKU41ZrzYrcvLISVgbrzbyCMxpmSLJvZ3ZamIJIZ29P6OhZIkNIQKosdeM6t1LYbA9hg==} dev: true + /on-exit-leak-free/2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + dev: true + /on-finished/2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -5703,9 +5886,9 @@ packages: yocto-queue: 0.1.0 dev: true - /p-limit/4.0.0: - resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + /p-limit/5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} dependencies: yocto-queue: 1.0.0 dev: true @@ -5869,6 +6052,13 @@ packages: split2: 4.1.0 dev: true + /pino-abstract-transport/1.1.0: + resolution: {integrity: sha512-lsleG3/2a/JIWUtf9Q5gUNErBqwIu1tUKTT3dUzaf5DySw9ra1wcqKjJjLX1VTY64Wk1eEOYsVGSaGfCK85ekA==} + dependencies: + readable-stream: 4.5.2 + split2: 4.1.0 + dev: true + /pino-pretty/7.6.1: resolution: {integrity: sha512-H7N6ZYkiyrfwBGW9CSjx0uyO9Q2Lyt73881+OTYk8v3TiTdgN92QHrWlEq/LeWw5XtDP64jeSk3mnc6T+xX9/w==} hasBin: true @@ -5892,6 +6082,10 @@ packages: resolution: {integrity: sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==} dev: true + /pino-std-serializers/6.2.2: + resolution: {integrity: sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==} + dev: true + /pino/7.11.0: resolution: {integrity: sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg==} hasBin: true @@ -5909,6 +6103,23 @@ packages: thread-stream: 0.15.2 dev: true + /pino/8.18.0: + resolution: {integrity: sha512-Mz/gKiRyuXu4HnpHgi1YWdHQCoWMufapzooisvFn78zl4dZciAxS+YeRkUxXl1ee/SzU80YCz1zpECCh4oC6Aw==} + hasBin: true + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.1.2 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 1.1.0 + pino-std-serializers: 6.2.2 + process-warning: 3.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.4.2 + sonic-boom: 3.8.0 + thread-stream: 2.4.1 + dev: true + /pirates/4.0.5: resolution: {integrity: sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==} engines: {node: '>= 6'} @@ -6025,6 +6236,19 @@ packages: resolution: {integrity: sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==} dev: true + /process-warning/2.3.2: + resolution: {integrity: sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==} + dev: true + + /process-warning/3.0.0: + resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} + dev: true + + /process/0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + dev: true + /proxy-addr/2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -6156,6 +6380,17 @@ packages: string_decoder: 1.3.0 util-deprecate: 1.0.2 + /readable-stream/4.5.2: + resolution: {integrity: sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + dev: true + /readdirp/3.4.0: resolution: {integrity: sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ==} engines: {node: '>=8.10.0'} @@ -6167,6 +6402,11 @@ packages: engines: {node: '>= 12.13.0'} dev: true + /real-require/0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + dev: true + /redent/3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -6274,6 +6514,11 @@ packages: signal-exit: 3.0.7 dev: true + /ret/0.2.2: + resolution: {integrity: sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==} + engines: {node: '>=4'} + dev: true + /reusify/1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -6363,6 +6608,12 @@ packages: is-regex: 1.1.4 dev: true + /safe-regex2/2.0.0: + resolution: {integrity: sha512-PaUSFsUaNNuKwkBijoAPHAK6/eM6VirvyPWlZ7BAQy4D+hCvh4B6lIG+nPdhbFfIbP+gTGBcrdsOaUs0F+ZBOQ==} + dependencies: + ret: 0.2.2 + dev: true + /safe-stable-stringify/2.4.2: resolution: {integrity: sha512-gMxvPJYhP0O9n2pvcfYfIuYgbledAOJFcqRThtPRmjscaipiwcwPPKLytpVzMkG2HAN87Qmo2d4PtGiri1dSLA==} engines: {node: '>=10'} @@ -6585,6 +6836,12 @@ packages: atomic-sleep: 1.0.0 dev: true + /sonic-boom/3.8.0: + resolution: {integrity: sha512-ybz6OYOUjoQQCQ/i4LU8kaToD8ACtYP+Cj5qd2AO36bwbdewxWJ3ArmJ2cr6AvxlL2o0PqnCcPGUgkILbfkaCA==} + dependencies: + atomic-sleep: 1.0.0 + dev: true + /source-list-map/2.0.1: resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==} dev: true @@ -6669,8 +6926,8 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} - /std-env/3.4.3: - resolution: {integrity: sha512-f9aPhy8fYBuMN+sNfakZV18U39PbalgjXG3lLB9WkaYTxijru61wb57V9wxxNthXM5Sd88ETBWi29qLAsHO52Q==} + /std-env/3.7.0: + resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} dev: true /stream-combiner2/1.1.1: @@ -6941,6 +7198,12 @@ packages: real-require: 0.1.0 dev: true + /thread-stream/2.4.1: + resolution: {integrity: sha512-d/Ex2iWd1whipbT681JmTINKw0ZwOUBZm7+Gjs64DHuX34mmw8vJL2bFAaNacaW72zYiTJxSHi5abUuOi5nsfg==} + dependencies: + real-require: 0.2.0 + dev: true + /through/2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -6961,8 +7224,8 @@ packages: resolution: {integrity: sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==} dev: true - /tinypool/0.7.0: - resolution: {integrity: sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==} + /tinypool/0.8.2: + resolution: {integrity: sha512-SUszKYe5wgsxnNOVlBYO6IC+8VGWdVGZWAqUxp3UErNBtptZvWbwyUOyzNL59zigz2rCA92QiL3wvG+JDSdJdQ==} engines: {node: '>=14.0.0'} dev: true @@ -6988,6 +7251,11 @@ packages: dependencies: is-number: 7.0.0 + /toad-cache/3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + dev: true + /toidentifier/1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -7360,14 +7628,13 @@ packages: engines: {node: '>= 0.8'} dev: true - /vite-node/0.34.6_@types+node@18.17.14: - resolution: {integrity: sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==} - engines: {node: '>=v14.18.0'} + /vite-node/1.2.2_@types+node@18.17.14: + resolution: {integrity: sha512-1as4rDTgVWJO3n1uHmUYqq7nsFgINQ9u+mRcXpjeOMJUmviqNKjcZB7UfRZrlM7MjYXMKpuWp5oGkjaFLnjawg==} + engines: {node: ^18.0.0 || >=20.0.0} hasBin: true dependencies: cac: 6.7.14 debug: 4.3.4 - mlly: 1.4.2 pathe: 1.1.1 picocolors: 1.0.0 vite: 5.0.12_@types+node@18.17.14 @@ -7418,7 +7685,7 @@ packages: fsevents: 2.3.3 dev: true - /vitest-environment-miniflare/2.14.1_vitest@0.34.6: + /vitest-environment-miniflare/2.14.1_vitest@1.2.2: resolution: {integrity: sha512-efMpx9XnpjHeIN1lnEMO+4Ky9xSFM0VeG8Ilf+5Uyh8U8lNuJ+qTTfr76LQ6MQcNzkLMo4byh0YxaZo8QfIYrw==} engines: {node: '>=16.13'} peerDependencies: @@ -7429,28 +7696,28 @@ packages: '@miniflare/shared': 2.14.1 '@miniflare/shared-test-environment': 2.14.1 undici: 5.20.0 - vitest: 0.34.6_jsdom@23.2.0 + vitest: 1.2.2_b4fzwn3atmkdkqrawvd5volizi transitivePeerDependencies: - bufferutil - utf-8-validate dev: true - /vitest/0.34.6_jsdom@23.2.0: - resolution: {integrity: sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==} - engines: {node: '>=v14.18.0'} + /vitest/1.2.2_b4fzwn3atmkdkqrawvd5volizi: + resolution: {integrity: sha512-d5Ouvrnms3GD9USIK36KG8OZ5bEvKEkITFtnGv56HFaSlbItJuYr7hv2Lkn903+AvRAgSixiamozUVfORUekjw==} + engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@vitest/browser': '*' - '@vitest/ui': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': ^1.0.0 + '@vitest/ui': ^1.0.0 happy-dom: '*' jsdom: '*' - playwright: '*' - safaridriver: '*' - webdriverio: '*' peerDependenciesMeta: '@edge-runtime/vm': optional: true + '@types/node': + optional: true '@vitest/browser': optional: true '@vitest/ui': @@ -7459,37 +7726,29 @@ packages: optional: true jsdom: optional: true - playwright: - optional: true - safaridriver: - optional: true - webdriverio: - optional: true dependencies: - '@types/chai': 4.3.9 - '@types/chai-subset': 1.3.4 '@types/node': 18.17.14 - '@vitest/expect': 0.34.6 - '@vitest/runner': 0.34.6 - '@vitest/snapshot': 0.34.6 - '@vitest/spy': 0.34.6 - '@vitest/utils': 0.34.6 - acorn: 8.11.2 - acorn-walk: 8.2.0 + '@vitest/expect': 1.2.2 + '@vitest/runner': 1.2.2 + '@vitest/snapshot': 1.2.2 + '@vitest/spy': 1.2.2 + '@vitest/utils': 1.2.2 + acorn-walk: 8.3.2 cac: 6.7.14 chai: 4.3.10 debug: 4.3.4 + execa: 8.0.1 jsdom: 23.2.0 - local-pkg: 0.4.3 + local-pkg: 0.5.0 magic-string: 0.30.5 pathe: 1.1.1 picocolors: 1.0.0 - std-env: 3.4.3 + std-env: 3.7.0 strip-literal: 1.3.0 tinybench: 2.5.1 - tinypool: 0.7.0 + tinypool: 0.8.2 vite: 5.0.12_@types+node@18.17.14 - vite-node: 0.34.6_@types+node@18.17.14 + vite-node: 1.2.2_@types+node@18.17.14 why-is-node-running: 2.2.2 transitivePeerDependencies: - less @@ -7771,19 +8030,6 @@ packages: optional: true dev: true - /ws/8.14.2: - resolution: {integrity: sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==} - 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 - dev: true - /ws/8.16.0: resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} engines: {node: '>=10.0.0'} From a1fb0476e09de50a4065503c7d11ea9c9b945d8b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 14 Feb 2024 18:02:39 +0100 Subject: [PATCH 011/105] fix(ws): opt-out form native event cancellation --- src/core/handlers/WebSocketHandler.ts | 28 +++++++++++++++----------- src/core/utils/handleWebSocketEvent.ts | 19 +++++++++++------ 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts index be0d4e136..abb43786c 100644 --- a/src/core/handlers/WebSocketHandler.ts +++ b/src/core/handlers/WebSocketHandler.ts @@ -30,7 +30,8 @@ type WebSocketHandlerIncomingEvent = MessageEvent<{ }> export const kEmitter = Symbol('kEmitter') -export const kRun = Symbol('kRun') +export const kDispatchEvent = Symbol('kDispatchEvent') +export const kDefaultPrevented = Symbol('kDefaultPrevented') export class WebSocketHandler { protected [kEmitter]: Emitter @@ -54,25 +55,28 @@ export class WebSocketHandler { event: WebSocketHandlerIncomingEvent parsedResult: WebSocketHandlerParsedResult }): boolean { - const { match } = args.parsedResult - return match.matches + return args.parsedResult.match.matches } - async [kRun](args: { event: MessageEvent }): Promise { - const parsedResult = this.parse({ event: args.event }) - const shouldIntercept = this.predicate({ event: args.event, parsedResult }) + async [kDispatchEvent](event: MessageEvent): Promise { + const parsedResult = this.parse({ event }) + const shouldIntercept = this.predicate({ event, parsedResult }) if (!shouldIntercept) { return } - const connectionEvent = args.event - - // At this point, the WebSocket connection URL has matched the handler. - // Prevent the default behavior of establishing the connection as-is. - connectionEvent.preventDefault() + // Account for other matching event handlers that've already prevented this event. + if (!Reflect.get(event, kDefaultPrevented)) { + // At this point, the WebSocket connection URL has matched the handler. + // Prevent the default behavior of establishing the connection as-is. + // Use internal symbol because we aren't actually dispatching this + // event. Events can only marked as cancelable and can be prevented + // when dispatched on an EventTarget. + Reflect.set(event, kDefaultPrevented, true) + } - const connection = connectionEvent.data + const connection = event.data // Emit the connection event on the handler. // This is what the developer adds listeners for. diff --git a/src/core/utils/handleWebSocketEvent.ts b/src/core/utils/handleWebSocketEvent.ts index 2f30e31a8..20e21a8b4 100644 --- a/src/core/utils/handleWebSocketEvent.ts +++ b/src/core/utils/handleWebSocketEvent.ts @@ -1,5 +1,9 @@ import { RequestHandler } from '../handlers/RequestHandler' -import { WebSocketHandler, kRun } from '../handlers/WebSocketHandler' +import { + WebSocketHandler, + kDefaultPrevented, + kDispatchEvent, +} from '../handlers/WebSocketHandler' import { webSocketInterceptor } from '../ws/webSocketInterceptor' export function handleWebSocketEvent( @@ -8,7 +12,12 @@ export function handleWebSocketEvent( webSocketInterceptor.on('connection', (connection) => { const connectionEvent = new MessageEvent('connection', { data: connection, - cancelable: true, + }) + + Object.defineProperty(connectionEvent, kDefaultPrevented, { + enumerable: false, + writable: true, + value: false, }) // Iterate over the handlers and forward the connection @@ -16,15 +25,13 @@ export function handleWebSocketEvent( // to dispatching that event onto multiple listeners. for (const handler of handlers) { if (handler instanceof WebSocketHandler) { - // Never await the run function because event handlers - // are side-effectful and don't block the event loop. - handler[kRun]({ event: connectionEvent }) + handler[kDispatchEvent](connectionEvent) } } // If none of the "ws" handlers matched, // establish the WebSocket connection as-is. - if (!connectionEvent.defaultPrevented) { + if (!Reflect.get(connectionEvent, kDefaultPrevented)) { connection.server.connect() connection.client.addEventListener('message', (event) => { connection.server.send(event.data) From 2f91ac8989f87048ed2664d0bb6c725bcd52c5b6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 14 Feb 2024 18:02:55 +0100 Subject: [PATCH 012/105] test(ws): add server tests --- ...pt.test.ts => ws.intercept.client.test.ts} | 1 + test/node/ws-api/ws.intercept.server.test.ts | 132 ++++++++++++++++++ test/support/WebSocketServer.ts | 8 +- .../vitest-environment-node-websocket.ts | 7 +- 4 files changed, 144 insertions(+), 4 deletions(-) rename test/node/ws-api/{ws.intercept.test.ts => ws.intercept.client.test.ts} (99%) create mode 100644 test/node/ws-api/ws.intercept.server.test.ts diff --git a/test/node/ws-api/ws.intercept.test.ts b/test/node/ws-api/ws.intercept.client.test.ts similarity index 99% rename from test/node/ws-api/ws.intercept.test.ts rename to test/node/ws-api/ws.intercept.client.test.ts index 70db69c7f..9fcc2ed12 100644 --- a/test/node/ws-api/ws.intercept.test.ts +++ b/test/node/ws-api/ws.intercept.client.test.ts @@ -17,6 +17,7 @@ beforeAll(async () => { }) afterEach(() => { + server.resetHandlers() wsServer.closeAllClients() wsServer.removeAllListeners() }) diff --git a/test/node/ws-api/ws.intercept.server.test.ts b/test/node/ws-api/ws.intercept.server.test.ts new file mode 100644 index 000000000..4bb76e2e2 --- /dev/null +++ b/test/node/ws-api/ws.intercept.server.test.ts @@ -0,0 +1,132 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' + +const server = setupServer() +const originalServer = new WebSocketServer() + +const service = ws.link('ws://*') + +beforeAll(async () => { + server.listen() + await originalServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + originalServer.closeAllClients() + originalServer.removeAllListeners() +}) + +afterAll(async () => { + server.close() + await originalServer.close() +}) + +it('intercepts incoming server text message', async () => { + const serverMessageListener = vi.fn() + const clientMessageListener = vi.fn() + + originalServer.on('connection', (client) => { + client.send('hello') + }) + server.use( + service.on('connection', ({ server }) => { + server.connect() + server.addEventListener('message', serverMessageListener) + }), + ) + + const ws = new WebSocket(originalServer.url) + ws.addEventListener('message', clientMessageListener) + + await vi.waitFor(() => { + expect(serverMessageListener).toHaveBeenCalledTimes(1) + + const serverMessage = serverMessageListener.mock.calls[0][0] as MessageEvent + expect(serverMessage.type).toBe('message') + expect(serverMessage.data).toBe('hello') + + expect(clientMessageListener).toHaveBeenCalledTimes(1) + + const clientMessage = clientMessageListener.mock.calls[0][0] as MessageEvent + expect(clientMessage.type).toBe('message') + expect(clientMessage.data).toBe('hello') + }) +}) + +it('intercepts incoming server Blob message', async () => { + const serverMessageListener = vi.fn() + const clientMessageListener = vi.fn() + + originalServer.on('connection', async (client) => { + /** + * @note You should use plain `Blob` instead. + * For some reason, the "ws" package has trouble accepting + * it as an input (expects a Buffer). + */ + client.send(await new Blob(['hello']).arrayBuffer()) + }) + server.use( + service.on('connection', ({ server }) => { + server.connect() + server.addEventListener('message', serverMessageListener) + }), + ) + + const ws = new WebSocket(originalServer.url) + ws.addEventListener('message', clientMessageListener) + + await vi.waitFor(() => { + expect(serverMessageListener).toHaveBeenCalledTimes(1) + + const serverMessage = serverMessageListener.mock.calls[0][0] as MessageEvent + expect(serverMessage.type).toBe('message') + expect(serverMessage.data).toEqual(new Blob(['hello'])) + + expect(clientMessageListener).toHaveBeenCalledTimes(1) + + const clientMessage = clientMessageListener.mock.calls[0][0] as MessageEvent + expect(clientMessage.type).toBe('message') + expect(clientMessage.data).toEqual(new Blob(['hello'])) + }) +}) + +it('intercepts incoming ArrayBuffer message', async () => { + const encoder = new TextEncoder() + const serverMessageListener = vi.fn() + const clientMessageListener = vi.fn() + + originalServer.on('connection', async (client) => { + client.send(encoder.encode('hello world')) + }) + server.use( + service.on('connection', ({ server }) => { + server.connect() + server.addEventListener('message', serverMessageListener) + }), + ) + + const ws = new WebSocket(originalServer.url) + ws.addEventListener('message', clientMessageListener) + + await vi.waitFor(() => { + expect(serverMessageListener).toHaveBeenCalledTimes(1) + + const serverMessage = serverMessageListener.mock.calls[0][0] as MessageEvent + expect(serverMessage.type).toBe('message') + /** + * @note For some reason, "ws" still sends back a Blob. + */ + expect(serverMessage.data).toEqual(new Blob(['hello world'])) + + expect(clientMessageListener).toHaveBeenCalledTimes(1) + + const clientMessage = clientMessageListener.mock.calls[0][0] as MessageEvent + expect(clientMessage.type).toBe('message') + expect(clientMessage.data).toEqual(new Blob(['hello world'])) + }) +}) diff --git a/test/support/WebSocketServer.ts b/test/support/WebSocketServer.ts index 546c14168..773a39e9f 100644 --- a/test/support/WebSocketServer.ts +++ b/test/support/WebSocketServer.ts @@ -21,9 +21,11 @@ export class WebSocketServer extends Emitter { this.app = fastify() this.app.register(fastifyWebSocket) this.app.register(async (fastify) => { - fastify.get('/', { websocket: true }, (connection) => { - this.clients.add(connection.socket) - this.emit('connection', connection.socket) + fastify.get('/', { websocket: true }, ({ socket }) => { + this.clients.add(socket) + socket.once('close', () => this.clients.delete(socket)) + + this.emit('connection', socket) }) }) } diff --git a/test/support/environments/vitest-environment-node-websocket.ts b/test/support/environments/vitest-environment-node-websocket.ts index 4fe1b93ad..16d616f7d 100644 --- a/test/support/environments/vitest-environment-node-websocket.ts +++ b/test/support/environments/vitest-environment-node-websocket.ts @@ -9,7 +9,12 @@ export default { name: 'node-with-websocket', transformMode: 'ssr', async setup(global, options) { - const { teardown } = await builtinEnvironments.jsdom.setup(global, options) + /** + * @note It's crucial this extend the Node.js environment. + * JSDOM polyfills the global "Event", making it unusable + * with Node's "EventTarget". + */ + const { teardown } = await builtinEnvironments.node.setup(global, options) Reflect.set(globalThis, 'WebSocket', WebSocket) From 292104c4a495f5151d3bb0648131d7590d01fcab Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 14 Feb 2024 18:32:51 +0100 Subject: [PATCH 013/105] fix(ws): stale ".currentHandlers()" ref --- src/core/utils/handleWebSocketEvent.ts | 3 ++- src/node/SetupServerCommonApi.ts | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/utils/handleWebSocketEvent.ts b/src/core/utils/handleWebSocketEvent.ts index 20e21a8b4..eebb0adc0 100644 --- a/src/core/utils/handleWebSocketEvent.ts +++ b/src/core/utils/handleWebSocketEvent.ts @@ -7,9 +7,10 @@ import { import { webSocketInterceptor } from '../ws/webSocketInterceptor' export function handleWebSocketEvent( - handlers: Array, + getCurrentHandlers: () => Array, ) { webSocketInterceptor.on('connection', (connection) => { + const handlers = getCurrentHandlers() const connectionEvent = new MessageEvent('connection', { data: connection, }) diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts index f02dbde56..7d6d8b00e 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -18,6 +18,7 @@ import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { mergeRight } from '~/core/utils/internal/mergeRight' import { devUtils } from '~/core/utils/internal/devUtils' import type { SetupServerCommon } from './glossary' +import { handleWebSocketEvent } from '~/core/utils/handleWebSocketEvent' export const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { onUnhandledRequest: 'warn', @@ -82,6 +83,10 @@ export class SetupServerCommonApi ) }, ) + + handleWebSocketEvent(() => { + return this.handlersController.currentHandlers() + }) } public listen(options: Partial = {}): void { From decffc09ce5fce4385ada3c7d9fbdd8bf9e772a5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 15 Feb 2024 11:30:22 +0100 Subject: [PATCH 014/105] test(ws): add runtime handler tests --- test/node/ws-api/ws.use.test.ts | 132 ++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 test/node/ws-api/ws.use.test.ts diff --git a/test/node/ws-api/ws.use.test.ts b/test/node/ws-api/ws.use.test.ts new file mode 100644 index 000000000..9af66826c --- /dev/null +++ b/test/node/ws-api/ws.use.test.ts @@ -0,0 +1,132 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const service = ws.link('wss://*') + +const server = setupServer( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('hello, client!') + } + }) + }), +) + +beforeAll(() => { + server.listen() +}) + +afterAll(() => { + server.close() +}) + +it.concurrent( + 'resolves outgoing events using initial handlers', + server.boundary(async () => { + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => messageListener(event.data) + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenCalledWith('hello, client!') + expect(messageListener).toHaveBeenCalledTimes(1) + }) + }), +) + +it.concurrent( + 'overrides an outgoing event listener', + server.boundary(async () => { + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Stopping immediate event propagation will prevent + // the same message listener in the initial handler + // from being called. + event.stopImmediatePropagation() + client.send('howdy, client!') + } + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => messageListener(event.data) + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenCalledWith('howdy, client!') + expect(messageListener).toHaveBeenCalledTimes(1) + }) + }), +) + +it.concurrent( + 'combines initial and override listeners', + server.boundary(async () => { + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Not stopping the event propagation will result in both + // the override handler and the runtime handler sending + // data to the client in order. The override handler is + // prepended, so it will send data first. + client.send('override data') + } + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => messageListener(event.data) + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + // The runtime handler is executed first, so it sends its message first. + expect(messageListener).toHaveBeenNthCalledWith(1, 'override data') + // The initial handler will send its message next. + expect(messageListener).toHaveBeenNthCalledWith(2, 'hello, client!') + expect(messageListener).toHaveBeenCalledTimes(2) + }) + }), +) + +it.concurrent( + 'combines initial and override listeners in the opposite order', + async () => { + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Queuing the send to the next tick will ensure + // that the initial handler sends data first, + // and this override handler sends data next. + queueMicrotask(() => { + client.send('override data') + }) + } + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => messageListener(event.data) + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'hello, client!') + expect(messageListener).toHaveBeenNthCalledWith(2, 'override data') + expect(messageListener).toHaveBeenCalledTimes(2) + }) + }, +) From 245f55fe4d8951e605d7f899164806c617f42e42 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 15 Feb 2024 13:38:35 +0100 Subject: [PATCH 015/105] test(ws): add server event patching tests --- test/node/ws-api/ws.event-patching.test.ts | 121 +++++++++++++++++++ test/node/ws-api/ws.intercept.server.test.ts | 3 +- test/node/ws-api/ws.use.test.ts | 44 +++++++ test/support/WebSocketServer.ts | 5 + 4 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 test/node/ws-api/ws.event-patching.test.ts diff --git a/test/node/ws-api/ws.event-patching.test.ts b/test/node/ws-api/ws.event-patching.test.ts new file mode 100644 index 000000000..320e1d7d4 --- /dev/null +++ b/test/node/ws-api/ws.event-patching.test.ts @@ -0,0 +1,121 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' + +const service = ws.link('ws://*') +const originalServer = new WebSocketServer() + +const server = setupServer( + service.on('connection', ({ server }) => { + server.connect() + }), +) + +beforeAll(async () => { + server.listen() + await originalServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + originalServer.resetState() +}) + +afterAll(async () => { + server.close() + await originalServer.close() +}) + +it('patches incoming server message', async () => { + originalServer.once('connection', (client) => { + client.send('hi from John') + }) + + server.use( + service.on('connection', ({ client, server }) => { + /** + * @note Since the initial handler connects to the server, + * there's no need to call `server.connect()` again. + */ + server.addEventListener('message', (event) => { + // Preventing the default stops the server-to-client forwarding. + // It means that the WebSocket client won't receive the + // actual server message. + event.preventDefault() + client.send(event.data.replace('John', 'Sarah')) + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket(originalServer.url) + ws.onmessage = (event) => messageListener(event.data) + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'hi from Sarah') + expect(messageListener).toHaveBeenCalledTimes(1) + }) +}) + +it('combines original and mock server messages', async () => { + originalServer.once('connection', (client) => { + client.send('original message') + }) + + server.use( + service.on('connection', ({ client, server }) => { + server.addEventListener('message', () => { + client.send('mocked message') + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket(originalServer.url) + ws.onopen = () => ws.send('hello') + ws.onmessage = (event) => messageListener(event.data) + + await vi.waitFor(() => { + /** + * @note That the server will send the message as soon as the client + * connects. This happens before the event handler is called. + */ + expect(messageListener).toHaveBeenNthCalledWith(1, 'original message') + expect(messageListener).toHaveBeenNthCalledWith(2, 'mocked message') + expect(messageListener).toHaveBeenCalledTimes(2) + }) +}) + +it('combines original and mock server messages in the different order', async () => { + originalServer.once('connection', (client) => { + client.send('original message') + }) + + server.use( + service.on('connection', ({ client, server }) => { + server.addEventListener('message', (event) => { + /** + * @note To change the incoming server events order, + * prevent the default, send a mocked message, and + * then send the original message as-is. + */ + event.preventDefault() + client.send('mocked message') + client.send(event.data) + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket(originalServer.url) + ws.onmessage = (event) => messageListener(event.data) + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'mocked message') + expect(messageListener).toHaveBeenNthCalledWith(2, 'original message') + expect(messageListener).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/node/ws-api/ws.intercept.server.test.ts b/test/node/ws-api/ws.intercept.server.test.ts index 4bb76e2e2..7cc3e7651 100644 --- a/test/node/ws-api/ws.intercept.server.test.ts +++ b/test/node/ws-api/ws.intercept.server.test.ts @@ -17,8 +17,7 @@ beforeAll(async () => { afterEach(() => { server.resetHandlers() - originalServer.closeAllClients() - originalServer.removeAllListeners() + originalServer.resetState() }) afterAll(async () => { diff --git a/test/node/ws-api/ws.use.test.ts b/test/node/ws-api/ws.use.test.ts index 9af66826c..f6cc8135d 100644 --- a/test/node/ws-api/ws.use.test.ts +++ b/test/node/ws-api/ws.use.test.ts @@ -12,6 +12,10 @@ const server = setupServer( if (event.data === 'hello') { client.send('hello, client!') } + + if (event.data === 'fallthrough') { + client.send('ok') + } }) }), ) @@ -130,3 +134,43 @@ it.concurrent( }) }, ) + +it.concurrent( + 'does not affect unrelated events', + server.boundary(async () => { + server.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Stopping immediate event propagation will prevent + // the same message listener in the initial handler + // from being called. + event.stopImmediatePropagation() + client.send('howdy, client!') + } + }) + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket('wss://example.com') + ws.onmessage = (event) => { + messageListener(event.data) + + if (event.data === 'howdy, client!') { + ws.send('fallthrough') + } + } + ws.onopen = () => ws.send('hello') + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'howdy, client!') + }) + + await vi.waitFor(() => { + // The initial handler still sends data to unrelated events. + expect(messageListener).toHaveBeenNthCalledWith(2, 'ok') + expect(messageListener).toHaveBeenCalledTimes(2) + }) + }), +) diff --git a/test/support/WebSocketServer.ts b/test/support/WebSocketServer.ts index 773a39e9f..c9e5861de 100644 --- a/test/support/WebSocketServer.ts +++ b/test/support/WebSocketServer.ts @@ -45,6 +45,11 @@ export class WebSocketServer extends Emitter { this._url = url.href } + public resetState(): void { + this.closeAllClients() + this.removeAllListeners() + } + public closeAllClients(): void { this.clients.forEach((client) => { client.close() From 2e7863f7c35fa5fdb141aac7a600becb8b5b3c2e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 15 Feb 2024 15:21:11 +0100 Subject: [PATCH 016/105] chore: update @mswjs/interceptors --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index ebee09b7b..042476af1 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.1", + "@mswjs/interceptors": "^0.26.2", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f05b82932..826df981c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: '@fastify/websocket': ^8.3.1 '@inquirer/confirm': ^3.0.0 '@mswjs/cookies': ^1.1.0 - '@mswjs/interceptors': ^0.26.1 + '@mswjs/interceptors': ^0.26.2 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.0 @@ -73,7 +73,7 @@ dependencies: '@bundled-es-modules/statuses': 1.0.1 '@inquirer/confirm': 3.0.0 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.26.1 + '@mswjs/interceptors': 0.26.2 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -1139,8 +1139,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.26.1: - resolution: {integrity: sha512-iK3hLdSp8153NQKU8BdnJQoa0V+tBdHZPNmmAZwLLG2GN/I64PpnbyEOT81SOPFDghpfdtTccfR1L0oEpfhxTA==} + /@mswjs/interceptors/0.26.2: + resolution: {integrity: sha512-78Y/5MMYrPckJoDYl3MA5dKWn9L7pB5ue5MRkx0oLuxhHCDBLgaYwvXxrGyiwNf3xfWUrSB9FWkjob/gG8NoFQ==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 From 5c72475ebe1324d62627c39e4c5d251e7541de40 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 15 Feb 2024 15:21:24 +0100 Subject: [PATCH 017/105] test(ws): add server error forwarding test --- test/node/ws-api/ws.server.connect.test.ts | 97 ++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 test/node/ws-api/ws.server.connect.test.ts diff --git a/test/node/ws-api/ws.server.connect.test.ts b/test/node/ws-api/ws.server.connect.test.ts new file mode 100644 index 000000000..82159ea1d --- /dev/null +++ b/test/node/ws-api/ws.server.connect.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' + +const service = ws.link('ws://*') +const originalServer = new WebSocketServer() + +const server = setupServer() + +beforeAll(async () => { + server.listen() + await originalServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + originalServer.resetState() +}) + +afterAll(async () => { + server.close() + await originalServer.close() +}) + +it('does not connect to the actual server by default', async () => { + const serverConnectionListener = vi.fn() + const mockConnectionListener = vi.fn() + + originalServer.once('connection', serverConnectionListener) + server.use(service.on('connection', mockConnectionListener)) + + new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(mockConnectionListener).toHaveBeenCalledTimes(1) + expect(serverConnectionListener).not.toHaveBeenCalled() + }) +}) + +it('connects to the actual server after calling "server.connect()"', async () => { + const serverConnectionListener = vi.fn() + const mockConnectionListener = vi.fn() + + originalServer.once('connection', serverConnectionListener) + + server.use( + service.on('connection', ({ server }) => { + mockConnectionListener() + server.connect() + }), + ) + + new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(mockConnectionListener).toHaveBeenCalledTimes(1) + expect(serverConnectionListener).toHaveBeenCalledTimes(1) + }) +}) + +it('forward incoming server events to the client by default', async () => { + originalServer.once('connection', (client) => client.send('hello')) + + server.use( + service.on('connection', ({ server }) => { + server.connect() + }), + ) + + const messageListener = vi.fn() + const ws = new WebSocket(originalServer.url) + ws.onmessage = (event) => messageListener(event.data) + + await vi.waitFor(() => { + expect(messageListener).toHaveBeenNthCalledWith(1, 'hello') + expect(messageListener).toHaveBeenCalledTimes(1) + }) +}) + +it('throws an error when connecting to a non-existing server', async () => { + server.use( + service.on('connection', ({ server }) => { + server.connect() + }), + ) + + const errorListener = vi.fn() + const ws = new WebSocket('wss://localhost:9876') + ws.onerror = errorListener + + await vi.waitFor(() => { + expect(errorListener).toHaveBeenCalledTimes(1) + }) +}) From 317a3db2252576ae39eabea0b5813ba9d4797767 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 16 Feb 2024 17:16:55 +0100 Subject: [PATCH 018/105] fix(setupWorker): add websocket event handling --- src/browser/setupWorker/setupWorker.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/browser/setupWorker/setupWorker.ts b/src/browser/setupWorker/setupWorker.ts index 44ac57e95..8061904b6 100644 --- a/src/browser/setupWorker/setupWorker.ts +++ b/src/browser/setupWorker/setupWorker.ts @@ -22,6 +22,7 @@ import type { LifeCycleEventsMap } from '~/core/sharedOptions' import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { SetupWorker } from './glossary' import { supportsReadableStreamTransfer } from '../utils/supportsReadableStreamTransfer' +import { handleWebSocketEvent } from '~/core/utils/handleWebSocketEvent' interface Listener { target: EventTarget @@ -177,6 +178,10 @@ export class SetupWorkerApi options, ) as SetupWorkerInternalContext['startOptions'] + handleWebSocketEvent(() => { + return this.handlersController.currentHandlers() + }) + return await this.startHandler(this.context.startOptions, options) } From 0a389e77bce8d1f5bf302eda2cdc1179f1e8e569 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 16 Feb 2024 17:17:13 +0100 Subject: [PATCH 019/105] feat(ws): add "broadcast" and "broadcastExcept" apis --- src/core/ws/ws.ts | 99 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index 516c87d16..0d693787b 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -1,3 +1,4 @@ +import { WebSocketClientConnection } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' import { WebSocketHandler, kEmitter, @@ -6,16 +7,29 @@ import { import type { Path } from '../utils/matching/matchRequestUrl' import { webSocketInterceptor } from './webSocketInterceptor' +const wsBroadcastChannel = new BroadcastChannel('msw:ws') + /** * Intercepts outgoing WebSocket connections to the given URL. * * @example * const chat = ws.link('wss://chat.example.com') - * chat.on('connection', (connection) => {}) + * chat.on('connection', ({ client }) => { + * client.send('hello from server!') + * }) */ function createWebSocketLinkHandler(url: Path) { webSocketInterceptor.apply() + /** + * @note The set of all WebSocket clients in THIS runtime. + * This will be the accumulated list of all clients for Node.js. + * But in the browser, each tab will create its own runtime, + * so this set will only contain the matching clients from + * that isolated runtime (no shared runtime). + */ + const runtimeClients = new Set() + return { on( event: K, @@ -23,6 +37,30 @@ function createWebSocketLinkHandler(url: Path) { ): WebSocketHandler { const handler = new WebSocketHandler(url) + wsBroadcastChannel.addEventListener('message', (event) => { + const { type, payload } = event.data + + switch (type) { + case 'message': { + const { data, ignoreClients } = payload + + runtimeClients.forEach((client) => { + if (!ignoreClients || !ignoreClients.includes(client.id)) { + client.send(data) + } + }) + break + } + } + }) + + handler[kEmitter].on('connection', ({ client }) => { + runtimeClients.add(client) + client.addEventListener('close', () => { + runtimeClients.delete(client) + }) + }) + // The "handleWebSocketEvent" function will invoke // the "run()" method on the WebSocketHandler. // If the handler matches, it will emit the "connection" @@ -31,6 +69,65 @@ function createWebSocketLinkHandler(url: Path) { return handler }, + + /** + * Broadcasts the given data to all WebSocket clients. + * + * @example + * const service = ws.link('wss://example.com') + * service.on('connection', () => { + * service.broadcast('hello, everyone!') + * }) + */ + broadcast(data: any): void { + // Broadcast to all the clients of this runtime. + runtimeClients.forEach((client) => client.send(data)) + + // Broadcast to all the clients from other runtimes. + wsBroadcastChannel.postMessage({ + type: 'message', + payload: { data }, + }) + }, + + /** + * Broadcasts the given data to all WebSocket clients + * except the ones provided in the `clients` argument. + * + * @example + * const service = ws.link('wss://example.com') + * service.on('connection', ({ client }) => { + * service.broadcastExcept(client, 'hi, the rest of you!') + * }) + */ + broadcastExcept( + clients: WebSocketClientConnection | Array, + data: any, + ): void { + const ignoreClients = Array.prototype + .concat(clients) + .map((client) => client.id) + + // Broadcast this event to all the clients of this runtime + // except for the given ignored clients. This is needed + // so a "broadcastExcept()" call in another runtime affects + // this runtime but respects the ignored clients. + runtimeClients.forEach((otherClient) => { + if (!ignoreClients.includes(otherClient.id)) { + otherClient.send(data) + } + }) + + // Broadcast to all the clients from other runtimes, + // respecting the list of ignored client IDs. + wsBroadcastChannel.postMessage({ + type: 'message', + payload: { + data, + ignoreClients, + }, + }) + }, } } From 073fe991b970d50829a8c361b27845feb77cd4f8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 18 Feb 2024 17:36:47 +0100 Subject: [PATCH 020/105] chore: update @mswjs/interceptors --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- src/core/ws/ws.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 042476af1..76407e527 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.2", + "@mswjs/interceptors": "^0.26.4", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 826df981c..2fef4672a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: '@fastify/websocket': ^8.3.1 '@inquirer/confirm': ^3.0.0 '@mswjs/cookies': ^1.1.0 - '@mswjs/interceptors': ^0.26.2 + '@mswjs/interceptors': ^0.26.4 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.0 @@ -73,7 +73,7 @@ dependencies: '@bundled-es-modules/statuses': 1.0.1 '@inquirer/confirm': 3.0.0 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.26.2 + '@mswjs/interceptors': 0.26.4 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -1139,8 +1139,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.26.2: - resolution: {integrity: sha512-78Y/5MMYrPckJoDYl3MA5dKWn9L7pB5ue5MRkx0oLuxhHCDBLgaYwvXxrGyiwNf3xfWUrSB9FWkjob/gG8NoFQ==} + /@mswjs/interceptors/0.26.4: + resolution: {integrity: sha512-EMXxLxO4u574IGt7rGC5GBjDCcZL2h1FqVu/V9IRsMKVSPE9QZl8bb8LGJFZwVp2GBcJNdiw7Vdn0t5wUSBvhg==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index 0d693787b..10fc1dd60 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -1,4 +1,4 @@ -import { WebSocketClientConnection } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' +import type { WebSocketClientConnection } from '@mswjs/interceptors/WebSocket' import { WebSocketHandler, kEmitter, From 654c91d512c507d78b989a9122d32b87559fe336 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 18 Feb 2024 19:03:43 +0100 Subject: [PATCH 021/105] feat: implement "clients" and "WebSocketClientManager" --- .../ws/SerializedWebSocketClientConnection.ts | 129 ++++++++++++++++++ src/core/ws/ws.ts | 81 ++++------- 2 files changed, 152 insertions(+), 58 deletions(-) create mode 100644 src/core/ws/SerializedWebSocketClientConnection.ts diff --git a/src/core/ws/SerializedWebSocketClientConnection.ts b/src/core/ws/SerializedWebSocketClientConnection.ts new file mode 100644 index 000000000..07d976dbb --- /dev/null +++ b/src/core/ws/SerializedWebSocketClientConnection.ts @@ -0,0 +1,129 @@ +import type { + WebSocketData, + WebSocketClientConnection, + WebSocketClientConnectionProtocol, +} from '@mswjs/interceptors/WebSocket' + +export const kAddByClientId = Symbol('kAddByClientId') + +/** + * A manager responsible for accumulating WebSocket client + * connections across different browser runtimes. + */ +export class WebSocketClientManager { + /** + * All active WebSocket client connections. + */ + public clients: Set + + constructor(private channel: BroadcastChannel) { + this.clients = new Set() + + this.channel.addEventListener('message', (message) => { + const { type, payload } = message.data + + switch (type) { + case 'connection:open': { + // When another runtime notifies about a new connection, + // create a connection wrapper class and add it to the set. + this.onRemoteConnection(payload.id, payload.url) + break + } + } + }) + } + + /** + * Adds the given WebSocket client connection to the set + * of all connections. The given connection is always the complete + * connection object because `addConnection()` is called only + * for the opened connections in the same runtime. + */ + public addConnection(client: WebSocketClientConnection): void { + // Add this connection to the immediate set of connections. + this.clients.add(client) + + // Signal to other runtimes about this connection. + this.channel.postMessage({ + type: 'connection:open', + payload: { + id: client.id, + url: client.url, + }, + }) + + // Instruct the current client how to handle events + // coming from other runtimes (e.g. when broadcasting). + this.channel.addEventListener('message', (message) => { + const { type, payload } = message.data + + // Ignore broadcasted messages for other clients. + if (payload.clientId !== client.id) { + return + } + + switch (type) { + case 'send': { + client.send(payload.data) + break + } + + case 'close': { + client.close(payload.code, payload.reason) + break + } + } + }) + } + + /** + * Adds a client connection wrapper to operate with + * WebSocket client connections in other runtimes. + */ + private onRemoteConnection(id: string, url: URL): void { + this.clients.add( + // Create a connection-compatible instance that can + // operate with this client from a different runtime + // using the BroadcastChannel messages. + new WebSocketRemoteClientConnection(id, url, this.channel), + ) + } +} + +/** + * A wrapper class to operate with WebSocket client connections + * from other runtimes. This class maintains 1-1 public API + * compatibility to the `WebSocketClientConnection` but relies + * on the given `BroadcastChannel` to communicate instructions + * with the client connections from other runtimes. + */ +class WebSocketRemoteClientConnection + implements WebSocketClientConnectionProtocol +{ + constructor( + public readonly id: string, + public readonly url: URL, + private channel: BroadcastChannel, + ) {} + + send(data: WebSocketData): void { + this.channel.postMessage({ + type: 'send', + payload: { + clientId: this.id, + data, + }, + }) + } + + close(code?: number | undefined, reason?: string | undefined): void { + this.channel.postMessage({ + type: 'close', + payload: { + clientId: this.id, + code, + reason, + }, + }) + } +} diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index 10fc1dd60..033b25c99 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -1,4 +1,7 @@ -import type { WebSocketClientConnection } from '@mswjs/interceptors/WebSocket' +import type { + WebSocketClientConnectionProtocol, + WebSocketData, +} from '@mswjs/interceptors/WebSocket' import { WebSocketHandler, kEmitter, @@ -6,8 +9,9 @@ import { } from '../handlers/WebSocketHandler' import type { Path } from '../utils/matching/matchRequestUrl' import { webSocketInterceptor } from './webSocketInterceptor' +import { WebSocketClientManager } from './SerializedWebSocketClientConnection' -const wsBroadcastChannel = new BroadcastChannel('msw:ws') +const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') /** * Intercepts outgoing WebSocket connections to the given URL. @@ -20,45 +24,22 @@ const wsBroadcastChannel = new BroadcastChannel('msw:ws') */ function createWebSocketLinkHandler(url: Path) { webSocketInterceptor.apply() - - /** - * @note The set of all WebSocket clients in THIS runtime. - * This will be the accumulated list of all clients for Node.js. - * But in the browser, each tab will create its own runtime, - * so this set will only contain the matching clients from - * that isolated runtime (no shared runtime). - */ - const runtimeClients = new Set() + const clientManager = new WebSocketClientManager(wsBroadcastChannel) return { + clients: clientManager.clients, on( event: K, listener: (...args: WebSocketHandlerEventMap[K]) => void, ): WebSocketHandler { const handler = new WebSocketHandler(url) - wsBroadcastChannel.addEventListener('message', (event) => { - const { type, payload } = event.data - - switch (type) { - case 'message': { - const { data, ignoreClients } = payload - - runtimeClients.forEach((client) => { - if (!ignoreClients || !ignoreClients.includes(client.id)) { - client.send(data) - } - }) - break - } - } - }) - + // Add the connection event listener for when the + // handler matches and emits a connection event. + // When that happens, store that connection in the + // set of all connections for reference. handler[kEmitter].on('connection', ({ client }) => { - runtimeClients.add(client) - client.addEventListener('close', () => { - runtimeClients.delete(client) - }) + clientManager.addConnection(client) }) // The "handleWebSocketEvent" function will invoke @@ -79,15 +60,11 @@ function createWebSocketLinkHandler(url: Path) { * service.broadcast('hello, everyone!') * }) */ - broadcast(data: any): void { - // Broadcast to all the clients of this runtime. - runtimeClients.forEach((client) => client.send(data)) - - // Broadcast to all the clients from other runtimes. - wsBroadcastChannel.postMessage({ - type: 'message', - payload: { data }, - }) + broadcast(data: WebSocketData): void { + // This will invoke "send()" on the immediate clients + // in this runtime and post a message to the broadcast channel + // to trigger send for the clients in other runtimes. + this.broadcastExcept([], data) }, /** @@ -101,32 +78,20 @@ function createWebSocketLinkHandler(url: Path) { * }) */ broadcastExcept( - clients: WebSocketClientConnection | Array, - data: any, + clients: + | WebSocketClientConnectionProtocol + | Array, + data: WebSocketData, ): void { const ignoreClients = Array.prototype .concat(clients) .map((client) => client.id) - // Broadcast this event to all the clients of this runtime - // except for the given ignored clients. This is needed - // so a "broadcastExcept()" call in another runtime affects - // this runtime but respects the ignored clients. - runtimeClients.forEach((otherClient) => { + clientManager.clients.forEach((otherClient) => { if (!ignoreClients.includes(otherClient.id)) { otherClient.send(data) } }) - - // Broadcast to all the clients from other runtimes, - // respecting the list of ignored client IDs. - wsBroadcastChannel.postMessage({ - type: 'message', - payload: { - data, - ignoreClients, - }, - }) }, } } From ec0154f9f151fe381b7d73cc1262f4aba0d8fb4c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 25 Feb 2024 22:52:32 +0100 Subject: [PATCH 022/105] chore(WebSocketClientManager): annotate channel messages --- .../ws/SerializedWebSocketClientConnection.ts | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/core/ws/SerializedWebSocketClientConnection.ts b/src/core/ws/SerializedWebSocketClientConnection.ts index 07d976dbb..5f354c7b0 100644 --- a/src/core/ws/SerializedWebSocketClientConnection.ts +++ b/src/core/ws/SerializedWebSocketClientConnection.ts @@ -4,6 +4,30 @@ import type { WebSocketClientConnectionProtocol, } from '@mswjs/interceptors/WebSocket' +type WebSocketBroadcastChannelMessage = + | { + type: 'connection:open' + payload: { + id: string + url: URL + } + } + | { + type: 'send' + payload: { + clientId: string + data: WebSocketData + } + } + | { + type: 'close' + payload: { + clientId: string + code?: number + reason?: string + } + } + export const kAddByClientId = Symbol('kAddByClientId') /** @@ -20,7 +44,7 @@ export class WebSocketClientManager { this.clients = new Set() this.channel.addEventListener('message', (message) => { - const { type, payload } = message.data + const { type, payload } = message.data as WebSocketBroadcastChannelMessage switch (type) { case 'connection:open': { @@ -50,15 +74,19 @@ export class WebSocketClientManager { id: client.id, url: client.url, }, - }) + } as WebSocketBroadcastChannelMessage) // Instruct the current client how to handle events // coming from other runtimes (e.g. when broadcasting). this.channel.addEventListener('message', (message) => { - const { type, payload } = message.data + const { type, payload } = message.data as WebSocketBroadcastChannelMessage // Ignore broadcasted messages for other clients. - if (payload.clientId !== client.id) { + if ( + typeof payload === 'object' && + 'clientId' in payload && + payload.clientId !== client.id + ) { return } @@ -113,7 +141,7 @@ class WebSocketRemoteClientConnection clientId: this.id, data, }, - }) + } as WebSocketBroadcastChannelMessage) } close(code?: number | undefined, reason?: string | undefined): void { @@ -124,6 +152,6 @@ class WebSocketRemoteClientConnection code, reason, }, - }) + } as WebSocketBroadcastChannelMessage) } } From e22174f85ae917997aa17ae0be0eae037876343e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 25 Feb 2024 22:55:35 +0100 Subject: [PATCH 023/105] chore: rename file to "webSocketClientManager" --- ...izedWebSocketClientConnection.ts => WebSocketClientManager.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/core/ws/{SerializedWebSocketClientConnection.ts => WebSocketClientManager.ts} (100%) diff --git a/src/core/ws/SerializedWebSocketClientConnection.ts b/src/core/ws/WebSocketClientManager.ts similarity index 100% rename from src/core/ws/SerializedWebSocketClientConnection.ts rename to src/core/ws/WebSocketClientManager.ts From 54c5bafa1f68dd7cf9f18222723ef58c9334b6d6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 27 Feb 2024 17:33:26 +0100 Subject: [PATCH 024/105] test(WebSocketClientManager): add unit tests --- src/core/ws/WebSocketClientManager.test.ts | 159 +++++++++++++++++++++ src/core/ws/WebSocketClientManager.ts | 45 +++--- src/core/ws/ws.ts | 2 +- vitest.config.ts | 2 + 4 files changed, 191 insertions(+), 17 deletions(-) create mode 100644 src/core/ws/WebSocketClientManager.test.ts diff --git a/src/core/ws/WebSocketClientManager.test.ts b/src/core/ws/WebSocketClientManager.test.ts new file mode 100644 index 000000000..56aa26f0f --- /dev/null +++ b/src/core/ws/WebSocketClientManager.test.ts @@ -0,0 +1,159 @@ +/** + * @vitest-environment node-websocket + */ +import { randomUUID } from 'node:crypto' +import { + WebSocketClientConnection, + WebSocketTransport, +} from '@mswjs/interceptors/WebSocket' +import { + WebSocketClientManager, + WebSocketBroadcastChannelMessage, + WebSocketRemoteClientConnection, +} from './WebSocketClientManager' + +const channel = new BroadcastChannel('test:channel') +vi.spyOn(channel, 'postMessage') + +const socket = new WebSocket('ws://localhost') +const transport = { + onOutgoing: vi.fn(), + onIncoming: vi.fn(), + onClose: vi.fn(), + send: vi.fn(), + close: vi.fn(), +} satisfies WebSocketTransport + +afterEach(() => { + vi.resetAllMocks() +}) + +it('adds a client from this runtime to the list of clients', () => { + const manager = new WebSocketClientManager(channel) + const connection = new WebSocketClientConnection(socket, transport) + + manager.addConnection(connection) + + // Must add the client to the list of clients. + expect(Array.from(manager.clients.values())).toEqual([connection]) + + // Must emit the connection open event to notify other runtimes. + expect(channel.postMessage).toHaveBeenCalledWith({ + type: 'connection:open', + payload: { + clientId: connection.id, + url: new URL(socket.url), + }, + } satisfies WebSocketBroadcastChannelMessage) +}) + +it('adds a client from another runtime to the list of clients', async () => { + const clientId = randomUUID() + const url = new URL('ws://localhost') + const manager = new WebSocketClientManager(channel) + + channel.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'connection:open', + payload: { + clientId, + url, + }, + }, + }), + ) + + await vi.waitFor(() => { + expect(Array.from(manager.clients.values())).toEqual([ + new WebSocketRemoteClientConnection(clientId, url, channel), + ]) + }) +}) + +it('replays a "send" event coming from another runtime', async () => { + const manager = new WebSocketClientManager(channel) + const connection = new WebSocketClientConnection(socket, transport) + manager.addConnection(connection) + vi.spyOn(connection, 'send') + + // Emulate another runtime signaling this connection to receive data. + channel.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'extraneous:send', + payload: { + clientId: connection.id, + data: 'hello', + }, + }, + }), + ) + + await vi.waitFor(() => { + // Must execute the requested operation on the connection. + expect(connection.send).toHaveBeenCalledWith('hello') + expect(connection.send).toHaveBeenCalledTimes(1) + }) +}) + +it('replays a "close" event coming from another runtime', async () => { + const manager = new WebSocketClientManager(channel) + const connection = new WebSocketClientConnection(socket, transport) + manager.addConnection(connection) + vi.spyOn(connection, 'close') + + // Emulate another runtime signaling this connection to close. + channel.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'extraneous:close', + payload: { + clientId: connection.id, + code: 1000, + reason: 'Normal closure', + }, + }, + }), + ) + + await vi.waitFor(() => { + // Must execute the requested operation on the connection. + expect(connection.close).toHaveBeenCalledWith(1000, 'Normal closure') + expect(connection.close).toHaveBeenCalledTimes(1) + }) +}) + +it('removes the extraneous message listener when the connection closes', async () => { + const manager = new WebSocketClientManager(channel) + const connection = new WebSocketClientConnection(socket, transport) + vi.spyOn(connection, 'close').mockImplementationOnce(() => { + /** + * @note This is a nasty hack so we don't have to uncouple + * the connection from transport. Creating a mock transport + * is difficult because it relies on the `WebSocketOverride` class. + * All we care here is that closing the connection triggers + * the transport closure, which it always does. + */ + connection['transport'].onClose() + }) + vi.spyOn(connection, 'send') + + manager.addConnection(connection) + connection.close() + + // Signals from other runtimes have no effect on the closed connection. + channel.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'extraneous:send', + payload: { + clientId: connection.id, + data: 'hello', + }, + }, + }), + ) + + expect(connection.send).not.toHaveBeenCalled() +}) diff --git a/src/core/ws/WebSocketClientManager.ts b/src/core/ws/WebSocketClientManager.ts index 5f354c7b0..06939f213 100644 --- a/src/core/ws/WebSocketClientManager.ts +++ b/src/core/ws/WebSocketClientManager.ts @@ -4,23 +4,23 @@ import type { WebSocketClientConnectionProtocol, } from '@mswjs/interceptors/WebSocket' -type WebSocketBroadcastChannelMessage = +export type WebSocketBroadcastChannelMessage = | { type: 'connection:open' payload: { - id: string + clientId: string url: URL } } | { - type: 'send' + type: 'extraneous:send' payload: { clientId: string data: WebSocketData } } | { - type: 'close' + type: 'extraneous:close' payload: { clientId: string code?: number @@ -50,7 +50,7 @@ export class WebSocketClientManager { case 'connection:open': { // When another runtime notifies about a new connection, // create a connection wrapper class and add it to the set. - this.onRemoteConnection(payload.id, payload.url) + this.onRemoteConnection(payload.clientId, payload.url) break } } @@ -58,28 +58,29 @@ export class WebSocketClientManager { } /** - * Adds the given WebSocket client connection to the set + * Adds the given `WebSocket` client connection to the set * of all connections. The given connection is always the complete * connection object because `addConnection()` is called only * for the opened connections in the same runtime. */ public addConnection(client: WebSocketClientConnection): void { - // Add this connection to the immediate set of connections. this.clients.add(client) // Signal to other runtimes about this connection. this.channel.postMessage({ type: 'connection:open', payload: { - id: client.id, + clientId: client.id, url: client.url, }, } as WebSocketBroadcastChannelMessage) // Instruct the current client how to handle events - // coming from other runtimes (e.g. when broadcasting). - this.channel.addEventListener('message', (message) => { - const { type, payload } = message.data as WebSocketBroadcastChannelMessage + // coming from other runtimes (e.g. when calling `.broadcast()`). + const handleExtraneousMessage = ( + message: MessageEvent, + ) => { + const { type, payload } = message.data // Ignore broadcasted messages for other clients. if ( @@ -91,16 +92,28 @@ export class WebSocketClientManager { } switch (type) { - case 'send': { + case 'extraneous:send': { client.send(payload.data) break } - case 'close': { + case 'extraneous:close': { client.close(payload.code, payload.reason) break } } + } + + const abortController = new AbortController() + + this.channel.addEventListener('message', handleExtraneousMessage, { + signal: abortController.signal, + }) + + // Once closed, this connection cannot be operated on. + // This must include the extraneous runtimes as well. + client.addEventListener('close', () => abortController.abort(), { + once: true, }) } @@ -125,7 +138,7 @@ export class WebSocketClientManager { * on the given `BroadcastChannel` to communicate instructions * with the client connections from other runtimes. */ -class WebSocketRemoteClientConnection +export class WebSocketRemoteClientConnection implements WebSocketClientConnectionProtocol { constructor( @@ -136,7 +149,7 @@ class WebSocketRemoteClientConnection send(data: WebSocketData): void { this.channel.postMessage({ - type: 'send', + type: 'extraneous:send', payload: { clientId: this.id, data, @@ -146,7 +159,7 @@ class WebSocketRemoteClientConnection close(code?: number | undefined, reason?: string | undefined): void { this.channel.postMessage({ - type: 'close', + type: 'extraneous:close', payload: { clientId: this.id, code, diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index 033b25c99..2b66dda88 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -9,7 +9,7 @@ import { } from '../handlers/WebSocketHandler' import type { Path } from '../utils/matching/matchRequestUrl' import { webSocketInterceptor } from './webSocketInterceptor' -import { WebSocketClientManager } from './SerializedWebSocketClientConnection' +import { WebSocketClientManager } from './WebSocketClientManager' const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') diff --git a/vitest.config.ts b/vitest.config.ts index f007e4c53..bf071eab6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,8 @@ export default defineConfig({ // they are located next to the source code they are testing. dir: './src', alias: { + 'vitest-environment-node-websocket': + './test/support/environments/vitest-environment-node-websocket', '~/core': path.resolve(__dirname, 'src/core'), }, typecheck: { From 8f1071b266aa5a2f09b388f21527fdfa7d98e927 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 27 Feb 2024 18:46:09 +0100 Subject: [PATCH 025/105] test(ws): add unit tests --- src/core/utils/matching/matchRequestUrl.ts | 4 ++++ src/core/ws/ws.test.ts | 23 ++++++++++++++++++++++ src/core/ws/ws.ts | 11 ++++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/core/ws/ws.test.ts diff --git a/src/core/utils/matching/matchRequestUrl.ts b/src/core/utils/matching/matchRequestUrl.ts index 3b9ce6ebf..5ea0115d4 100644 --- a/src/core/utils/matching/matchRequestUrl.ts +++ b/src/core/utils/matching/matchRequestUrl.ts @@ -71,3 +71,7 @@ export function matchRequestUrl(url: URL, path: Path, baseUrl?: string): Match { params, } } + +export function isPath(value: unknown): value is Path { + return typeof value === 'string' || value instanceof RegExp +} diff --git a/src/core/ws/ws.test.ts b/src/core/ws/ws.test.ts new file mode 100644 index 000000000..b5a7ef46d --- /dev/null +++ b/src/core/ws/ws.test.ts @@ -0,0 +1,23 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from './ws' + +it('exports the "link()" method', () => { + expect(ws).toHaveProperty('link') + expect(ws.link).toBeInstanceOf(Function) +}) + +it('throws an error when calling "ws.link()" without a URL argument', () => { + expect(() => + // @ts-expect-error Intentionally invalid call. + ws.link(), + ).toThrow('Expected a WebSocket server URL but got undefined') +}) + +it('throws an error when given a non-path argument to "ws.link()"', () => { + expect(() => + // @ts-expect-error Intentionally invalid argument. + ws.link(2), + ).toThrow('Expected a WebSocket server URL but got number') +}) diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index 2b66dda88..01cec7973 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -1,3 +1,4 @@ +import { invariant } from 'outvariant' import type { WebSocketClientConnectionProtocol, WebSocketData, @@ -7,7 +8,7 @@ import { kEmitter, type WebSocketHandlerEventMap, } from '../handlers/WebSocketHandler' -import type { Path } from '../utils/matching/matchRequestUrl' +import { Path, isPath } from '../utils/matching/matchRequestUrl' import { webSocketInterceptor } from './webSocketInterceptor' import { WebSocketClientManager } from './WebSocketClientManager' @@ -23,6 +24,14 @@ const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') * }) */ function createWebSocketLinkHandler(url: Path) { + invariant(url, 'Expected a WebSocket server URL but got undefined') + + invariant( + isPath(url), + 'Expected a WebSocket server URL but got %s', + typeof url, + ) + webSocketInterceptor.apply() const clientManager = new WebSocketClientManager(wsBroadcastChannel) From b070b341a8e74da912fede2d5ceaff7bcbda603c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 27 Feb 2024 20:27:58 +0100 Subject: [PATCH 026/105] chore: upgrade @mswjs/interceptors to 0.26.6 --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 76407e527..c38f304b4 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.4", + "@mswjs/interceptors": "^0.26.6", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fef4672a..1bda1b38f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: '@fastify/websocket': ^8.3.1 '@inquirer/confirm': ^3.0.0 '@mswjs/cookies': ^1.1.0 - '@mswjs/interceptors': ^0.26.4 + '@mswjs/interceptors': ^0.26.6 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.0 @@ -73,7 +73,7 @@ dependencies: '@bundled-es-modules/statuses': 1.0.1 '@inquirer/confirm': 3.0.0 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.26.4 + '@mswjs/interceptors': 0.26.6 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -1139,8 +1139,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.26.4: - resolution: {integrity: sha512-EMXxLxO4u574IGt7rGC5GBjDCcZL2h1FqVu/V9IRsMKVSPE9QZl8bb8LGJFZwVp2GBcJNdiw7Vdn0t5wUSBvhg==} + /@mswjs/interceptors/0.26.6: + resolution: {integrity: sha512-ce2Jn2ODQfVm3VaTy38Uwadh61FhY7mWMBHJB7IltLnWXAa/eXepbqFnXgrw8PCemPXsyf+WOQ0D0CB1GzazGg==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 From 8f070153ceaa177708ed16e6fe8ec6ee3e19dd73 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 28 Feb 2024 19:31:28 +0100 Subject: [PATCH 027/105] chore: fix "singleThread" vitest option --- test/node/vitest.config.ts | 35 +++++++++++++++-------------------- test/support/alias.ts | 20 ++++++++++++++++++++ vitest.config.ts | 13 +++++++++---- 3 files changed, 44 insertions(+), 24 deletions(-) create mode 100644 test/support/alias.ts diff --git a/test/node/vitest.config.ts b/test/node/vitest.config.ts index 53f3e1525..0557df44e 100644 --- a/test/node/vitest.config.ts +++ b/test/node/vitest.config.ts @@ -1,35 +1,30 @@ -import * as path from 'node:path' import { defineConfig } from 'vitest/config' - -const LIB_DIR = path.resolve(__dirname, '../../lib') +import { mswExports, customViteEnvironments } from '../support/alias' export default defineConfig({ test: { - /** - * @note Paths are resolved against CWD. - */ dir: './test/node', globals: true, alias: { - 'vitest-environment-node-websocket': - './test/support/environments/vitest-environment-node-websocket', - 'msw/node': path.resolve(LIB_DIR, 'node/index.mjs'), - 'msw/native': path.resolve(LIB_DIR, 'native/index.mjs'), - 'msw/browser': path.resolve(LIB_DIR, 'browser/index.mjs'), - msw: path.resolve(LIB_DIR, 'core/index.mjs'), + ...mswExports, + ...customViteEnvironments, }, environmentOptions: { jsdom: { url: 'http://localhost/', }, }, - /** - * @note Run Node.js integration tests in sequence. - * There's a test that involves building the library, - * which results in the "lib" directory being deleted. - * If any tests attempt to run during that window, - * they will fail, unable to resolve the "msw" import alias. - */ - singleThread: true, + poolOptions: { + threads: { + /** + * @note Run Node.js integration tests in sequence. + * There's a test that involves building the library, + * which results in the "lib" directory being deleted. + * If any tests attempt to run during that window, + * they will fail, unable to resolve the "msw" import alias. + */ + singleThread: true, + }, + }, }, }) diff --git a/test/support/alias.ts b/test/support/alias.ts new file mode 100644 index 000000000..7b8b49433 --- /dev/null +++ b/test/support/alias.ts @@ -0,0 +1,20 @@ +import * as path from 'node:path' + +const ROOT = path.resolve(__dirname, '../..') + +export function fromRoot(...paths: Array): string { + return path.resolve(ROOT, ...paths) +} + +export const mswExports = { + 'msw/node': fromRoot('/lib/node/index.mjs'), + 'msw/native': fromRoot('/lib/native/index.mjs'), + 'msw/browser': fromRoot('/lib/browser/index.mjs'), + msw: fromRoot('lib/core/index.mjs'), +} + +export const customViteEnvironments = { + 'vitest-environment-node-websocket': fromRoot( + '/test/support/environments/vitest-environment-node-websocket', + ), +} diff --git a/vitest.config.ts b/vitest.config.ts index bf071eab6..57ebb5dde 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,9 @@ -import * as path from 'node:path' import { defineConfig } from 'vitest/config' +import { + mswExports, + customViteEnvironments, + fromRoot, +} from './test/support/alias' export default defineConfig({ test: { @@ -8,9 +12,9 @@ export default defineConfig({ // they are located next to the source code they are testing. dir: './src', alias: { - 'vitest-environment-node-websocket': - './test/support/environments/vitest-environment-node-websocket', - '~/core': path.resolve(__dirname, 'src/core'), + ...mswExports, + ...customViteEnvironments, + '~/core': fromRoot('src/core'), }, typecheck: { // Load the TypeScript configuration to the unit tests. @@ -26,4 +30,5 @@ export default defineConfig({ }, }, }, + plugins: [tsconfigPaths()], }) From 7fed9732cacc57cb174c737055e3e32b03b2199d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 28 Feb 2024 20:37:18 +0100 Subject: [PATCH 028/105] chore: tidying up --- src/core/utils/handleWebSocketEvent.ts | 6 ++++++ test/node/ws-api/ws.intercept.client.test.ts | 10 ++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/core/utils/handleWebSocketEvent.ts b/src/core/utils/handleWebSocketEvent.ts index eebb0adc0..284775807 100644 --- a/src/core/utils/handleWebSocketEvent.ts +++ b/src/core/utils/handleWebSocketEvent.ts @@ -13,6 +13,12 @@ export function handleWebSocketEvent( const handlers = getCurrentHandlers() const connectionEvent = new MessageEvent('connection', { data: connection, + /** + * @note This message event should be marked as "cancelable" + * to have its default prevented using "event.preventDefault()". + * There's a bug in Node.js that breaks the "cancelable" flag. + * @see https://github.com/nodejs/node/issues/51767 + */ }) Object.defineProperty(connectionEvent, kDefaultPrevented, { diff --git a/test/node/ws-api/ws.intercept.client.test.ts b/test/node/ws-api/ws.intercept.client.test.ts index 9fcc2ed12..d16ae9b35 100644 --- a/test/node/ws-api/ws.intercept.client.test.ts +++ b/test/node/ws-api/ws.intercept.client.test.ts @@ -4,7 +4,6 @@ import { ws } from 'msw' import { setupServer } from 'msw/node' import { WebSocketServer } from '../../support/WebSocketServer' -import { waitFor } from '../../support/waitFor' const server = setupServer() const wsServer = new WebSocketServer() @@ -18,8 +17,7 @@ beforeAll(async () => { afterEach(() => { server.resetHandlers() - wsServer.closeAllClients() - wsServer.removeAllListeners() + wsServer.resetState() }) afterAll(async () => { @@ -41,7 +39,7 @@ it('intercepts outgoing client text message', async () => { const ws = new WebSocket(wsServer.url) ws.onopen = () => ws.send('hello') - await waitFor(() => { + await vi.waitFor(() => { // Must intercept the outgoing client message event. expect(mockMessageListener).toHaveBeenCalledTimes(1) @@ -69,7 +67,7 @@ it('intercepts outgoing client Blob message', async () => { const ws = new WebSocket(wsServer.url) ws.onopen = () => ws.send(new Blob(['hello'])) - await waitFor(() => { + await vi.waitFor(() => { expect(mockMessageListener).toHaveBeenCalledTimes(1) const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent @@ -96,7 +94,7 @@ it('intercepts outgoing client ArrayBuffer message', async () => { const ws = new WebSocket(wsServer.url) ws.onopen = () => ws.send(new TextEncoder().encode('hello')) - await waitFor(() => { + await vi.waitFor(() => { expect(mockMessageListener).toHaveBeenCalledTimes(1) const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent From 9c6eeba57967d2be2d9c50d087bd1d76fea002fa Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 29 Feb 2024 16:21:56 +0100 Subject: [PATCH 029/105] fix: apply interceptor in listen/start --- src/browser/setupWorker/setupWorker.ts | 6 ++++++ src/core/utils/handleWebSocketEvent.ts | 1 + src/core/ws/ws.ts | 2 -- src/node/SetupServerCommonApi.ts | 3 +++ test/support/WebSocketServer.ts | 4 ++-- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/browser/setupWorker/setupWorker.ts b/src/browser/setupWorker/setupWorker.ts index 8061904b6..24c008b30 100644 --- a/src/browser/setupWorker/setupWorker.ts +++ b/src/browser/setupWorker/setupWorker.ts @@ -22,6 +22,7 @@ import type { LifeCycleEventsMap } from '~/core/sharedOptions' import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { SetupWorker } from './glossary' import { supportsReadableStreamTransfer } from '../utils/supportsReadableStreamTransfer' +import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' import { handleWebSocketEvent } from '~/core/utils/handleWebSocketEvent' interface Listener { @@ -181,6 +182,11 @@ export class SetupWorkerApi handleWebSocketEvent(() => { return this.handlersController.currentHandlers() }) + webSocketInterceptor.apply() + + this.subscriptions.push(() => { + webSocketInterceptor.dispose() + }) return await this.startHandler(this.context.startOptions, options) } diff --git a/src/core/utils/handleWebSocketEvent.ts b/src/core/utils/handleWebSocketEvent.ts index 284775807..5563d49a6 100644 --- a/src/core/utils/handleWebSocketEvent.ts +++ b/src/core/utils/handleWebSocketEvent.ts @@ -11,6 +11,7 @@ export function handleWebSocketEvent( ) { webSocketInterceptor.on('connection', (connection) => { const handlers = getCurrentHandlers() + const connectionEvent = new MessageEvent('connection', { data: connection, /** diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index 01cec7973..c20b6bafb 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -9,7 +9,6 @@ import { type WebSocketHandlerEventMap, } from '../handlers/WebSocketHandler' import { Path, isPath } from '../utils/matching/matchRequestUrl' -import { webSocketInterceptor } from './webSocketInterceptor' import { WebSocketClientManager } from './WebSocketClientManager' const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') @@ -32,7 +31,6 @@ function createWebSocketLinkHandler(url: Path) { typeof url, ) - webSocketInterceptor.apply() const clientManager = new WebSocketClientManager(wsBroadcastChannel) return { diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts index 7d6d8b00e..d293efdfd 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -19,6 +19,7 @@ import { mergeRight } from '~/core/utils/internal/mergeRight' import { devUtils } from '~/core/utils/internal/devUtils' import type { SetupServerCommon } from './glossary' import { handleWebSocketEvent } from '~/core/utils/handleWebSocketEvent' +import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' export const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { onUnhandledRequest: 'warn', @@ -97,9 +98,11 @@ export class SetupServerCommonApi // Apply the interceptor when starting the server. this.interceptor.apply() + webSocketInterceptor.apply() this.subscriptions.push(() => { this.interceptor.dispose() + webSocketInterceptor.dispose() }) // Assert that the interceptor has been applied successfully. diff --git a/test/support/WebSocketServer.ts b/test/support/WebSocketServer.ts index c9e5861de..1aa76eb43 100644 --- a/test/support/WebSocketServer.ts +++ b/test/support/WebSocketServer.ts @@ -38,8 +38,8 @@ export class WebSocketServer extends Emitter { return this._url } - public async listen(): Promise { - const address = await this.app.listen({ port: 0 }) + public async listen(port = 0): Promise { + const address = await this.app.listen({ port }) const url = new URL(address) url.protocol = url.protocol.replace(/^http/, 'ws') this._url = url.href From 082610a373e0c691ce7af7e7fea06b212cdd3577 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 29 Feb 2024 16:29:36 +0100 Subject: [PATCH 030/105] fix: updates interceptors to ditch revocable proxy --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c38f304b4..3bb136c99 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.6", + "@mswjs/interceptors": "^0.26.7", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bda1b38f..cd54fcf67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: '@fastify/websocket': ^8.3.1 '@inquirer/confirm': ^3.0.0 '@mswjs/cookies': ^1.1.0 - '@mswjs/interceptors': ^0.26.6 + '@mswjs/interceptors': ^0.26.7 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.0 @@ -73,7 +73,7 @@ dependencies: '@bundled-es-modules/statuses': 1.0.1 '@inquirer/confirm': 3.0.0 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.26.6 + '@mswjs/interceptors': 0.26.7 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -1139,8 +1139,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.26.6: - resolution: {integrity: sha512-ce2Jn2ODQfVm3VaTy38Uwadh61FhY7mWMBHJB7IltLnWXAa/eXepbqFnXgrw8PCemPXsyf+WOQ0D0CB1GzazGg==} + /@mswjs/interceptors/0.26.7: + resolution: {integrity: sha512-5i7QZqJSmLLerm4HWDenpQmo0OBpegAP/q9mX2qobP1rz7ozfyFR5nXxzlCFrWBJk3JmHXlxQk32Vl2j4ONcTA==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 From c5f6e52925cdccce8411f0ab5c934092a1f2ef6f Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 29 Feb 2024 17:25:19 +0100 Subject: [PATCH 031/105] chore: remove "tsconfigPaths" from vitest.config.ts --- vitest.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/vitest.config.ts b/vitest.config.ts index 57ebb5dde..43406fdc1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,5 +30,4 @@ export default defineConfig({ }, }, }, - plugins: [tsconfigPaths()], }) From 9750c3b6d1ef095c97dff5c90a211fc49d2bb514 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 29 Feb 2024 17:56:27 +0100 Subject: [PATCH 032/105] chore: forcefully exit in node-esm tests --- .gitignore | 3 ++- package.json | 2 +- test/modules/node/esm-node.test.ts | 6 ++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 89624bdd8..e5a2045fd 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ msw-*.tgz # Smoke test temporary files. /package.json.copy -/examples \ No newline at end of file +/examples +/test/modules/node/node-esm-tests diff --git a/package.json b/package.json index 987966201..cd71916b7 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "test:node": "vitest run --config=./test/node/vitest.config.ts", "test:native": "vitest --config=./test/native/vitest.config.ts", "test:browser": "playwright test -c ./test/browser/playwright.config.ts", - "test:modules:node": "vitest --config=./test/modules/node/vitest.config.ts", + "test:modules:node": "vitest run --config=./test/modules/node/vitest.config.ts", "test:modules:browser": "playwright test -c ./test/modules/browser/playwright.config.ts", "test:ts": "ts-node test/typings/run.ts", "prepare": "pnpm simple-git-hooks init", diff --git a/test/modules/node/esm-node.test.ts b/test/modules/node/esm-node.test.ts index 303a19e7f..e8619916e 100644 --- a/test/modules/node/esm-node.test.ts +++ b/test/modules/node/esm-node.test.ts @@ -32,6 +32,7 @@ const server = setupServer( http.get('/resource', () => new Response()) ) console.log(typeof server.listen) +process.exit(0) `, }) @@ -77,12 +78,13 @@ console.log('msw/node:', require.resolve('msw/node')) console.log('msw/native:', require.resolve('msw/native')) `, 'runtime.cjs': ` -import { http } from 'msw' -import { setupServer } from 'msw/node' +const { http } = require('msw') +const { setupServer } = require('msw/node') const server = setupServer( http.get('/resource', () => new Response()) ) console.log(typeof server.listen) +process.exit(0) `, }) From 1bb42ae5a8ca354e207b2fe0ddffce1b25974ff4 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 12 Mar 2024 12:37:06 +0100 Subject: [PATCH 033/105] fix(WebSocketClientManager): post URL string, cannot clone URL instances --- src/core/ws/WebSocketClientManager.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/ws/WebSocketClientManager.ts b/src/core/ws/WebSocketClientManager.ts index 06939f213..4048878b6 100644 --- a/src/core/ws/WebSocketClientManager.ts +++ b/src/core/ws/WebSocketClientManager.ts @@ -9,7 +9,7 @@ export type WebSocketBroadcastChannelMessage = type: 'connection:open' payload: { clientId: string - url: URL + url: string } } | { @@ -50,7 +50,7 @@ export class WebSocketClientManager { case 'connection:open': { // When another runtime notifies about a new connection, // create a connection wrapper class and add it to the set. - this.onRemoteConnection(payload.clientId, payload.url) + this.onRemoteConnection(payload.clientId, new URL(payload.url)) break } } @@ -71,7 +71,7 @@ export class WebSocketClientManager { type: 'connection:open', payload: { clientId: client.id, - url: client.url, + url: client.url.toString(), }, } as WebSocketBroadcastChannelMessage) From 69f13903cfe1a1b81bbc63aacd613c7deaad0b5d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 12 Mar 2024 12:39:59 +0100 Subject: [PATCH 034/105] test: add client interception browser tests --- .../ws.intercept.cilent.browser.test.ts | 102 ++++++++++++++++++ test/browser/ws-api/ws.runtime.js | 10 ++ 2 files changed, 112 insertions(+) create mode 100644 test/browser/ws-api/ws.intercept.cilent.browser.test.ts create mode 100644 test/browser/ws-api/ws.runtime.js diff --git a/test/browser/ws-api/ws.intercept.cilent.browser.test.ts b/test/browser/ws-api/ws.intercept.cilent.browser.test.ts new file mode 100644 index 000000000..891402465 --- /dev/null +++ b/test/browser/ws-api/ws.intercept.cilent.browser.test.ts @@ -0,0 +1,102 @@ +import { test, expect } from '../playwright.extend' +import { ws } from '../../../src/core/ws/ws' +import { SetupWorker } from '../../../src/browser' + +declare global { + interface Window { + msw: { + ws: typeof ws + worker: SetupWorker + } + } +} + +test('intercepts outgoing client text message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + const clientMessagePromise = page.evaluate(() => { + const { worker, ws } = window.msw + + const service = ws.link('wss://example.com') + + return new Promise((resolve) => { + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + resolve(event.data) + }) + }), + ) + }) + }) + + await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + socket.onopen = () => socket.send('hello world') + }) + + expect(await clientMessagePromise).toBe('hello world') +}) + +test('intercepts outgoing client Blob message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + const clientMessagePromise = page.evaluate(() => { + const { worker, ws } = window.msw + + const service = ws.link('wss://example.com') + + return new Promise((resolve) => { + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + resolve(event.data.text()) + }) + }), + ) + }) + }) + + await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + socket.onopen = () => socket.send(new Blob(['hello world'])) + }) + + expect(await clientMessagePromise).toBe('hello world') +}) + +test('intercepts outgoing client ArrayBuffer message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + const clientMessagePromise = page.evaluate(() => { + const { worker, ws } = window.msw + + const service = ws.link('wss://example.com') + + return new Promise((resolve) => { + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + resolve(new TextDecoder().decode(event.data)) + }) + }), + ) + }) + }) + + await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + socket.onopen = () => socket.send(new TextEncoder().encode('hello world')) + }) + + expect(await clientMessagePromise).toBe('hello world') +}) diff --git a/test/browser/ws-api/ws.runtime.js b/test/browser/ws-api/ws.runtime.js new file mode 100644 index 000000000..377022dde --- /dev/null +++ b/test/browser/ws-api/ws.runtime.js @@ -0,0 +1,10 @@ +import { ws } from 'msw' +import { setupWorker } from 'msw/browser' + +const worker = setupWorker() +worker.start() + +window.msw = { + ws, + worker, +} From f1f9f73765b510961c83e1c8df4e62f38599166a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 12 Mar 2024 14:07:49 +0100 Subject: [PATCH 035/105] chore: update @mswjs/interceptors to 0.26.8 --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 0481c1ee5..1b56d106b 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.7", + "@mswjs/interceptors": "^0.26.8", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96b56730f..373ea46e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: '@fastify/websocket': ^8.3.1 '@inquirer/confirm': ^3.0.0 '@mswjs/cookies': ^1.1.0 - '@mswjs/interceptors': ^0.26.7 + '@mswjs/interceptors': ^0.26.8 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.0 @@ -73,7 +73,7 @@ dependencies: '@bundled-es-modules/statuses': 1.0.1 '@inquirer/confirm': 3.0.0 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.26.7 + '@mswjs/interceptors': 0.26.8 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -1116,8 +1116,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.26.7: - resolution: {integrity: sha512-5i7QZqJSmLLerm4HWDenpQmo0OBpegAP/q9mX2qobP1rz7ozfyFR5nXxzlCFrWBJk3JmHXlxQk32Vl2j4ONcTA==} + /@mswjs/interceptors/0.26.8: + resolution: {integrity: sha512-3vxmn2JDZqK4bGdH/0Dip9sZGE/ALCPtfJmDqrli7aL3zBv2pciti2etdqC0xGYcbGHpeRd+CkMVt7F/FYjikQ==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 From 3b5fa3d99282b2f23619f355ea1c9ef058372bfe Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 12 Mar 2024 14:08:11 +0100 Subject: [PATCH 036/105] chore(WebSocketServer): use ipv4 addresses for "path-to-regexp" matching --- test/support/WebSocketServer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/support/WebSocketServer.ts b/test/support/WebSocketServer.ts index 1aa76eb43..8995fb8d7 100644 --- a/test/support/WebSocketServer.ts +++ b/test/support/WebSocketServer.ts @@ -39,7 +39,10 @@ export class WebSocketServer extends Emitter { } public async listen(port = 0): Promise { - const address = await this.app.listen({ port }) + const address = await this.app.listen({ + host: '127.0.0.1', + port, + }) const url = new URL(address) url.protocol = url.protocol.replace(/^http/, 'ws') this._url = url.href From 01ccef7ce029dd248696e10597c367ea5eb7dc4e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 12 Mar 2024 14:08:40 +0100 Subject: [PATCH 037/105] test: call socket instance "socket" in tests --- test/node/ws-api/ws.intercept.client.test.ts | 19 ++++++++++--------- test/node/ws-api/ws.intercept.server.test.ts | 13 +++++++------ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/test/node/ws-api/ws.intercept.client.test.ts b/test/node/ws-api/ws.intercept.client.test.ts index d16ae9b35..9f9380482 100644 --- a/test/node/ws-api/ws.intercept.client.test.ts +++ b/test/node/ws-api/ws.intercept.client.test.ts @@ -36,8 +36,8 @@ it('intercepts outgoing client text message', async () => { ) wsServer.on('connection', realConnectionListener) - const ws = new WebSocket(wsServer.url) - ws.onopen = () => ws.send('hello') + const socket = new WebSocket(wsServer.url) + socket.onopen = () => socket.send('hello') await vi.waitFor(() => { // Must intercept the outgoing client message event. @@ -46,7 +46,7 @@ it('intercepts outgoing client text message', async () => { const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent expect(messageEvent.type).toBe('message') expect(messageEvent.data).toBe('hello') - expect(messageEvent.target).toBe(ws) + expect(messageEvent.target).toBe(socket) // Must not connect to the actual server by default. expect(realConnectionListener).not.toHaveBeenCalled() @@ -64,8 +64,8 @@ it('intercepts outgoing client Blob message', async () => { ) wsServer.on('connection', realConnectionListener) - const ws = new WebSocket(wsServer.url) - ws.onopen = () => ws.send(new Blob(['hello'])) + const socket = new WebSocket(wsServer.url) + socket.onopen = () => socket.send(new Blob(['hello'])) await vi.waitFor(() => { expect(mockMessageListener).toHaveBeenCalledTimes(1) @@ -73,7 +73,7 @@ it('intercepts outgoing client Blob message', async () => { const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent expect(messageEvent.type).toBe('message') expect(messageEvent.data.size).toBe(5) - expect(messageEvent.target).toEqual(ws) + expect(messageEvent.target).toEqual(socket) // Must not connect to the actual server by default. expect(realConnectionListener).not.toHaveBeenCalled() @@ -91,8 +91,9 @@ it('intercepts outgoing client ArrayBuffer message', async () => { ) wsServer.on('connection', realConnectionListener) - const ws = new WebSocket(wsServer.url) - ws.onopen = () => ws.send(new TextEncoder().encode('hello')) + const socket = new WebSocket(wsServer.url) + socket.binaryType = 'arraybuffer' + socket.onopen = () => socket.send(new TextEncoder().encode('hello')) await vi.waitFor(() => { expect(mockMessageListener).toHaveBeenCalledTimes(1) @@ -100,7 +101,7 @@ it('intercepts outgoing client ArrayBuffer message', async () => { const messageEvent = mockMessageListener.mock.calls[0][0] as MessageEvent expect(messageEvent.type).toBe('message') expect(messageEvent.data).toEqual(new TextEncoder().encode('hello')) - expect(messageEvent.target).toEqual(ws) + expect(messageEvent.target).toEqual(socket) // Must not connect to the actual server by default. expect(realConnectionListener).not.toHaveBeenCalled() diff --git a/test/node/ws-api/ws.intercept.server.test.ts b/test/node/ws-api/ws.intercept.server.test.ts index 7cc3e7651..cfb4542b5 100644 --- a/test/node/ws-api/ws.intercept.server.test.ts +++ b/test/node/ws-api/ws.intercept.server.test.ts @@ -39,8 +39,8 @@ it('intercepts incoming server text message', async () => { }), ) - const ws = new WebSocket(originalServer.url) - ws.addEventListener('message', clientMessageListener) + const socket = new WebSocket(originalServer.url) + socket.addEventListener('message', clientMessageListener) await vi.waitFor(() => { expect(serverMessageListener).toHaveBeenCalledTimes(1) @@ -76,8 +76,8 @@ it('intercepts incoming server Blob message', async () => { }), ) - const ws = new WebSocket(originalServer.url) - ws.addEventListener('message', clientMessageListener) + const socket = new WebSocket(originalServer.url) + socket.addEventListener('message', clientMessageListener) await vi.waitFor(() => { expect(serverMessageListener).toHaveBeenCalledTimes(1) @@ -109,8 +109,9 @@ it('intercepts incoming ArrayBuffer message', async () => { }), ) - const ws = new WebSocket(originalServer.url) - ws.addEventListener('message', clientMessageListener) + const socket = new WebSocket(originalServer.url) + socket.binaryType = 'arraybuffer' + socket.addEventListener('message', clientMessageListener) await vi.waitFor(() => { expect(serverMessageListener).toHaveBeenCalledTimes(1) From 7daef9e1e0d10081777e0d1c294a31a8786edcab Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 12 Mar 2024 14:08:47 +0100 Subject: [PATCH 038/105] test: add server websocket tests --- .../ws.intercept.server.browser.test.ts | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 test/browser/ws-api/ws.intercept.server.browser.test.ts diff --git a/test/browser/ws-api/ws.intercept.server.browser.test.ts b/test/browser/ws-api/ws.intercept.server.browser.test.ts new file mode 100644 index 000000000..b547226b1 --- /dev/null +++ b/test/browser/ws-api/ws.intercept.server.browser.test.ts @@ -0,0 +1,152 @@ +import { test, expect } from '../playwright.extend' +import type { ws } from '../../../src/core/ws/ws' +import type { SetupWorker } from '../../../src/browser' +import { WebSocketServer } from '../../support/WebSocketServer' + +const server = new WebSocketServer() + +declare global { + interface Window { + msw: { + ws: typeof ws + worker: SetupWorker + } + } +} + +test.beforeAll(async () => { + await server.listen() +}) + +test.afterAll(async () => { + await server.close() +}) + +test('intercepts incoming server text message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + server.on('connection', (client) => { + client.send('hello') + }) + + const serverMessagePromise = page.evaluate((serverUrl) => { + const { worker, ws } = window.msw + const service = ws.link(serverUrl) + + return new Promise((resolve) => { + worker.use( + service.on('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + resolve(event.data) + }) + }), + ) + }) + }, server.url) + + const clientMessage = await page.evaluate((serverUrl) => { + const socket = new WebSocket(serverUrl) + + return new Promise((resolve, reject) => { + socket.onerror = () => reject(new Error('Socket error')) + socket.addEventListener('message', (event) => { + resolve(event.data) + }) + }) + }, server.url) + + expect(clientMessage).toBe('hello') + expect(await serverMessagePromise).toBe('hello') +}) + +test('intercepts incoming server Blob message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + server.on('connection', async (client) => { + /** + * @note `ws` doesn't accept sending Blobs. + */ + client.send(await new Blob(['hello']).arrayBuffer()) + }) + + const serverMessagePromise = page.evaluate((serverUrl) => { + const { worker, ws } = window.msw + const service = ws.link(serverUrl) + + return new Promise((resolve) => { + worker.use( + service.on('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + resolve(event.data.text()) + }) + }), + ) + }) + }, server.url) + + const clientMessage = await page.evaluate((serverUrl) => { + const socket = new WebSocket(serverUrl) + + return new Promise((resolve, reject) => { + socket.onerror = () => reject(new Error('Socket error')) + socket.addEventListener('message', (event) => { + resolve(event.data.text()) + }) + }) + }, server.url) + + expect(clientMessage).toBe('hello') + expect(await serverMessagePromise).toBe('hello') +}) + +test('intercepts outgoing server ArrayBuffer message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + const encoder = new TextEncoder() + server.on('connection', async (client) => { + client.binaryType = 'arraybuffer' + client.send(encoder.encode('hello')) + }) + + const serverMessagePromise = page.evaluate((serverUrl) => { + const { worker, ws } = window.msw + const service = ws.link(serverUrl) + + return new Promise((resolve) => { + worker.use( + service.on('connection', ({ server }) => { + server.connect() + server.addEventListener('message', (event) => { + resolve(new TextDecoder().decode(event.data)) + }) + }), + ) + }) + }, server.url) + + const clientMessage = await page.evaluate((serverUrl) => { + const socket = new WebSocket(serverUrl) + socket.binaryType = 'arraybuffer' + + return new Promise((resolve, reject) => { + socket.onerror = () => reject(new Error('Socket error')) + socket.addEventListener('message', (event) => { + resolve(new TextDecoder().decode(event.data)) + }) + }) + }, server.url) + + expect(clientMessage).toBe('hello') + expect(await serverMessagePromise).toBe('hello') +}) From 7ca1306dfb70aea4f480ad1fdcad96857afc9d54 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 12 Mar 2024 14:14:59 +0100 Subject: [PATCH 039/105] test(WebSocketClientManager): adjust tests for "url" type change --- src/core/ws/WebSocketClientManager.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/ws/WebSocketClientManager.test.ts b/src/core/ws/WebSocketClientManager.test.ts index 56aa26f0f..9cac29c04 100644 --- a/src/core/ws/WebSocketClientManager.test.ts +++ b/src/core/ws/WebSocketClientManager.test.ts @@ -42,7 +42,7 @@ it('adds a client from this runtime to the list of clients', () => { type: 'connection:open', payload: { clientId: connection.id, - url: new URL(socket.url), + url: socket.url, }, } satisfies WebSocketBroadcastChannelMessage) }) @@ -58,7 +58,7 @@ it('adds a client from another runtime to the list of clients', async () => { type: 'connection:open', payload: { clientId, - url, + url: url.href, }, }, }), From 082d62860eb276c8ce86351b88497a0f80a7a108 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 13 Mar 2024 16:35:46 +0100 Subject: [PATCH 040/105] test: assert on ArrayBuffer instead of Blob --- test/node/ws-api/ws.intercept.server.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/node/ws-api/ws.intercept.server.test.ts b/test/node/ws-api/ws.intercept.server.test.ts index cfb4542b5..261291cc4 100644 --- a/test/node/ws-api/ws.intercept.server.test.ts +++ b/test/node/ws-api/ws.intercept.server.test.ts @@ -100,6 +100,7 @@ it('intercepts incoming ArrayBuffer message', async () => { const clientMessageListener = vi.fn() originalServer.on('connection', async (client) => { + client.binaryType = 'arraybuffer' client.send(encoder.encode('hello world')) }) server.use( @@ -118,15 +119,12 @@ it('intercepts incoming ArrayBuffer message', async () => { const serverMessage = serverMessageListener.mock.calls[0][0] as MessageEvent expect(serverMessage.type).toBe('message') - /** - * @note For some reason, "ws" still sends back a Blob. - */ - expect(serverMessage.data).toEqual(new Blob(['hello world'])) + expect(new TextDecoder().decode(serverMessage.data)).toBe('hello world') expect(clientMessageListener).toHaveBeenCalledTimes(1) const clientMessage = clientMessageListener.mock.calls[0][0] as MessageEvent expect(clientMessage.type).toBe('message') - expect(clientMessage.data).toEqual(new Blob(['hello world'])) + expect(new TextDecoder().decode(clientMessage.data)).toBe('hello world') }) }) From a0e0db167dc095380e630f6ad6b84ba05035c8a1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 13 Mar 2024 16:52:21 +0100 Subject: [PATCH 041/105] test: add server.connect() browser tests --- .../ws.intercept.server.browser.test.ts | 4 +- .../ws-api/ws.server.connect.browser.test.ts | 122 ++++++++++++++++++ test/node/ws-api/ws.server.connect.test.ts | 2 +- 3 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 test/browser/ws-api/ws.server.connect.browser.test.ts diff --git a/test/browser/ws-api/ws.intercept.server.browser.test.ts b/test/browser/ws-api/ws.intercept.server.browser.test.ts index b547226b1..b77e341ba 100644 --- a/test/browser/ws-api/ws.intercept.server.browser.test.ts +++ b/test/browser/ws-api/ws.intercept.server.browser.test.ts @@ -3,8 +3,6 @@ import type { ws } from '../../../src/core/ws/ws' import type { SetupWorker } from '../../../src/browser' import { WebSocketServer } from '../../support/WebSocketServer' -const server = new WebSocketServer() - declare global { interface Window { msw: { @@ -14,6 +12,8 @@ declare global { } } +const server = new WebSocketServer() + test.beforeAll(async () => { await server.listen() }) diff --git a/test/browser/ws-api/ws.server.connect.browser.test.ts b/test/browser/ws-api/ws.server.connect.browser.test.ts new file mode 100644 index 000000000..bb6b35f8a --- /dev/null +++ b/test/browser/ws-api/ws.server.connect.browser.test.ts @@ -0,0 +1,122 @@ +import { test, expect } from '../playwright.extend' +import type { ws } from '../../../src/core/ws/ws' +import type { SetupWorker } from '../../../src/browser' +import { WebSocketServer } from '../../support/WebSocketServer' + +declare global { + interface Window { + msw: { + ws: typeof ws + worker: SetupWorker + } + } +} + +const server = new WebSocketServer() + +test.beforeAll(async () => { + await server.listen() +}) + +test.afterAll(async () => { + await server.close() +}) + +test('does not connect to the actual server by default', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + server.once('connection', (client) => { + client.send('must not receive this') + }) + + await page.evaluate((serverUrl) => { + const { worker, ws } = window.msw + const service = ws.link(serverUrl) + + worker.use( + service.on('connection', ({ client }) => { + queueMicrotask(() => client.send('mock')) + }), + ) + }, server.url) + + const clientMessage = await page.evaluate((serverUrl) => { + const socket = new WebSocket(serverUrl) + + return new Promise((resolve, reject) => { + socket.onmessage = (event) => { + resolve(event.data) + socket.close() + } + socket.onerror = reject + }) + }, server.url) + + expect(clientMessage).toBe('mock') +}) + +test('forwards incoming server events to the client once connected', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + server.once('connection', (client) => { + client.send('hello from server') + }) + + await page.evaluate((serverUrl) => { + const { worker, ws } = window.msw + const service = ws.link(serverUrl) + + worker.use( + service.on('connection', ({ server }) => { + // Calling "connect()" establishes the connection + // to the actual WebSocket server. + server.connect() + }), + ) + }, server.url) + + const clientMessage = await page.evaluate((serverUrl) => { + const socket = new WebSocket(serverUrl) + + return new Promise((resolve, reject) => { + socket.onmessage = (event) => { + resolve(event.data) + socket.close() + } + socket.onerror = reject + }) + }, server.url) + + expect(clientMessage).toBe('hello from server') +}) + +test('throws an error when connecting to a non-existing server', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + const error = await page.evaluate((serverUrl) => { + const { worker, ws } = window.msw + const service = ws.link(serverUrl) + + return new Promise((resolve) => { + worker.use( + service.on('connection', ({ server }) => { + server.connect() + }), + ) + + const socket = new WebSocket(serverUrl) + socket.onerror = () => resolve('Connection failed') + }) + }, 'ws://non-existing-websocket-address.com') + + expect(error).toMatch('Connection failed') +}) diff --git a/test/node/ws-api/ws.server.connect.test.ts b/test/node/ws-api/ws.server.connect.test.ts index 82159ea1d..59a1ddf44 100644 --- a/test/node/ws-api/ws.server.connect.test.ts +++ b/test/node/ws-api/ws.server.connect.test.ts @@ -61,7 +61,7 @@ it('connects to the actual server after calling "server.connect()"', async () => }) }) -it('forward incoming server events to the client by default', async () => { +it('forwards incoming server events to the client once connected', async () => { originalServer.once('connection', (client) => client.send('hello')) server.use( From 38ea8422acb27aa418f1041666f4123ead5fb418 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 13 Mar 2024 16:52:35 +0100 Subject: [PATCH 042/105] test: add no error on non-existing connect browser test --- .../ws.intercept.cilent.browser.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/browser/ws-api/ws.intercept.cilent.browser.test.ts b/test/browser/ws-api/ws.intercept.cilent.browser.test.ts index 891402465..9ab18de82 100644 --- a/test/browser/ws-api/ws.intercept.cilent.browser.test.ts +++ b/test/browser/ws-api/ws.intercept.cilent.browser.test.ts @@ -11,6 +11,35 @@ declare global { } } +test('does not throw on connecting to a non-existing host', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js')) + + await page.evaluate(() => { + const { worker, ws } = window.msw + const service = ws.link('*') + + worker.use( + service.on('connection', ({ client }) => { + queueMicrotask(() => client.close()) + }), + ) + }) + + const clientClosePromise = page.evaluate(() => { + const socket = new WebSocket('ws://non-existing-host.com') + + return new Promise((resolve, reject) => { + socket.onclose = () => resolve() + socket.onerror = reject + }) + }) + + await expect(clientClosePromise).resolves.toBeUndefined() +}) + test('intercepts outgoing client text message', async ({ loadExample, page, From 8ec2183ae5cbb88da87bf43bbd49a6a1a68e3925 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 13 Mar 2024 17:12:09 +0100 Subject: [PATCH 043/105] test: add ws.use() browser tests --- .../ws.intercept.cilent.browser.test.ts | 53 ++-- .../ws.intercept.server.browser.test.ts | 42 +-- test/browser/ws-api/ws.runtime.js | 5 +- test/browser/ws-api/ws.use.browser.test.ts | 258 ++++++++++++++++++ 4 files changed, 316 insertions(+), 42 deletions(-) create mode 100644 test/browser/ws-api/ws.use.browser.test.ts diff --git a/test/browser/ws-api/ws.intercept.cilent.browser.test.ts b/test/browser/ws-api/ws.intercept.cilent.browser.test.ts index 9ab18de82..ac0e9fc33 100644 --- a/test/browser/ws-api/ws.intercept.cilent.browser.test.ts +++ b/test/browser/ws-api/ws.intercept.cilent.browser.test.ts @@ -1,12 +1,12 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' import { test, expect } from '../playwright.extend' -import { ws } from '../../../src/core/ws/ws' -import { SetupWorker } from '../../../src/browser' declare global { interface Window { msw: { ws: typeof ws - worker: SetupWorker + setupWorker: typeof setupWorker } } } @@ -15,17 +15,20 @@ test('does not throw on connecting to a non-existing host', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) - await page.evaluate(() => { - const { worker, ws } = window.msw + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw const service = ws.link('*') - worker.use( + const worker = setupWorker( service.on('connection', ({ client }) => { queueMicrotask(() => client.close()) }), ) + await worker.start() }) const clientClosePromise = page.evaluate(() => { @@ -44,21 +47,23 @@ test('intercepts outgoing client text message', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) const clientMessagePromise = page.evaluate(() => { - const { worker, ws } = window.msw - + const { setupWorker, ws } = window.msw const service = ws.link('wss://example.com') - return new Promise((resolve) => { - worker.use( + return new Promise(async (resolve) => { + const worker = setupWorker( service.on('connection', ({ client }) => { client.addEventListener('message', (event) => { resolve(event.data) }) }), ) + await worker.start() }) }) @@ -74,21 +79,23 @@ test('intercepts outgoing client Blob message', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) const clientMessagePromise = page.evaluate(() => { - const { worker, ws } = window.msw - + const { setupWorker, ws } = window.msw const service = ws.link('wss://example.com') - return new Promise((resolve) => { - worker.use( + return new Promise(async (resolve) => { + const worker = setupWorker( service.on('connection', ({ client }) => { client.addEventListener('message', (event) => { resolve(event.data.text()) }) }), ) + await worker.start() }) }) @@ -104,21 +111,23 @@ test('intercepts outgoing client ArrayBuffer message', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) const clientMessagePromise = page.evaluate(() => { - const { worker, ws } = window.msw - + const { setupWorker, ws } = window.msw const service = ws.link('wss://example.com') - return new Promise((resolve) => { - worker.use( + return new Promise(async (resolve) => { + const worker = setupWorker( service.on('connection', ({ client }) => { client.addEventListener('message', (event) => { resolve(new TextDecoder().decode(event.data)) }) }), ) + await worker.start() }) }) diff --git a/test/browser/ws-api/ws.intercept.server.browser.test.ts b/test/browser/ws-api/ws.intercept.server.browser.test.ts index b77e341ba..d70b972b6 100644 --- a/test/browser/ws-api/ws.intercept.server.browser.test.ts +++ b/test/browser/ws-api/ws.intercept.server.browser.test.ts @@ -1,13 +1,13 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' import { test, expect } from '../playwright.extend' -import type { ws } from '../../../src/core/ws/ws' -import type { SetupWorker } from '../../../src/browser' import { WebSocketServer } from '../../support/WebSocketServer' declare global { interface Window { msw: { ws: typeof ws - worker: SetupWorker + setupWorker: typeof setupWorker } } } @@ -26,18 +26,20 @@ test('intercepts incoming server text message', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) server.on('connection', (client) => { client.send('hello') }) const serverMessagePromise = page.evaluate((serverUrl) => { - const { worker, ws } = window.msw + const { setupWorker, ws } = window.msw const service = ws.link(serverUrl) - return new Promise((resolve) => { - worker.use( + return new Promise(async (resolve) => { + const worker = setupWorker( service.on('connection', ({ server }) => { server.connect() server.addEventListener('message', (event) => { @@ -45,6 +47,7 @@ test('intercepts incoming server text message', async ({ }) }), ) + await worker.start() }) }, server.url) @@ -67,21 +70,24 @@ test('intercepts incoming server Blob message', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) server.on('connection', async (client) => { /** - * @note `ws` doesn't accept sending Blobs. + * `ws` doesn't support sending Blobs. + * @see https://github.com/websockets/ws/issues/2206 */ client.send(await new Blob(['hello']).arrayBuffer()) }) const serverMessagePromise = page.evaluate((serverUrl) => { - const { worker, ws } = window.msw + const { setupWorker, ws } = window.msw const service = ws.link(serverUrl) - return new Promise((resolve) => { - worker.use( + return new Promise(async (resolve) => { + const worker = setupWorker( service.on('connection', ({ server }) => { server.connect() server.addEventListener('message', (event) => { @@ -89,6 +95,7 @@ test('intercepts incoming server Blob message', async ({ }) }), ) + await worker.start() }) }, server.url) @@ -111,7 +118,9 @@ test('intercepts outgoing server ArrayBuffer message', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) const encoder = new TextEncoder() server.on('connection', async (client) => { @@ -120,11 +129,11 @@ test('intercepts outgoing server ArrayBuffer message', async ({ }) const serverMessagePromise = page.evaluate((serverUrl) => { - const { worker, ws } = window.msw + const { setupWorker, ws } = window.msw const service = ws.link(serverUrl) - return new Promise((resolve) => { - worker.use( + return new Promise(async (resolve) => { + const worker = setupWorker( service.on('connection', ({ server }) => { server.connect() server.addEventListener('message', (event) => { @@ -132,6 +141,7 @@ test('intercepts outgoing server ArrayBuffer message', async ({ }) }), ) + await worker.start() }) }, server.url) diff --git a/test/browser/ws-api/ws.runtime.js b/test/browser/ws-api/ws.runtime.js index 377022dde..59b41ee87 100644 --- a/test/browser/ws-api/ws.runtime.js +++ b/test/browser/ws-api/ws.runtime.js @@ -1,10 +1,7 @@ import { ws } from 'msw' import { setupWorker } from 'msw/browser' -const worker = setupWorker() -worker.start() - window.msw = { ws, - worker, + setupWorker, } diff --git a/test/browser/ws-api/ws.use.browser.test.ts b/test/browser/ws-api/ws.use.browser.test.ts new file mode 100644 index 000000000..9d5b07165 --- /dev/null +++ b/test/browser/ws-api/ws.use.browser.test.ts @@ -0,0 +1,258 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +test('resolves outgoing events using initial handlers', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('hello from mock') + } + }) + }), + ) + await worker.start() + }) + + const clientMessage = await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + return new Promise((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => resolve(event.data) + socket.onerror = reject + }) + }) + + expect(clientMessage).toBe('hello from mock') +}) + +test('overrides an outgoing event listener', async ({ loadExample, page }) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('must not be sent') + } + }) + }), + ) + await worker.start() + + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + event.stopImmediatePropagation() + client.send('howdy, client!') + } + }) + }), + ) + }) + + const clientMessage = await page.evaluate(() => { + const socket = new WebSocket('wss://example.com') + return new Promise((resolve, reject) => { + socket.onopen = () => socket.send('hello') + + socket.onmessage = (event) => resolve(event.data) + socket.onerror = reject + }) + }) + + expect(clientMessage).toBe('howdy, client!') +}) + +test('combines initial and override listeners', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // This will be sent the last since the initial + // event listener is attached the first. + client.send('hello from mock') + client.close() + } + }) + }), + ) + await worker.start() + + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // This will be sent first since the override listener + // is attached the last. + client.send('override data') + } + }) + }), + ) + }) + + const clientMessages = await page.evaluate(() => { + const messages: Array = [] + const socket = new WebSocket('wss://example.com') + + return new Promise>((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => messages.push(event.data) + socket.onclose = () => resolve(messages) + socket.onerror = reject + }) + }) + + expect(clientMessages).toEqual(['override data', 'hello from mock']) +}) + +test('combines initial and override listeners in the opposite order', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('hello from mock') + } + }) + }), + ) + await worker.start() + + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + // Queue this send to the next tick so it + // happens after the initial listener's send. + queueMicrotask(() => { + client.send('override data') + client.close() + }) + } + }) + }), + ) + }) + + const clientMessages = await page.evaluate(() => { + const messages: Array = [] + const socket = new WebSocket('wss://example.com') + + return new Promise>((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => messages.push(event.data) + socket.onclose = () => resolve(messages) + socket.onerror = reject + }) + }) + + expect(clientMessages).toEqual(['hello from mock', 'override data']) +}) + +test('does not affect unrelated events', async ({ loadExample, page }) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('*') + + const worker = setupWorker( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + client.send('must not be sent') + } + + if (event.data === 'fallthrough') { + client.send('ok') + client.close() + } + }) + }), + ) + await worker.start() + + worker.use( + service.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data === 'hello') { + event.stopImmediatePropagation() + client.send('howdy, client!') + } + }) + }), + ) + }) + + const clientMessages = await page.evaluate(() => { + const messages: Array = [] + const socket = new WebSocket('wss://example.com') + + return new Promise>((resolve, reject) => { + socket.onopen = () => socket.send('hello') + socket.onmessage = (event) => { + messages.push(event.data) + if (event.data === 'howdy, client!') { + socket.send('fallthrough') + } + } + socket.onclose = () => resolve(messages) + socket.onerror = reject + }) + }) + + expect(clientMessages).toEqual(['howdy, client!', 'ok']) +}) From 47aa4732a5205f87bbc7060e0557f9fd94b0a74a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 13 Mar 2024 17:19:26 +0100 Subject: [PATCH 044/105] test: fix leftout test --- .../ws-api/ws.server.connect.browser.test.ts | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/test/browser/ws-api/ws.server.connect.browser.test.ts b/test/browser/ws-api/ws.server.connect.browser.test.ts index bb6b35f8a..6577a99ee 100644 --- a/test/browser/ws-api/ws.server.connect.browser.test.ts +++ b/test/browser/ws-api/ws.server.connect.browser.test.ts @@ -1,13 +1,13 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' import { test, expect } from '../playwright.extend' -import type { ws } from '../../../src/core/ws/ws' -import type { SetupWorker } from '../../../src/browser' import { WebSocketServer } from '../../support/WebSocketServer' declare global { interface Window { msw: { ws: typeof ws - worker: SetupWorker + setupWorker: typeof setupWorker } } } @@ -26,21 +26,24 @@ test('does not connect to the actual server by default', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) server.once('connection', (client) => { client.send('must not receive this') }) - await page.evaluate((serverUrl) => { - const { worker, ws } = window.msw + await page.evaluate(async (serverUrl) => { + const { setupWorker, ws } = window.msw const service = ws.link(serverUrl) - worker.use( + const worker = setupWorker( service.on('connection', ({ client }) => { queueMicrotask(() => client.send('mock')) }), ) + await worker.start() }, server.url) const clientMessage = await page.evaluate((serverUrl) => { @@ -62,23 +65,26 @@ test('forwards incoming server events to the client once connected', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) server.once('connection', (client) => { client.send('hello from server') }) - await page.evaluate((serverUrl) => { - const { worker, ws } = window.msw + await page.evaluate(async (serverUrl) => { + const { setupWorker, ws } = window.msw const service = ws.link(serverUrl) - worker.use( + const worker = setupWorker( service.on('connection', ({ server }) => { // Calling "connect()" establishes the connection // to the actual WebSocket server. server.connect() }), ) + await worker.start() }, server.url) const clientMessage = await page.evaluate((serverUrl) => { @@ -100,18 +106,21 @@ test('throws an error when connecting to a non-existing server', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./ws.runtime.js')) + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) const error = await page.evaluate((serverUrl) => { - const { worker, ws } = window.msw + const { setupWorker, ws } = window.msw const service = ws.link(serverUrl) - return new Promise((resolve) => { - worker.use( + return new Promise(async (resolve) => { + const worker = setupWorker( service.on('connection', ({ server }) => { server.connect() }), ) + await worker.start() const socket = new WebSocket(serverUrl) socket.onerror = () => resolve('Connection failed') From 09b4087f9f2ece97ff9114a06cd57be22a9ccbe3 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 13 Mar 2024 17:26:23 +0100 Subject: [PATCH 045/105] chore(release): v2.3.0-ws.rc-1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1b56d106b..92637b4f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.2.3", + "version": "2.3.0-ws.rc-1", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From 372f1833b2ecb34fee668959d1cec1c3d6d37ed7 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 14 Mar 2024 18:34:20 +0100 Subject: [PATCH 046/105] fix: export "WebSocketHandler" and "WebSocketHandlerEventMap" --- src/core/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/index.ts b/src/core/index.ts index 5cfdb4b59..18d06ab56 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -2,15 +2,19 @@ import { checkGlobals } from './utils/internal/checkGlobals' export { SetupApi } from './SetupApi' -/* Request handlers */ +/* HTTP handlers */ export { RequestHandler } from './handlers/RequestHandler' export { http } from './http' export { HttpHandler, HttpMethods } from './handlers/HttpHandler' export { graphql } from './graphql' export { GraphQLHandler } from './handlers/GraphQLHandler' -/* WebSocket */ +/* WebSocket handler */ export { ws } from './ws/ws' +export { + WebSocketHandler, + WebSocketHandlerEventMap, +} from './handlers/WebSocketHandler' /* Utils */ export { matchRequestUrl } from './utils/matching/matchRequestUrl' From b04859c95ed3f6c8e5f18274b221d52ecb6236ef Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 17 Mar 2024 22:15:37 +0100 Subject: [PATCH 047/105] fix: marks "WebSocketHandlerEventMap" as type export --- src/core/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/index.ts b/src/core/index.ts index 18d06ab56..97564c760 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -13,7 +13,7 @@ export { GraphQLHandler } from './handlers/GraphQLHandler' export { ws } from './ws/ws' export { WebSocketHandler, - WebSocketHandlerEventMap, + type WebSocketHandlerEventMap, } from './handlers/WebSocketHandler' /* Utils */ From 3e8a345a83d5fc9c2ce56b533b931266f58beb20 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 24 Mar 2024 19:06:11 +0100 Subject: [PATCH 048/105] chore: update @mswjs/interceptors to 0.26.11 (server.close) --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c4d7ad127..65243f627 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.8", + "@mswjs/interceptors": "^0.26.11", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0518856f2..026b6f59e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: '@fastify/websocket': ^8.3.1 '@inquirer/confirm': ^3.0.0 '@mswjs/cookies': ^1.1.0 - '@mswjs/interceptors': ^0.26.8 + '@mswjs/interceptors': ^0.26.11 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.1 @@ -73,7 +73,7 @@ dependencies: '@bundled-es-modules/statuses': 1.0.1 '@inquirer/confirm': 3.0.0 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.26.8 + '@mswjs/interceptors': 0.26.11 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -1116,8 +1116,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.26.8: - resolution: {integrity: sha512-3vxmn2JDZqK4bGdH/0Dip9sZGE/ALCPtfJmDqrli7aL3zBv2pciti2etdqC0xGYcbGHpeRd+CkMVt7F/FYjikQ==} + /@mswjs/interceptors/0.26.11: + resolution: {integrity: sha512-hSRh0Ia1br2vf+Tec++btQ402XM+IcAqqGdbka54h1HkxIZUc7s2WpJux1Cfke0ubequL2BkNg4Be3xKfvehGA==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 From 7a93c9503f627e5fd44c8aba571a2037825aeba0 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 24 Mar 2024 19:14:21 +0100 Subject: [PATCH 049/105] test(setupServer): ws apply test --- test/node/ws-api/ws.listen.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 test/node/ws-api/ws.listen.test.ts diff --git a/test/node/ws-api/ws.listen.test.ts b/test/node/ws-api/ws.listen.test.ts new file mode 100644 index 000000000..a4911bd77 --- /dev/null +++ b/test/node/ws-api/ws.listen.test.ts @@ -0,0 +1,24 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const api = ws.link('wss://example.com') +const server = setupServer(api.on('connection', () => {})) + +afterAll(() => { + server.close() +}) + +it('does not apply the interceptor until server.listen() is called', async () => { + const raw = new WebSocket('wss://example.com') + expect(raw.constructor.name).toBe('WebSocket') + expect(raw).toBeInstanceOf(EventTarget) + + server.listen() + + const mocked = new WebSocket('wss://example.com') + expect(mocked.constructor.name).not.toBe('WebSocket') + expect(mocked).toBeInstanceOf(EventTarget) +}) From 04ae9e9a942dc2d080a1b4a4bbcd5889fc597eb8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 24 Mar 2024 19:18:17 +0100 Subject: [PATCH 050/105] test(setupWorker): ws apply test --- test/browser/ws-api/ws.apply.browser.test.ts | 44 ++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 test/browser/ws-api/ws.apply.browser.test.ts diff --git a/test/browser/ws-api/ws.apply.browser.test.ts b/test/browser/ws-api/ws.apply.browser.test.ts new file mode 100644 index 000000000..749d0f7c1 --- /dev/null +++ b/test/browser/ws-api/ws.apply.browser.test.ts @@ -0,0 +1,44 @@ +import type { ws } from 'msw' +import type { SetupWorker, setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare global { + interface Window { + worker: SetupWorker + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +test('does not apply the interceptor until "worker.start()" is called', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(() => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + window.worker = setupWorker(api.on('connection', () => {})) + }) + + await expect( + page.evaluate(() => { + return new WebSocket('wss://example.com').constructor.name + }), + ).resolves.toBe('WebSocket') + + await page.evaluate(async () => { + await window.worker.start() + }) + + await expect( + page.evaluate(() => { + return new WebSocket('wss://example.com').constructor.name + }), + ).resolves.not.toBe('WebSocket') +}) From 59a648b51b5f2c18129183797027ec57a56a4be0 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 24 Mar 2024 19:18:36 +0100 Subject: [PATCH 051/105] test: fix typo in "ws.intercept.client.browser.test.ts" --- ...cilent.browser.test.ts => ws.intercept.client.browser.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/browser/ws-api/{ws.intercept.cilent.browser.test.ts => ws.intercept.client.browser.test.ts} (100%) diff --git a/test/browser/ws-api/ws.intercept.cilent.browser.test.ts b/test/browser/ws-api/ws.intercept.client.browser.test.ts similarity index 100% rename from test/browser/ws-api/ws.intercept.cilent.browser.test.ts rename to test/browser/ws-api/ws.intercept.client.browser.test.ts From 32dacec76854dff6ede68a6dc5afb8beca989bea Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 24 Mar 2024 20:19:00 +0100 Subject: [PATCH 052/105] test: rename test for consistency --- test/node/ws-api/ws.apply.test.ts | 34 ++++++++++++++++++++++++++++++ test/node/ws-api/ws.listen.test.ts | 24 --------------------- 2 files changed, 34 insertions(+), 24 deletions(-) create mode 100644 test/node/ws-api/ws.apply.test.ts delete mode 100644 test/node/ws-api/ws.listen.test.ts diff --git a/test/node/ws-api/ws.apply.test.ts b/test/node/ws-api/ws.apply.test.ts new file mode 100644 index 000000000..137c65ef4 --- /dev/null +++ b/test/node/ws-api/ws.apply.test.ts @@ -0,0 +1,34 @@ +/** + * @vitest-environment node-websocket + */ +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const server = setupServer() + +afterEach(() => { + server.close() +}) + +it('does not patch WebSocket class if no event handlers were defined', () => { + server.listen() + + const raw = new WebSocket('wss://example.com') + expect(raw.constructor.name).toBe('WebSocket') + expect(raw).toBeInstanceOf(EventTarget) +}) + +it('does not patch WebSocket class until server.listen() is called', () => { + const api = ws.link('wss://example.com') + server.use(api.on('connection', () => {})) + + const raw = new WebSocket('wss://example.com') + expect(raw.constructor.name).toBe('WebSocket') + expect(raw).toBeInstanceOf(EventTarget) + + server.listen() + + const mocked = new WebSocket('wss://example.com') + expect(mocked.constructor.name).not.toBe('WebSocket') + expect(mocked).toBeInstanceOf(EventTarget) +}) diff --git a/test/node/ws-api/ws.listen.test.ts b/test/node/ws-api/ws.listen.test.ts deleted file mode 100644 index a4911bd77..000000000 --- a/test/node/ws-api/ws.listen.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @vitest-environment node-websocket - */ -import { ws } from 'msw' -import { setupServer } from 'msw/node' - -const api = ws.link('wss://example.com') -const server = setupServer(api.on('connection', () => {})) - -afterAll(() => { - server.close() -}) - -it('does not apply the interceptor until server.listen() is called', async () => { - const raw = new WebSocket('wss://example.com') - expect(raw.constructor.name).toBe('WebSocket') - expect(raw).toBeInstanceOf(EventTarget) - - server.listen() - - const mocked = new WebSocket('wss://example.com') - expect(mocked.constructor.name).not.toBe('WebSocket') - expect(mocked).toBeInstanceOf(EventTarget) -}) From 180205d24621cc5d55921b1b50872f448dd06aaf Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 26 Mar 2024 14:58:47 +0100 Subject: [PATCH 053/105] fix(getTimestamp): support milliseconds --- src/core/utils/logging/getTimestamp.test.ts | 20 ++++++++++++++------ src/core/utils/logging/getTimestamp.ts | 15 +++++++++++++-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/core/utils/logging/getTimestamp.test.ts b/src/core/utils/logging/getTimestamp.test.ts index f7c70dc0c..be71c21d4 100644 --- a/src/core/utils/logging/getTimestamp.test.ts +++ b/src/core/utils/logging/getTimestamp.test.ts @@ -1,18 +1,26 @@ import { getTimestamp } from './getTimestamp' beforeAll(() => { - // Stub native `Date` prototype methods used in the tested module, - // to always produce a predictable value for testing purposes. - vi.spyOn(global.Date.prototype, 'getHours').mockImplementation(() => 12) - vi.spyOn(global.Date.prototype, 'getMinutes').mockImplementation(() => 4) - vi.spyOn(global.Date.prototype, 'getSeconds').mockImplementation(() => 8) + vi.useFakeTimers() }) afterAll(() => { - vi.restoreAllMocks() + vi.useRealTimers() }) test('returns a timestamp string of the invocation time', () => { + vi.setSystemTime(new Date('2024-01-01 12:4:8')) const timestamp = getTimestamp() expect(timestamp).toBe('12:04:08') }) + +test('returns a timestamp with milliseconds', () => { + vi.setSystemTime(new Date('2024-01-01 12:4:8')) + expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.000') + + vi.setSystemTime(new Date('2024-01-01 12:4:8.4')) + expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.400') + + vi.setSystemTime(new Date('2024-01-01 12:4:8.123')) + expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.123') +}) diff --git a/src/core/utils/logging/getTimestamp.ts b/src/core/utils/logging/getTimestamp.ts index 28e8d689a..6a4be8a07 100644 --- a/src/core/utils/logging/getTimestamp.ts +++ b/src/core/utils/logging/getTimestamp.ts @@ -1,12 +1,23 @@ +interface GetTimestampOptions { + milliseconds?: boolean +} + /** * Returns a timestamp string in a "HH:MM:SS" format. */ -export function getTimestamp(): string { +export function getTimestamp(options?: GetTimestampOptions): string { const now = new Date() - return [now.getHours(), now.getMinutes(), now.getSeconds()] + let timestamp = [now.getHours(), now.getMinutes(), now.getSeconds()] + .filter(Boolean) .map(String) .map((chunk) => chunk.slice(0, 2)) .map((chunk) => chunk.padStart(2, '0')) .join(':') + + if (options?.milliseconds) { + timestamp += `.${now.getMilliseconds().toString().padStart(3, '0')}` + } + + return timestamp } From 64b6a82e1a9a79a6e650ee2a50628cfd7583018d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 29 Mar 2024 17:45:50 +0100 Subject: [PATCH 054/105] test(ws): assert WebSocket patch without event handlers --- test/node/ws-api/ws.apply.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/node/ws-api/ws.apply.test.ts b/test/node/ws-api/ws.apply.test.ts index 137c65ef4..e958f1797 100644 --- a/test/node/ws-api/ws.apply.test.ts +++ b/test/node/ws-api/ws.apply.test.ts @@ -10,11 +10,11 @@ afterEach(() => { server.close() }) -it('does not patch WebSocket class if no event handlers were defined', () => { +it('patches WebSocket class even if no event handlers were defined', () => { server.listen() const raw = new WebSocket('wss://example.com') - expect(raw.constructor.name).toBe('WebSocket') + expect(raw.constructor.name).toBe('WebSocketOverride') expect(raw).toBeInstanceOf(EventTarget) }) From fc8b67d32d82f59fc0a4b4e454ed69938ae7f2f4 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 30 Mar 2024 10:38:12 +0100 Subject: [PATCH 055/105] feat(ws): add logging to WebSocket connections (#2112) --- package.json | 2 +- pnpm-lock.yaml | 8 +- src/browser/setupWorker/setupWorker.ts | 21 +- src/core/handlers/WebSocketHandler.ts | 60 +- src/core/utils/handleWebSocketEvent.ts | 68 +- src/core/utils/logging/getTimestamp.test.ts | 6 + src/core/utils/logging/getTimestamp.ts | 10 +- src/core/ws/utils/attachWebSocketLogger.ts | 262 ++++++++ src/core/ws/utils/getMessageLength.test.ts | 16 + src/core/ws/utils/getMessageLength.ts | 19 + src/core/ws/utils/getPublicData.test.ts | 38 ++ src/core/ws/utils/getPublicData.ts | 17 + src/core/ws/utils/truncateMessage.test.ts | 12 + src/core/ws/utils/truncateMessage.ts | 9 + src/node/SetupServerCommonApi.ts | 8 +- .../browser/ws-api/ws.logging.browser.test.ts | 636 ++++++++++++++++++ 16 files changed, 1107 insertions(+), 85 deletions(-) create mode 100644 src/core/ws/utils/attachWebSocketLogger.ts create mode 100644 src/core/ws/utils/getMessageLength.test.ts create mode 100644 src/core/ws/utils/getMessageLength.ts create mode 100644 src/core/ws/utils/getPublicData.test.ts create mode 100644 src/core/ws/utils/getPublicData.ts create mode 100644 src/core/ws/utils/truncateMessage.test.ts create mode 100644 src/core/ws/utils/truncateMessage.ts create mode 100644 test/browser/ws-api/ws.logging.browser.test.ts diff --git a/package.json b/package.json index 03add0b14..0485a5629 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.14", + "@mswjs/interceptors": "^0.26.15", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 805b289b4..c5a458c75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: '@fastify/websocket': ^8.3.1 '@inquirer/confirm': ^3.0.0 '@mswjs/cookies': ^1.1.0 - '@mswjs/interceptors': ^0.26.14 + '@mswjs/interceptors': ^0.26.15 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.1 @@ -73,7 +73,7 @@ dependencies: '@bundled-es-modules/statuses': 1.0.1 '@inquirer/confirm': 3.0.0 '@mswjs/cookies': 1.1.0 - '@mswjs/interceptors': 0.26.14 + '@mswjs/interceptors': 0.26.15 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.4 @@ -1139,8 +1139,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors/0.26.14: - resolution: {integrity: sha512-q4S8RGjOUzv3A3gCawuKkUEcNJXjdPaSqoRHFvuZPWQnc7yOw702iGBRDMJoBK+l0KSv9XN8YP5ek6duRzrpqw==} + /@mswjs/interceptors/0.26.15: + resolution: {integrity: sha512-HM47Lu1YFmnYHKMBynFfjCp0U/yRskHj/8QEJW0CBEPOlw8Gkmjfll+S9b8M7V5CNDw2/ciRxjjnWeaCiblSIQ==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 diff --git a/src/browser/setupWorker/setupWorker.ts b/src/browser/setupWorker/setupWorker.ts index 24c008b30..54d446b95 100644 --- a/src/browser/setupWorker/setupWorker.ts +++ b/src/browser/setupWorker/setupWorker.ts @@ -24,6 +24,7 @@ import { SetupWorker } from './glossary' import { supportsReadableStreamTransfer } from '../utils/supportsReadableStreamTransfer' import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' import { handleWebSocketEvent } from '~/core/utils/handleWebSocketEvent' +import { attachWebSocketLogger } from '~/core/ws/utils/attachWebSocketLogger' interface Listener { target: EventTarget @@ -179,9 +180,25 @@ export class SetupWorkerApi options, ) as SetupWorkerInternalContext['startOptions'] - handleWebSocketEvent(() => { - return this.handlersController.currentHandlers() + // Enable WebSocket interception. + handleWebSocketEvent({ + getHandlers: () => { + return this.handlersController.currentHandlers() + }, + onMockedConnection: (connection) => { + if (!this.context.startOptions.quiet) { + // Attach the logger for mocked connections since + // those won't be visible in the browser's devtools. + attachWebSocketLogger(connection) + } + }, + onPassthroughConnection() { + /** + * @fixme Call some "onUnhandledConnection". + */ + }, }) + webSocketInterceptor.apply() this.subscriptions.push(() => { diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts index abb43786c..bf916f37d 100644 --- a/src/core/handlers/WebSocketHandler.ts +++ b/src/core/handlers/WebSocketHandler.ts @@ -1,47 +1,41 @@ import { Emitter } from 'strict-event-emitter' -import type { - WebSocketClientConnection, - WebSocketServerConnection, -} from '@mswjs/interceptors/WebSocket' +import type { WebSocketConnectionData } from '@mswjs/interceptors/WebSocket' import { type Match, type Path, type PathParams, matchRequestUrl, } from '../utils/matching/matchRequestUrl' +import { getCallFrame } from '../utils/internal/getCallFrame' type WebSocketHandlerParsedResult = { match: Match } export type WebSocketHandlerEventMap = { - connection: [ - args: { - client: WebSocketClientConnection - server: WebSocketServerConnection - params: PathParams - }, - ] + connection: [args: WebSocketHandlerConnection] } -type WebSocketHandlerIncomingEvent = MessageEvent<{ - client: WebSocketClientConnection - server: WebSocketServerConnection -}> +interface WebSocketHandlerConnection extends WebSocketConnectionData { + params: PathParams +} export const kEmitter = Symbol('kEmitter') export const kDispatchEvent = Symbol('kDispatchEvent') -export const kDefaultPrevented = Symbol('kDefaultPrevented') +export const kSender = Symbol('kSender') export class WebSocketHandler { + public callFrame?: string + protected [kEmitter]: Emitter constructor(private readonly url: Path) { this[kEmitter] = new Emitter() + this.callFrame = getCallFrame(new Error()) } public parse(args: { - event: WebSocketHandlerIncomingEvent + event: MessageEvent }): WebSocketHandlerParsedResult { const connection = args.event.data const match = matchRequestUrl(connection.client.url, this.url) @@ -52,38 +46,26 @@ export class WebSocketHandler { } public predicate(args: { - event: WebSocketHandlerIncomingEvent + event: MessageEvent parsedResult: WebSocketHandlerParsedResult }): boolean { return args.parsedResult.match.matches } - async [kDispatchEvent](event: MessageEvent): Promise { + async [kDispatchEvent]( + event: MessageEvent, + ): Promise { const parsedResult = this.parse({ event }) - const shouldIntercept = this.predicate({ event, parsedResult }) - - if (!shouldIntercept) { - return - } - - // Account for other matching event handlers that've already prevented this event. - if (!Reflect.get(event, kDefaultPrevented)) { - // At this point, the WebSocket connection URL has matched the handler. - // Prevent the default behavior of establishing the connection as-is. - // Use internal symbol because we aren't actually dispatching this - // event. Events can only marked as cancelable and can be prevented - // when dispatched on an EventTarget. - Reflect.set(event, kDefaultPrevented, true) - } - const connection = event.data - // Emit the connection event on the handler. - // This is what the developer adds listeners for. - this[kEmitter].emit('connection', { + const resolvedConnection: WebSocketHandlerConnection = { client: connection.client, server: connection.server, params: parsedResult.match.params || {}, - }) + } + + // Emit the connection event on the handler. + // This is what the developer adds listeners for. + this[kEmitter].emit('connection', resolvedConnection) } } diff --git a/src/core/utils/handleWebSocketEvent.ts b/src/core/utils/handleWebSocketEvent.ts index 5563d49a6..6649e359b 100644 --- a/src/core/utils/handleWebSocketEvent.ts +++ b/src/core/utils/handleWebSocketEvent.ts @@ -1,45 +1,55 @@ +import type { WebSocketConnectionData } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' import { RequestHandler } from '../handlers/RequestHandler' -import { - WebSocketHandler, - kDefaultPrevented, - kDispatchEvent, -} from '../handlers/WebSocketHandler' +import { WebSocketHandler, kDispatchEvent } from '../handlers/WebSocketHandler' import { webSocketInterceptor } from '../ws/webSocketInterceptor' -export function handleWebSocketEvent( - getCurrentHandlers: () => Array, -) { +interface HandleWebSocketEventOptions { + getHandlers: () => Array + onMockedConnection: (connection: WebSocketConnectionData) => void + onPassthroughConnection: (onnection: WebSocketConnectionData) => void +} + +export function handleWebSocketEvent(options: HandleWebSocketEventOptions) { webSocketInterceptor.on('connection', (connection) => { - const handlers = getCurrentHandlers() + const handlers = options.getHandlers() const connectionEvent = new MessageEvent('connection', { data: connection, - /** - * @note This message event should be marked as "cancelable" - * to have its default prevented using "event.preventDefault()". - * There's a bug in Node.js that breaks the "cancelable" flag. - * @see https://github.com/nodejs/node/issues/51767 - */ }) - Object.defineProperty(connectionEvent, kDefaultPrevented, { - enumerable: false, - writable: true, - value: false, - }) + // First, filter only those WebSocket handlers that + // match the "ws.link()" endpoint predicate. Don't dispatch + // anything yet so the logger can be attached to the connection + // before it potentially sends events. + const matchingHandlers = handlers.filter( + (handler): handler is WebSocketHandler => { + if (handler instanceof WebSocketHandler) { + return handler.predicate({ + event: connectionEvent, + parsedResult: handler.parse({ + event: connectionEvent, + }), + }) + } - // Iterate over the handlers and forward the connection - // event to WebSocket event handlers. This is equivalent - // to dispatching that event onto multiple listeners. - for (const handler of handlers) { - if (handler instanceof WebSocketHandler) { + return false + }, + ) + + if (matchingHandlers.length > 0) { + options?.onMockedConnection(connection) + + // Iterate over the handlers and forward the connection + // event to WebSocket event handlers. This is equivalent + // to dispatching that event onto multiple listeners. + for (const handler of matchingHandlers) { handler[kDispatchEvent](connectionEvent) } - } + } else { + options?.onPassthroughConnection(connection) - // If none of the "ws" handlers matched, - // establish the WebSocket connection as-is. - if (!Reflect.get(connectionEvent, kDefaultPrevented)) { + // If none of the "ws" handlers matched, + // establish the WebSocket connection as-is. connection.server.connect() connection.client.addEventListener('message', (event) => { connection.server.send(event.data) diff --git a/src/core/utils/logging/getTimestamp.test.ts b/src/core/utils/logging/getTimestamp.test.ts index be71c21d4..04b488686 100644 --- a/src/core/utils/logging/getTimestamp.test.ts +++ b/src/core/utils/logging/getTimestamp.test.ts @@ -18,9 +18,15 @@ test('returns a timestamp with milliseconds', () => { vi.setSystemTime(new Date('2024-01-01 12:4:8')) expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.000') + vi.setSystemTime(new Date('2024-01-01 12:4:8.000')) + expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.000') + vi.setSystemTime(new Date('2024-01-01 12:4:8.4')) expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.400') vi.setSystemTime(new Date('2024-01-01 12:4:8.123')) expect(getTimestamp({ milliseconds: true })).toBe('12:04:08.123') + + vi.setSystemTime(new Date('2024-01-01 12:00:00')) + expect(getTimestamp({ milliseconds: true })).toBe('12:00:00.000') }) diff --git a/src/core/utils/logging/getTimestamp.ts b/src/core/utils/logging/getTimestamp.ts index 6a4be8a07..a53605355 100644 --- a/src/core/utils/logging/getTimestamp.ts +++ b/src/core/utils/logging/getTimestamp.ts @@ -7,16 +7,10 @@ interface GetTimestampOptions { */ export function getTimestamp(options?: GetTimestampOptions): string { const now = new Date() - - let timestamp = [now.getHours(), now.getMinutes(), now.getSeconds()] - .filter(Boolean) - .map(String) - .map((chunk) => chunk.slice(0, 2)) - .map((chunk) => chunk.padStart(2, '0')) - .join(':') + const timestamp = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}` if (options?.milliseconds) { - timestamp += `.${now.getMilliseconds().toString().padStart(3, '0')}` + return `${timestamp}.${now.getMilliseconds().toString().padStart(3, '0')}` } return timestamp diff --git a/src/core/ws/utils/attachWebSocketLogger.ts b/src/core/ws/utils/attachWebSocketLogger.ts new file mode 100644 index 000000000..bcc25fc81 --- /dev/null +++ b/src/core/ws/utils/attachWebSocketLogger.ts @@ -0,0 +1,262 @@ +import type { + WebSocketClientConnection, + WebSocketConnectionData, + WebSocketData, +} from '@mswjs/interceptors/WebSocket' +import { devUtils } from '../../utils/internal/devUtils' +import { getTimestamp } from '../../utils/logging/getTimestamp' +import { toPublicUrl } from '../../utils/request/toPublicUrl' +import { getMessageLength } from './getMessageLength' +import { getPublicData } from './getPublicData' + +export function attachWebSocketLogger( + connection: WebSocketConnectionData, +): void { + const { client, server } = connection + + logConnectionOpen(client) + + // Log the events sent from the WebSocket client. + // WebSocket client connection object is written from the + // server's perspective so these message events are outgoing. + /** + * @todo Provide the reference to the exact event handler + * that called this `client.send()`. + */ + client.addEventListener('message', (event) => { + logOutgoingClientMessage(event) + }) + + client.addEventListener('close', (event) => { + logConnectionClose(event) + }) + + // Log the events received by the WebSocket client. + // "client.socket" references the actual WebSocket instance + // so these message events are incoming messages. + client.socket.addEventListener('message', (event) => { + logIncomingClientMessage(event) + }) + + // Log client errors (connection closures due to errors). + client.socket.addEventListener('error', (event) => { + logClientError(event) + }) + + client.send = new Proxy(client.send, { + apply(target, thisArg, args) { + const [data] = args + const messageEvent = new MessageEvent('message', { data }) + Object.defineProperties(messageEvent, { + currentTarget: { + enumerable: true, + writable: false, + value: client.socket, + }, + target: { + enumerable: true, + writable: false, + value: client.socket, + }, + }) + logIncomingMockedClientMessage(messageEvent) + + return Reflect.apply(target, thisArg, args) + }, + }) + + server.addEventListener( + 'open', + () => { + server.addEventListener('message', (event) => { + logIncomingServerMessage(event) + }) + }, + { once: true }, + ) + + // Log outgoing client events initiated by the event handler. + // The actual client never sent these but the handler did. + server.send = new Proxy(server.send, { + apply(target, thisArg, args) { + const [data] = args + const messageEvent = new MessageEvent('message', { data }) + Object.defineProperties(messageEvent, { + currentTarget: { + enumerable: true, + writable: false, + value: server['realWebSocket'], + }, + target: { + enumerable: true, + writable: false, + value: server['realWebSocket'], + }, + }) + + logOutgoingMockedClientMessage(messageEvent) + + return Reflect.apply(target, thisArg, args) + }, + }) +} + +/** + * Prints the WebSocket connection. + * This is meant to be logged by every WebSocket handler + * that intercepted this connection. This helps you see + * what handlers observe this connection. + */ +export function logConnectionOpen(client: WebSocketClientConnection) { + const publicUrl = toPublicUrl(client.url) + + console.groupCollapsed( + devUtils.formatMessage(`${getTimestamp()} %c▸%c ${publicUrl}`), + 'color:blue', + 'color:inherit', + ) + console.log('Client:', client.socket) + console.groupEnd() +} + +/** + * Prints the outgoing client message. + */ +export async function logOutgoingClientMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c↑%c ${publicData} %c${byteLength}%c`, + ), + 'color:green', + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + console.log(event) + console.groupEnd() +} + +/** + * Prints the outgoing client message initiated + * by `server.send()` in the event handler. + */ +export async function logOutgoingMockedClientMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c⇡%c ${publicData} %c${byteLength}%c`, + ), + 'color:orangered', + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + console.log(event) + console.groupEnd() +} + +/** + * Prings the message received by the WebSocket client. + * This is fired when the "message" event is dispatched + * on the actual WebSocket client instance, and translates to + * the client receiving a message from the server. + */ +export async function logIncomingClientMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c↓%c ${publicData} %c${byteLength}%c`, + ), + 'color:red', + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + console.log(event) + console.groupEnd() +} + +/** + * Prints the outgoing client message initiated + * by `client.send()` in the event handler. + */ +export async function logIncomingMockedClientMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c⇣%c ${publicData} %c${byteLength}%c`, + ), + 'color:orangered', + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + console.log(event) + console.groupEnd() +} + +function logConnectionClose(event: CloseEvent) { + const target = event.target as WebSocket + const publicUrl = toPublicUrl(target.url) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c■%c ${publicUrl}`, + ), + 'color:blue', + 'color:inherit', + ) + console.log(event) + console.groupEnd() +} + +export async function logIncomingServerMessage( + event: MessageEvent, +) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c⇣%c ${publicData} %c${byteLength}%c`, + ), + 'color:orangered', + 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', + ) + console.log(event) + console.groupEnd() +} + +function logClientError(event: Event) { + const socket = event.target as WebSocket + const publicUrl = toPublicUrl(socket.url) + + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp({ milliseconds: true })} %c\u00D7%c ${publicUrl}`, + ), + 'color:red', + 'color:inherit', + ) + console.log(event) + console.groupEnd() +} diff --git a/src/core/ws/utils/getMessageLength.test.ts b/src/core/ws/utils/getMessageLength.test.ts new file mode 100644 index 000000000..af45718ee --- /dev/null +++ b/src/core/ws/utils/getMessageLength.test.ts @@ -0,0 +1,16 @@ +import { getMessageLength } from './getMessageLength' + +it('returns the length of the string', () => { + expect(getMessageLength('')).toBe(0) + expect(getMessageLength('hello')).toBe(5) +}) + +it('returns the size of the Blob', () => { + expect(getMessageLength(new Blob())).toBe(0) + expect(getMessageLength(new Blob(['hello']))).toBe(5) +}) + +it('returns the byte length of ArrayBuffer', () => { + expect(getMessageLength(new ArrayBuffer(0))).toBe(0) + expect(getMessageLength(new ArrayBuffer(5))).toBe(5) +}) diff --git a/src/core/ws/utils/getMessageLength.ts b/src/core/ws/utils/getMessageLength.ts new file mode 100644 index 000000000..a8e041955 --- /dev/null +++ b/src/core/ws/utils/getMessageLength.ts @@ -0,0 +1,19 @@ +import type { WebSocketData } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' + +/** + * Returns the byte length of the given WebSocket message. + * @example + * getMessageLength('hello') // 5 + * getMessageLength(new Blob(['hello'])) // 5 + */ +export function getMessageLength(data: WebSocketData): number { + if (data instanceof Blob) { + return data.size + } + + if (data instanceof ArrayBuffer) { + return data.byteLength + } + + return new Blob([data]).size +} diff --git a/src/core/ws/utils/getPublicData.test.ts b/src/core/ws/utils/getPublicData.test.ts new file mode 100644 index 000000000..2820301f7 --- /dev/null +++ b/src/core/ws/utils/getPublicData.test.ts @@ -0,0 +1,38 @@ +import { getPublicData } from './getPublicData' + +it('returns a short string as-is', async () => { + expect(await getPublicData('')).toBe('') + expect(await getPublicData('hello')).toBe('hello') +}) + +it('returns a truncated long string', async () => { + expect(await getPublicData('this is a very long string')).toBe( + 'this is a very long stri…', + ) +}) + +it('returns a short Blob text as-is', async () => { + expect(await getPublicData(new Blob(['']))).toBe('Blob()') + expect(await getPublicData(new Blob(['hello']))).toBe('Blob(hello)') +}) + +it('returns a truncated long Blob text', async () => { + expect(await getPublicData(new Blob(['this is a very long string']))).toBe( + 'Blob(this is a very long stri…)', + ) +}) + +it('returns a short ArrayBuffer text as-is', async () => { + expect(await getPublicData(new TextEncoder().encode(''))).toBe( + 'ArrayBuffer()', + ) + expect(await getPublicData(new TextEncoder().encode('hello'))).toBe( + 'ArrayBuffer(hello)', + ) +}) + +it('returns a truncated ArrayBuffer text', async () => { + expect( + await getPublicData(new TextEncoder().encode('this is a very long string')), + ).toBe('ArrayBuffer(this is a very long stri…)') +}) diff --git a/src/core/ws/utils/getPublicData.ts b/src/core/ws/utils/getPublicData.ts new file mode 100644 index 000000000..8fd41b606 --- /dev/null +++ b/src/core/ws/utils/getPublicData.ts @@ -0,0 +1,17 @@ +import { WebSocketData } from '@mswjs/interceptors/WebSocket' +import { truncateMessage } from './truncateMessage' + +export async function getPublicData(data: WebSocketData): Promise { + if (data instanceof Blob) { + const text = await data.text() + return `Blob(${truncateMessage(text)})` + } + + // Handle all ArrayBuffer-like objects. + if (typeof data === 'object' && 'byteLength' in data) { + const text = new TextDecoder().decode(data) + return `ArrayBuffer(${truncateMessage(text)})` + } + + return truncateMessage(data) +} diff --git a/src/core/ws/utils/truncateMessage.test.ts b/src/core/ws/utils/truncateMessage.test.ts new file mode 100644 index 000000000..5e247a0e3 --- /dev/null +++ b/src/core/ws/utils/truncateMessage.test.ts @@ -0,0 +1,12 @@ +import { truncateMessage } from './truncateMessage' + +it('returns a short string as-is', () => { + expect(truncateMessage('')).toBe('') + expect(truncateMessage('hello')).toBe('hello') +}) + +it('truncates a long string', () => { + expect(truncateMessage('this is a very long string')).toBe( + 'this is a very long stri…', + ) +}) diff --git a/src/core/ws/utils/truncateMessage.ts b/src/core/ws/utils/truncateMessage.ts new file mode 100644 index 000000000..eae145e91 --- /dev/null +++ b/src/core/ws/utils/truncateMessage.ts @@ -0,0 +1,9 @@ +const MAX_LENGTH = 24 + +export function truncateMessage(message: string): string { + if (message.length <= MAX_LENGTH) { + return message + } + + return `${message.slice(0, MAX_LENGTH)}…` +} diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts index d293efdfd..910f5e61b 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -85,8 +85,12 @@ export class SetupServerCommonApi }, ) - handleWebSocketEvent(() => { - return this.handlersController.currentHandlers() + handleWebSocketEvent({ + getHandlers: () => { + return this.handlersController.currentHandlers() + }, + onMockedConnection: () => {}, + onPassthroughConnection: () => {}, }) } diff --git a/test/browser/ws-api/ws.logging.browser.test.ts b/test/browser/ws-api/ws.logging.browser.test.ts new file mode 100644 index 000000000..500f053bc --- /dev/null +++ b/test/browser/ws-api/ws.logging.browser.test.ts @@ -0,0 +1,636 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' +import { WebSocketServer } from '../../support/WebSocketServer' +import { waitFor } from '../../support/waitFor' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +const server = new WebSocketServer() + +test.beforeAll(async () => { + await server.listen() +}) + +test.afterEach(async () => { + server.resetState() +}) + +test.afterAll(async () => { + await server.close() +}) + +test('does not log anything if "quiet" was set to "true"', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start({ quiet: true }) + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => { + ws.send('hello') + ws.send('world') + queueMicrotask(() => ws.close()) + } + + return new Promise((resolve, reject) => { + ws.onclose = () => resolve() + ws.onerror = () => reject(new Error('Client connection closed')) + }) + }) + + expect(consoleSpy.get('startGroupCollapsed')).toBeUndefined() +}) + +test('logs the client connection', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + new WebSocket('wss://example.com/path') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2} %c▸%c wss:\/\/example\.com\/path color:blue color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending text data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => ws.send('hello world') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c hello world %c11%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending a long text data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => ws.send('this is an extremely long sentence to log out') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c this is an extremely lon… %c45%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending Blob data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => ws.send(new Blob(['hello world'])) + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c Blob\(hello world\) %c11%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending a long Blob data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => + ws.send(new Blob(['this is an extremely long sentence to log out'])) + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c Blob\(this is an extremely lon…\) %c45%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending ArrayBuffer data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => ws.send(new TextEncoder().encode('hello world')) + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c ArrayBuffer\(hello world\) %c11%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client event sending a long ArrayBuffer data', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => + ws.send( + new TextEncoder().encode( + 'this is an extremely long sentence to log out', + ), + ) + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c ArrayBuffer\(this is an extremely lon…\) %c45%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs incoming client events', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.addListener('connection', (ws) => { + ws.send('hello from server') + + ws.addEventListener('message', (event) => { + if (event.data === 'how are you, server?') { + ws.send('thanks, not bad') + } + }) + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.on('connection', ({ client, server }) => { + server.connect() + client.addEventListener('message', (event) => { + server.send(event.data) + }) + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + const ws = new WebSocket(url) + ws.addEventListener('message', (event) => { + if (event.data === 'hello from server') { + ws.send('how are you, server?') + } + }) + }, server.url) + + // Initial message sent to every connected client. + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c hello from server %c17%c color:red color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) + + // Message sent in response to a client message. + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c thanks, not bad %c15%c color:red color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs raw incoming server events', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.addListener('connection', (ws) => { + ws.send('hello from server') + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.on('connection', ({ client, server }) => { + server.connect() + client.addEventListener('message', (event) => { + server.send(event.data) + }) + server.addEventListener('message', (event) => { + event.preventDefault() + // This is the only data the client will receive + // but we should still print the raw server message. + client.send('intercepted server event') + }) + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + // Raw server message. + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from server %c17%c color:orangered color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + // The actual message the client received (mocked). + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c intercepted server event %c24%c color:red color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs the close event initiated by the client', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://example.com/path') + ws.onopen = () => ws.close() + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/example\.com\/path color:blue color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs the close event initiated by the event handler', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker( + api.on('connection', ({ client }) => { + client.close() + }), + ) + await worker.start() + }) + + await page.evaluate(() => { + new WebSocket('wss://example.com/path') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/example\.com\/path color:blue color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client events sent vi "server.send()"', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.on('connection', ({ server }) => { + server.connect() + server.send('hello from handler') + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇡%c hello from handler %c18%c color:orangered color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs incoming client events sent vi "client.send()"', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.on('connection', ({ client }) => { + client.send('hello from handler') + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from handler %c18%c color:orangered color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + ]), + ) + }) +}) + +test('logs client errors received via "client.close()"', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com/*') + const worker = setupWorker( + api.on('connection', ({ client }) => { + queueMicrotask(() => client.close(1003, 'Custom error')) + }), + ) + await worker.start() + }) + + await page.evaluate(() => { + new WebSocket('wss://example.com/path') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c\u00D7%c wss:\/\/example\.com\/path color:red color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs client errors received via server-sent close', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.on('connection', (ws) => { + queueMicrotask(() => ws.close(1003)) + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.on('connection', ({ server }) => { + server.connect() + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c\u00D7%c ws:\/\/(.+):\d{4,}\/ color:red color:inherit$/, + ), + ]), + ) + }) +}) From 0776e73fbca32177157907a741d175d70febbece Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 1 Apr 2024 10:51:21 +0200 Subject: [PATCH 056/105] chore(release): v2.3.0-ws.rc-2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0485a5629..78284246a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-1", + "version": "2.3.0-ws.rc-2", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From c6450d919d8e4561b2da5fe410101376d46e1474 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 2 Apr 2024 10:41:42 +0200 Subject: [PATCH 057/105] chore: move "handleWebSocketEvent" to "ws" --- src/browser/setupWorker/setupWorker.ts | 2 +- src/core/{utils => ws}/handleWebSocketEvent.ts | 2 +- src/core/ws/ws.ts | 6 +++--- src/node/SetupServerCommonApi.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) rename src/core/{utils => ws}/handleWebSocketEvent.ts (96%) diff --git a/src/browser/setupWorker/setupWorker.ts b/src/browser/setupWorker/setupWorker.ts index 54d446b95..4cc2ad14b 100644 --- a/src/browser/setupWorker/setupWorker.ts +++ b/src/browser/setupWorker/setupWorker.ts @@ -23,7 +23,7 @@ import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { SetupWorker } from './glossary' import { supportsReadableStreamTransfer } from '../utils/supportsReadableStreamTransfer' import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' -import { handleWebSocketEvent } from '~/core/utils/handleWebSocketEvent' +import { handleWebSocketEvent } from '~/core/ws/handleWebSocketEvent' import { attachWebSocketLogger } from '~/core/ws/utils/attachWebSocketLogger' interface Listener { diff --git a/src/core/utils/handleWebSocketEvent.ts b/src/core/ws/handleWebSocketEvent.ts similarity index 96% rename from src/core/utils/handleWebSocketEvent.ts rename to src/core/ws/handleWebSocketEvent.ts index 6649e359b..cfc6d18a9 100644 --- a/src/core/utils/handleWebSocketEvent.ts +++ b/src/core/ws/handleWebSocketEvent.ts @@ -1,7 +1,7 @@ import type { WebSocketConnectionData } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' import { RequestHandler } from '../handlers/RequestHandler' import { WebSocketHandler, kDispatchEvent } from '../handlers/WebSocketHandler' -import { webSocketInterceptor } from '../ws/webSocketInterceptor' +import { webSocketInterceptor } from './webSocketInterceptor' interface HandleWebSocketEventOptions { getHandlers: () => Array diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index c20b6bafb..4b7896d9b 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -35,9 +35,9 @@ function createWebSocketLinkHandler(url: Path) { return { clients: clientManager.clients, - on( - event: K, - listener: (...args: WebSocketHandlerEventMap[K]) => void, + on( + event: EventType, + listener: (...args: WebSocketHandlerEventMap[EventType]) => void, ): WebSocketHandler { const handler = new WebSocketHandler(url) diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts index 910f5e61b..8140582e8 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -18,7 +18,7 @@ import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { mergeRight } from '~/core/utils/internal/mergeRight' import { devUtils } from '~/core/utils/internal/devUtils' import type { SetupServerCommon } from './glossary' -import { handleWebSocketEvent } from '~/core/utils/handleWebSocketEvent' +import { handleWebSocketEvent } from '~/core/ws/handleWebSocketEvent' import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' export const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { From b29ceeac6dfc0c0a1e79bbd4346b6242b4dad437 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 2 Apr 2024 10:56:36 +0200 Subject: [PATCH 058/105] fix: export "WebSocketLink" type --- src/core/index.ts | 2 +- src/core/ws/ws.ts | 78 ++++++++++++++++++++++++++++------------------- 2 files changed, 47 insertions(+), 33 deletions(-) diff --git a/src/core/index.ts b/src/core/index.ts index 97564c760..79b2e945c 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -10,7 +10,7 @@ export { graphql } from './graphql' export { GraphQLHandler } from './handlers/GraphQLHandler' /* WebSocket handler */ -export { ws } from './ws/ws' +export { ws, type WebSocketLink } from './ws/ws' export { WebSocketHandler, type WebSocketHandlerEventMap, diff --git a/src/core/ws/ws.ts b/src/core/ws/ws.ts index 4b7896d9b..911ec5059 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws/ws.ts @@ -13,6 +13,47 @@ import { WebSocketClientManager } from './WebSocketClientManager' const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') +export type WebSocketLink = { + /** + * A set of all WebSocket clients connected + * to this link. + */ + clients: Set + + on( + event: EventType, + listener: (...args: WebSocketHandlerEventMap[EventType]) => void, + ): WebSocketHandler + + /** + * Broadcasts the given data to all WebSocket clients. + * + * @example + * const service = ws.link('wss://example.com') + * service.on('connection', () => { + * service.broadcast('hello, everyone!') + * }) + */ + broadcast(data: WebSocketData): void + + /** + * Broadcasts the given data to all WebSocket clients + * except the ones provided in the `clients` argument. + * + * @example + * const service = ws.link('wss://example.com') + * service.on('connection', ({ client }) => { + * service.broadcastExcept(client, 'hi, the rest of you!') + * }) + */ + broadcastExcept( + clients: + | WebSocketClientConnectionProtocol + | Array, + data: WebSocketData, + ): void +} + /** * Intercepts outgoing WebSocket connections to the given URL. * @@ -22,12 +63,12 @@ const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') * client.send('hello from server!') * }) */ -function createWebSocketLinkHandler(url: Path) { +function createWebSocketLinkHandler(url: Path): WebSocketLink { invariant(url, 'Expected a WebSocket server URL but got undefined') invariant( isPath(url), - 'Expected a WebSocket server URL but got %s', + 'Expected a WebSocket server URL to be a valid path but got %s', typeof url, ) @@ -35,10 +76,7 @@ function createWebSocketLinkHandler(url: Path) { return { clients: clientManager.clients, - on( - event: EventType, - listener: (...args: WebSocketHandlerEventMap[EventType]) => void, - ): WebSocketHandler { + on(event, listener) { const handler = new WebSocketHandler(url) // Add the connection event listener for when the @@ -58,38 +96,14 @@ function createWebSocketLinkHandler(url: Path) { return handler }, - /** - * Broadcasts the given data to all WebSocket clients. - * - * @example - * const service = ws.link('wss://example.com') - * service.on('connection', () => { - * service.broadcast('hello, everyone!') - * }) - */ - broadcast(data: WebSocketData): void { + broadcast(data) { // This will invoke "send()" on the immediate clients // in this runtime and post a message to the broadcast channel // to trigger send for the clients in other runtimes. this.broadcastExcept([], data) }, - /** - * Broadcasts the given data to all WebSocket clients - * except the ones provided in the `clients` argument. - * - * @example - * const service = ws.link('wss://example.com') - * service.on('connection', ({ client }) => { - * service.broadcastExcept(client, 'hi, the rest of you!') - * }) - */ - broadcastExcept( - clients: - | WebSocketClientConnectionProtocol - | Array, - data: WebSocketData, - ): void { + broadcastExcept(clients, data) { const ignoreClients = Array.prototype .concat(clients) .map((client) => client.id) From 2ace2897f2c3ae1a4e287868caa5685e3fafc017 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 2 Apr 2024 11:12:56 +0200 Subject: [PATCH 059/105] chore: move "ws" to core root --- src/core/index.ts | 2 +- src/core/{ws => }/ws.test.ts | 0 src/core/{ws => }/ws.ts | 6 +++--- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/core/{ws => }/ws.test.ts (100%) rename src/core/{ws => }/ws.ts (95%) diff --git a/src/core/index.ts b/src/core/index.ts index 79b2e945c..c4de17047 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -10,7 +10,7 @@ export { graphql } from './graphql' export { GraphQLHandler } from './handlers/GraphQLHandler' /* WebSocket handler */ -export { ws, type WebSocketLink } from './ws/ws' +export { ws, type WebSocketLink } from './ws' export { WebSocketHandler, type WebSocketHandlerEventMap, diff --git a/src/core/ws/ws.test.ts b/src/core/ws.test.ts similarity index 100% rename from src/core/ws/ws.test.ts rename to src/core/ws.test.ts diff --git a/src/core/ws/ws.ts b/src/core/ws.ts similarity index 95% rename from src/core/ws/ws.ts rename to src/core/ws.ts index 911ec5059..718d1ef0d 100644 --- a/src/core/ws/ws.ts +++ b/src/core/ws.ts @@ -7,9 +7,9 @@ import { WebSocketHandler, kEmitter, type WebSocketHandlerEventMap, -} from '../handlers/WebSocketHandler' -import { Path, isPath } from '../utils/matching/matchRequestUrl' -import { WebSocketClientManager } from './WebSocketClientManager' +} from './handlers/WebSocketHandler' +import { Path, isPath } from './utils/matching/matchRequestUrl' +import { WebSocketClientManager } from './ws/WebSocketClientManager' const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') From 718cf0eb287f9f0ea8133249f2f83f090d87cd53 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 2 Apr 2024 14:26:58 +0200 Subject: [PATCH 060/105] chore(release): v2.3.0-ws.rc-3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 813e0d92b..c30e536dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-2", + "version": "2.3.0-ws.rc-3", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From 1ffaed9e18c48129ab4f1c7f4f370366a19284b5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 2 Apr 2024 18:23:30 +0200 Subject: [PATCH 061/105] fix: use pretty colors for logs --- src/core/ws/utils/attachWebSocketLogger.ts | 25 +++++++---- .../browser/ws-api/ws.logging.browser.test.ts | 44 +++++++++++-------- 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/src/core/ws/utils/attachWebSocketLogger.ts b/src/core/ws/utils/attachWebSocketLogger.ts index bcc25fc81..97fc330bc 100644 --- a/src/core/ws/utils/attachWebSocketLogger.ts +++ b/src/core/ws/utils/attachWebSocketLogger.ts @@ -9,6 +9,13 @@ import { toPublicUrl } from '../../utils/request/toPublicUrl' import { getMessageLength } from './getMessageLength' import { getPublicData } from './getPublicData' +const colors = { + blue: '#3b82f6', + green: '#22c55e', + red: '#ef4444', + orange: '#ff6a33', +} + export function attachWebSocketLogger( connection: WebSocketConnectionData, ): void { @@ -111,8 +118,8 @@ export function logConnectionOpen(client: WebSocketClientConnection) { const publicUrl = toPublicUrl(client.url) console.groupCollapsed( - devUtils.formatMessage(`${getTimestamp()} %c▸%c ${publicUrl}`), - 'color:blue', + devUtils.formatMessage(`${getTimestamp()} %c▶%c ${publicUrl}`), + `color:${colors.blue}`, 'color:inherit', ) console.log('Client:', client.socket) @@ -132,7 +139,7 @@ export async function logOutgoingClientMessage( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c↑%c ${publicData} %c${byteLength}%c`, ), - 'color:green', + `color:${colors.green}`, 'color:inherit', 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', @@ -155,7 +162,7 @@ export async function logOutgoingMockedClientMessage( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c⇡%c ${publicData} %c${byteLength}%c`, ), - 'color:orangered', + `color:${colors.orange}`, 'color:inherit', 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', @@ -180,7 +187,7 @@ export async function logIncomingClientMessage( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c↓%c ${publicData} %c${byteLength}%c`, ), - 'color:red', + `color:${colors.red}`, 'color:inherit', 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', @@ -203,7 +210,7 @@ export async function logIncomingMockedClientMessage( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c⇣%c ${publicData} %c${byteLength}%c`, ), - 'color:orangered', + `color:${colors.orange}`, 'color:inherit', 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', @@ -220,7 +227,7 @@ function logConnectionClose(event: CloseEvent) { devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c■%c ${publicUrl}`, ), - 'color:blue', + `color:${colors.blue}`, 'color:inherit', ) console.log(event) @@ -237,7 +244,7 @@ export async function logIncomingServerMessage( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c⇣%c ${publicData} %c${byteLength}%c`, ), - 'color:orangered', + `color:${colors.green}`, 'color:inherit', 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', @@ -254,7 +261,7 @@ function logClientError(event: Event) { devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c\u00D7%c ${publicUrl}`, ), - 'color:red', + `color:${colors.blue}`, 'color:inherit', ) console.log(event) diff --git a/test/browser/ws-api/ws.logging.browser.test.ts b/test/browser/ws-api/ws.logging.browser.test.ts index 500f053bc..9ad968566 100644 --- a/test/browser/ws-api/ws.logging.browser.test.ts +++ b/test/browser/ws-api/ws.logging.browser.test.ts @@ -86,7 +86,7 @@ test('logs the client connection', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2} %c▸%c wss:\/\/example\.com\/path color:blue color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2} %c▶%c wss:\/\/example\.com\/path color:#3b82f6 color:inherit$/, ), ]), ) @@ -119,7 +119,7 @@ test('logs outgoing client event sending text data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c hello world %c11%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c hello world %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -152,7 +152,7 @@ test('logs outgoing client event sending a long text data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c this is an extremely lon… %c45%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c this is an extremely lon… %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -185,7 +185,7 @@ test('logs outgoing client event sending Blob data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c Blob\(hello world\) %c11%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c Blob\(hello world\) %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -219,7 +219,7 @@ test('logs outgoing client event sending a long Blob data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c Blob\(this is an extremely lon…\) %c45%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c Blob\(this is an extremely lon…\) %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -252,7 +252,7 @@ test('logs outgoing client event sending ArrayBuffer data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c ArrayBuffer\(hello world\) %c11%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c ArrayBuffer\(hello world\) %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -290,7 +290,7 @@ test('logs outgoing client event sending a long ArrayBuffer data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c ArrayBuffer\(this is an extremely lon…\) %c45%c color:green color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c ArrayBuffer\(this is an extremely lon…\) %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -345,7 +345,7 @@ test('logs incoming client events', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c hello from server %c17%c color:red color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c hello from server %c17%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -356,7 +356,7 @@ test('logs incoming client events', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c thanks, not bad %c15%c color:red color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c thanks, not bad %c15%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -404,13 +404,19 @@ test('logs raw incoming server events', async ({ await waitFor(() => { expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ - // Raw server message. + // The actual (raw) message recieved from the server. expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from server %c17%c color:orangered color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from server %c17%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), - // The actual message the client received (mocked). + + // The mocked message sent from the event handler (client.send()). + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c intercepted server event %c24%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + ), + + // The actual message the client received (i.e. mocked). expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c intercepted server event %c24%c color:red color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c intercepted server event %c24%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -443,7 +449,7 @@ test('logs the close event initiated by the client', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/example\.com\/path color:blue color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/example\.com\/path color:#3b82f6 color:inherit$/, ), ]), ) @@ -479,7 +485,7 @@ test('logs the close event initiated by the event handler', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/example\.com\/path color:blue color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/example\.com\/path color:#3b82f6 color:inherit$/, ), ]), ) @@ -516,7 +522,7 @@ test('logs outgoing client events sent vi "server.send()"', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇡%c hello from handler %c18%c color:orangered color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇡%c hello from handler %c18%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -552,7 +558,7 @@ test('logs incoming client events sent vi "client.send()"', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from handler %c18%c color:orangered color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from handler %c18%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -588,7 +594,7 @@ test('logs client errors received via "client.close()"', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c\u00D7%c wss:\/\/example\.com\/path color:red color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c\u00D7%c wss:\/\/example\.com\/path color:#3b82f6 color:inherit$/, ), ]), ) @@ -628,7 +634,7 @@ test('logs client errors received via server-sent close', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c\u00D7%c ws:\/\/(.+):\d{4,}\/ color:red color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c\u00D7%c ws:\/\/(.+):\d{4,}\/ color:#3b82f6 color:inherit$/, ), ]), ) From 46ffbc413e525fd7a3e0129d070949704e9111a1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 6 Apr 2024 18:19:56 +0200 Subject: [PATCH 062/105] test(ws): fix wrong assertion message --- src/core/ws.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/ws.test.ts b/src/core/ws.test.ts index b5a7ef46d..22cc58b47 100644 --- a/src/core/ws.test.ts +++ b/src/core/ws.test.ts @@ -19,5 +19,5 @@ it('throws an error when given a non-path argument to "ws.link()"', () => { expect(() => // @ts-expect-error Intentionally invalid argument. ws.link(2), - ).toThrow('Expected a WebSocket server URL but got number') + ).toThrow('Expected a WebSocket server URL to be a valid path but got number') }) From dddd4fb7f019a07bb9d3ae04300dec398f2e5414 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 7 Apr 2024 11:15:57 +0200 Subject: [PATCH 063/105] feat(ws): enable client-to-server forwarding by default --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- src/core/ws/handleWebSocketEvent.ts | 3 --- test/browser/ws-api/ws.logging.browser.test.ts | 7 +------ 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index c30e536dd..c9b5d4a28 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.15", + "@mswjs/interceptors": "^0.27.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6d2830d3..4de33bf40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ dependencies: specifier: ^1.1.0 version: 1.1.0 '@mswjs/interceptors': - specifier: ^0.26.15 - version: 0.26.15 + specifier: ^0.27.0 + version: 0.27.0 '@open-draft/until': specifier: ^2.1.0 version: 2.1.0 @@ -1453,8 +1453,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors@0.26.15: - resolution: {integrity: sha512-HM47Lu1YFmnYHKMBynFfjCp0U/yRskHj/8QEJW0CBEPOlw8Gkmjfll+S9b8M7V5CNDw2/ciRxjjnWeaCiblSIQ==} + /@mswjs/interceptors@0.27.0: + resolution: {integrity: sha512-Thxe9GXcw1rSlQA4eNs1j72MJrvl3PuzwnSk7OmevLoHkNGwltAWgGAK8EATCweAG41wKPmSjsA5mV9KUOwBew==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 diff --git a/src/core/ws/handleWebSocketEvent.ts b/src/core/ws/handleWebSocketEvent.ts index cfc6d18a9..115213863 100644 --- a/src/core/ws/handleWebSocketEvent.ts +++ b/src/core/ws/handleWebSocketEvent.ts @@ -51,9 +51,6 @@ export function handleWebSocketEvent(options: HandleWebSocketEventOptions) { // If none of the "ws" handlers matched, // establish the WebSocket connection as-is. connection.server.connect() - connection.client.addEventListener('message', (event) => { - connection.server.send(event.data) - }) } }) } diff --git a/test/browser/ws-api/ws.logging.browser.test.ts b/test/browser/ws-api/ws.logging.browser.test.ts index 9ad968566..b7393bbaa 100644 --- a/test/browser/ws-api/ws.logging.browser.test.ts +++ b/test/browser/ws-api/ws.logging.browser.test.ts @@ -323,9 +323,6 @@ test('logs incoming client events', async ({ const worker = setupWorker( api.on('connection', ({ client, server }) => { server.connect() - client.addEventListener('message', (event) => { - server.send(event.data) - }) }), ) await worker.start() @@ -383,9 +380,7 @@ test('logs raw incoming server events', async ({ const worker = setupWorker( api.on('connection', ({ client, server }) => { server.connect() - client.addEventListener('message', (event) => { - server.send(event.data) - }) + server.addEventListener('message', (event) => { event.preventDefault() // This is the only data the client will receive From 7d2a971ec2e9e63f96668cb45caeea37d833edf8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 9 Apr 2024 16:29:24 -0600 Subject: [PATCH 064/105] chore(release): v2.3.0-ws.rc-4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c9b5d4a28..86196e7c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-3", + "version": "2.3.0-ws.rc-4", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From f6a8723395e32907cf7f98d00f180e7a773f0800 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 9 Apr 2024 17:04:49 -0600 Subject: [PATCH 065/105] fix: update @mswjs/interceptors to 0.27.1 --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 86196e7c3..e185cce1f 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.27.0", + "@mswjs/interceptors": "^0.27.1", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4de33bf40..d1d0f84d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ dependencies: specifier: ^1.1.0 version: 1.1.0 '@mswjs/interceptors': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.27.1 + version: 0.27.1 '@open-draft/until': specifier: ^2.1.0 version: 2.1.0 @@ -1453,8 +1453,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors@0.27.0: - resolution: {integrity: sha512-Thxe9GXcw1rSlQA4eNs1j72MJrvl3PuzwnSk7OmevLoHkNGwltAWgGAK8EATCweAG41wKPmSjsA5mV9KUOwBew==} + /@mswjs/interceptors@0.27.1: + resolution: {integrity: sha512-V76Q1iKW/FO7j1nln5GN9alyVSKvgjSCm1ZQhxvMk+6IZybjPuE907POQLPhdvcSgp6vFpAPffTakfEnfs+46Q==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 From 74215a351c1f1a80ef8bf95ad066d64885e0323f Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 9 Apr 2024 17:34:09 -0600 Subject: [PATCH 066/105] chore(release): v2.3.0-ws.rc-5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e185cce1f..226583f4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-4", + "version": "2.3.0-ws.rc-5", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From ef0ebe38b7ee90bfa3a76e96d1d91216010ac355 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 12 Apr 2024 13:04:32 -0600 Subject: [PATCH 067/105] fix(WebSocketClientManager): use localStorage for clients persistence (#2127) --- src/browser/setupWorker/stop/createStop.ts | 4 + src/core/ws.ts | 6 +- src/core/ws/WebSocketClientManager.test.ts | 88 ++++---- src/core/ws/WebSocketClientManager.ts | 152 +++++++++---- .../browser/ws-api/ws.clients.browser.test.ts | 205 ++++++++++++++++++ 5 files changed, 364 insertions(+), 91 deletions(-) create mode 100644 test/browser/ws-api/ws.clients.browser.test.ts diff --git a/src/browser/setupWorker/stop/createStop.ts b/src/browser/setupWorker/stop/createStop.ts index 48c37996d..6c0e7b7d9 100644 --- a/src/browser/setupWorker/stop/createStop.ts +++ b/src/browser/setupWorker/stop/createStop.ts @@ -1,4 +1,5 @@ import { devUtils } from '~/core/utils/internal/devUtils' +import { MSW_WEBSOCKET_CLIENTS_KEY } from '~/core/ws/WebSocketClientManager' import { SetupWorkerInternalContext, StopHandler } from '../glossary' import { printStopMessage } from './utils/printStopMessage' @@ -24,6 +25,9 @@ export const createStop = ( context.isMockingEnabled = false window.clearInterval(context.keepAliveInterval) + // Clear the WebSocket clients from the shared storage. + localStorage.removeItem(MSW_WEBSOCKET_CLIENTS_KEY) + printStopMessage({ quiet: context.startOptions?.quiet }) } } diff --git a/src/core/ws.ts b/src/core/ws.ts index 718d1ef0d..77fb681ef 100644 --- a/src/core/ws.ts +++ b/src/core/ws.ts @@ -72,10 +72,12 @@ function createWebSocketLinkHandler(url: Path): WebSocketLink { typeof url, ) - const clientManager = new WebSocketClientManager(wsBroadcastChannel) + const clientManager = new WebSocketClientManager(wsBroadcastChannel, url) return { - clients: clientManager.clients, + get clients() { + return clientManager.clients + }, on(event, listener) { const handler = new WebSocketHandler(url) diff --git a/src/core/ws/WebSocketClientManager.test.ts b/src/core/ws/WebSocketClientManager.test.ts index 9cac29c04..8db322f8e 100644 --- a/src/core/ws/WebSocketClientManager.test.ts +++ b/src/core/ws/WebSocketClientManager.test.ts @@ -1,79 +1,73 @@ /** * @vitest-environment node-websocket */ -import { randomUUID } from 'node:crypto' import { WebSocketClientConnection, + WebSocketData, WebSocketTransport, } from '@mswjs/interceptors/WebSocket' import { WebSocketClientManager, WebSocketBroadcastChannelMessage, - WebSocketRemoteClientConnection, } from './WebSocketClientManager' const channel = new BroadcastChannel('test:channel') vi.spyOn(channel, 'postMessage') const socket = new WebSocket('ws://localhost') -const transport = { - onOutgoing: vi.fn(), - onIncoming: vi.fn(), - onClose: vi.fn(), - send: vi.fn(), - close: vi.fn(), -} satisfies WebSocketTransport + +class TestWebSocketTransport extends EventTarget implements WebSocketTransport { + send(_data: WebSocketData): void {} + close(_code?: number | undefined, _reason?: string | undefined): void {} +} afterEach(() => { vi.resetAllMocks() }) it('adds a client from this runtime to the list of clients', () => { - const manager = new WebSocketClientManager(channel) - const connection = new WebSocketClientConnection(socket, transport) + const manager = new WebSocketClientManager(channel, '*') + const connection = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) manager.addConnection(connection) // Must add the client to the list of clients. expect(Array.from(manager.clients.values())).toEqual([connection]) - - // Must emit the connection open event to notify other runtimes. - expect(channel.postMessage).toHaveBeenCalledWith({ - type: 'connection:open', - payload: { - clientId: connection.id, - url: socket.url, - }, - } satisfies WebSocketBroadcastChannelMessage) }) -it('adds a client from another runtime to the list of clients', async () => { - const clientId = randomUUID() - const url = new URL('ws://localhost') - const manager = new WebSocketClientManager(channel) +it('adds multiple clients from this runtime to the list of clients', () => { + const manager = new WebSocketClientManager(channel, '*') + const connectionOne = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) + manager.addConnection(connectionOne) - channel.dispatchEvent( - new MessageEvent('message', { - data: { - type: 'connection:open', - payload: { - clientId, - url: url.href, - }, - }, - }), + // Must add the client to the list of clients. + expect(Array.from(manager.clients.values())).toEqual([connectionOne]) + + const connectionTwo = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), ) + manager.addConnection(connectionTwo) - await vi.waitFor(() => { - expect(Array.from(manager.clients.values())).toEqual([ - new WebSocketRemoteClientConnection(clientId, url, channel), - ]) - }) + // Must add the new cilent to the list as well. + expect(Array.from(manager.clients.values())).toEqual([ + connectionOne, + connectionTwo, + ]) }) it('replays a "send" event coming from another runtime', async () => { - const manager = new WebSocketClientManager(channel) - const connection = new WebSocketClientConnection(socket, transport) + const manager = new WebSocketClientManager(channel, '*') + const connection = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) manager.addConnection(connection) vi.spyOn(connection, 'send') @@ -98,8 +92,11 @@ it('replays a "send" event coming from another runtime', async () => { }) it('replays a "close" event coming from another runtime', async () => { - const manager = new WebSocketClientManager(channel) - const connection = new WebSocketClientConnection(socket, transport) + const manager = new WebSocketClientManager(channel, '*') + const connection = new WebSocketClientConnection( + socket, + new TestWebSocketTransport(), + ) manager.addConnection(connection) vi.spyOn(connection, 'close') @@ -125,7 +122,8 @@ it('replays a "close" event coming from another runtime', async () => { }) it('removes the extraneous message listener when the connection closes', async () => { - const manager = new WebSocketClientManager(channel) + const manager = new WebSocketClientManager(channel, '*') + const transport = new TestWebSocketTransport() const connection = new WebSocketClientConnection(socket, transport) vi.spyOn(connection, 'close').mockImplementationOnce(() => { /** @@ -135,7 +133,7 @@ it('removes the extraneous message listener when the connection closes', async ( * All we care here is that closing the connection triggers * the transport closure, which it always does. */ - connection['transport'].onClose() + transport.dispatchEvent(new Event('close')) }) vi.spyOn(connection, 'send') diff --git a/src/core/ws/WebSocketClientManager.ts b/src/core/ws/WebSocketClientManager.ts index 4048878b6..e9d11d468 100644 --- a/src/core/ws/WebSocketClientManager.ts +++ b/src/core/ws/WebSocketClientManager.ts @@ -1,17 +1,14 @@ +import { invariant } from 'outvariant' import type { WebSocketData, WebSocketClientConnection, WebSocketClientConnectionProtocol, } from '@mswjs/interceptors/WebSocket' +import { matchRequestUrl, type Path } from '../utils/matching/matchRequestUrl' + +export const MSW_WEBSOCKET_CLIENTS_KEY = 'msw:ws:clients' export type WebSocketBroadcastChannelMessage = - | { - type: 'connection:open' - payload: { - clientId: string - url: string - } - } | { type: 'extraneous:send' payload: { @@ -28,33 +25,122 @@ export type WebSocketBroadcastChannelMessage = } } -export const kAddByClientId = Symbol('kAddByClientId') +type SerializedClient = { + clientId: string + url: string +} /** * A manager responsible for accumulating WebSocket client * connections across different browser runtimes. */ export class WebSocketClientManager { + private inMemoryClients: Set + + constructor( + private channel: BroadcastChannel, + private url: Path, + ) { + this.inMemoryClients = new Set() + + if (typeof localStorage !== 'undefined') { + // When the worker clears the local storage key in "worker.stop()", + // also clear the in-memory clients map. + localStorage.removeItem = new Proxy(localStorage.removeItem, { + apply: (target, thisArg, args) => { + const [key] = args + + if (key === MSW_WEBSOCKET_CLIENTS_KEY) { + this.inMemoryClients.clear() + } + + return Reflect.apply(target, thisArg, args) + }, + }) + } + } + /** * All active WebSocket client connections. */ - public clients: Set + get clients(): Set { + // In the browser, different runtimes use "localStorage" + // as the shared source of all the clients. + if (typeof localStorage !== 'undefined') { + const inMemoryClients = Array.from(this.inMemoryClients) + + console.log('get clients()', inMemoryClients, this.getSerializedClients()) + + return new Set( + inMemoryClients.concat( + this.getSerializedClients() + // Filter out the serialized clients that are already present + // in this runtime in-memory. This is crucial because a remote client + // wrapper CANNOT send a message to the client in THIS runtime + // (the "message" event on broadcast channel won't trigger). + .filter((serializedClient) => { + if ( + inMemoryClients.every( + (client) => client.id !== serializedClient.clientId, + ) + ) { + return serializedClient + } + }) + .map((serializedClient) => { + return new WebSocketRemoteClientConnection( + serializedClient.clientId, + new URL(serializedClient.url), + this.channel, + ) + }), + ), + ) + } + + // In Node.js, the manager acts as a singleton, and all clients + // are kept in-memory. + return this.inMemoryClients + } - constructor(private channel: BroadcastChannel) { - this.clients = new Set() + private getSerializedClients(): Array { + invariant( + typeof localStorage !== 'undefined', + 'Failed to call WebSocketClientManager#getSerializedClients() in a non-browser environment. This is likely a bug in MSW. Please, report it on GitHub: https://github.com/mswjs/msw', + ) - this.channel.addEventListener('message', (message) => { - const { type, payload } = message.data as WebSocketBroadcastChannelMessage + const clientsJson = localStorage.getItem(MSW_WEBSOCKET_CLIENTS_KEY) - switch (type) { - case 'connection:open': { - // When another runtime notifies about a new connection, - // create a connection wrapper class and add it to the set. - this.onRemoteConnection(payload.clientId, new URL(payload.url)) - break - } - } + if (!clientsJson) { + return [] + } + + const allClients = JSON.parse(clientsJson) as Array + const matchingClients = allClients.filter((client) => { + return matchRequestUrl(new URL(client.url), this.url).matches }) + + return matchingClients + } + + private addClient(client: WebSocketClientConnection): void { + this.inMemoryClients.add(client) + + if (typeof localStorage !== 'undefined') { + const serializedClients = this.getSerializedClients() + + // Serialize the current client for other runtimes to create + // a remote wrapper over it. This has no effect on the current runtime. + const nextSerializedClients = serializedClients.concat({ + clientId: client.id, + url: client.url.href, + } as SerializedClient) + + localStorage.setItem( + MSW_WEBSOCKET_CLIENTS_KEY, + JSON.stringify(nextSerializedClients), + ) + } } /** @@ -64,16 +150,7 @@ export class WebSocketClientManager { * for the opened connections in the same runtime. */ public addConnection(client: WebSocketClientConnection): void { - this.clients.add(client) - - // Signal to other runtimes about this connection. - this.channel.postMessage({ - type: 'connection:open', - payload: { - clientId: client.id, - url: client.url.toString(), - }, - } as WebSocketBroadcastChannelMessage) + this.addClient(client) // Instruct the current client how to handle events // coming from other runtimes (e.g. when calling `.broadcast()`). @@ -116,19 +193,6 @@ export class WebSocketClientManager { once: true, }) } - - /** - * Adds a client connection wrapper to operate with - * WebSocket client connections in other runtimes. - */ - private onRemoteConnection(id: string, url: URL): void { - this.clients.add( - // Create a connection-compatible instance that can - // operate with this client from a different runtime - // using the BroadcastChannel messages. - new WebSocketRemoteClientConnection(id, url, this.channel), - ) - } } /** diff --git a/test/browser/ws-api/ws.clients.browser.test.ts b/test/browser/ws-api/ws.clients.browser.test.ts new file mode 100644 index 000000000..4d0a4a77d --- /dev/null +++ b/test/browser/ws-api/ws.clients.browser.test.ts @@ -0,0 +1,205 @@ +import type { WebSocketLink, ws } from 'msw' +import type { SetupWorker, setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + worker: SetupWorker + link: WebSocketLink + ws: WebSocket + messages: string[] + } +} + +test('returns the number of active clients in the same runtime', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker(api.on('connection', () => {})) + window.link = api + await worker.start() + }) + + // Must return 0 when no clients are present. + expect( + await page.evaluate(() => { + return window.link.clients.size + }), + ).toBe(0) + + await page.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + // Must return 1 after a single client joined. + expect( + await page.evaluate(() => { + return window.link.clients.size + }), + ).toBe(1) + + await page.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + // Must return 2 now that another client has joined. + expect( + await page.evaluate(() => { + return window.link.clients.size + }), + ).toBe(2) +}) + +test('returns the number of active clients across different runtimes', async ({ + loadExample, + context, +}) => { + const { compilation } = await loadExample( + require.resolve('./ws.runtime.js'), + { + skipActivation: true, + }, + ) + + const pageOne = await context.newPage() + const pageTwo = await context.newPage() + + for (const page of [pageOne, pageTwo]) { + await page.goto(compilation.previewUrl) + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker(api.on('connection', () => {})) + window.link = api + await worker.start() + }) + } + + await pageOne.bringToFront() + await pageOne.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + expect(await pageOne.evaluate(() => window.link.clients.size)).toBe(1) + expect(await pageTwo.evaluate(() => window.link.clients.size)).toBe(1) + + await pageTwo.bringToFront() + await pageTwo.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + expect(await pageTwo.evaluate(() => window.link.clients.size)).toBe(2) + expect(await pageOne.evaluate(() => window.link.clients.size)).toBe(2) +}) + +test('broadcasts messages across runtimes', async ({ + loadExample, + context, +}) => { + const { compilation } = await loadExample( + require.resolve('./ws.runtime.js'), + { + skipActivation: true, + }, + ) + + const pageOne = await context.newPage() + const pageTwo = await context.newPage() + + for (const page of [pageOne, pageTwo]) { + await page.goto(compilation.previewUrl) + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker( + api.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + api.broadcast(event.data) + }) + }), + ) + await worker.start() + }) + + await page.evaluate(() => { + window.messages = [] + const ws = new WebSocket('wss://example.com') + window.ws = ws + ws.onmessage = (event) => { + window.messages.push(event.data) + } + }) + } + + await pageOne.evaluate(() => { + window.ws.send('hi from one') + }) + expect(await pageOne.evaluate(() => window.messages)).toEqual(['hi from one']) + expect(await pageTwo.evaluate(() => window.messages)).toEqual(['hi from one']) + + await pageTwo.evaluate(() => { + window.ws.send('hi from two') + }) + + expect(await pageTwo.evaluate(() => window.messages)).toEqual([ + 'hi from one', + 'hi from two', + ]) + expect(await pageOne.evaluate(() => window.messages)).toEqual([ + 'hi from one', + 'hi from two', + ]) +}) + +test('clears the list of clients when the worker is stopped', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker(api.on('connection', () => {})) + window.link = api + window.worker = worker + await worker.start() + }) + + await page.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + // Must return 1 after a single client joined. + expect( + await page.evaluate(() => { + return window.link.clients.size + }), + ).toBe(1) + + await page.evaluate(() => { + window.worker.stop() + }) + + // Must return 0. + // The localStorage has been purged, and the in-memory manager clients too. + expect(await page.evaluate(() => window.link.clients.size)).toBe(0) +}) From 25da0dad4ca6f2a034a1baa567f3f82a2edcf003 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 12 Apr 2024 13:05:07 -0600 Subject: [PATCH 068/105] chore(release): v2.3.0-ws.rc-6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 226583f4c..bd22b5894 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-5", + "version": "2.3.0-ws.rc-6", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From 026f24f27e1cc1bb91fea119dcc74f376aff48b6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 15 Apr 2024 22:51:40 +0200 Subject: [PATCH 069/105] fix: purge persisted clients on page reload (#2133) --- .../setupWorker/start/createStartHandler.ts | 6 ++ src/core/ws/WebSocketClientManager.ts | 3 +- .../browser/ws-api/ws.clients.browser.test.ts | 55 ++++++++++++++++--- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/browser/setupWorker/start/createStartHandler.ts b/src/browser/setupWorker/start/createStartHandler.ts index dd9c35ebb..6629590f2 100644 --- a/src/browser/setupWorker/start/createStartHandler.ts +++ b/src/browser/setupWorker/start/createStartHandler.ts @@ -1,4 +1,5 @@ import { devUtils } from '~/core/utils/internal/devUtils' +import { MSW_WEBSOCKET_CLIENTS_KEY } from '~/core/ws/WebSocketClientManager' import { getWorkerInstance } from './utils/getWorkerInstance' import { enableMocking } from './utils/enableMocking' import { SetupWorkerInternalContext, StartHandler } from '../glossary' @@ -71,6 +72,11 @@ Please consider using a custom "serviceWorker.url" option to point to the actual // Make sure we're always clearing the interval - there are reports that not doing this can // cause memory leaks in headless browser environments. window.clearInterval(context.keepAliveInterval) + + // Purge persisted clients on page reload. + // WebSocket clients will get new IDs on reload so persisting them + // makes little sense. + localStorage.removeItem(MSW_WEBSOCKET_CLIENTS_KEY) }) // Check if the active Service Worker has been generated diff --git a/src/core/ws/WebSocketClientManager.ts b/src/core/ws/WebSocketClientManager.ts index e9d11d468..0a7680736 100644 --- a/src/core/ws/WebSocketClientManager.ts +++ b/src/core/ws/WebSocketClientManager.ts @@ -43,9 +43,8 @@ export class WebSocketClientManager { ) { this.inMemoryClients = new Set() + // Purge in-memory clients when the worker stops. if (typeof localStorage !== 'undefined') { - // When the worker clears the local storage key in "worker.stop()", - // also clear the in-memory clients map. localStorage.removeItem = new Proxy(localStorage.removeItem, { apply: (target, thisArg, args) => { const [key] = args diff --git a/test/browser/ws-api/ws.clients.browser.test.ts b/test/browser/ws-api/ws.clients.browser.test.ts index 4d0a4a77d..f811a3838 100644 --- a/test/browser/ws-api/ws.clients.browser.test.ts +++ b/test/browser/ws-api/ws.clients.browser.test.ts @@ -183,23 +183,62 @@ test('clears the list of clients when the worker is stopped', async ({ await worker.start() }) + expect(await page.evaluate(() => window.link.clients.size)).toBe(0) + await page.evaluate(async () => { const ws = new WebSocket('wss://example.com') await new Promise((done) => (ws.onopen = done)) }) - // Must return 1 after a single client joined. - expect( - await page.evaluate(() => { - return window.link.clients.size - }), - ).toBe(1) + // Must return the number of joined clients. + expect(await page.evaluate(() => window.link.clients.size)).toBe(1) await page.evaluate(() => { window.worker.stop() }) - // Must return 0. - // The localStorage has been purged, and the in-memory manager clients too. + // Must purge the local storage on reload. + // The worker has been started as a part of the test, not runtime, + // so it will start with empty clients. + expect(await page.evaluate(() => window.link.clients.size)).toBe(0) +}) + +test('clears the list of clients when the page is reloaded', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + const enableMocking = async () => { + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://example.com') + const worker = setupWorker(api.on('connection', () => {})) + window.link = api + window.worker = worker + await worker.start() + }) + } + + await enableMocking(page) + + expect(await page.evaluate(() => window.link.clients.size)).toBe(0) + + await page.evaluate(async () => { + const ws = new WebSocket('wss://example.com') + await new Promise((done) => (ws.onopen = done)) + }) + + // Must return the number of joined clients. + expect(await page.evaluate(() => window.link.clients.size)).toBe(1) + + await page.reload() + await enableMocking() + + // Must purge the local storage on reload. + // The worker has been started as a part of the test, not runtime, + // so it will start with empty clients. expect(await page.evaluate(() => window.link.clients.size)).toBe(0) }) From f5f6020f13e9a952ee962b36becb427e3f267052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABlle=20Huisman?= Date: Thu, 13 Jun 2024 10:48:14 +0200 Subject: [PATCH 070/105] fix: update `@mswjs/interceptors` to 0.30.0 (#2175) --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- src/core/handlers/WebSocketHandler.ts | 3 +-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index ec89d5e9c..1b5f51657 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.29.0", + "@mswjs/interceptors": "^0.30.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bea03f1df..2d58daee8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ dependencies: specifier: ^1.1.0 version: 1.1.0 '@mswjs/interceptors': - specifier: ^0.29.0 - version: 0.29.0 + specifier: ^0.30.0 + version: 0.30.0 '@open-draft/until': specifier: ^2.1.0 version: 2.1.0 @@ -1453,8 +1453,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors@0.29.0: - resolution: {integrity: sha512-eppU9TxaRS2t5IcR00nuh+36zMHcK09pyhUvWJLO1ae5+U8KL7iatUGKlLUlbxXaq3BvDjlcF0Q8Xhzyosk/xA==} + /@mswjs/interceptors@0.30.0: + resolution: {integrity: sha512-wOsatc3+3LALw7JuvBZGvx8rg2Qz2nGNJrGvcH0qnBpDNNOCS7wHPPK90UIUwp7fB/eRPLPSawdzMBaxSifU1Q==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts index bf916f37d..1cd5043d9 100644 --- a/src/core/handlers/WebSocketHandler.ts +++ b/src/core/handlers/WebSocketHandler.ts @@ -59,8 +59,7 @@ export class WebSocketHandler { const connection = event.data const resolvedConnection: WebSocketHandlerConnection = { - client: connection.client, - server: connection.server, + ...connection, params: parsedResult.match.params || {}, } From d7a6ce9cdc87d1d180ab7d249cfafc729040738a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 13 Jun 2024 12:53:29 +0200 Subject: [PATCH 071/105] chore(release): v2.3.0-ws.rc-7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1b5f51657..54abd1a18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-6", + "version": "2.3.0-ws.rc-7", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From f6922228f3ff47e059eb29480daa5dc6172316bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABlle=20Huisman?= Date: Fri, 5 Jul 2024 16:48:03 +0200 Subject: [PATCH 072/105] fix(ws): call BroadcastChannel.unref in Node.js environment (#2196) --- src/core/ws.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/core/ws.ts b/src/core/ws.ts index 77fb681ef..d4a2b1171 100644 --- a/src/core/ws.ts +++ b/src/core/ws.ts @@ -11,8 +11,20 @@ import { import { Path, isPath } from './utils/matching/matchRequestUrl' import { WebSocketClientManager } from './ws/WebSocketClientManager' +function isBroadcastChannelWithUnref( + channel: BroadcastChannel, +): channel is BroadcastChannel & NodeJS.RefCounted { + return typeof Reflect.get(channel, 'unref') !== 'undefined' +} + const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') +if (isBroadcastChannelWithUnref(wsBroadcastChannel)) { + // Allows the Node.js thread to exit if it is the only active handle in the event system. + // https://nodejs.org/api/worker_threads.html#broadcastchannelunref + wsBroadcastChannel.unref() +} + export type WebSocketLink = { /** * A set of all WebSocket clients connected From 1889dab225e963f4c295aea85879875ab37e4c84 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 31 Jul 2024 18:17:12 +0200 Subject: [PATCH 073/105] chore: polish the implementation --- src/core/ws.ts | 8 ++++++++ src/core/ws/WebSocketClientManager.ts | 2 -- test/node/ws-api/ws.intercept.client.test.ts | 4 +--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/core/ws.ts b/src/core/ws.ts index d4a2b1171..57eb278f0 100644 --- a/src/core/ws.ts +++ b/src/core/ws.ts @@ -131,6 +131,14 @@ function createWebSocketLinkHandler(url: Path): WebSocketLink { } } +/** + * A namespace to intercept and mock WebSocket connections. + * + * @example + * const chat = ws.link('wss://chat.example.com') + * + * @see {@link https://mswjs.io/docs/api/ws `ws` API reference} + */ export const ws = { link: createWebSocketLinkHandler, } diff --git a/src/core/ws/WebSocketClientManager.ts b/src/core/ws/WebSocketClientManager.ts index 0a7680736..aa830e280 100644 --- a/src/core/ws/WebSocketClientManager.ts +++ b/src/core/ws/WebSocketClientManager.ts @@ -68,8 +68,6 @@ export class WebSocketClientManager { if (typeof localStorage !== 'undefined') { const inMemoryClients = Array.from(this.inMemoryClients) - console.log('get clients()', inMemoryClients, this.getSerializedClients()) - return new Set( inMemoryClients.concat( this.getSerializedClients() diff --git a/test/node/ws-api/ws.intercept.client.test.ts b/test/node/ws-api/ws.intercept.client.test.ts index 9f9380482..3e0345705 100644 --- a/test/node/ws-api/ws.intercept.client.test.ts +++ b/test/node/ws-api/ws.intercept.client.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment node-websocket - */ +// @vitest-environment node-websocket import { ws } from 'msw' import { setupServer } from 'msw/node' import { WebSocketServer } from '../../support/WebSocketServer' From a556b7c6b453070538cb92db861e18caef1440c4 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 31 Jul 2024 18:23:27 +0200 Subject: [PATCH 074/105] docs: add jsdoc to "ws" link --- src/core/ws.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/core/ws.ts b/src/core/ws.ts index 57eb278f0..96de7cb54 100644 --- a/src/core/ws.ts +++ b/src/core/ws.ts @@ -29,9 +29,20 @@ export type WebSocketLink = { /** * A set of all WebSocket clients connected * to this link. + * + * @see {@link https://mswjs.io/docs/api/ws#clients `clients` API reference} */ clients: Set + /** + * Adds an event listener to this WebSocket link. + * + * @example + * const chat = ws.link('wss://chat.example.com') + * chat.on('connection', listener) + * + * @see {@link https://mswjs.io/docs/api/ws#onevent-listener `on()` API reference} + */ on( event: EventType, listener: (...args: WebSocketHandlerEventMap[EventType]) => void, @@ -45,6 +56,8 @@ export type WebSocketLink = { * service.on('connection', () => { * service.broadcast('hello, everyone!') * }) + * + * @see {@link https://mswjs.io/docs/api/ws#broadcastdata `broadcast()` API reference} */ broadcast(data: WebSocketData): void @@ -57,6 +70,8 @@ export type WebSocketLink = { * service.on('connection', ({ client }) => { * service.broadcastExcept(client, 'hi, the rest of you!') * }) + * + * @see {@link https://mswjs.io/docs/api/ws#broadcastexceptclients-data `broadcast()` API reference} */ broadcastExcept( clients: @@ -138,6 +153,7 @@ function createWebSocketLinkHandler(url: Path): WebSocketLink { * const chat = ws.link('wss://chat.example.com') * * @see {@link https://mswjs.io/docs/api/ws `ws` API reference} + * @see {@link https://mswjs.io/docs/basics/handling-websocket-events Handling WebSocket events} */ export const ws = { link: createWebSocketLinkHandler, From f27dd03ccca0f152ab18f6deb0480e7ae5ec4363 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 1 Aug 2024 14:17:05 +0200 Subject: [PATCH 075/105] chore: update @mswjs/interceptors --- package.json | 2 +- pnpm-lock.yaml | 16 ++++++---- .../start/createFallbackRequestListener.ts | 4 +-- src/node/SetupServerCommonApi.ts | 31 ++++++++++--------- 4 files changed, 30 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index 5f3273b45..c768842bf 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^3.0.0", - "@mswjs/interceptors": "^0.30.0", + "@mswjs/interceptors": "^0.34.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c56ada179..48f34fbec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ dependencies: specifier: ^3.0.0 version: 3.1.1 '@mswjs/interceptors': - specifier: ^0.30.0 - version: 0.30.0 + specifier: ^0.34.0 + version: 0.34.0 '@open-draft/until': specifier: ^2.1.0 version: 2.1.0 @@ -1455,15 +1455,15 @@ packages: - utf-8-validate dev: true - /@mswjs/interceptors@0.30.0: - resolution: {integrity: sha512-wOsatc3+3LALw7JuvBZGvx8rg2Qz2nGNJrGvcH0qnBpDNNOCS7wHPPK90UIUwp7fB/eRPLPSawdzMBaxSifU1Q==} + /@mswjs/interceptors@0.34.0: + resolution: {integrity: sha512-XB6f2oks1dsRBgK3JlSUhSTI9z73DrTWM9DwJjHnZ6iyZmlGP/G2Kia0OEW2CIMbErDhfg+dYP8HDhqoWnsemQ==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 '@open-draft/until': 2.1.0 is-node-process: 1.2.0 - outvariant: 1.4.2 + outvariant: 1.4.3 strict-event-emitter: 0.5.1 dev: false @@ -1495,7 +1495,7 @@ packages: resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} dependencies: is-node-process: 1.2.0 - outvariant: 1.4.2 + outvariant: 1.4.3 dev: false /@open-draft/test-server@0.4.2: @@ -6478,6 +6478,10 @@ packages: /outvariant@1.4.2: resolution: {integrity: sha512-Ou3dJ6bA/UJ5GVHxah4LnqDwZRwAmWxrG3wtrHrbGnP4RnLCtA64A4F+ae7Y8ww660JaddSoArUR5HjipWSHAQ==} + /outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + dev: false + /p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} diff --git a/src/browser/setupWorker/start/createFallbackRequestListener.ts b/src/browser/setupWorker/start/createFallbackRequestListener.ts index e7376e955..a87e5da81 100644 --- a/src/browser/setupWorker/start/createFallbackRequestListener.ts +++ b/src/browser/setupWorker/start/createFallbackRequestListener.ts @@ -18,7 +18,7 @@ export function createFallbackRequestListener( interceptors: [new FetchInterceptor(), new XMLHttpRequestInterceptor()], }) - interceptor.on('request', async ({ request, requestId }) => { + interceptor.on('request', async ({ request, requestId, controller }) => { const requestCloneForLogs = request.clone() const response = await handleRequest( @@ -43,7 +43,7 @@ export function createFallbackRequestListener( ) if (response) { - request.respondWith(response) + controller.respondWith(response) } }) diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts index 111bd6581..041197fd4 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -55,21 +55,24 @@ export class SetupServerCommonApi * Subscribe to all requests that are using the interceptor object */ private init(): void { - this.interceptor.on('request', async ({ request, requestId }) => { - const response = await handleRequest( - request, - requestId, - this.handlersController.currentHandlers(), - this.resolvedOptions, - this.emitter, - ) - - if (response) { - request.respondWith(response) - } + this.interceptor.on( + 'request', + async ({ request, requestId, controller }) => { + const response = await handleRequest( + request, + requestId, + this.handlersController.currentHandlers(), + this.resolvedOptions, + this.emitter, + ) - return - }) + if (response) { + controller.respondWith(response) + } + + return + }, + ) this.interceptor.on('unhandledException', ({ error }) => { if (error instanceof InternalError) { From 8dd31a1bb99fc329f7bc9c1c74e698d48d463490 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 6 Aug 2024 12:10:44 +0200 Subject: [PATCH 076/105] test(ws.logging): closure do not trigger errors --- .../browser/ws-api/ws.logging.browser.test.ts | 116 +++++++++++++----- 1 file changed, 82 insertions(+), 34 deletions(-) diff --git a/test/browser/ws-api/ws.logging.browser.test.ts b/test/browser/ws-api/ws.logging.browser.test.ts index b7393bbaa..0fa404a60 100644 --- a/test/browser/ws-api/ws.logging.browser.test.ts +++ b/test/browser/ws-api/ws.logging.browser.test.ts @@ -2,7 +2,6 @@ import type { ws } from 'msw' import type { setupWorker } from 'msw/browser' import { test, expect } from '../playwright.extend' import { WebSocketServer } from '../../support/WebSocketServer' -import { waitFor } from '../../support/waitFor' declare global { interface Window { @@ -39,17 +38,17 @@ test('does not log anything if "quiet" was set to "true"', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw - const api = ws.link('wss://example.com/*') + const api = ws.link('wss://localhost/*') const worker = setupWorker(api.on('connection', () => {})) await worker.start({ quiet: true }) }) await page.evaluate(() => { - const ws = new WebSocket('wss://example.com/path') + const ws = new WebSocket('wss://localhost/path') ws.onopen = () => { ws.send('hello') ws.send('world') - queueMicrotask(() => ws.close()) + ws.close() } return new Promise((resolve, reject) => { @@ -65,6 +64,7 @@ test('logs the client connection', async ({ loadExample, page, spyOnConsole, + waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(require.resolve('./ws.runtime.js'), { @@ -73,20 +73,20 @@ test('logs the client connection', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw - const api = ws.link('wss://example.com/*') + const api = ws.link('wss://localhost/*') const worker = setupWorker(api.on('connection', () => {})) await worker.start() }) await page.evaluate(() => { - new WebSocket('wss://example.com/path') + new WebSocket('wss://localhost/path') }) await waitFor(() => { expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2} %c▶%c wss:\/\/example\.com\/path color:#3b82f6 color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2} %c▶%c wss:\/\/localhost\/path color:#3b82f6 color:inherit$/, ), ]), ) @@ -97,6 +97,7 @@ test('logs outgoing client event sending text data', async ({ loadExample, page, spyOnConsole, + waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(require.resolve('./ws.runtime.js'), { @@ -105,13 +106,13 @@ test('logs outgoing client event sending text data', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw - const api = ws.link('wss://example.com/*') + const api = ws.link('wss://localhost/*') const worker = setupWorker(api.on('connection', () => {})) await worker.start() }) await page.evaluate(() => { - const ws = new WebSocket('wss://example.com/path') + const ws = new WebSocket('wss://localhost/path') ws.onopen = () => ws.send('hello world') }) @@ -130,6 +131,7 @@ test('logs outgoing client event sending a long text data', async ({ loadExample, page, spyOnConsole, + waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(require.resolve('./ws.runtime.js'), { @@ -138,13 +140,13 @@ test('logs outgoing client event sending a long text data', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw - const api = ws.link('wss://example.com/*') + const api = ws.link('wss://localhost/*') const worker = setupWorker(api.on('connection', () => {})) await worker.start() }) await page.evaluate(() => { - const ws = new WebSocket('wss://example.com/path') + const ws = new WebSocket('wss://localhost/path') ws.onopen = () => ws.send('this is an extremely long sentence to log out') }) @@ -163,6 +165,7 @@ test('logs outgoing client event sending Blob data', async ({ loadExample, page, spyOnConsole, + waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(require.resolve('./ws.runtime.js'), { @@ -171,13 +174,13 @@ test('logs outgoing client event sending Blob data', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw - const api = ws.link('wss://example.com/*') + const api = ws.link('wss://localhost/*') const worker = setupWorker(api.on('connection', () => {})) await worker.start() }) await page.evaluate(() => { - const ws = new WebSocket('wss://example.com/path') + const ws = new WebSocket('wss://localhost/path') ws.onopen = () => ws.send(new Blob(['hello world'])) }) @@ -196,6 +199,7 @@ test('logs outgoing client event sending a long Blob data', async ({ loadExample, page, spyOnConsole, + waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(require.resolve('./ws.runtime.js'), { @@ -204,13 +208,13 @@ test('logs outgoing client event sending a long Blob data', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw - const api = ws.link('wss://example.com/*') + const api = ws.link('wss://localhost/*') const worker = setupWorker(api.on('connection', () => {})) await worker.start() }) await page.evaluate(() => { - const ws = new WebSocket('wss://example.com/path') + const ws = new WebSocket('wss://localhost/path') ws.onopen = () => ws.send(new Blob(['this is an extremely long sentence to log out'])) }) @@ -230,6 +234,7 @@ test('logs outgoing client event sending ArrayBuffer data', async ({ loadExample, page, spyOnConsole, + waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(require.resolve('./ws.runtime.js'), { @@ -238,13 +243,13 @@ test('logs outgoing client event sending ArrayBuffer data', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw - const api = ws.link('wss://example.com/*') + const api = ws.link('wss://localhost/*') const worker = setupWorker(api.on('connection', () => {})) await worker.start() }) await page.evaluate(() => { - const ws = new WebSocket('wss://example.com/path') + const ws = new WebSocket('wss://localhost/path') ws.onopen = () => ws.send(new TextEncoder().encode('hello world')) }) @@ -263,6 +268,7 @@ test('logs outgoing client event sending a long ArrayBuffer data', async ({ loadExample, page, spyOnConsole, + waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(require.resolve('./ws.runtime.js'), { @@ -271,13 +277,13 @@ test('logs outgoing client event sending a long ArrayBuffer data', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw - const api = ws.link('wss://example.com/*') + const api = ws.link('wss://localhost/*') const worker = setupWorker(api.on('connection', () => {})) await worker.start() }) await page.evaluate(() => { - const ws = new WebSocket('wss://example.com/path') + const ws = new WebSocket('wss://localhost/path') ws.onopen = () => ws.send( new TextEncoder().encode( @@ -301,6 +307,7 @@ test('logs incoming client events', async ({ loadExample, page, spyOnConsole, + waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(require.resolve('./ws.runtime.js'), { @@ -364,6 +371,7 @@ test('logs raw incoming server events', async ({ loadExample, page, spyOnConsole, + waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(require.resolve('./ws.runtime.js'), { @@ -422,6 +430,7 @@ test('logs the close event initiated by the client', async ({ loadExample, page, spyOnConsole, + waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(require.resolve('./ws.runtime.js'), { @@ -430,13 +439,13 @@ test('logs the close event initiated by the client', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw - const api = ws.link('wss://example.com/*') + const api = ws.link('wss://localhost/*') const worker = setupWorker(api.on('connection', () => {})) await worker.start() }) await page.evaluate(() => { - const ws = new WebSocket('wss://example.com/path') + const ws = new WebSocket('wss://localhost/path') ws.onopen = () => ws.close() }) @@ -444,7 +453,7 @@ test('logs the close event initiated by the client', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/example\.com\/path color:#3b82f6 color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/localhost\/path color:#3b82f6 color:inherit$/, ), ]), ) @@ -455,6 +464,7 @@ test('logs the close event initiated by the event handler', async ({ loadExample, page, spyOnConsole, + waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(require.resolve('./ws.runtime.js'), { @@ -463,7 +473,7 @@ test('logs the close event initiated by the event handler', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw - const api = ws.link('wss://example.com/*') + const api = ws.link('wss://localhost/*') const worker = setupWorker( api.on('connection', ({ client }) => { client.close() @@ -473,14 +483,14 @@ test('logs the close event initiated by the event handler', async ({ }) await page.evaluate(() => { - new WebSocket('wss://example.com/path') + new WebSocket('wss://localhost/path') }) await waitFor(() => { expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/example\.com\/path color:#3b82f6 color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/localhost\/path color:#3b82f6 color:inherit$/, ), ]), ) @@ -491,6 +501,7 @@ test('logs outgoing client events sent vi "server.send()"', async ({ loadExample, page, spyOnConsole, + waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(require.resolve('./ws.runtime.js'), { @@ -528,6 +539,7 @@ test('logs incoming client events sent vi "client.send()"', async ({ loadExample, page, spyOnConsole, + waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(require.resolve('./ws.runtime.js'), { @@ -560,10 +572,45 @@ test('logs incoming client events sent vi "client.send()"', async ({ }) }) -test('logs client errors received via "client.close()"', async ({ +test('logs connection closure initiated by the client', async ({ loadExample, + spyOnConsole, page, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://localhost/*') + const worker = setupWorker(api.on('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/path') + ws.onopen = () => ws.close() + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/localhost\/path color:#3b82f6 color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs connection closure initiated by the interceptor', async ({ + loadExample, spyOnConsole, + page, + waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(require.resolve('./ws.runtime.js'), { @@ -572,34 +619,35 @@ test('logs client errors received via "client.close()"', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw - const api = ws.link('wss://example.com/*') + const api = ws.link('wss://localhost/*') const worker = setupWorker( api.on('connection', ({ client }) => { - queueMicrotask(() => client.close(1003, 'Custom error')) + client.close(1003, 'Custom error') }), ) await worker.start() }) await page.evaluate(() => { - new WebSocket('wss://example.com/path') + new WebSocket('wss://localhost/path') }) await waitFor(() => { expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c\u00D7%c wss:\/\/example\.com\/path color:#3b82f6 color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/localhost\/path color:#3b82f6 color:inherit$/, ), ]), ) }) }) -test('logs client errors received via server-sent close', async ({ +test('logs connection closure initiated by the original server', async ({ loadExample, - page, spyOnConsole, + page, + waitFor, }) => { const consoleSpy = spyOnConsole() await loadExample(require.resolve('./ws.runtime.js'), { @@ -607,7 +655,7 @@ test('logs client errors received via server-sent close', async ({ }) server.on('connection', (ws) => { - queueMicrotask(() => ws.close(1003)) + ws.close(1003) }) await page.evaluate(async (url) => { @@ -629,7 +677,7 @@ test('logs client errors received via server-sent close', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c\u00D7%c ws:\/\/(.+):\d{4,}\/ color:#3b82f6 color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c ws:\/\/(.+):\d{4,}\/ color:#3b82f6 color:inherit$/, ), ]), ) From 6e0cbc61c9082e5b86698eb7dc3ea81136ff333b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 17 Sep 2024 12:27:25 +0200 Subject: [PATCH 077/105] feat(ws): use IndexedDB to store clients --- package.json | 1 + pnpm-lock.yaml | 3 + src/core/ws.ts | 8 +- src/core/ws/WebSocketClientManager.ts | 210 +++++++++++------- .../browser/ws-api/ws.clients.browser.test.ts | 9 +- 5 files changed, 143 insertions(+), 88 deletions(-) diff --git a/package.json b/package.json index 3d0e5613d..ad413d7fe 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^3.0.0", "@mswjs/interceptors": "^0.35.6", + "@open-draft/deferred-promise": "^2.2.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac7892679..18e78b213 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ dependencies: '@mswjs/interceptors': specifier: ^0.35.6 version: 0.35.6 + '@open-draft/deferred-promise': + specifier: ^2.2.0 + version: 2.2.0 '@open-draft/until': specifier: ^2.1.0 version: 2.1.0 diff --git a/src/core/ws.ts b/src/core/ws.ts index 96de7cb54..fe35d5d4c 100644 --- a/src/core/ws.ts +++ b/src/core/ws.ts @@ -17,12 +17,12 @@ function isBroadcastChannelWithUnref( return typeof Reflect.get(channel, 'unref') !== 'undefined' } -const wsBroadcastChannel = new BroadcastChannel('msw:ws-client-manager') +const webSocketChannel = new BroadcastChannel('msw:websocket-client-manager') -if (isBroadcastChannelWithUnref(wsBroadcastChannel)) { +if (isBroadcastChannelWithUnref(webSocketChannel)) { // Allows the Node.js thread to exit if it is the only active handle in the event system. // https://nodejs.org/api/worker_threads.html#broadcastchannelunref - wsBroadcastChannel.unref() + webSocketChannel.unref() } export type WebSocketLink = { @@ -99,7 +99,7 @@ function createWebSocketLinkHandler(url: Path): WebSocketLink { typeof url, ) - const clientManager = new WebSocketClientManager(wsBroadcastChannel, url) + const clientManager = new WebSocketClientManager(webSocketChannel, url) return { get clients() { diff --git a/src/core/ws/WebSocketClientManager.ts b/src/core/ws/WebSocketClientManager.ts index aa830e280..ce1766eac 100644 --- a/src/core/ws/WebSocketClientManager.ts +++ b/src/core/ws/WebSocketClientManager.ts @@ -1,13 +1,21 @@ -import { invariant } from 'outvariant' +import { DeferredPromise } from '@open-draft/deferred-promise' import type { WebSocketData, WebSocketClientConnection, WebSocketClientConnectionProtocol, } from '@mswjs/interceptors/WebSocket' -import { matchRequestUrl, type Path } from '../utils/matching/matchRequestUrl' +import { type Path } from '../utils/matching/matchRequestUrl' +/** + * @todo REMOVE this. + * This is used by the worker to notify this manager when the worker closes + * by removing this key from localStorage. + */ export const MSW_WEBSOCKET_CLIENTS_KEY = 'msw:ws:clients' +const DB_NAME = 'msw-websocket-clients' +const DB_STORE_NAME = 'clients' + export type WebSocketBroadcastChannelMessage = | { type: 'extraneous:send' @@ -26,7 +34,7 @@ export type WebSocketBroadcastChannelMessage = } type SerializedClient = { - clientId: string + id: string url: string } @@ -35,109 +43,143 @@ type SerializedClient = { * connections across different browser runtimes. */ export class WebSocketClientManager { + private db: Promise + private runtimeClients: Map private inMemoryClients: Set constructor( private channel: BroadcastChannel, private url: Path, ) { + this.db = this.createDatabase() + this.runtimeClients = new Map() this.inMemoryClients = new Set() - // Purge in-memory clients when the worker stops. - if (typeof localStorage !== 'undefined') { - localStorage.removeItem = new Proxy(localStorage.removeItem, { - apply: (target, thisArg, args) => { - const [key] = args - - if (key === MSW_WEBSOCKET_CLIENTS_KEY) { - this.inMemoryClients.clear() - } + this.channel.addEventListener('message', (message) => { + if (message.data?.type === 'db:update') { + this.flushDatabaseToMemory() + } + }) - return Reflect.apply(target, thisArg, args) - }, + if (typeof window !== 'undefined') { + // When the worker closes, drop the IndexedDB store. + window.addEventListener('message', (message) => { + if (message.data?.type === 'msw/worker:stop') { + this.clearStore() + } }) } } - /** - * All active WebSocket client connections. - */ - get clients(): Set { - // In the browser, different runtimes use "localStorage" - // as the shared source of all the clients. - if (typeof localStorage !== 'undefined') { - const inMemoryClients = Array.from(this.inMemoryClients) - - return new Set( - inMemoryClients.concat( - this.getSerializedClients() - // Filter out the serialized clients that are already present - // in this runtime in-memory. This is crucial because a remote client - // wrapper CANNOT send a message to the client in THIS runtime - // (the "message" event on broadcast channel won't trigger). - .filter((serializedClient) => { - if ( - inMemoryClients.every( - (client) => client.id !== serializedClient.clientId, - ) - ) { - return serializedClient - } - }) - .map((serializedClient) => { - return new WebSocketRemoteClientConnection( - serializedClient.clientId, - new URL(serializedClient.url), - this.channel, - ) - }), - ), - ) + private async createDatabase() { + const promise = new DeferredPromise() + const request = indexedDB.open(DB_NAME, 1) + + request.onsuccess = ({ currentTarget }) => { + const db = Reflect.get(currentTarget!, 'result') as IDBDatabase + + if (db.objectStoreNames.contains(DB_STORE_NAME)) { + return promise.resolve(db) + } } - // In Node.js, the manager acts as a singleton, and all clients - // are kept in-memory. - return this.inMemoryClients + request.onupgradeneeded = async ({ currentTarget }) => { + const db = Reflect.get(currentTarget!, 'result') as IDBDatabase + if (db.objectStoreNames.contains(DB_STORE_NAME)) { + return + } + + const store = db.createObjectStore(DB_STORE_NAME, { keyPath: 'id' }) + store.transaction.oncomplete = () => { + promise.resolve(db) + } + store.transaction.onerror = () => { + promise.reject(new Error('Failed to create WebSocket client store')) + } + } + request.onerror = () => { + promise.reject(new Error('Failed to open an IndexedDB database')) + } + + return promise } - private getSerializedClients(): Array { - invariant( - typeof localStorage !== 'undefined', - 'Failed to call WebSocketClientManager#getSerializedClients() in a non-browser environment. This is likely a bug in MSW. Please, report it on GitHub: https://github.com/mswjs/msw', - ) + private flushDatabaseToMemory() { + this.getStore().then((store) => { + const request = store.getAll() as IDBRequest> - const clientsJson = localStorage.getItem(MSW_WEBSOCKET_CLIENTS_KEY) + request.onsuccess = () => { + this.inMemoryClients = new Set( + request.result.map((client) => { + const runtimeClient = this.runtimeClients.get(client.id) - if (!clientsJson) { - return [] - } + /** + * @note For clients originating in this runtime, use their + * direct references. No need to wrap them in a remote connection. + */ + if (runtimeClient) { + return runtimeClient + } - const allClients = JSON.parse(clientsJson) as Array - const matchingClients = allClients.filter((client) => { - return matchRequestUrl(new URL(client.url), this.url).matches + return new WebSocketRemoteClientConnection( + client.id, + new URL(client.url), + this.channel, + ) + }), + ) + } }) + } + + private async getStore(): Promise { + const db = await this.db + return db.transaction(DB_STORE_NAME, 'readwrite').objectStore(DB_STORE_NAME) + } - return matchingClients + private async clearStore(): Promise { + const db = await this.db + db.deleteObjectStore(DB_STORE_NAME) + } + + /** + * All active WebSocket client connections. + */ + get clients(): Set { + return this.inMemoryClients } private addClient(client: WebSocketClientConnection): void { - this.inMemoryClients.add(client) - - if (typeof localStorage !== 'undefined') { - const serializedClients = this.getSerializedClients() - - // Serialize the current client for other runtimes to create - // a remote wrapper over it. This has no effect on the current runtime. - const nextSerializedClients = serializedClients.concat({ - clientId: client.id, - url: client.url.href, - } as SerializedClient) - - localStorage.setItem( - MSW_WEBSOCKET_CLIENTS_KEY, - JSON.stringify(nextSerializedClients), - ) - } + this.getStore() + .then((store) => { + const request = store.add({ + id: client.id, + url: client.url.href, + } satisfies SerializedClient) + + request.onsuccess = () => { + // Sync the in-memory clients in this runtime with the + // updated database. This pulls in all the stored clients. + this.flushDatabaseToMemory() + + // Notify other runtimes to sync their in-memory clients + // with the updated database. + this.channel.postMessage({ type: 'db:update' }) + } + + request.onerror = () => { + // eslint-disable-next-line no-console + console.error( + `Failed to add a WebSocket client ("${client.id}") connection to the store.`, + ) + } + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error( + `Failed to add a WebSocket client ("${client.id}") to the store: ${error}`, + ) + }) } /** @@ -147,10 +189,12 @@ export class WebSocketClientManager { * for the opened connections in the same runtime. */ public addConnection(client: WebSocketClientConnection): void { + this.runtimeClients.set(client.id, client) this.addClient(client) - // Instruct the current client how to handle events - // coming from other runtimes (e.g. when calling `.broadcast()`). + // Handle the incoming BroadcastChannel messages from other runtimes + // that attempt to control this runtime (via a remote connection wrapper). + // E.g. another runtime calling `client.send()` for the client in this runtime. const handleExtraneousMessage = ( message: MessageEvent, ) => { diff --git a/test/browser/ws-api/ws.clients.browser.test.ts b/test/browser/ws-api/ws.clients.browser.test.ts index f811a3838..9c8bd36a1 100644 --- a/test/browser/ws-api/ws.clients.browser.test.ts +++ b/test/browser/ws-api/ws.clients.browser.test.ts @@ -107,9 +107,10 @@ test('returns the number of active clients across different runtimes', async ({ expect(await pageOne.evaluate(() => window.link.clients.size)).toBe(2) }) -test('broadcasts messages across runtimes', async ({ +test.only('broadcasts messages across runtimes', async ({ loadExample, context, + page, }) => { const { compilation } = await loadExample( require.resolve('./ws.runtime.js'), @@ -126,6 +127,10 @@ test('broadcasts messages across runtimes', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw const api = ws.link('wss://example.com') + + // @ts-ignore + window.api = api + const worker = setupWorker( api.on('connection', ({ client }) => { client.addEventListener('message', (event) => { @@ -146,6 +151,8 @@ test('broadcasts messages across runtimes', async ({ }) } + await page.pause() + await pageOne.evaluate(() => { window.ws.send('hi from one') }) From a50bed27545a30fc41519d1f6e183694995c67e2 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 17 Sep 2024 12:47:20 +0200 Subject: [PATCH 078/105] fix(ws): delete runtime clients when worker stops --- .../setupWorker/start/createStartHandler.ts | 9 ++- src/browser/setupWorker/stop/createStop.ts | 8 ++- src/core/ws/WebSocketClientManager.ts | 56 ++++++++++++------- .../browser/ws-api/ws.clients.browser.test.ts | 2 + 4 files changed, 46 insertions(+), 29 deletions(-) diff --git a/src/browser/setupWorker/start/createStartHandler.ts b/src/browser/setupWorker/start/createStartHandler.ts index 9454e4553..c77034b6d 100644 --- a/src/browser/setupWorker/start/createStartHandler.ts +++ b/src/browser/setupWorker/start/createStartHandler.ts @@ -1,5 +1,4 @@ import { devUtils } from '~/core/utils/internal/devUtils' -import { MSW_WEBSOCKET_CLIENTS_KEY } from '~/core/ws/WebSocketClientManager' import { getWorkerInstance } from './utils/getWorkerInstance' import { enableMocking } from './utils/enableMocking' import { SetupWorkerInternalContext, StartHandler } from '../glossary' @@ -73,10 +72,10 @@ Please consider using a custom "serviceWorker.url" option to point to the actual // cause memory leaks in headless browser environments. window.clearInterval(context.keepAliveInterval) - // Purge persisted clients on page reload. - // WebSocket clients will get new IDs on reload so persisting them - // makes little sense. - localStorage.removeItem(MSW_WEBSOCKET_CLIENTS_KEY) + // Notify others about this client disconnecting. + // E.g. this will purge the in-memory WebSocket clients since + // starting the worker again will assign them new IDs. + window.postMessage({ type: 'msw/worker:stop' }) }) // Check if the active Service Worker has been generated diff --git a/src/browser/setupWorker/stop/createStop.ts b/src/browser/setupWorker/stop/createStop.ts index 6c0e7b7d9..331f62196 100644 --- a/src/browser/setupWorker/stop/createStop.ts +++ b/src/browser/setupWorker/stop/createStop.ts @@ -1,5 +1,4 @@ import { devUtils } from '~/core/utils/internal/devUtils' -import { MSW_WEBSOCKET_CLIENTS_KEY } from '~/core/ws/WebSocketClientManager' import { SetupWorkerInternalContext, StopHandler } from '../glossary' import { printStopMessage } from './utils/printStopMessage' @@ -25,8 +24,11 @@ export const createStop = ( context.isMockingEnabled = false window.clearInterval(context.keepAliveInterval) - // Clear the WebSocket clients from the shared storage. - localStorage.removeItem(MSW_WEBSOCKET_CLIENTS_KEY) + // Post the internal stop message on the window + // to let any logic know when the worker has stopped. + // E.g. the WebSocket client manager needs this to know + // when to clear its in-memory clients list. + window.postMessage({ type: 'msw/worker:stop' }) printStopMessage({ quiet: context.startOptions?.quiet }) } diff --git a/src/core/ws/WebSocketClientManager.ts b/src/core/ws/WebSocketClientManager.ts index ce1766eac..44481dde9 100644 --- a/src/core/ws/WebSocketClientManager.ts +++ b/src/core/ws/WebSocketClientManager.ts @@ -6,13 +6,6 @@ import type { } from '@mswjs/interceptors/WebSocket' import { type Path } from '../utils/matching/matchRequestUrl' -/** - * @todo REMOVE this. - * This is used by the worker to notify this manager when the worker closes - * by removing this key from localStorage. - */ -export const MSW_WEBSOCKET_CLIENTS_KEY = 'msw:ws:clients' - const DB_NAME = 'msw-websocket-clients' const DB_STORE_NAME = 'clients' @@ -45,7 +38,7 @@ type SerializedClient = { export class WebSocketClientManager { private db: Promise private runtimeClients: Map - private inMemoryClients: Set + private allClients: Set constructor( private channel: BroadcastChannel, @@ -53,7 +46,7 @@ export class WebSocketClientManager { ) { this.db = this.createDatabase() this.runtimeClients = new Map() - this.inMemoryClients = new Set() + this.allClients = new Set() this.channel.addEventListener('message', (message) => { if (message.data?.type === 'db:update') { @@ -62,10 +55,9 @@ export class WebSocketClientManager { }) if (typeof window !== 'undefined') { - // When the worker closes, drop the IndexedDB store. - window.addEventListener('message', (message) => { + window.addEventListener('message', async (message) => { if (message.data?.type === 'msw/worker:stop') { - this.clearStore() + await this.removeRuntimeClients() } }) } @@ -109,7 +101,7 @@ export class WebSocketClientManager { const request = store.getAll() as IDBRequest> request.onsuccess = () => { - this.inMemoryClients = new Set( + this.allClients = new Set( request.result.map((client) => { const runtimeClient = this.runtimeClients.get(client.id) @@ -137,16 +129,41 @@ export class WebSocketClientManager { return db.transaction(DB_STORE_NAME, 'readwrite').objectStore(DB_STORE_NAME) } - private async clearStore(): Promise { - const db = await this.db - db.deleteObjectStore(DB_STORE_NAME) + private async removeRuntimeClients(): Promise { + const promise = new DeferredPromise() + const store = await this.getStore() + + this.runtimeClients.forEach((client) => { + store.delete(client.id) + }) + + store.transaction.oncomplete = () => { + this.runtimeClients.clear() + this.flushDatabaseToMemory() + this.notifyOthersAboutDatabaseUpdate() + promise.resolve() + } + + store.transaction.onerror = () => { + promise.reject( + new Error('Failed to remove runtime clients from the store'), + ) + } + + return promise } /** * All active WebSocket client connections. */ get clients(): Set { - return this.inMemoryClients + return this.allClients + } + + private notifyOthersAboutDatabaseUpdate(): void { + // Notify other runtimes to sync their in-memory clients + // with the updated database. + this.channel.postMessage({ type: 'db:update' }) } private addClient(client: WebSocketClientConnection): void { @@ -161,10 +178,7 @@ export class WebSocketClientManager { // Sync the in-memory clients in this runtime with the // updated database. This pulls in all the stored clients. this.flushDatabaseToMemory() - - // Notify other runtimes to sync their in-memory clients - // with the updated database. - this.channel.postMessage({ type: 'db:update' }) + this.notifyOthersAboutDatabaseUpdate() } request.onerror = () => { diff --git a/test/browser/ws-api/ws.clients.browser.test.ts b/test/browser/ws-api/ws.clients.browser.test.ts index 9c8bd36a1..350ee8e67 100644 --- a/test/browser/ws-api/ws.clients.browser.test.ts +++ b/test/browser/ws-api/ws.clients.browser.test.ts @@ -139,6 +139,8 @@ test.only('broadcasts messages across runtimes', async ({ }), ) await worker.start() + + window.worker = worker }) await page.evaluate(() => { From 434e4cfcc1503a7088678bff322105bc7958b143 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 17 Sep 2024 13:13:36 +0200 Subject: [PATCH 079/105] fix: use different websocket client store in browser and node --- src/core/ws/WebSocketClientManager.ts | 164 +++++------------- src/core/ws/WebSocketClientStore.ts | 14 ++ src/core/ws/WebSocketIndexedDBClientStore.ts | 112 ++++++++++++ src/core/ws/WebSocketMemoryClientStore.ts | 27 +++ .../browser/ws-api/ws.clients.browser.test.ts | 2 +- 5 files changed, 200 insertions(+), 119 deletions(-) create mode 100644 src/core/ws/WebSocketClientStore.ts create mode 100644 src/core/ws/WebSocketIndexedDBClientStore.ts create mode 100644 src/core/ws/WebSocketMemoryClientStore.ts diff --git a/src/core/ws/WebSocketClientManager.ts b/src/core/ws/WebSocketClientManager.ts index 44481dde9..cc3eaceea 100644 --- a/src/core/ws/WebSocketClientManager.ts +++ b/src/core/ws/WebSocketClientManager.ts @@ -1,13 +1,12 @@ -import { DeferredPromise } from '@open-draft/deferred-promise' import type { WebSocketData, WebSocketClientConnection, WebSocketClientConnectionProtocol, } from '@mswjs/interceptors/WebSocket' import { type Path } from '../utils/matching/matchRequestUrl' - -const DB_NAME = 'msw-websocket-clients' -const DB_STORE_NAME = 'clients' +import { WebSocketClientStore } from './WebSocketClientStore' +import { WebSocketMemoryClientStore } from './WebSocketMemoryClientStore' +import { WebSocketIndexedDBClientStore } from './WebSocketIndexedDBClientStore' export type WebSocketBroadcastChannelMessage = | { @@ -26,17 +25,12 @@ export type WebSocketBroadcastChannelMessage = } } -type SerializedClient = { - id: string - url: string -} - /** * A manager responsible for accumulating WebSocket client * connections across different browser runtimes. */ export class WebSocketClientManager { - private db: Promise + private store: WebSocketClientStore private runtimeClients: Map private allClients: Set @@ -44,7 +38,13 @@ export class WebSocketClientManager { private channel: BroadcastChannel, private url: Path, ) { - this.db = this.createDatabase() + // Store the clients in the IndexedDB in the browser, + // otherwise, store the clients in memory. + this.store = + typeof indexedDB !== 'undefined' + ? new WebSocketIndexedDBClientStore() + : new WebSocketMemoryClientStore() + this.runtimeClients = new Map() this.allClients = new Set() @@ -63,94 +63,36 @@ export class WebSocketClientManager { } } - private async createDatabase() { - const promise = new DeferredPromise() - const request = indexedDB.open(DB_NAME, 1) - - request.onsuccess = ({ currentTarget }) => { - const db = Reflect.get(currentTarget!, 'result') as IDBDatabase - - if (db.objectStoreNames.contains(DB_STORE_NAME)) { - return promise.resolve(db) - } - } + private async flushDatabaseToMemory() { + const storedClients = await this.store.getAll() - request.onupgradeneeded = async ({ currentTarget }) => { - const db = Reflect.get(currentTarget!, 'result') as IDBDatabase - if (db.objectStoreNames.contains(DB_STORE_NAME)) { - return - } + this.allClients = new Set( + storedClients.map((client) => { + const runtimeClient = this.runtimeClients.get(client.id) - const store = db.createObjectStore(DB_STORE_NAME, { keyPath: 'id' }) - store.transaction.oncomplete = () => { - promise.resolve(db) - } - store.transaction.onerror = () => { - promise.reject(new Error('Failed to create WebSocket client store')) - } - } - request.onerror = () => { - promise.reject(new Error('Failed to open an IndexedDB database')) - } - - return promise - } - - private flushDatabaseToMemory() { - this.getStore().then((store) => { - const request = store.getAll() as IDBRequest> - - request.onsuccess = () => { - this.allClients = new Set( - request.result.map((client) => { - const runtimeClient = this.runtimeClients.get(client.id) - - /** - * @note For clients originating in this runtime, use their - * direct references. No need to wrap them in a remote connection. - */ - if (runtimeClient) { - return runtimeClient - } + /** + * @note For clients originating in this runtime, use their + * direct references. No need to wrap them in a remote connection. + */ + if (runtimeClient) { + return runtimeClient + } - return new WebSocketRemoteClientConnection( - client.id, - new URL(client.url), - this.channel, - ) - }), + return new WebSocketRemoteClientConnection( + client.id, + new URL(client.url), + this.channel, ) - } - }) - } - - private async getStore(): Promise { - const db = await this.db - return db.transaction(DB_STORE_NAME, 'readwrite').objectStore(DB_STORE_NAME) + }), + ) } private async removeRuntimeClients(): Promise { - const promise = new DeferredPromise() - const store = await this.getStore() - - this.runtimeClients.forEach((client) => { - store.delete(client.id) - }) - - store.transaction.oncomplete = () => { + this.store.deleteMany(Array.from(this.runtimeClients.keys())).then(() => { this.runtimeClients.clear() this.flushDatabaseToMemory() this.notifyOthersAboutDatabaseUpdate() - promise.resolve() - } - - store.transaction.onerror = () => { - promise.reject( - new Error('Failed to remove runtime clients from the store'), - ) - } - - return promise + }) } /** @@ -160,40 +102,21 @@ export class WebSocketClientManager { return this.allClients } + /** + * Notify other runtimes about the database update + * using the shared `BroadcastChannel` instance. + */ private notifyOthersAboutDatabaseUpdate(): void { - // Notify other runtimes to sync their in-memory clients - // with the updated database. this.channel.postMessage({ type: 'db:update' }) } private addClient(client: WebSocketClientConnection): void { - this.getStore() - .then((store) => { - const request = store.add({ - id: client.id, - url: client.url.href, - } satisfies SerializedClient) - - request.onsuccess = () => { - // Sync the in-memory clients in this runtime with the - // updated database. This pulls in all the stored clients. - this.flushDatabaseToMemory() - this.notifyOthersAboutDatabaseUpdate() - } - - request.onerror = () => { - // eslint-disable-next-line no-console - console.error( - `Failed to add a WebSocket client ("${client.id}") connection to the store.`, - ) - } - }) - .catch((error) => { - // eslint-disable-next-line no-console - console.error( - `Failed to add a WebSocket client ("${client.id}") to the store: ${error}`, - ) - }) + this.store.add(client).then(() => { + // Sync the in-memory clients in this runtime with the + // updated database. This pulls in all the stored clients. + this.flushDatabaseToMemory() + this.notifyOthersAboutDatabaseUpdate() + }) } /** @@ -203,7 +126,12 @@ export class WebSocketClientManager { * for the opened connections in the same runtime. */ public addConnection(client: WebSocketClientConnection): void { + // Store this client in the map of clients created in this runtime. + // This way, the manager can distinguish between this runtime clients + // and extraneous runtime clients when synchronizing clients storage. this.runtimeClients.set(client.id, client) + + // Add the new client to the storage. this.addClient(client) // Handle the incoming BroadcastChannel messages from other runtimes diff --git a/src/core/ws/WebSocketClientStore.ts b/src/core/ws/WebSocketClientStore.ts new file mode 100644 index 000000000..8a982d09c --- /dev/null +++ b/src/core/ws/WebSocketClientStore.ts @@ -0,0 +1,14 @@ +import type { WebSocketClientConnectionProtocol } from '@mswjs/interceptors/WebSocket' + +export type SerializedWebSocketClient = { + id: string + url: string +} + +export abstract class WebSocketClientStore { + public abstract add(client: WebSocketClientConnectionProtocol): Promise + + public abstract getAll(): Promise> + + public abstract deleteMany(clientIds: Array): Promise +} diff --git a/src/core/ws/WebSocketIndexedDBClientStore.ts b/src/core/ws/WebSocketIndexedDBClientStore.ts new file mode 100644 index 000000000..b37a42568 --- /dev/null +++ b/src/core/ws/WebSocketIndexedDBClientStore.ts @@ -0,0 +1,112 @@ +import { DeferredPromise } from '@open-draft/deferred-promise' +import { WebSocketClientConnectionProtocol } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' +import { + type SerializedWebSocketClient, + WebSocketClientStore, +} from './WebSocketClientStore' + +const DB_NAME = 'msw-websocket-clients' +const DB_STORE_NAME = 'clients' + +export class WebSocketIndexedDBClientStore implements WebSocketClientStore { + private db: Promise + + constructor() { + this.db = this.createDatabase() + } + + public async add(client: WebSocketClientConnectionProtocol): Promise { + const promise = new DeferredPromise() + const store = await this.getStore() + const request = store.add({ + id: client.id, + url: client.url.href, + } satisfies SerializedWebSocketClient) + + request.onsuccess = () => { + promise.resolve() + } + request.onerror = () => { + promise.reject(new Error(`Failed to add WebSocket client ${client.id}`)) + } + + return promise + } + + public async getAll(): Promise> { + const promise = new DeferredPromise>() + const store = await this.getStore() + const request = store.getAll() as IDBRequest< + Array + > + + request.onsuccess = () => { + promise.resolve(request.result) + } + request.onerror = () => { + promise.reject(new Error(`Failed to get all WebSocket clients`)) + } + + return promise + } + + public async deleteMany(clientIds: Array): Promise { + const promise = new DeferredPromise() + const store = await this.getStore() + + for (const clientId of clientIds) { + store.delete(clientId) + } + + store.transaction.oncomplete = () => { + promise.resolve() + } + store.transaction.onerror = () => { + promise.reject( + new Error( + `Failed to delete WebSocket clients [${clientIds.join(', ')}]`, + ), + ) + } + + return promise + } + + private async createDatabase(): Promise { + const promise = new DeferredPromise() + const request = indexedDB.open(DB_NAME, 1) + + request.onsuccess = ({ currentTarget }) => { + const db = Reflect.get(currentTarget!, 'result') as IDBDatabase + + if (db.objectStoreNames.contains(DB_STORE_NAME)) { + return promise.resolve(db) + } + } + + request.onupgradeneeded = async ({ currentTarget }) => { + const db = Reflect.get(currentTarget!, 'result') as IDBDatabase + if (db.objectStoreNames.contains(DB_STORE_NAME)) { + return + } + + const store = db.createObjectStore(DB_STORE_NAME, { keyPath: 'id' }) + store.transaction.oncomplete = () => { + promise.resolve(db) + } + store.transaction.onerror = () => { + promise.reject(new Error('Failed to create WebSocket client store')) + } + } + request.onerror = () => { + promise.reject(new Error('Failed to open an IndexedDB database')) + } + + return promise + } + + private async getStore(): Promise { + const db = await this.db + return db.transaction(DB_STORE_NAME, 'readwrite').objectStore(DB_STORE_NAME) + } +} diff --git a/src/core/ws/WebSocketMemoryClientStore.ts b/src/core/ws/WebSocketMemoryClientStore.ts new file mode 100644 index 000000000..2f97a26fb --- /dev/null +++ b/src/core/ws/WebSocketMemoryClientStore.ts @@ -0,0 +1,27 @@ +import { WebSocketClientConnectionProtocol } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' +import { + SerializedWebSocketClient, + WebSocketClientStore, +} from './WebSocketClientStore' + +export class WebSocketMemoryClientStore implements WebSocketClientStore { + private store: Map + + constructor() { + this.store = new Map() + } + + public async add(client: WebSocketClientConnectionProtocol): Promise { + this.store.set(client.id, { id: client.id, url: client.url.href }) + } + + public getAll(): Promise> { + return Promise.resolve(Array.from(this.store.values())) + } + + public async deleteMany(clientIds: Array): Promise { + for (const clientId of clientIds) { + this.store.delete(clientId) + } + } +} diff --git a/test/browser/ws-api/ws.clients.browser.test.ts b/test/browser/ws-api/ws.clients.browser.test.ts index 350ee8e67..75837f4fa 100644 --- a/test/browser/ws-api/ws.clients.browser.test.ts +++ b/test/browser/ws-api/ws.clients.browser.test.ts @@ -107,7 +107,7 @@ test('returns the number of active clients across different runtimes', async ({ expect(await pageOne.evaluate(() => window.link.clients.size)).toBe(2) }) -test.only('broadcasts messages across runtimes', async ({ +test('broadcasts messages across runtimes', async ({ loadExample, context, page, From fde2b4e845f831f350c8d5eedf97ed4a053a547d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 17 Sep 2024 13:50:58 +0200 Subject: [PATCH 080/105] chore: fix logger console usage --- src/core/ws/utils/attachWebSocketLogger.ts | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/core/ws/utils/attachWebSocketLogger.ts b/src/core/ws/utils/attachWebSocketLogger.ts index 97fc330bc..93f18e7f3 100644 --- a/src/core/ws/utils/attachWebSocketLogger.ts +++ b/src/core/ws/utils/attachWebSocketLogger.ts @@ -117,12 +117,15 @@ export function attachWebSocketLogger( export function logConnectionOpen(client: WebSocketClientConnection) { const publicUrl = toPublicUrl(client.url) + // eslint-disable-next-line no-console console.groupCollapsed( devUtils.formatMessage(`${getTimestamp()} %c▶%c ${publicUrl}`), `color:${colors.blue}`, 'color:inherit', ) + // eslint-disable-next-line no-console console.log('Client:', client.socket) + // eslint-disable-next-line no-console console.groupEnd() } @@ -135,6 +138,7 @@ export async function logOutgoingClientMessage( const byteLength = getMessageLength(event.data) const publicData = await getPublicData(event.data) + // eslint-disable-next-line no-console console.groupCollapsed( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c↑%c ${publicData} %c${byteLength}%c`, @@ -144,7 +148,9 @@ export async function logOutgoingClientMessage( 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', ) + // eslint-disable-next-line no-console console.log(event) + // eslint-disable-next-line no-console console.groupEnd() } @@ -158,6 +164,7 @@ export async function logOutgoingMockedClientMessage( const byteLength = getMessageLength(event.data) const publicData = await getPublicData(event.data) + // eslint-disable-next-line no-console console.groupCollapsed( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c⇡%c ${publicData} %c${byteLength}%c`, @@ -167,7 +174,9 @@ export async function logOutgoingMockedClientMessage( 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', ) + // eslint-disable-next-line no-console console.log(event) + // eslint-disable-next-line no-console console.groupEnd() } @@ -183,6 +192,7 @@ export async function logIncomingClientMessage( const byteLength = getMessageLength(event.data) const publicData = await getPublicData(event.data) + // eslint-disable-next-line no-console console.groupCollapsed( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c↓%c ${publicData} %c${byteLength}%c`, @@ -192,7 +202,9 @@ export async function logIncomingClientMessage( 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', ) + // eslint-disable-next-line no-console console.log(event) + // eslint-disable-next-line no-console console.groupEnd() } @@ -206,6 +218,7 @@ export async function logIncomingMockedClientMessage( const byteLength = getMessageLength(event.data) const publicData = await getPublicData(event.data) + // eslint-disable-next-line no-console console.groupCollapsed( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c⇣%c ${publicData} %c${byteLength}%c`, @@ -215,7 +228,9 @@ export async function logIncomingMockedClientMessage( 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', ) + // eslint-disable-next-line no-console console.log(event) + // eslint-disable-next-line no-console console.groupEnd() } @@ -223,6 +238,7 @@ function logConnectionClose(event: CloseEvent) { const target = event.target as WebSocket const publicUrl = toPublicUrl(target.url) + // eslint-disable-next-line no-console console.groupCollapsed( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c■%c ${publicUrl}`, @@ -230,7 +246,9 @@ function logConnectionClose(event: CloseEvent) { `color:${colors.blue}`, 'color:inherit', ) + // eslint-disable-next-line no-console console.log(event) + // eslint-disable-next-line no-console console.groupEnd() } @@ -240,6 +258,7 @@ export async function logIncomingServerMessage( const byteLength = getMessageLength(event.data) const publicData = await getPublicData(event.data) + // eslint-disable-next-line no-console console.groupCollapsed( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c⇣%c ${publicData} %c${byteLength}%c`, @@ -249,7 +268,9 @@ export async function logIncomingServerMessage( 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', ) + // eslint-disable-next-line no-console console.log(event) + // eslint-disable-next-line no-console console.groupEnd() } @@ -257,6 +278,7 @@ function logClientError(event: Event) { const socket = event.target as WebSocket const publicUrl = toPublicUrl(socket.url) + // eslint-disable-next-line no-console console.groupCollapsed( devUtils.formatMessage( `${getTimestamp({ milliseconds: true })} %c\u00D7%c ${publicUrl}`, @@ -264,6 +286,8 @@ function logClientError(event: Event) { `color:${colors.blue}`, 'color:inherit', ) + // eslint-disable-next-line no-console console.log(event) + // eslint-disable-next-line no-console console.groupEnd() } From 06c9cf26b954952afa49671b954f5fb7f28c4193 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 17 Sep 2024 13:54:15 +0200 Subject: [PATCH 081/105] fix(WebSocketClientManager): account for async nature of "addConnection" --- src/core/ws.ts | 4 ++-- src/core/ws/WebSocketClientManager.test.ts | 20 ++++++++--------- src/core/ws/WebSocketClientManager.ts | 26 ++++++++++------------ 3 files changed, 23 insertions(+), 27 deletions(-) diff --git a/src/core/ws.ts b/src/core/ws.ts index fe35d5d4c..3c15d11f6 100644 --- a/src/core/ws.ts +++ b/src/core/ws.ts @@ -112,8 +112,8 @@ function createWebSocketLinkHandler(url: Path): WebSocketLink { // handler matches and emits a connection event. // When that happens, store that connection in the // set of all connections for reference. - handler[kEmitter].on('connection', ({ client }) => { - clientManager.addConnection(client) + handler[kEmitter].on('connection', async ({ client }) => { + await clientManager.addConnection(client) }) // The "handleWebSocketEvent" function will invoke diff --git a/src/core/ws/WebSocketClientManager.test.ts b/src/core/ws/WebSocketClientManager.test.ts index 8db322f8e..b94569126 100644 --- a/src/core/ws/WebSocketClientManager.test.ts +++ b/src/core/ws/WebSocketClientManager.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment node-websocket - */ +// @vitest-environment node-websocket import { WebSocketClientConnection, WebSocketData, @@ -25,26 +23,26 @@ afterEach(() => { vi.resetAllMocks() }) -it('adds a client from this runtime to the list of clients', () => { +it('adds a client from this runtime to the list of clients', async () => { const manager = new WebSocketClientManager(channel, '*') const connection = new WebSocketClientConnection( socket, new TestWebSocketTransport(), ) - manager.addConnection(connection) + await manager.addConnection(connection) // Must add the client to the list of clients. expect(Array.from(manager.clients.values())).toEqual([connection]) }) -it('adds multiple clients from this runtime to the list of clients', () => { +it('adds multiple clients from this runtime to the list of clients', async () => { const manager = new WebSocketClientManager(channel, '*') const connectionOne = new WebSocketClientConnection( socket, new TestWebSocketTransport(), ) - manager.addConnection(connectionOne) + await manager.addConnection(connectionOne) // Must add the client to the list of clients. expect(Array.from(manager.clients.values())).toEqual([connectionOne]) @@ -53,7 +51,7 @@ it('adds multiple clients from this runtime to the list of clients', () => { socket, new TestWebSocketTransport(), ) - manager.addConnection(connectionTwo) + await manager.addConnection(connectionTwo) // Must add the new cilent to the list as well. expect(Array.from(manager.clients.values())).toEqual([ @@ -68,7 +66,7 @@ it('replays a "send" event coming from another runtime', async () => { socket, new TestWebSocketTransport(), ) - manager.addConnection(connection) + await manager.addConnection(connection) vi.spyOn(connection, 'send') // Emulate another runtime signaling this connection to receive data. @@ -97,7 +95,7 @@ it('replays a "close" event coming from another runtime', async () => { socket, new TestWebSocketTransport(), ) - manager.addConnection(connection) + await manager.addConnection(connection) vi.spyOn(connection, 'close') // Emulate another runtime signaling this connection to close. @@ -137,7 +135,7 @@ it('removes the extraneous message listener when the connection closes', async ( }) vi.spyOn(connection, 'send') - manager.addConnection(connection) + await manager.addConnection(connection) connection.close() // Signals from other runtimes have no effect on the closed connection. diff --git a/src/core/ws/WebSocketClientManager.ts b/src/core/ws/WebSocketClientManager.ts index cc3eaceea..ecbb90321 100644 --- a/src/core/ws/WebSocketClientManager.ts +++ b/src/core/ws/WebSocketClientManager.ts @@ -88,11 +88,10 @@ export class WebSocketClientManager { } private async removeRuntimeClients(): Promise { - this.store.deleteMany(Array.from(this.runtimeClients.keys())).then(() => { - this.runtimeClients.clear() - this.flushDatabaseToMemory() - this.notifyOthersAboutDatabaseUpdate() - }) + await this.store.deleteMany(Array.from(this.runtimeClients.keys())) + this.runtimeClients.clear() + await this.flushDatabaseToMemory() + this.notifyOthersAboutDatabaseUpdate() } /** @@ -110,13 +109,12 @@ export class WebSocketClientManager { this.channel.postMessage({ type: 'db:update' }) } - private addClient(client: WebSocketClientConnection): void { - this.store.add(client).then(() => { - // Sync the in-memory clients in this runtime with the - // updated database. This pulls in all the stored clients. - this.flushDatabaseToMemory() - this.notifyOthersAboutDatabaseUpdate() - }) + private async addClient(client: WebSocketClientConnection): Promise { + await this.store.add(client) + // Sync the in-memory clients in this runtime with the + // updated database. This pulls in all the stored clients. + await this.flushDatabaseToMemory() + this.notifyOthersAboutDatabaseUpdate() } /** @@ -125,14 +123,14 @@ export class WebSocketClientManager { * connection object because `addConnection()` is called only * for the opened connections in the same runtime. */ - public addConnection(client: WebSocketClientConnection): void { + public async addConnection(client: WebSocketClientConnection): Promise { // Store this client in the map of clients created in this runtime. // This way, the manager can distinguish between this runtime clients // and extraneous runtime clients when synchronizing clients storage. this.runtimeClients.set(client.id, client) // Add the new client to the storage. - this.addClient(client) + await this.addClient(client) // Handle the incoming BroadcastChannel messages from other runtimes // that attempt to control this runtime (via a remote connection wrapper). From bdf9e884853d2d46d0621e4baa6a514a5a0f0669 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 17 Sep 2024 14:12:25 +0200 Subject: [PATCH 082/105] chore(release): v2.3.0-ws.rc-8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ad413d7fe..49742acbf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-7", + "version": "2.3.0-ws.rc-8", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From 96319c993c2245fe24c6b8191c341e0fd1f674f9 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 17 Sep 2024 14:39:15 +0200 Subject: [PATCH 083/105] fix: export `WebSocketData` and `WebSocketEventListener` types --- src/core/index.ts | 2 ++ src/core/ws.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/core/index.ts b/src/core/index.ts index c4de17047..770b58b37 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -52,6 +52,8 @@ export type { } from './handlers/GraphQLHandler' export type { GraphQLRequestHandler, GraphQLResponseResolver } from './graphql' +export type { WebSocketData, WebSocketEventListener } from './ws' + export type { Path, PathParams, Match } from './utils/matching/matchRequestUrl' export type { ParsedGraphQLRequest } from './utils/internal/parseGraphQLRequest' diff --git a/src/core/ws.ts b/src/core/ws.ts index 3c15d11f6..603e765f9 100644 --- a/src/core/ws.ts +++ b/src/core/ws.ts @@ -1,7 +1,7 @@ import { invariant } from 'outvariant' import type { - WebSocketClientConnectionProtocol, WebSocketData, + WebSocketClientConnectionProtocol, } from '@mswjs/interceptors/WebSocket' import { WebSocketHandler, @@ -25,6 +25,10 @@ if (isBroadcastChannelWithUnref(webSocketChannel)) { webSocketChannel.unref() } +export type WebSocketEventListener< + EventType extends keyof WebSocketHandlerEventMap, +> = (...args: WebSocketHandlerEventMap[EventType]) => void + export type WebSocketLink = { /** * A set of all WebSocket clients connected @@ -45,7 +49,7 @@ export type WebSocketLink = { */ on( event: EventType, - listener: (...args: WebSocketHandlerEventMap[EventType]) => void, + listener: WebSocketEventListener, ): WebSocketHandler /** @@ -158,3 +162,5 @@ function createWebSocketLinkHandler(url: Path): WebSocketLink { export const ws = { link: createWebSocketLinkHandler, } + +export { WebSocketData } From f7f9f8a7fbf133481f5f7d980a01a5a99b548c82 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 18 Sep 2024 15:28:49 +0200 Subject: [PATCH 084/105] feat: rename ".on()" to ".addEventListener()" on ws.link --- src/core/ws.ts | 12 +++---- test/browser/ws-api/ws.apply.browser.test.ts | 2 +- .../browser/ws-api/ws.clients.browser.test.ts | 10 +++--- .../ws.intercept.client.browser.test.ts | 8 ++--- .../ws.intercept.server.browser.test.ts | 6 ++-- .../browser/ws-api/ws.logging.browser.test.ts | 34 +++++++++---------- .../ws-api/ws.server.connect.browser.test.ts | 6 ++-- test/browser/ws-api/ws.use.browser.test.ts | 18 +++++----- test/node/ws-api/ws.apply.test.ts | 2 +- test/node/ws-api/ws.event-patching.test.ts | 8 ++--- test/node/ws-api/ws.intercept.client.test.ts | 6 ++-- test/node/ws-api/ws.intercept.server.test.ts | 6 ++-- test/node/ws-api/ws.server.connect.test.ts | 8 ++--- test/node/ws-api/ws.use.test.ts | 10 +++--- 14 files changed, 68 insertions(+), 68 deletions(-) diff --git a/src/core/ws.ts b/src/core/ws.ts index 603e765f9..1757bf8df 100644 --- a/src/core/ws.ts +++ b/src/core/ws.ts @@ -43,11 +43,11 @@ export type WebSocketLink = { * * @example * const chat = ws.link('wss://chat.example.com') - * chat.on('connection', listener) + * chat.addEventListener('connection', listener) * * @see {@link https://mswjs.io/docs/api/ws#onevent-listener `on()` API reference} */ - on( + addEventListener( event: EventType, listener: WebSocketEventListener, ): WebSocketHandler @@ -57,7 +57,7 @@ export type WebSocketLink = { * * @example * const service = ws.link('wss://example.com') - * service.on('connection', () => { + * service.addEventListener('connection', () => { * service.broadcast('hello, everyone!') * }) * @@ -71,7 +71,7 @@ export type WebSocketLink = { * * @example * const service = ws.link('wss://example.com') - * service.on('connection', ({ client }) => { + * service.addEventListener('connection', ({ client }) => { * service.broadcastExcept(client, 'hi, the rest of you!') * }) * @@ -90,7 +90,7 @@ export type WebSocketLink = { * * @example * const chat = ws.link('wss://chat.example.com') - * chat.on('connection', ({ client }) => { + * chat.addEventListener('connection', ({ client }) => { * client.send('hello from server!') * }) */ @@ -109,7 +109,7 @@ function createWebSocketLinkHandler(url: Path): WebSocketLink { get clients() { return clientManager.clients }, - on(event, listener) { + addEventListener(event, listener) { const handler = new WebSocketHandler(url) // Add the connection event listener for when the diff --git a/test/browser/ws-api/ws.apply.browser.test.ts b/test/browser/ws-api/ws.apply.browser.test.ts index 749d0f7c1..478c92597 100644 --- a/test/browser/ws-api/ws.apply.browser.test.ts +++ b/test/browser/ws-api/ws.apply.browser.test.ts @@ -23,7 +23,7 @@ test('does not apply the interceptor until "worker.start()" is called', async ({ await page.evaluate(() => { const { setupWorker, ws } = window.msw const api = ws.link('wss://example.com') - window.worker = setupWorker(api.on('connection', () => {})) + window.worker = setupWorker(api.addEventListener('connection', () => {})) }) await expect( diff --git a/test/browser/ws-api/ws.clients.browser.test.ts b/test/browser/ws-api/ws.clients.browser.test.ts index 75837f4fa..e2eba5472 100644 --- a/test/browser/ws-api/ws.clients.browser.test.ts +++ b/test/browser/ws-api/ws.clients.browser.test.ts @@ -26,7 +26,7 @@ test('returns the number of active clients in the same runtime', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw const api = ws.link('wss://example.com') - const worker = setupWorker(api.on('connection', () => {})) + const worker = setupWorker(api.addEventListener('connection', () => {})) window.link = api await worker.start() }) @@ -82,7 +82,7 @@ test('returns the number of active clients across different runtimes', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw const api = ws.link('wss://example.com') - const worker = setupWorker(api.on('connection', () => {})) + const worker = setupWorker(api.addEventListener('connection', () => {})) window.link = api await worker.start() }) @@ -132,7 +132,7 @@ test('broadcasts messages across runtimes', async ({ window.api = api const worker = setupWorker( - api.on('connection', ({ client }) => { + api.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { api.broadcast(event.data) }) @@ -186,7 +186,7 @@ test('clears the list of clients when the worker is stopped', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw const api = ws.link('wss://example.com') - const worker = setupWorker(api.on('connection', () => {})) + const worker = setupWorker(api.addEventListener('connection', () => {})) window.link = api window.worker = worker await worker.start() @@ -224,7 +224,7 @@ test('clears the list of clients when the page is reloaded', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw const api = ws.link('wss://example.com') - const worker = setupWorker(api.on('connection', () => {})) + const worker = setupWorker(api.addEventListener('connection', () => {})) window.link = api window.worker = worker await worker.start() diff --git a/test/browser/ws-api/ws.intercept.client.browser.test.ts b/test/browser/ws-api/ws.intercept.client.browser.test.ts index ac0e9fc33..3b0e2a359 100644 --- a/test/browser/ws-api/ws.intercept.client.browser.test.ts +++ b/test/browser/ws-api/ws.intercept.client.browser.test.ts @@ -24,7 +24,7 @@ test('does not throw on connecting to a non-existing host', async ({ const service = ws.link('*') const worker = setupWorker( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { queueMicrotask(() => client.close()) }), ) @@ -57,7 +57,7 @@ test('intercepts outgoing client text message', async ({ return new Promise(async (resolve) => { const worker = setupWorker( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { resolve(event.data) }) @@ -89,7 +89,7 @@ test('intercepts outgoing client Blob message', async ({ return new Promise(async (resolve) => { const worker = setupWorker( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { resolve(event.data.text()) }) @@ -121,7 +121,7 @@ test('intercepts outgoing client ArrayBuffer message', async ({ return new Promise(async (resolve) => { const worker = setupWorker( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { resolve(new TextDecoder().decode(event.data)) }) diff --git a/test/browser/ws-api/ws.intercept.server.browser.test.ts b/test/browser/ws-api/ws.intercept.server.browser.test.ts index d70b972b6..006bf17dd 100644 --- a/test/browser/ws-api/ws.intercept.server.browser.test.ts +++ b/test/browser/ws-api/ws.intercept.server.browser.test.ts @@ -40,7 +40,7 @@ test('intercepts incoming server text message', async ({ return new Promise(async (resolve) => { const worker = setupWorker( - service.on('connection', ({ server }) => { + service.addEventListener('connection', ({ server }) => { server.connect() server.addEventListener('message', (event) => { resolve(event.data) @@ -88,7 +88,7 @@ test('intercepts incoming server Blob message', async ({ return new Promise(async (resolve) => { const worker = setupWorker( - service.on('connection', ({ server }) => { + service.addEventListener('connection', ({ server }) => { server.connect() server.addEventListener('message', (event) => { resolve(event.data.text()) @@ -134,7 +134,7 @@ test('intercepts outgoing server ArrayBuffer message', async ({ return new Promise(async (resolve) => { const worker = setupWorker( - service.on('connection', ({ server }) => { + service.addEventListener('connection', ({ server }) => { server.connect() server.addEventListener('message', (event) => { resolve(new TextDecoder().decode(event.data)) diff --git a/test/browser/ws-api/ws.logging.browser.test.ts b/test/browser/ws-api/ws.logging.browser.test.ts index 0fa404a60..a4874f30c 100644 --- a/test/browser/ws-api/ws.logging.browser.test.ts +++ b/test/browser/ws-api/ws.logging.browser.test.ts @@ -39,7 +39,7 @@ test('does not log anything if "quiet" was set to "true"', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw const api = ws.link('wss://localhost/*') - const worker = setupWorker(api.on('connection', () => {})) + const worker = setupWorker(api.addEventListener('connection', () => {})) await worker.start({ quiet: true }) }) @@ -74,7 +74,7 @@ test('logs the client connection', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw const api = ws.link('wss://localhost/*') - const worker = setupWorker(api.on('connection', () => {})) + const worker = setupWorker(api.addEventListener('connection', () => {})) await worker.start() }) @@ -107,7 +107,7 @@ test('logs outgoing client event sending text data', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw const api = ws.link('wss://localhost/*') - const worker = setupWorker(api.on('connection', () => {})) + const worker = setupWorker(api.addEventListener('connection', () => {})) await worker.start() }) @@ -141,7 +141,7 @@ test('logs outgoing client event sending a long text data', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw const api = ws.link('wss://localhost/*') - const worker = setupWorker(api.on('connection', () => {})) + const worker = setupWorker(api.addEventListener('connection', () => {})) await worker.start() }) @@ -175,7 +175,7 @@ test('logs outgoing client event sending Blob data', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw const api = ws.link('wss://localhost/*') - const worker = setupWorker(api.on('connection', () => {})) + const worker = setupWorker(api.addEventListener('connection', () => {})) await worker.start() }) @@ -209,7 +209,7 @@ test('logs outgoing client event sending a long Blob data', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw const api = ws.link('wss://localhost/*') - const worker = setupWorker(api.on('connection', () => {})) + const worker = setupWorker(api.addEventListener('connection', () => {})) await worker.start() }) @@ -244,7 +244,7 @@ test('logs outgoing client event sending ArrayBuffer data', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw const api = ws.link('wss://localhost/*') - const worker = setupWorker(api.on('connection', () => {})) + const worker = setupWorker(api.addEventListener('connection', () => {})) await worker.start() }) @@ -278,7 +278,7 @@ test('logs outgoing client event sending a long ArrayBuffer data', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw const api = ws.link('wss://localhost/*') - const worker = setupWorker(api.on('connection', () => {})) + const worker = setupWorker(api.addEventListener('connection', () => {})) await worker.start() }) @@ -328,7 +328,7 @@ test('logs incoming client events', async ({ const { setupWorker, ws } = window.msw const api = ws.link(url) const worker = setupWorker( - api.on('connection', ({ client, server }) => { + api.addEventListener('connection', ({ client, server }) => { server.connect() }), ) @@ -386,7 +386,7 @@ test('logs raw incoming server events', async ({ const { setupWorker, ws } = window.msw const api = ws.link(url) const worker = setupWorker( - api.on('connection', ({ client, server }) => { + api.addEventListener('connection', ({ client, server }) => { server.connect() server.addEventListener('message', (event) => { @@ -440,7 +440,7 @@ test('logs the close event initiated by the client', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw const api = ws.link('wss://localhost/*') - const worker = setupWorker(api.on('connection', () => {})) + const worker = setupWorker(api.addEventListener('connection', () => {})) await worker.start() }) @@ -475,7 +475,7 @@ test('logs the close event initiated by the event handler', async ({ const { setupWorker, ws } = window.msw const api = ws.link('wss://localhost/*') const worker = setupWorker( - api.on('connection', ({ client }) => { + api.addEventListener('connection', ({ client }) => { client.close() }), ) @@ -512,7 +512,7 @@ test('logs outgoing client events sent vi "server.send()"', async ({ const { setupWorker, ws } = window.msw const api = ws.link(url) const worker = setupWorker( - api.on('connection', ({ server }) => { + api.addEventListener('connection', ({ server }) => { server.connect() server.send('hello from handler') }), @@ -550,7 +550,7 @@ test('logs incoming client events sent vi "client.send()"', async ({ const { setupWorker, ws } = window.msw const api = ws.link(url) const worker = setupWorker( - api.on('connection', ({ client }) => { + api.addEventListener('connection', ({ client }) => { client.send('hello from handler') }), ) @@ -586,7 +586,7 @@ test('logs connection closure initiated by the client', async ({ await page.evaluate(async () => { const { setupWorker, ws } = window.msw const api = ws.link('wss://localhost/*') - const worker = setupWorker(api.on('connection', () => {})) + const worker = setupWorker(api.addEventListener('connection', () => {})) await worker.start() }) @@ -621,7 +621,7 @@ test('logs connection closure initiated by the interceptor', async ({ const { setupWorker, ws } = window.msw const api = ws.link('wss://localhost/*') const worker = setupWorker( - api.on('connection', ({ client }) => { + api.addEventListener('connection', ({ client }) => { client.close(1003, 'Custom error') }), ) @@ -662,7 +662,7 @@ test('logs connection closure initiated by the original server', async ({ const { setupWorker, ws } = window.msw const api = ws.link(url) const worker = setupWorker( - api.on('connection', ({ server }) => { + api.addEventListener('connection', ({ server }) => { server.connect() }), ) diff --git a/test/browser/ws-api/ws.server.connect.browser.test.ts b/test/browser/ws-api/ws.server.connect.browser.test.ts index 6577a99ee..7741e9b47 100644 --- a/test/browser/ws-api/ws.server.connect.browser.test.ts +++ b/test/browser/ws-api/ws.server.connect.browser.test.ts @@ -39,7 +39,7 @@ test('does not connect to the actual server by default', async ({ const service = ws.link(serverUrl) const worker = setupWorker( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { queueMicrotask(() => client.send('mock')) }), ) @@ -78,7 +78,7 @@ test('forwards incoming server events to the client once connected', async ({ const service = ws.link(serverUrl) const worker = setupWorker( - service.on('connection', ({ server }) => { + service.addEventListener('connection', ({ server }) => { // Calling "connect()" establishes the connection // to the actual WebSocket server. server.connect() @@ -116,7 +116,7 @@ test('throws an error when connecting to a non-existing server', async ({ return new Promise(async (resolve) => { const worker = setupWorker( - service.on('connection', ({ server }) => { + service.addEventListener('connection', ({ server }) => { server.connect() }), ) diff --git a/test/browser/ws-api/ws.use.browser.test.ts b/test/browser/ws-api/ws.use.browser.test.ts index 9d5b07165..077b8b8c0 100644 --- a/test/browser/ws-api/ws.use.browser.test.ts +++ b/test/browser/ws-api/ws.use.browser.test.ts @@ -24,7 +24,7 @@ test('resolves outgoing events using initial handlers', async ({ const service = ws.link('*') const worker = setupWorker( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { if (event.data === 'hello') { client.send('hello from mock') @@ -57,7 +57,7 @@ test('overrides an outgoing event listener', async ({ loadExample, page }) => { const service = ws.link('*') const worker = setupWorker( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { if (event.data === 'hello') { client.send('must not be sent') @@ -68,7 +68,7 @@ test('overrides an outgoing event listener', async ({ loadExample, page }) => { await worker.start() worker.use( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { if (event.data === 'hello') { event.stopImmediatePropagation() @@ -105,7 +105,7 @@ test('combines initial and override listeners', async ({ const service = ws.link('*') const worker = setupWorker( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { if (event.data === 'hello') { // This will be sent the last since the initial @@ -119,7 +119,7 @@ test('combines initial and override listeners', async ({ await worker.start() worker.use( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { if (event.data === 'hello') { // This will be sent first since the override listener @@ -159,7 +159,7 @@ test('combines initial and override listeners in the opposite order', async ({ const service = ws.link('*') const worker = setupWorker( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { if (event.data === 'hello') { client.send('hello from mock') @@ -170,7 +170,7 @@ test('combines initial and override listeners in the opposite order', async ({ await worker.start() worker.use( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { if (event.data === 'hello') { // Queue this send to the next tick so it @@ -210,7 +210,7 @@ test('does not affect unrelated events', async ({ loadExample, page }) => { const service = ws.link('*') const worker = setupWorker( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { if (event.data === 'hello') { client.send('must not be sent') @@ -226,7 +226,7 @@ test('does not affect unrelated events', async ({ loadExample, page }) => { await worker.start() worker.use( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { if (event.data === 'hello') { event.stopImmediatePropagation() diff --git a/test/node/ws-api/ws.apply.test.ts b/test/node/ws-api/ws.apply.test.ts index e958f1797..ef6fdafb2 100644 --- a/test/node/ws-api/ws.apply.test.ts +++ b/test/node/ws-api/ws.apply.test.ts @@ -20,7 +20,7 @@ it('patches WebSocket class even if no event handlers were defined', () => { it('does not patch WebSocket class until server.listen() is called', () => { const api = ws.link('wss://example.com') - server.use(api.on('connection', () => {})) + server.use(api.addEventListener('connection', () => {})) const raw = new WebSocket('wss://example.com') expect(raw.constructor.name).toBe('WebSocket') diff --git a/test/node/ws-api/ws.event-patching.test.ts b/test/node/ws-api/ws.event-patching.test.ts index 320e1d7d4..0497e1fa3 100644 --- a/test/node/ws-api/ws.event-patching.test.ts +++ b/test/node/ws-api/ws.event-patching.test.ts @@ -9,7 +9,7 @@ const service = ws.link('ws://*') const originalServer = new WebSocketServer() const server = setupServer( - service.on('connection', ({ server }) => { + service.addEventListener('connection', ({ server }) => { server.connect() }), ) @@ -35,7 +35,7 @@ it('patches incoming server message', async () => { }) server.use( - service.on('connection', ({ client, server }) => { + service.addEventListener('connection', ({ client, server }) => { /** * @note Since the initial handler connects to the server, * there's no need to call `server.connect()` again. @@ -66,7 +66,7 @@ it('combines original and mock server messages', async () => { }) server.use( - service.on('connection', ({ client, server }) => { + service.addEventListener('connection', ({ client, server }) => { server.addEventListener('message', () => { client.send('mocked message') }) @@ -95,7 +95,7 @@ it('combines original and mock server messages in the different order', async () }) server.use( - service.on('connection', ({ client, server }) => { + service.addEventListener('connection', ({ client, server }) => { server.addEventListener('message', (event) => { /** * @note To change the incoming server events order, diff --git a/test/node/ws-api/ws.intercept.client.test.ts b/test/node/ws-api/ws.intercept.client.test.ts index 3e0345705..f511da037 100644 --- a/test/node/ws-api/ws.intercept.client.test.ts +++ b/test/node/ws-api/ws.intercept.client.test.ts @@ -28,7 +28,7 @@ it('intercepts outgoing client text message', async () => { const realConnectionListener = vi.fn() server.use( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', mockMessageListener) }), ) @@ -56,7 +56,7 @@ it('intercepts outgoing client Blob message', async () => { const realConnectionListener = vi.fn() server.use( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', mockMessageListener) }), ) @@ -83,7 +83,7 @@ it('intercepts outgoing client ArrayBuffer message', async () => { const realConnectionListener = vi.fn() server.use( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', mockMessageListener) }), ) diff --git a/test/node/ws-api/ws.intercept.server.test.ts b/test/node/ws-api/ws.intercept.server.test.ts index 261291cc4..c34d82ee1 100644 --- a/test/node/ws-api/ws.intercept.server.test.ts +++ b/test/node/ws-api/ws.intercept.server.test.ts @@ -33,7 +33,7 @@ it('intercepts incoming server text message', async () => { client.send('hello') }) server.use( - service.on('connection', ({ server }) => { + service.addEventListener('connection', ({ server }) => { server.connect() server.addEventListener('message', serverMessageListener) }), @@ -70,7 +70,7 @@ it('intercepts incoming server Blob message', async () => { client.send(await new Blob(['hello']).arrayBuffer()) }) server.use( - service.on('connection', ({ server }) => { + service.addEventListener('connection', ({ server }) => { server.connect() server.addEventListener('message', serverMessageListener) }), @@ -104,7 +104,7 @@ it('intercepts incoming ArrayBuffer message', async () => { client.send(encoder.encode('hello world')) }) server.use( - service.on('connection', ({ server }) => { + service.addEventListener('connection', ({ server }) => { server.connect() server.addEventListener('message', serverMessageListener) }), diff --git a/test/node/ws-api/ws.server.connect.test.ts b/test/node/ws-api/ws.server.connect.test.ts index 59a1ddf44..9b17f1e21 100644 --- a/test/node/ws-api/ws.server.connect.test.ts +++ b/test/node/ws-api/ws.server.connect.test.ts @@ -30,7 +30,7 @@ it('does not connect to the actual server by default', async () => { const mockConnectionListener = vi.fn() originalServer.once('connection', serverConnectionListener) - server.use(service.on('connection', mockConnectionListener)) + server.use(service.addEventListener('connection', mockConnectionListener)) new WebSocket(originalServer.url) @@ -47,7 +47,7 @@ it('connects to the actual server after calling "server.connect()"', async () => originalServer.once('connection', serverConnectionListener) server.use( - service.on('connection', ({ server }) => { + service.addEventListener('connection', ({ server }) => { mockConnectionListener() server.connect() }), @@ -65,7 +65,7 @@ it('forwards incoming server events to the client once connected', async () => { originalServer.once('connection', (client) => client.send('hello')) server.use( - service.on('connection', ({ server }) => { + service.addEventListener('connection', ({ server }) => { server.connect() }), ) @@ -82,7 +82,7 @@ it('forwards incoming server events to the client once connected', async () => { it('throws an error when connecting to a non-existing server', async () => { server.use( - service.on('connection', ({ server }) => { + service.addEventListener('connection', ({ server }) => { server.connect() }), ) diff --git a/test/node/ws-api/ws.use.test.ts b/test/node/ws-api/ws.use.test.ts index f6cc8135d..0fd7af581 100644 --- a/test/node/ws-api/ws.use.test.ts +++ b/test/node/ws-api/ws.use.test.ts @@ -7,7 +7,7 @@ import { setupServer } from 'msw/node' const service = ws.link('wss://*') const server = setupServer( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { if (event.data === 'hello') { client.send('hello, client!') @@ -47,7 +47,7 @@ it.concurrent( 'overrides an outgoing event listener', server.boundary(async () => { server.use( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { if (event.data === 'hello') { // Stopping immediate event propagation will prevent @@ -76,7 +76,7 @@ it.concurrent( 'combines initial and override listeners', server.boundary(async () => { server.use( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { if (event.data === 'hello') { // Not stopping the event propagation will result in both @@ -108,7 +108,7 @@ it.concurrent( 'combines initial and override listeners in the opposite order', async () => { server.use( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { if (event.data === 'hello') { // Queuing the send to the next tick will ensure @@ -139,7 +139,7 @@ it.concurrent( 'does not affect unrelated events', server.boundary(async () => { server.use( - service.on('connection', ({ client }) => { + service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { if (event.data === 'hello') { // Stopping immediate event propagation will prevent From 0c990780d673e118010845d0a620db9c0d800622 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 19 Sep 2024 13:44:12 +0200 Subject: [PATCH 085/105] chore(release): v2.3.0-ws.rc-9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 49742acbf..12a1f791c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-8", + "version": "2.3.0-ws.rc-9", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From a45981f9d0ccd08c62a9e6a7b1cedbe9565afeb7 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 19 Sep 2024 17:39:04 +0200 Subject: [PATCH 086/105] test: check `event.data` before resolving --- test/browser/ws-api/ws.clients.browser.test.ts | 2 +- .../ws-api/ws.intercept.client.browser.test.ts | 12 +++++++++--- .../ws-api/ws.intercept.server.browser.test.ts | 10 +++++++--- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/test/browser/ws-api/ws.clients.browser.test.ts b/test/browser/ws-api/ws.clients.browser.test.ts index e2eba5472..098fed76c 100644 --- a/test/browser/ws-api/ws.clients.browser.test.ts +++ b/test/browser/ws-api/ws.clients.browser.test.ts @@ -231,7 +231,7 @@ test('clears the list of clients when the page is reloaded', async ({ }) } - await enableMocking(page) + await enableMocking() expect(await page.evaluate(() => window.link.clients.size)).toBe(0) diff --git a/test/browser/ws-api/ws.intercept.client.browser.test.ts b/test/browser/ws-api/ws.intercept.client.browser.test.ts index 3b0e2a359..8fddd4e3d 100644 --- a/test/browser/ws-api/ws.intercept.client.browser.test.ts +++ b/test/browser/ws-api/ws.intercept.client.browser.test.ts @@ -59,7 +59,9 @@ test('intercepts outgoing client text message', async ({ const worker = setupWorker( service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { - resolve(event.data) + if (typeof event.data === 'string') { + resolve(event.data) + } }) }), ) @@ -91,7 +93,9 @@ test('intercepts outgoing client Blob message', async ({ const worker = setupWorker( service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { - resolve(event.data.text()) + if (event.data instanceof Blob) { + resolve(event.data.text()) + } }) }), ) @@ -123,7 +127,9 @@ test('intercepts outgoing client ArrayBuffer message', async ({ const worker = setupWorker( service.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { - resolve(new TextDecoder().decode(event.data)) + if (event.data instanceof Uint8Array) { + resolve(new TextDecoder().decode(event.data)) + } }) }), ) diff --git a/test/browser/ws-api/ws.intercept.server.browser.test.ts b/test/browser/ws-api/ws.intercept.server.browser.test.ts index 006bf17dd..34dd7c9e5 100644 --- a/test/browser/ws-api/ws.intercept.server.browser.test.ts +++ b/test/browser/ws-api/ws.intercept.server.browser.test.ts @@ -43,7 +43,9 @@ test('intercepts incoming server text message', async ({ service.addEventListener('connection', ({ server }) => { server.connect() server.addEventListener('message', (event) => { - resolve(event.data) + if (typeof event.data === 'string') { + resolve(event.data) + } }) }), ) @@ -91,7 +93,9 @@ test('intercepts incoming server Blob message', async ({ service.addEventListener('connection', ({ server }) => { server.connect() server.addEventListener('message', (event) => { - resolve(event.data.text()) + if (event.data instanceof Blob) { + resolve(event.data.text()) + } }) }), ) @@ -137,7 +141,7 @@ test('intercepts outgoing server ArrayBuffer message', async ({ service.addEventListener('connection', ({ server }) => { server.connect() server.addEventListener('message', (event) => { - resolve(new TextDecoder().decode(event.data)) + resolve(new TextDecoder().decode(event.data as Uint8Array)) }) }), ) From 4bf38e0703cbaad34879821a5e44dbf47a4f9584 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 19 Sep 2024 17:49:01 +0200 Subject: [PATCH 087/105] test: add `client.send` tests --- test/browser/ws-api/ws.client.send.test.ts | 132 ++++++++++++++++++ .../ws-api/ws.server.connect.browser.test.ts | 9 +- 2 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 test/browser/ws-api/ws.client.send.test.ts diff --git a/test/browser/ws-api/ws.client.send.test.ts b/test/browser/ws-api/ws.client.send.test.ts new file mode 100644 index 000000000..84a30b226 --- /dev/null +++ b/test/browser/ws-api/ws.client.send.test.ts @@ -0,0 +1,132 @@ +import type { ws } from 'msw' +import type { setupWorker } from 'msw/browser' +import type { Page } from '@playwright/test' +import { test, expect } from '../playwright.extend' + +declare global { + interface Window { + msw: { + ws: typeof ws + setupWorker: typeof setupWorker + } + } +} + +test('sends data to a single client on connection', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('wss://example.com') + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + // Send a message to the client as soon as it connects. + client.send('hello world') + }), + ) + await worker.start() + }) + + const clientMessage = await page.evaluate(async () => { + const socket = new WebSocket('wss://example.com') + return new Promise((resolve, reject) => { + socket.onmessage = (event) => resolve(event.data) + socket.onerror = () => reject(new Error('WebSocket error')) + }).finally(() => socket.close()) + }) + + expect(clientMessage).toBe('hello world') +}) + +test('sends data to multiple clients on connection', async ({ + loadExample, + browser, + page, +}) => { + const { compilation } = await loadExample( + require.resolve('./ws.runtime.js'), + { + skipActivation: true, + }, + ) + + async function createSocketAndGetFirstMessage(page: Page) { + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('wss://example.com') + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + // Send a message to the client as soon as it connects. + client.send('hello world') + }), + ) + await worker.start() + }) + + return page.evaluate(async () => { + const socket = new WebSocket('wss://example.com') + return new Promise((resolve, reject) => { + socket.onmessage = (event) => resolve(event.data) + socket.onerror = () => reject(new Error('WebSocket error')) + }).finally(() => socket.close()) + }) + } + + const secondPage = await browser.newPage() + await secondPage.goto(compilation.previewUrl) + + const [firstClientMessage, secondClientMessage] = await Promise.all([ + createSocketAndGetFirstMessage(page), + createSocketAndGetFirstMessage(secondPage), + ]) + + expect(firstClientMessage).toBe('hello world') + expect(secondClientMessage).toBe('hello world') +}) + +test('sends data in response to a client message', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const service = ws.link('wss://example.com') + + const worker = setupWorker( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (typeof event.data === 'string' && event.data === 'hello') { + client.send('hello world') + } + }) + }), + ) + await worker.start() + }) + + const clientMessage = await page.evaluate(async () => { + const socket = new WebSocket('wss://example.com') + socket.onopen = () => { + socket.send('ignore this') + socket.send('hello') + } + + return new Promise((resolve, reject) => { + socket.onmessage = (event) => resolve(event.data) + socket.onerror = () => reject(new Error('WebSocket error')) + }).finally(() => socket.close()) + }) + + expect(clientMessage).toBe('hello world') +}) diff --git a/test/browser/ws-api/ws.server.connect.browser.test.ts b/test/browser/ws-api/ws.server.connect.browser.test.ts index 7741e9b47..c9f070cb5 100644 --- a/test/browser/ws-api/ws.server.connect.browser.test.ts +++ b/test/browser/ws-api/ws.server.connect.browser.test.ts @@ -50,12 +50,9 @@ test('does not connect to the actual server by default', async ({ const socket = new WebSocket(serverUrl) return new Promise((resolve, reject) => { - socket.onmessage = (event) => { - resolve(event.data) - socket.close() - } - socket.onerror = reject - }) + socket.onmessage = (event) => resolve(event.data) + socket.onerror = () => reject(new Error('WebSocket error')) + }).finally(() => socket.close()) }, server.url) expect(clientMessage).toBe('mock') From b12d53ebbcbf503d94e491e67e29834ca0075f03 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Sep 2024 18:38:09 +0200 Subject: [PATCH 088/105] chore: iterate over handlers via for/of --- src/core/ws/handleWebSocketEvent.ts | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/core/ws/handleWebSocketEvent.ts b/src/core/ws/handleWebSocketEvent.ts index 115213863..73d295956 100644 --- a/src/core/ws/handleWebSocketEvent.ts +++ b/src/core/ws/handleWebSocketEvent.ts @@ -21,20 +21,21 @@ export function handleWebSocketEvent(options: HandleWebSocketEventOptions) { // match the "ws.link()" endpoint predicate. Don't dispatch // anything yet so the logger can be attached to the connection // before it potentially sends events. - const matchingHandlers = handlers.filter( - (handler): handler is WebSocketHandler => { - if (handler instanceof WebSocketHandler) { - return handler.predicate({ + const matchingHandlers: Array = [] + + for (const handler of handlers) { + if ( + handler instanceof WebSocketHandler && + handler.predicate({ + event: connectionEvent, + parsedResult: handler.parse({ event: connectionEvent, - parsedResult: handler.parse({ - event: connectionEvent, - }), - }) - } - - return false - }, - ) + }), + }) + ) { + matchingHandlers.push(handler) + } + } if (matchingHandlers.length > 0) { options?.onMockedConnection(connection) From 4b60591a4a5320ebfe0c6d115cd6ca02dd3a6da3 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Sep 2024 19:28:10 +0200 Subject: [PATCH 089/105] feat: support `onUnhandledRequest` for WebSocket --- src/browser/setupWorker/setupWorker.ts | 12 ++- src/core/ws.ts | 2 +- src/core/ws/WebSocketClientManager.ts | 6 +- src/core/ws/WebSocketClientStore.ts | 2 +- src/core/ws/handleWebSocketEvent.ts | 27 ++++++- src/node/SetupServerCommonApi.ts | 14 ++-- .../on-unhandled-request/callback.test.ts | 60 ++++++++++++++ .../ws-api/on-unhandled-request/error.test.ts | 80 +++++++++++++++++++ .../ws-api/on-unhandled-request/warn.test.ts | 63 +++++++++++++++ test/node/ws-api/ws.apply.test.ts | 4 +- test/node/ws-api/ws.event-patching.test.ts | 4 +- test/node/ws-api/ws.intercept.server.test.ts | 4 +- test/node/ws-api/ws.server.connect.test.ts | 4 +- test/node/ws-api/ws.use.test.ts | 4 +- 14 files changed, 251 insertions(+), 35 deletions(-) create mode 100644 test/node/ws-api/on-unhandled-request/callback.test.ts create mode 100644 test/node/ws-api/on-unhandled-request/error.test.ts create mode 100644 test/node/ws-api/on-unhandled-request/warn.test.ts diff --git a/src/browser/setupWorker/setupWorker.ts b/src/browser/setupWorker/setupWorker.ts index 4cc2ad14b..f83c67e0d 100644 --- a/src/browser/setupWorker/setupWorker.ts +++ b/src/browser/setupWorker/setupWorker.ts @@ -180,8 +180,11 @@ export class SetupWorkerApi options, ) as SetupWorkerInternalContext['startOptions'] - // Enable WebSocket interception. + // Enable the WebSocket interception. handleWebSocketEvent({ + getUnhandledRequestStrategy: () => { + return this.context.startOptions.onUnhandledRequest + }, getHandlers: () => { return this.handlersController.currentHandlers() }, @@ -192,13 +195,8 @@ export class SetupWorkerApi attachWebSocketLogger(connection) } }, - onPassthroughConnection() { - /** - * @fixme Call some "onUnhandledConnection". - */ - }, + onPassthroughConnection() {}, }) - webSocketInterceptor.apply() this.subscriptions.push(() => { diff --git a/src/core/ws.ts b/src/core/ws.ts index 1757bf8df..745615951 100644 --- a/src/core/ws.ts +++ b/src/core/ws.ts @@ -103,7 +103,7 @@ function createWebSocketLinkHandler(url: Path): WebSocketLink { typeof url, ) - const clientManager = new WebSocketClientManager(webSocketChannel, url) + const clientManager = new WebSocketClientManager(webSocketChannel) return { get clients() { diff --git a/src/core/ws/WebSocketClientManager.ts b/src/core/ws/WebSocketClientManager.ts index ecbb90321..44ce81e1c 100644 --- a/src/core/ws/WebSocketClientManager.ts +++ b/src/core/ws/WebSocketClientManager.ts @@ -3,7 +3,6 @@ import type { WebSocketClientConnection, WebSocketClientConnectionProtocol, } from '@mswjs/interceptors/WebSocket' -import { type Path } from '../utils/matching/matchRequestUrl' import { WebSocketClientStore } from './WebSocketClientStore' import { WebSocketMemoryClientStore } from './WebSocketMemoryClientStore' import { WebSocketIndexedDBClientStore } from './WebSocketIndexedDBClientStore' @@ -34,10 +33,7 @@ export class WebSocketClientManager { private runtimeClients: Map private allClients: Set - constructor( - private channel: BroadcastChannel, - private url: Path, - ) { + constructor(private channel: BroadcastChannel) { // Store the clients in the IndexedDB in the browser, // otherwise, store the clients in memory. this.store = diff --git a/src/core/ws/WebSocketClientStore.ts b/src/core/ws/WebSocketClientStore.ts index 8a982d09c..6e4c302b6 100644 --- a/src/core/ws/WebSocketClientStore.ts +++ b/src/core/ws/WebSocketClientStore.ts @@ -1,6 +1,6 @@ import type { WebSocketClientConnectionProtocol } from '@mswjs/interceptors/WebSocket' -export type SerializedWebSocketClient = { +export interface SerializedWebSocketClient { id: string url: string } diff --git a/src/core/ws/handleWebSocketEvent.ts b/src/core/ws/handleWebSocketEvent.ts index 73d295956..a20bd6ec4 100644 --- a/src/core/ws/handleWebSocketEvent.ts +++ b/src/core/ws/handleWebSocketEvent.ts @@ -2,15 +2,20 @@ import type { WebSocketConnectionData } from '@mswjs/interceptors/lib/browser/in import { RequestHandler } from '../handlers/RequestHandler' import { WebSocketHandler, kDispatchEvent } from '../handlers/WebSocketHandler' import { webSocketInterceptor } from './webSocketInterceptor' +import { + onUnhandledRequest, + UnhandledRequestStrategy, +} from '../utils/request/onUnhandledRequest' interface HandleWebSocketEventOptions { + getUnhandledRequestStrategy: () => UnhandledRequestStrategy getHandlers: () => Array onMockedConnection: (connection: WebSocketConnectionData) => void onPassthroughConnection: (onnection: WebSocketConnectionData) => void } export function handleWebSocketEvent(options: HandleWebSocketEventOptions) { - webSocketInterceptor.on('connection', (connection) => { + webSocketInterceptor.on('connection', async (connection) => { const handlers = options.getHandlers() const connectionEvent = new MessageEvent('connection', { @@ -47,6 +52,26 @@ export function handleWebSocketEvent(options: HandleWebSocketEventOptions) { handler[kDispatchEvent](connectionEvent) } } else { + // Construct a request representing this WebSocket connection. + const request = new Request(connection.client.url, { + headers: { + upgrade: 'websocket', + connection: 'upgrade', + }, + }) + await onUnhandledRequest( + request, + options.getUnhandledRequestStrategy(), + ).catch((error) => { + const errorEvent = new Event('error') + Object.defineProperty(errorEvent, 'cause', { + enumerable: true, + configurable: false, + value: error, + }) + connection.client.socket.dispatchEvent(errorEvent) + }) + options?.onPassthroughConnection(connection) // If none of the "ws" handlers matched, diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts index 041197fd4..32a4f187f 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -94,7 +94,12 @@ export class SetupServerCommonApi }, ) + // Preconfigure the WebSocket interception but don't enable it just yet. + // It will be enabled when the server starts. handleWebSocketEvent({ + getUnhandledRequestStrategy: () => { + return this.resolvedOptions.onUnhandledRequest + }, getHandlers: () => { return this.handlersController.currentHandlers() }, @@ -111,12 +116,11 @@ export class SetupServerCommonApi // Apply the interceptor when starting the server. this.interceptor.apply() - webSocketInterceptor.apply() + this.subscriptions.push(() => this.interceptor.dispose()) - this.subscriptions.push(() => { - this.interceptor.dispose() - webSocketInterceptor.dispose() - }) + // Apply the WebSocket interception. + webSocketInterceptor.apply() + this.subscriptions.push(() => webSocketInterceptor.dispose()) // Assert that the interceptor has been applied successfully. // Also guards us from forgetting to call "interceptor.apply()" diff --git a/test/node/ws-api/on-unhandled-request/callback.test.ts b/test/node/ws-api/on-unhandled-request/callback.test.ts new file mode 100644 index 000000000..e49c9ed9f --- /dev/null +++ b/test/node/ws-api/on-unhandled-request/callback.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const service = ws.link('wss://localhost:4321') +const server = setupServer() + +const onUnhandledRequest = vi.fn() + +beforeAll(() => { + server.listen({ onUnhandledRequest }) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + server.resetHandlers() + vi.resetAllMocks() +}) + +afterAll(() => { + server.close() + vi.restoreAllMocks() +}) + +it('calls a custom callback on an unhandled WebSocket connection', async () => { + const socket = new WebSocket('wss://localhost:4321') + + await vi.waitFor(() => { + return new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + }) + + expect(onUnhandledRequest).toHaveBeenCalledOnce() + + const [request] = onUnhandledRequest.mock.calls[0] + expect(request).toBeInstanceOf(Request) + expect(request.method).toBe('GET') + expect(request.url).toBe('wss://localhost:4321/') + expect(Array.from(request.headers)).toEqual([ + ['connection', 'upgrade'], + ['upgrade', 'websocket'], + ]) +}) + +it('does not call a custom callback for a handled WebSocket connection', async () => { + server.use(service.addEventListener('connection', () => {})) + + const socket = new WebSocket('wss://localhost:4321') + + await vi.waitFor(() => { + return new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + }) + + expect(onUnhandledRequest).not.toHaveBeenCalled() +}) diff --git a/test/node/ws-api/on-unhandled-request/error.test.ts b/test/node/ws-api/on-unhandled-request/error.test.ts new file mode 100644 index 000000000..36873939c --- /dev/null +++ b/test/node/ws-api/on-unhandled-request/error.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { InternalError } from '../../../../src/core/utils/internal/devUtils' + +const service = ws.link('wss://localhost:4321') +const server = setupServer() + +beforeAll(() => { + server.listen({ onUnhandledRequest: 'error' }) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + server.resetHandlers() + vi.resetAllMocks() +}) + +afterAll(() => { + server.close() + vi.restoreAllMocks() +}) + +it( + 'errors on unhandled WebSocket connection', + server.boundary(async () => { + const socket = new WebSocket('wss://localhost:4321') + const errorListener = vi.fn() + + await vi.waitFor(() => { + return new Promise((resolve, reject) => { + // These are intentionally swapped. The connection MUST error. + socket.addEventListener('error', errorListener) + socket.addEventListener('error', resolve) + socket.onopen = reject + }) + }) + + expect(console.error).toHaveBeenCalledWith( + `\ +[MSW] Error: intercepted a request without a matching request handler: + + • GET 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/getting-started/mocks`, + ) + + expect(errorListener).toHaveBeenCalledOnce() + + // Must forward the original `onUnhandledRequest` error as the + // `cause` property of the error event emitted on the connection. + const [event] = errorListener.mock.calls[0] + expect(event).toBeInstanceOf(Event) + expect(event.type).toBe('error') + expect(event.cause).toEqual( + new InternalError( + '[MSW] Cannot bypass a request when using the "error" strategy for the "onUnhandledRequest" option.', + ), + ) + }), +) + +it( + 'does not error on handled WebSocket connection', + server.boundary(async () => { + server.use(service.addEventListener('connection', () => {})) + + const socket = new WebSocket('wss://localhost:4321') + + await vi.waitFor(() => { + return new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + }) + + expect(console.error).not.toHaveBeenCalled() + }), +) diff --git a/test/node/ws-api/on-unhandled-request/warn.test.ts b/test/node/ws-api/on-unhandled-request/warn.test.ts new file mode 100644 index 000000000..5473692a3 --- /dev/null +++ b/test/node/ws-api/on-unhandled-request/warn.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' + +const service = ws.link('wss://localhost:4321') +const server = setupServer() + +beforeAll(() => { + server.listen({ onUnhandledRequest: 'warn' }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + server.resetHandlers() + vi.resetAllMocks() +}) + +afterAll(() => { + server.close() + vi.restoreAllMocks() +}) + +it( + 'warns on unhandled WebSocket connection', + server.boundary(async () => { + const socket = new WebSocket('wss://localhost:4321') + + await vi.waitFor(() => { + return new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + }) + + expect(console.warn).toHaveBeenCalledWith( + `\ +[MSW] Warning: intercepted a request without a matching request handler: + + • GET 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/getting-started/mocks`, + ) + }), +) + +it( + 'does not warn on handled WebSocket connection', + server.boundary(async () => { + server.use(service.addEventListener('connection', () => {})) + + const socket = new WebSocket('wss://localhost:4321') + + await vi.waitFor(() => { + return new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + }) + + expect(console.warn).not.toHaveBeenCalled() + }), +) diff --git a/test/node/ws-api/ws.apply.test.ts b/test/node/ws-api/ws.apply.test.ts index ef6fdafb2..22b750334 100644 --- a/test/node/ws-api/ws.apply.test.ts +++ b/test/node/ws-api/ws.apply.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment node-websocket - */ +// @vitest-environment node-websocket import { ws } from 'msw' import { setupServer } from 'msw/node' diff --git a/test/node/ws-api/ws.event-patching.test.ts b/test/node/ws-api/ws.event-patching.test.ts index 0497e1fa3..1027caa41 100644 --- a/test/node/ws-api/ws.event-patching.test.ts +++ b/test/node/ws-api/ws.event-patching.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment node-websocket - */ +// @vitest-environment node-websocket import { ws } from 'msw' import { setupServer } from 'msw/node' import { WebSocketServer } from '../../support/WebSocketServer' diff --git a/test/node/ws-api/ws.intercept.server.test.ts b/test/node/ws-api/ws.intercept.server.test.ts index c34d82ee1..a67c3884b 100644 --- a/test/node/ws-api/ws.intercept.server.test.ts +++ b/test/node/ws-api/ws.intercept.server.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment node-websocket - */ +// @vitest-environment node-websocket import { ws } from 'msw' import { setupServer } from 'msw/node' import { WebSocketServer } from '../../support/WebSocketServer' diff --git a/test/node/ws-api/ws.server.connect.test.ts b/test/node/ws-api/ws.server.connect.test.ts index 9b17f1e21..468b43ae6 100644 --- a/test/node/ws-api/ws.server.connect.test.ts +++ b/test/node/ws-api/ws.server.connect.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment node-websocket - */ +// @vitest-environment node-websocket import { ws } from 'msw' import { setupServer } from 'msw/node' import { WebSocketServer } from '../../support/WebSocketServer' diff --git a/test/node/ws-api/ws.use.test.ts b/test/node/ws-api/ws.use.test.ts index 0fd7af581..83405a414 100644 --- a/test/node/ws-api/ws.use.test.ts +++ b/test/node/ws-api/ws.use.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment node-websocket - */ +// @vitest-environment node-websocket import { ws } from 'msw' import { setupServer } from 'msw/node' From 504143387d774b7d898fe046a6c6acad7fff52e6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Sep 2024 20:04:20 +0200 Subject: [PATCH 090/105] fix: ignore duplicate saves of clients in the database --- src/core/ws/WebSocketIndexedDBClientStore.ts | 45 +++++++++++++++++--- test/browser/ws-api/ws.use.browser.test.ts | 1 - 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/core/ws/WebSocketIndexedDBClientStore.ts b/src/core/ws/WebSocketIndexedDBClientStore.ts index b37a42568..b8509b7a9 100644 --- a/src/core/ws/WebSocketIndexedDBClientStore.ts +++ b/src/core/ws/WebSocketIndexedDBClientStore.ts @@ -18,7 +18,14 @@ export class WebSocketIndexedDBClientStore implements WebSocketClientStore { public async add(client: WebSocketClientConnectionProtocol): Promise { const promise = new DeferredPromise() const store = await this.getStore() - const request = store.add({ + + /** + * @note Use `.put()` instead of `.add()` to allow setting clients + * that already exist in the database. This can happen if a single page + * has multiple event handlers. Each handler will receive the "connection" + * event in parallel, and try to set that WebSocket client in the database. + */ + const request = store.put({ id: client.id, url: client.url.href, } satisfies SerializedWebSocketClient) @@ -27,7 +34,13 @@ export class WebSocketIndexedDBClientStore implements WebSocketClientStore { promise.resolve() } request.onerror = () => { - promise.reject(new Error(`Failed to add WebSocket client ${client.id}`)) + // eslint-disable-next-line no-console + console.error(request.error) + promise.reject( + new Error( + `Failed to add WebSocket client "${client.id}". There is likely an additional output above.`, + ), + ) } return promise @@ -44,7 +57,13 @@ export class WebSocketIndexedDBClientStore implements WebSocketClientStore { promise.resolve(request.result) } request.onerror = () => { - promise.reject(new Error(`Failed to get all WebSocket clients`)) + // eslint-disable-next-line no-console + console.log(request.error) + promise.reject( + new Error( + `Failed to get all WebSocket clients. There is likely an additional output above.`, + ), + ) } return promise @@ -62,9 +81,11 @@ export class WebSocketIndexedDBClientStore implements WebSocketClientStore { promise.resolve() } store.transaction.onerror = () => { + // eslint-disable-next-line no-console + console.error(store.transaction.error) promise.reject( new Error( - `Failed to delete WebSocket clients [${clientIds.join(', ')}]`, + `Failed to delete WebSocket clients [${clientIds.join(', ')}]. There is likely an additional output above.`, ), ) } @@ -95,11 +116,23 @@ export class WebSocketIndexedDBClientStore implements WebSocketClientStore { promise.resolve(db) } store.transaction.onerror = () => { - promise.reject(new Error('Failed to create WebSocket client store')) + // eslint-disable-next-line no-console + console.error(store.transaction.error) + promise.reject( + new Error( + 'Failed to create WebSocket client store. There is likely an additional output above.', + ), + ) } } request.onerror = () => { - promise.reject(new Error('Failed to open an IndexedDB database')) + // eslint-disable-next-line no-console + console.error(request.error) + promise.reject( + new Error( + 'Failed to open an IndexedDB database. There is likely an additional output above.', + ), + ) } return promise diff --git a/test/browser/ws-api/ws.use.browser.test.ts b/test/browser/ws-api/ws.use.browser.test.ts index 077b8b8c0..d323192b7 100644 --- a/test/browser/ws-api/ws.use.browser.test.ts +++ b/test/browser/ws-api/ws.use.browser.test.ts @@ -83,7 +83,6 @@ test('overrides an outgoing event listener', async ({ loadExample, page }) => { const socket = new WebSocket('wss://example.com') return new Promise((resolve, reject) => { socket.onopen = () => socket.send('hello') - socket.onmessage = (event) => resolve(event.data) socket.onerror = reject }) From c384c1343382d9c58fe1f52f5bfd58d6c9069d03 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 26 Sep 2024 16:25:25 +0200 Subject: [PATCH 091/105] fix(handleRequest): use `RequestHandler` as `handlers` type --- src/browser/setupWorker/start/createRequestListener.ts | 8 +++++++- src/core/utils/handleRequest.ts | 3 ++- src/node/SetupServerCommonApi.ts | 9 ++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/browser/setupWorker/start/createRequestListener.ts b/src/browser/setupWorker/start/createRequestListener.ts index 627617e49..e9e1cf904 100644 --- a/src/browser/setupWorker/start/createRequestListener.ts +++ b/src/browser/setupWorker/start/createRequestListener.ts @@ -9,6 +9,8 @@ import { } from './utils/createMessageChannel' import { parseWorkerRequest } from '../../utils/parseWorkerRequest' import { RequestHandler } from '~/core/handlers/RequestHandler' +import { HttpHandler } from '~/core/handlers/HttpHandler' +import { GraphQLHandler } from '~/core/handlers/GraphQLHandler' import { handleRequest } from '~/core/utils/handleRequest' import { RequiredDeep } from '~/core/typeUtils' import { devUtils } from '~/core/utils/internal/devUtils' @@ -43,7 +45,11 @@ export const createRequestListener = ( await handleRequest( request, requestId, - context.getRequestHandlers(), + context.getRequestHandlers().filter((handler) => { + return ( + handler instanceof HttpHandler || handler instanceof GraphQLHandler + ) + }), options, context.emitter, { diff --git a/src/core/utils/handleRequest.ts b/src/core/utils/handleRequest.ts index baaaa0e35..d78259c2e 100644 --- a/src/core/utils/handleRequest.ts +++ b/src/core/utils/handleRequest.ts @@ -2,6 +2,7 @@ import { until } from '@open-draft/until' import { Emitter } from 'strict-event-emitter' import { LifeCycleEventsMap, SharedOptions } from '../sharedOptions' import { RequiredDeep } from '../typeUtils' +import type { RequestHandler } from '../handlers/RequestHandler' import { HandlersExecutionResult, executeHandlers } from './executeHandlers' import { onUnhandledRequest } from './request/onUnhandledRequest' import { storeResponseCookies } from './request/storeResponseCookies' @@ -44,7 +45,7 @@ export interface HandleRequestOptions { export async function handleRequest( request: Request, requestId: string, - handlers: Array, + handlers: Array, options: RequiredDeep, emitter: Emitter, handleRequestOptions?: HandleRequestOptions, diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts index 32a4f187f..dca9bd85a 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -14,6 +14,8 @@ import type { LifeCycleEventsMap, SharedOptions } from '~/core/sharedOptions' import { SetupApi } from '~/core/SetupApi' import { handleRequest } from '~/core/utils/handleRequest' import type { RequestHandler } from '~/core/handlers/RequestHandler' +import { HttpHandler } from '~/core/handlers/HttpHandler' +import { GraphQLHandler } from '~/core/handlers/GraphQLHandler' import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { mergeRight } from '~/core/utils/internal/mergeRight' import { InternalError, devUtils } from '~/core/utils/internal/devUtils' @@ -61,7 +63,12 @@ export class SetupServerCommonApi const response = await handleRequest( request, requestId, - this.handlersController.currentHandlers(), + this.handlersController.currentHandlers().filter((handler) => { + return ( + handler instanceof HttpHandler || + handler instanceof GraphQLHandler + ) + }), this.resolvedOptions, this.emitter, ) From 1b1e0f8e1930d997cc7e84805ea511aa00a68e01 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 27 Sep 2024 16:22:37 +0200 Subject: [PATCH 092/105] fix: make `close` event cancelable --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 4c195be56..146bc56b2 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^3.0.0", - "@mswjs/interceptors": "^0.35.8", + "@mswjs/interceptors": "^0.35.9", "@open-draft/deferred-promise": "^2.2.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a34faa7b..e20bf762a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ dependencies: specifier: ^3.0.0 version: 3.1.1 '@mswjs/interceptors': - specifier: ^0.35.8 - version: 0.35.8 + specifier: ^0.35.9 + version: 0.35.9 '@open-draft/deferred-promise': specifier: ^2.2.0 version: 2.2.0 @@ -1457,8 +1457,8 @@ packages: - utf-8-validate dev: true - /@mswjs/interceptors@0.35.8: - resolution: {integrity: sha512-PFfqpHplKa7KMdoQdj5td03uG05VK2Ng1dG0sP4pT9h0dGSX2v9txYt/AnrzPb/vAmfyBBC0NQV7VaBEX+efgQ==} + /@mswjs/interceptors@0.35.9: + resolution: {integrity: sha512-SSnyl/4ni/2ViHKkiZb8eajA/eN1DNFaHjhGiLUdZvDz6PKF4COSf/17xqSz64nOo2Ia29SA6B2KNCsyCbVmaQ==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 From 6b8b0a2a9ef24b7e6039bb1dc3e8b7631427715a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 30 Sep 2024 11:44:08 +0200 Subject: [PATCH 093/105] fix: cancelable server closures, `server.socket` --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- src/core/ws/utils/attachWebSocketLogger.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 146bc56b2..d5c275e48 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^3.0.0", - "@mswjs/interceptors": "^0.35.9", + "@mswjs/interceptors": "^0.36.0", "@open-draft/deferred-promise": "^2.2.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e20bf762a..6fc5ae052 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ dependencies: specifier: ^3.0.0 version: 3.1.1 '@mswjs/interceptors': - specifier: ^0.35.9 - version: 0.35.9 + specifier: ^0.36.0 + version: 0.36.0 '@open-draft/deferred-promise': specifier: ^2.2.0 version: 2.2.0 @@ -1457,8 +1457,8 @@ packages: - utf-8-validate dev: true - /@mswjs/interceptors@0.35.9: - resolution: {integrity: sha512-SSnyl/4ni/2ViHKkiZb8eajA/eN1DNFaHjhGiLUdZvDz6PKF4COSf/17xqSz64nOo2Ia29SA6B2KNCsyCbVmaQ==} + /@mswjs/interceptors@0.36.0: + resolution: {integrity: sha512-PxeMuXKYG897a8zX/V0ZqHZv925QBhwPapH7g2y4LFnqAckHsy58N4T2SxiebUMsd7uN3Axu1ERPrs080dbjZQ==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 diff --git a/src/core/ws/utils/attachWebSocketLogger.ts b/src/core/ws/utils/attachWebSocketLogger.ts index 93f18e7f3..fdf419f32 100644 --- a/src/core/ws/utils/attachWebSocketLogger.ts +++ b/src/core/ws/utils/attachWebSocketLogger.ts @@ -92,12 +92,12 @@ export function attachWebSocketLogger( currentTarget: { enumerable: true, writable: false, - value: server['realWebSocket'], + value: server.socket, }, target: { enumerable: true, writable: false, - value: server['realWebSocket'], + value: server.socket, }, }) From ab543aca6218b06195f8259a6498f324e3d8a0d7 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 30 Sep 2024 11:50:37 +0200 Subject: [PATCH 094/105] chore(release): v2.3.0-ws.rc-10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d5c275e48..0b87b2c60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-9", + "version": "2.3.0-ws.rc-10", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From d9a048012ab412dd259822b787e37d5d6db23c28 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 2 Oct 2024 12:41:57 +0200 Subject: [PATCH 095/105] fix: simplify websocket logging --- pnpm-lock.yaml | 25 +- src/core/ws/utils/attachWebSocketLogger.ts | 120 +++---- .../browser/ws-api/ws.logging.browser.test.ts | 301 +++++++++--------- 3 files changed, 199 insertions(+), 247 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fc5ae052..f7ffd8efe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ dependencies: version: 3.1.1 '@mswjs/interceptors': specifier: ^0.36.0 - version: 0.36.0 + version: link:../interceptors '@open-draft/deferred-promise': specifier: ^2.2.0 version: 2.2.0 @@ -1457,18 +1457,6 @@ packages: - utf-8-validate dev: true - /@mswjs/interceptors@0.36.0: - resolution: {integrity: sha512-PxeMuXKYG897a8zX/V0ZqHZv925QBhwPapH7g2y4LFnqAckHsy58N4T2SxiebUMsd7uN3Axu1ERPrs080dbjZQ==} - engines: {node: '>=18'} - dependencies: - '@open-draft/deferred-promise': 2.2.0 - '@open-draft/logger': 0.3.0 - '@open-draft/until': 2.1.0 - is-node-process: 1.2.0 - outvariant: 1.4.3 - strict-event-emitter: 0.5.1 - dev: false - /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1493,13 +1481,6 @@ packages: /@open-draft/deferred-promise@2.2.0: resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} - /@open-draft/logger@0.3.0: - resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} - dependencies: - is-node-process: 1.2.0 - outvariant: 1.4.3 - dev: false - /@open-draft/test-server@0.4.2: resolution: {integrity: sha512-J9wbdQkPx5WKcDNtgfnXsx5ew4UJd6BZyGr89YlHeaUkOShkO2iO5QIyCCsG4qpjIvr2ZTkEYJA9ujOXXyO6Pg==} dependencies: @@ -6627,10 +6608,6 @@ packages: /outvariant@1.4.2: resolution: {integrity: sha512-Ou3dJ6bA/UJ5GVHxah4LnqDwZRwAmWxrG3wtrHrbGnP4RnLCtA64A4F+ae7Y8ww660JaddSoArUR5HjipWSHAQ==} - /outvariant@1.4.3: - resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} - dev: false - /p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} diff --git a/src/core/ws/utils/attachWebSocketLogger.ts b/src/core/ws/utils/attachWebSocketLogger.ts index fdf419f32..7e416a639 100644 --- a/src/core/ws/utils/attachWebSocketLogger.ts +++ b/src/core/ws/utils/attachWebSocketLogger.ts @@ -10,10 +10,10 @@ import { getMessageLength } from './getMessageLength' import { getPublicData } from './getPublicData' const colors = { - blue: '#3b82f6', - green: '#22c55e', - red: '#ef4444', - orange: '#ff6a33', + system: '#3b82f6', + outgoing: '#22c55e', + incoming: '#ef4444', + mocked: '#ff6a33', } export function attachWebSocketLogger( @@ -38,13 +38,6 @@ export function attachWebSocketLogger( logConnectionClose(event) }) - // Log the events received by the WebSocket client. - // "client.socket" references the actual WebSocket instance - // so these message events are incoming messages. - client.socket.addEventListener('message', (event) => { - logIncomingClientMessage(event) - }) - // Log client errors (connection closures due to errors). client.socket.addEventListener('error', (event) => { logClientError(event) @@ -66,7 +59,10 @@ export function attachWebSocketLogger( value: client.socket, }, }) - logIncomingMockedClientMessage(messageEvent) + + queueMicrotask(() => { + logIncomingMockedClientMessage(messageEvent) + }) return Reflect.apply(target, thisArg, args) }, @@ -120,7 +116,7 @@ export function logConnectionOpen(client: WebSocketClientConnection) { // eslint-disable-next-line no-console console.groupCollapsed( devUtils.formatMessage(`${getTimestamp()} %c▶%c ${publicUrl}`), - `color:${colors.blue}`, + `color:${colors.system}`, 'color:inherit', ) // eslint-disable-next-line no-console @@ -129,24 +125,17 @@ export function logConnectionOpen(client: WebSocketClientConnection) { console.groupEnd() } -/** - * Prints the outgoing client message. - */ -export async function logOutgoingClientMessage( - event: MessageEvent, -) { - const byteLength = getMessageLength(event.data) - const publicData = await getPublicData(event.data) +function logConnectionClose(event: CloseEvent) { + const target = event.target as WebSocket + const publicUrl = toPublicUrl(target.url) // eslint-disable-next-line no-console console.groupCollapsed( devUtils.formatMessage( - `${getTimestamp({ milliseconds: true })} %c↑%c ${publicData} %c${byteLength}%c`, + `${getTimestamp({ milliseconds: true })} %c■%c ${publicUrl}`, ), - `color:${colors.green}`, + `color:${colors.system}`, 'color:inherit', - 'color:gray;font-weight:normal', - 'color:inherit;font-weight:inherit', ) // eslint-disable-next-line no-console console.log(event) @@ -154,25 +143,17 @@ export async function logOutgoingClientMessage( console.groupEnd() } -/** - * Prints the outgoing client message initiated - * by `server.send()` in the event handler. - */ -export async function logOutgoingMockedClientMessage( - event: MessageEvent, -) { - const byteLength = getMessageLength(event.data) - const publicData = await getPublicData(event.data) +function logClientError(event: Event) { + const socket = event.target as WebSocket + const publicUrl = toPublicUrl(socket.url) // eslint-disable-next-line no-console console.groupCollapsed( devUtils.formatMessage( - `${getTimestamp({ milliseconds: true })} %c⇡%c ${publicData} %c${byteLength}%c`, + `${getTimestamp({ milliseconds: true })} %c\u00D7%c ${publicUrl}`, ), - `color:${colors.orange}`, + `color:${colors.system}`, 'color:inherit', - 'color:gray;font-weight:normal', - 'color:inherit;font-weight:inherit', ) // eslint-disable-next-line no-console console.log(event) @@ -181,23 +162,19 @@ export async function logOutgoingMockedClientMessage( } /** - * Prings the message received by the WebSocket client. - * This is fired when the "message" event is dispatched - * on the actual WebSocket client instance, and translates to - * the client receiving a message from the server. + * Prints the outgoing client message. */ -export async function logIncomingClientMessage( - event: MessageEvent, -) { +async function logOutgoingClientMessage(event: MessageEvent) { const byteLength = getMessageLength(event.data) const publicData = await getPublicData(event.data) + const arrow = event.defaultPrevented ? '⇡' : '⬆' // eslint-disable-next-line no-console console.groupCollapsed( devUtils.formatMessage( - `${getTimestamp({ milliseconds: true })} %c↓%c ${publicData} %c${byteLength}%c`, + `${getTimestamp({ milliseconds: true })} %c${arrow}%c ${publicData} %c${byteLength}%c`, ), - `color:${colors.red}`, + `color:${colors.outgoing}`, 'color:inherit', 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', @@ -210,9 +187,9 @@ export async function logIncomingClientMessage( /** * Prints the outgoing client message initiated - * by `client.send()` in the event handler. + * by `server.send()` in the event handler. */ -export async function logIncomingMockedClientMessage( +async function logOutgoingMockedClientMessage( event: MessageEvent, ) { const byteLength = getMessageLength(event.data) @@ -221,9 +198,9 @@ export async function logIncomingMockedClientMessage( // eslint-disable-next-line no-console console.groupCollapsed( devUtils.formatMessage( - `${getTimestamp({ milliseconds: true })} %c⇣%c ${publicData} %c${byteLength}%c`, + `${getTimestamp({ milliseconds: true })} %c⬆%c ${publicData} %c${byteLength}%c`, ), - `color:${colors.orange}`, + `color:${colors.mocked}`, 'color:inherit', 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', @@ -234,25 +211,11 @@ export async function logIncomingMockedClientMessage( console.groupEnd() } -function logConnectionClose(event: CloseEvent) { - const target = event.target as WebSocket - const publicUrl = toPublicUrl(target.url) - - // eslint-disable-next-line no-console - console.groupCollapsed( - devUtils.formatMessage( - `${getTimestamp({ milliseconds: true })} %c■%c ${publicUrl}`, - ), - `color:${colors.blue}`, - 'color:inherit', - ) - // eslint-disable-next-line no-console - console.log(event) - // eslint-disable-next-line no-console - console.groupEnd() -} - -export async function logIncomingServerMessage( +/** + * Prints the outgoing client message initiated + * by `client.send()` in the event handler. + */ +async function logIncomingMockedClientMessage( event: MessageEvent, ) { const byteLength = getMessageLength(event.data) @@ -261,9 +224,9 @@ export async function logIncomingServerMessage( // eslint-disable-next-line no-console console.groupCollapsed( devUtils.formatMessage( - `${getTimestamp({ milliseconds: true })} %c⇣%c ${publicData} %c${byteLength}%c`, + `${getTimestamp({ milliseconds: true })} %c⬇%c ${publicData} %c${byteLength}%c`, ), - `color:${colors.green}`, + `color:${colors.mocked}`, 'color:inherit', 'color:gray;font-weight:normal', 'color:inherit;font-weight:inherit', @@ -274,17 +237,20 @@ export async function logIncomingServerMessage( console.groupEnd() } -function logClientError(event: Event) { - const socket = event.target as WebSocket - const publicUrl = toPublicUrl(socket.url) +async function logIncomingServerMessage(event: MessageEvent) { + const byteLength = getMessageLength(event.data) + const publicData = await getPublicData(event.data) + const arrow = event.defaultPrevented ? '⇣' : '⬇' // eslint-disable-next-line no-console console.groupCollapsed( devUtils.formatMessage( - `${getTimestamp({ milliseconds: true })} %c\u00D7%c ${publicUrl}`, + `${getTimestamp({ milliseconds: true })} %c${arrow}%c ${publicData} %c${byteLength}%c`, ), - `color:${colors.blue}`, + `color:${colors.incoming}`, 'color:inherit', + 'color:gray;font-weight:normal', + 'color:inherit;font-weight:inherit', ) // eslint-disable-next-line no-console console.log(event) diff --git a/test/browser/ws-api/ws.logging.browser.test.ts b/test/browser/ws-api/ws.logging.browser.test.ts index a4874f30c..383f0330b 100644 --- a/test/browser/ws-api/ws.logging.browser.test.ts +++ b/test/browser/ws-api/ws.logging.browser.test.ts @@ -60,7 +60,7 @@ test('does not log anything if "quiet" was set to "true"', async ({ expect(consoleSpy.get('startGroupCollapsed')).toBeUndefined() }) -test('logs the client connection', async ({ +test('logs the open event', async ({ loadExample, page, spyOnConsole, @@ -93,7 +93,119 @@ test('logs the client connection', async ({ }) }) -test('logs outgoing client event sending text data', async ({ +test('logs the close event initiated by the client', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://localhost/*') + const worker = setupWorker(api.addEventListener('connection', () => {})) + await worker.start() + }) + + await page.evaluate(() => { + const ws = new WebSocket('wss://localhost/path') + ws.onopen = () => ws.close() + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/localhost\/path color:#3b82f6 color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs the close event initiated by the original server', async ({ + loadExample, + spyOnConsole, + page, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + server.on('connection', (ws) => { + ws.close(1003) + }) + + await page.evaluate(async (url) => { + const { setupWorker, ws } = window.msw + const api = ws.link(url) + const worker = setupWorker( + api.addEventListener('connection', ({ server }) => { + server.connect() + }), + ) + await worker.start() + }, server.url) + + await page.evaluate((url) => { + new WebSocket(url) + }, server.url) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c ws:\/\/(.+):\d{4,}\/ color:#3b82f6 color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs the close event initiated by the event handler', async ({ + loadExample, + page, + spyOnConsole, + waitFor, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./ws.runtime.js'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, ws } = window.msw + const api = ws.link('wss://localhost/*') + const worker = setupWorker( + api.addEventListener('connection', ({ client }) => { + client.close() + }), + ) + await worker.start() + }) + + await page.evaluate(() => { + new WebSocket('wss://localhost/path') + }) + + await waitFor(() => { + expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/localhost\/path color:#3b82f6 color:inherit$/, + ), + ]), + ) + }) +}) + +test('logs outgoing client message sending text', async ({ loadExample, page, spyOnConsole, @@ -120,14 +232,14 @@ test('logs outgoing client event sending text data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c hello world %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬆%c hello world %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) }) }) -test('logs outgoing client event sending a long text data', async ({ +test('logs outgoing client message sending long text', async ({ loadExample, page, spyOnConsole, @@ -154,14 +266,14 @@ test('logs outgoing client event sending a long text data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c this is an extremely lon… %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬆%c this is an extremely lon… %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) }) }) -test('logs outgoing client event sending Blob data', async ({ +test('logs outgoing client message sending Blob', async ({ loadExample, page, spyOnConsole, @@ -188,14 +300,14 @@ test('logs outgoing client event sending Blob data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c Blob\(hello world\) %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬆%c Blob\(hello world\) %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) }) }) -test('logs outgoing client event sending a long Blob data', async ({ +test('logs outgoing client message sending long Blob', async ({ loadExample, page, spyOnConsole, @@ -223,14 +335,14 @@ test('logs outgoing client event sending a long Blob data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c Blob\(this is an extremely lon…\) %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬆%c Blob\(this is an extremely lon…\) %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) }) }) -test('logs outgoing client event sending ArrayBuffer data', async ({ +test('logs outgoing client message sending ArrayBuffer data', async ({ loadExample, page, spyOnConsole, @@ -257,14 +369,14 @@ test('logs outgoing client event sending ArrayBuffer data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c ArrayBuffer\(hello world\) %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬆%c ArrayBuffer\(hello world\) %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) }) }) -test('logs outgoing client event sending a long ArrayBuffer data', async ({ +test('logs outgoing client message sending long ArrayBuffer', async ({ loadExample, page, spyOnConsole, @@ -296,14 +408,14 @@ test('logs outgoing client event sending a long ArrayBuffer data', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↑%c ArrayBuffer\(this is an extremely lon…\) %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬆%c ArrayBuffer\(this is an extremely lon…\) %c45%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) }) }) -test('logs incoming client events', async ({ +test('logs incoming server messages', async ({ loadExample, page, spyOnConsole, @@ -349,7 +461,7 @@ test('logs incoming client events', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c hello from server %c17%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬇%c hello from server %c17%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -360,7 +472,7 @@ test('logs incoming client events', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c thanks, not bad %c15%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬇%c thanks, not bad %c15%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) @@ -408,96 +520,21 @@ test('logs raw incoming server events', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ // The actual (raw) message recieved from the server. + // The arrow is dotted because the message's default has been prevented. expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from server %c17%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from server %c17%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), // The mocked message sent from the event handler (client.send()). expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c intercepted server event %c24%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, - ), - - // The actual message the client received (i.e. mocked). - expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c↓%c intercepted server event %c24%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, - ), - ]), - ) - }) -}) - -test('logs the close event initiated by the client', async ({ - loadExample, - page, - spyOnConsole, - waitFor, -}) => { - const consoleSpy = spyOnConsole() - await loadExample(require.resolve('./ws.runtime.js'), { - skipActivation: true, - }) - - await page.evaluate(async () => { - const { setupWorker, ws } = window.msw - const api = ws.link('wss://localhost/*') - const worker = setupWorker(api.addEventListener('connection', () => {})) - await worker.start() - }) - - await page.evaluate(() => { - const ws = new WebSocket('wss://localhost/path') - ws.onopen = () => ws.close() - }) - - await waitFor(() => { - expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( - expect.arrayContaining([ - expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/localhost\/path color:#3b82f6 color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬇%c intercepted server event %c24%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) }) }) -test('logs the close event initiated by the event handler', async ({ - loadExample, - page, - spyOnConsole, - waitFor, -}) => { - const consoleSpy = spyOnConsole() - await loadExample(require.resolve('./ws.runtime.js'), { - skipActivation: true, - }) - - await page.evaluate(async () => { - const { setupWorker, ws } = window.msw - const api = ws.link('wss://localhost/*') - const worker = setupWorker( - api.addEventListener('connection', ({ client }) => { - client.close() - }), - ) - await worker.start() - }) - - await page.evaluate(() => { - new WebSocket('wss://localhost/path') - }) - - await waitFor(() => { - expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( - expect.arrayContaining([ - expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/localhost\/path color:#3b82f6 color:inherit$/, - ), - ]), - ) - }) -}) - -test('logs outgoing client events sent vi "server.send()"', async ({ +test('logs mocked outgoing client message (server.send)', async ({ loadExample, page, spyOnConsole, @@ -528,14 +565,14 @@ test('logs outgoing client events sent vi "server.send()"', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇡%c hello from handler %c18%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬆%c hello from handler %c18%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) }) }) -test('logs incoming client events sent vi "client.send()"', async ({ +test('logs mocked incoming server message (client.send)', async ({ loadExample, page, spyOnConsole, @@ -565,51 +602,17 @@ test('logs incoming client events sent vi "client.send()"', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from handler %c18%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⬇%c hello from handler %c18%c color:#ff6a33 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) }) }) -test('logs connection closure initiated by the client', async ({ +test('marks the prevented outgoing client event as dashed', async ({ loadExample, - spyOnConsole, page, - waitFor, -}) => { - const consoleSpy = spyOnConsole() - await loadExample(require.resolve('./ws.runtime.js'), { - skipActivation: true, - }) - - await page.evaluate(async () => { - const { setupWorker, ws } = window.msw - const api = ws.link('wss://localhost/*') - const worker = setupWorker(api.addEventListener('connection', () => {})) - await worker.start() - }) - - await page.evaluate(() => { - const ws = new WebSocket('wss://localhost/path') - ws.onopen = () => ws.close() - }) - - await waitFor(() => { - expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( - expect.arrayContaining([ - expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/localhost\/path color:#3b82f6 color:inherit$/, - ), - ]), - ) - }) -}) - -test('logs connection closure initiated by the interceptor', async ({ - loadExample, spyOnConsole, - page, waitFor, }) => { const consoleSpy = spyOnConsole() @@ -617,36 +620,39 @@ test('logs connection closure initiated by the interceptor', async ({ skipActivation: true, }) - await page.evaluate(async () => { + await page.evaluate(async (url) => { const { setupWorker, ws } = window.msw - const api = ws.link('wss://localhost/*') + const api = ws.link(url) const worker = setupWorker( api.addEventListener('connection', ({ client }) => { - client.close(1003, 'Custom error') + client.addEventListener('message', (event) => { + event.preventDefault() + }) }), ) await worker.start() - }) + }, server.url) - await page.evaluate(() => { - new WebSocket('wss://localhost/path') - }) + await page.evaluate((url) => { + const socket = new WebSocket(url) + socket.onopen = () => socket.send('hello world') + }, server.url) await waitFor(() => { expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c wss:\/\/localhost\/path color:#3b82f6 color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇡%c hello world %c11%c color:#22c55e color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) }) }) -test('logs connection closure initiated by the original server', async ({ +test('marks the prevented incoming server event as dashed', async ({ loadExample, - spyOnConsole, page, + spyOnConsole, waitFor, }) => { const consoleSpy = spyOnConsole() @@ -654,8 +660,8 @@ test('logs connection closure initiated by the original server', async ({ skipActivation: true, }) - server.on('connection', (ws) => { - ws.close(1003) + server.addListener('connection', (ws) => { + ws.send('hello from server') }) await page.evaluate(async (url) => { @@ -664,6 +670,9 @@ test('logs connection closure initiated by the original server', async ({ const worker = setupWorker( api.addEventListener('connection', ({ server }) => { server.connect() + server.addEventListener('message', (event) => { + event.preventDefault() + }) }), ) await worker.start() @@ -677,7 +686,7 @@ test('logs connection closure initiated by the original server', async ({ expect(consoleSpy.get('raw')!.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( - /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c■%c ws:\/\/(.+):\d{4,}\/ color:#3b82f6 color:inherit$/, + /^\[MSW\] \d{2}:\d{2}:\d{2}\.\d{3} %c⇣%c hello from server %c17%c color:#ef4444 color:inherit color:gray;font-weight:normal color:inherit;font-weight:inherit$/, ), ]), ) From a9cd86142303f7ddfda5885e9da0679cd719a537 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 2 Oct 2024 12:42:42 +0200 Subject: [PATCH 096/105] fix: prevent internal `server.send` being logged out --- package.json | 2 +- pnpm-lock.yaml | 27 +++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 0b87b2c60..7cfd175ba 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^3.0.0", - "@mswjs/interceptors": "^0.36.0", + "@mswjs/interceptors": "^0.36.1", "@open-draft/deferred-promise": "^2.2.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7ffd8efe..3a630b654 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ dependencies: specifier: ^3.0.0 version: 3.1.1 '@mswjs/interceptors': - specifier: ^0.36.0 - version: link:../interceptors + specifier: ^0.36.1 + version: 0.36.1 '@open-draft/deferred-promise': specifier: ^2.2.0 version: 2.2.0 @@ -1457,6 +1457,18 @@ packages: - utf-8-validate dev: true + /@mswjs/interceptors@0.36.1: + resolution: {integrity: sha512-wjKbjecynhZIqtvTuq61Q1JOzC1obSlrn1U6/f9JvwIw0zFX7+CZD3C/ncdUPbxpmfgq4GoyY2mFU+JobK+1SQ==} + engines: {node: '>=18'} + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1481,6 +1493,13 @@ packages: /@open-draft/deferred-promise@2.2.0: resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + /@open-draft/logger@0.3.0: + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + dev: false + /@open-draft/test-server@0.4.2: resolution: {integrity: sha512-J9wbdQkPx5WKcDNtgfnXsx5ew4UJd6BZyGr89YlHeaUkOShkO2iO5QIyCCsG4qpjIvr2ZTkEYJA9ujOXXyO6Pg==} dependencies: @@ -6608,6 +6627,10 @@ packages: /outvariant@1.4.2: resolution: {integrity: sha512-Ou3dJ6bA/UJ5GVHxah4LnqDwZRwAmWxrG3wtrHrbGnP4RnLCtA64A4F+ae7Y8ww660JaddSoArUR5HjipWSHAQ==} + /outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + dev: false + /p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} From b6c8374f4eef6f2c63d410191c520ef05df90e35 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 2 Oct 2024 15:10:12 +0200 Subject: [PATCH 097/105] chore(release): v2.3.0-ws.rc-11 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7cfd175ba..438fc6063 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-10", + "version": "2.3.0-ws.rc-11", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From 7f89cace330782c25bdebf26b2ac99fa4417e3a6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 7 Oct 2024 12:21:52 +0200 Subject: [PATCH 098/105] fix: support stopping event propagation --- package.json | 2 +- pnpm-lock.yaml | 8 +- src/core/handlers/WebSocketHandler.ts | 72 +++ .../ws-api/on-unhandled-request/error.test.ts | 6 +- test/node/ws-api/ws.stop-propagation.test.ts | 493 ++++++++++++++++++ 5 files changed, 574 insertions(+), 7 deletions(-) create mode 100644 test/node/ws-api/ws.stop-propagation.test.ts diff --git a/package.json b/package.json index 438fc6063..6b169f544 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^3.0.0", - "@mswjs/interceptors": "^0.36.1", + "@mswjs/interceptors": "^0.36.4", "@open-draft/deferred-promise": "^2.2.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a630b654..22fc02bc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ dependencies: specifier: ^3.0.0 version: 3.1.1 '@mswjs/interceptors': - specifier: ^0.36.1 - version: 0.36.1 + specifier: ^0.36.4 + version: 0.36.4 '@open-draft/deferred-promise': specifier: ^2.2.0 version: 2.2.0 @@ -1457,8 +1457,8 @@ packages: - utf-8-validate dev: true - /@mswjs/interceptors@0.36.1: - resolution: {integrity: sha512-wjKbjecynhZIqtvTuq61Q1JOzC1obSlrn1U6/f9JvwIw0zFX7+CZD3C/ncdUPbxpmfgq4GoyY2mFU+JobK+1SQ==} + /@mswjs/interceptors@0.36.4: + resolution: {integrity: sha512-ktzj7bra4HatOGqXw/PXyresXxFtnZa570rm4olAyf9HbvNdEWRkQl81ykmJK0nCHxNndmh2zQ84TBYKFDM+sg==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts index 1cd5043d9..55f66def0 100644 --- a/src/core/handlers/WebSocketHandler.ts +++ b/src/core/handlers/WebSocketHandler.ts @@ -1,4 +1,5 @@ import { Emitter } from 'strict-event-emitter' +import { createRequestId } from '@mswjs/interceptors' import type { WebSocketConnectionData } from '@mswjs/interceptors/WebSocket' import { type Match, @@ -23,13 +24,18 @@ interface WebSocketHandlerConnection extends WebSocketConnectionData { export const kEmitter = Symbol('kEmitter') export const kDispatchEvent = Symbol('kDispatchEvent') export const kSender = Symbol('kSender') +const kStopPropagationPatched = Symbol('kStopPropagationPatched') +const KOnStopPropagation = Symbol('KOnStopPropagation') export class WebSocketHandler { + public id: string public callFrame?: string protected [kEmitter]: Emitter constructor(private readonly url: Path) { + this.id = createRequestId() + this[kEmitter] = new Emitter() this.callFrame = getCallFrame(new Error()) } @@ -63,8 +69,74 @@ export class WebSocketHandler { params: parsedResult.match.params || {}, } + // Support `event.stopPropagation()` for various client/server events. + connection.client.addEventListener( + 'message', + createStopPropagationListener(this), + ) + connection.client.addEventListener( + 'close', + createStopPropagationListener(this), + ) + + connection.server.addEventListener( + 'open', + createStopPropagationListener(this), + ) + connection.server.addEventListener( + 'message', + createStopPropagationListener(this), + ) + connection.server.addEventListener( + 'error', + createStopPropagationListener(this), + ) + connection.server.addEventListener( + 'close', + createStopPropagationListener(this), + ) + // Emit the connection event on the handler. // This is what the developer adds listeners for. this[kEmitter].emit('connection', resolvedConnection) } } + +function createStopPropagationListener(handler: WebSocketHandler) { + return function stopPropagationListener(event: Event) { + const propagationStoppedAt = Reflect.get(event, 'kPropagationStoppedAt') as + | string + | undefined + + if (propagationStoppedAt && handler.id !== propagationStoppedAt) { + event.stopImmediatePropagation() + return + } + + Object.defineProperty(event, KOnStopPropagation, { + value(this: WebSocketHandler) { + Object.defineProperty(event, 'kPropagationStoppedAt', { + value: handler.id, + }) + }, + configurable: true, + }) + + // Since the same event instance is shared between all client/server objects, + // make sure to patch its `stopPropagation` method only once. + if (!Reflect.get(event, kStopPropagationPatched)) { + event.stopPropagation = new Proxy(event.stopPropagation, { + apply: (target, thisArg, args) => { + Reflect.get(event, KOnStopPropagation)?.call(handler) + return Reflect.apply(target, thisArg, args) + }, + }) + + Object.defineProperty(event, kStopPropagationPatched, { + value: true, + // If something else attempts to redefine this, throw. + configurable: false, + }) + } + } +} 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 36873939c..5b34d2b1b 100644 --- a/test/node/ws-api/on-unhandled-request/error.test.ts +++ b/test/node/ws-api/on-unhandled-request/error.test.ts @@ -27,12 +27,14 @@ it( const socket = new WebSocket('wss://localhost:4321') const errorListener = vi.fn() - await vi.waitFor(() => { + await vi.waitUntil(() => { return new Promise((resolve, reject) => { // These are intentionally swapped. The connection MUST error. socket.addEventListener('error', errorListener) socket.addEventListener('error', resolve) - socket.onopen = reject + socket.onopen = () => { + reject(new Error('WebSocket connection opened unexpectedly')) + } }) }) diff --git a/test/node/ws-api/ws.stop-propagation.test.ts b/test/node/ws-api/ws.stop-propagation.test.ts new file mode 100644 index 000000000..b7cbb3cf6 --- /dev/null +++ b/test/node/ws-api/ws.stop-propagation.test.ts @@ -0,0 +1,493 @@ +// @vitest-environment node-websocket +import { ws } from 'msw' +import { setupServer } from 'msw/node' +import { WebSocketServer } from '../../support/WebSocketServer' + +const server = setupServer() +const service = ws.link('ws://*') + +const originalServer = new WebSocketServer() + +beforeAll(async () => { + server.listen({ + // We are intentionally connecting to non-existing WebSocket URLs. + // Skip the unhandled request warnings, they are intentional. + onUnhandledRequest: 'bypass', + }) + await originalServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + originalServer.resetState() +}) + +afterAll(async () => { + server.close() + await originalServer.close() +}) + +it('stops propagation for client "message" event', async () => { + const clientMessageListener = vi.fn<[number]>() + + server.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + // Calling `stopPropagation` will prevent this event from being + // dispatched on the `client` beloning to a different event handler. + event.stopPropagation() + clientMessageListener(1) + }) + + client.addEventListener('message', () => { + clientMessageListener(2) + }) + }), + + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', () => { + clientMessageListener(3) + }) + }), + + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', () => { + clientMessageListener(4) + }) + + process.nextTick(() => { + client.close() + }) + }), + ) + + const ws = new WebSocket('ws://localhost') + ws.onopen = () => ws.send('hello world') + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(clientMessageListener).toHaveBeenNthCalledWith(1, 1) + expect(clientMessageListener).toHaveBeenNthCalledWith(2, 2) + expect(clientMessageListener).toHaveBeenCalledTimes(2) +}) + +it('stops immediate propagation for client "message" event', async () => { + const clientMessageListener = vi.fn<[number]>() + + server.use( + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + // Calling `stopPropagation` will prevent this event from being + // dispatched on the `client` beloning to a different event handler. + event.stopImmediatePropagation() + clientMessageListener(1) + }) + + client.addEventListener('message', () => { + clientMessageListener(2) + }) + + client.addEventListener('message', () => { + clientMessageListener(3) + }) + }), + + service.addEventListener('connection', ({ client }) => { + client.addEventListener('message', () => { + clientMessageListener(4) + }) + + process.nextTick(() => { + client.close() + }) + }), + ) + + const ws = new WebSocket('ws://localhost') + ws.onopen = () => ws.send('hello world') + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(clientMessageListener).toHaveBeenNthCalledWith(1, 1) + expect(clientMessageListener).toHaveBeenCalledOnce() +}) + +it('stops propagation for server "open" event', async () => { + const serverOpenListener = vi.fn<[number]>() + + originalServer.addListener('connection', () => {}) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('open', (event) => { + // Calling `stopPropagation` will prevent this event from being + // dispatched on the `server` beloning to a different event handler. + event.stopPropagation() + serverOpenListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('open', () => { + serverOpenListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('open', () => { + serverOpenListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('open', () => { + serverOpenListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverOpenListener).toHaveBeenNthCalledWith(1, 1) + expect(serverOpenListener).toHaveBeenNthCalledWith(2, 2) + expect(serverOpenListener).toHaveBeenCalledTimes(2) +}) + +it('stops immediate propagation for server "open" event', async () => { + const serverOpenListener = vi.fn<[number]>() + + originalServer.addListener('connection', () => {}) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('open', (event) => { + event.stopImmediatePropagation() + serverOpenListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('open', () => { + serverOpenListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('open', () => { + serverOpenListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('open', () => { + serverOpenListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverOpenListener).toHaveBeenNthCalledWith(1, 1) + expect(serverOpenListener).toHaveBeenCalledOnce() +}) + +it('stops propagation for server "message" event', async () => { + const serverMessageListener = vi.fn<[number]>() + + originalServer.addListener('connection', (ws) => { + // Send data from the original server to trigger the "message" event. + ws.send('hello') + }) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('message', (event) => { + // Calling `stopPropagation` will prevent this event from being + // dispatched on the `server` beloning to a different event handler. + event.stopPropagation() + serverMessageListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('message', () => { + serverMessageListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('message', () => { + serverMessageListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('message', () => { + serverMessageListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverMessageListener).toHaveBeenNthCalledWith(1, 1) + expect(serverMessageListener).toHaveBeenNthCalledWith(2, 2) + expect(serverMessageListener).toHaveBeenCalledTimes(2) +}) + +it('stops immediate propagation for server "message" event', async () => { + const serverMessageListener = vi.fn<[number]>() + + originalServer.addListener('connection', (ws) => { + // Send data from the original server to trigger the "message" event. + ws.send('hello') + }) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('message', (event) => { + event.stopImmediatePropagation() + serverMessageListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('message', () => { + serverMessageListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('message', () => { + serverMessageListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('message', () => { + serverMessageListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverMessageListener).toHaveBeenNthCalledWith(1, 1) + expect(serverMessageListener).toHaveBeenCalledOnce() +}) + +it('stops propagation for server "error" event', async () => { + const serverErrorListener = vi.fn<[number]>() + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('error', (event) => { + event.stopPropagation() + serverErrorListener(1) + }) + + server.addEventListener('error', () => { + serverErrorListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('error', () => { + serverErrorListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('error', () => { + serverErrorListener(4) + }) + }), + ) + + const ws = new WebSocket('ws://localhost/non-existing-path') + + await vi.waitFor(() => { + /** + * @note Ideally, await the "CLOSED" ready state, + * but Node.js doesn't dispatch it correctly. + * @see https://github.com/nodejs/undici/issues/3697 + */ + return new Promise((resolve) => { + ws.onerror = () => resolve() + }) + }) + + expect(serverErrorListener).toHaveBeenNthCalledWith(1, 1) + expect(serverErrorListener).toHaveBeenNthCalledWith(2, 2) + expect(serverErrorListener).toHaveBeenCalledTimes(2) +}) + +it('stops immediate propagation for server "error" event', async () => { + const serverErrorListener = vi.fn<[number]>() + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('error', (event) => { + event.stopImmediatePropagation() + serverErrorListener(1) + }) + + server.addEventListener('error', () => { + serverErrorListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('error', () => { + serverErrorListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('error', () => { + serverErrorListener(4) + }) + }), + ) + + const ws = new WebSocket('ws://localhost/non-existing-path') + + await vi.waitFor(() => { + /** + * @note Ideally, await the "CLOSED" ready state, + * but Node.js doesn't dispatch it correctly. + * @see https://github.com/nodejs/undici/issues/3697 + */ + return new Promise((resolve) => { + ws.onerror = () => resolve() + }) + }) + + expect(serverErrorListener).toHaveBeenNthCalledWith(1, 1) + expect(serverErrorListener).toHaveBeenCalledOnce() +}) + +it('stops propagation for server "close" event', async () => { + const serverCloseListener = vi.fn<[number]>() + + originalServer.addListener('connection', (ws) => { + ws.close() + }) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('close', (event) => { + event.stopPropagation() + serverCloseListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('close', () => { + serverCloseListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('close', () => { + serverCloseListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('close', () => { + serverCloseListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverCloseListener).toHaveBeenNthCalledWith(1, 1) + expect(serverCloseListener).toHaveBeenNthCalledWith(2, 2) + expect(serverCloseListener).toHaveBeenCalledTimes(2) +}) + +it('stops immediate propagation for server "close" event', async () => { + const serverCloseListener = vi.fn<[number]>() + + originalServer.addListener('connection', (ws) => { + ws.close() + }) + + server.use( + service.addEventListener('connection', ({ client, server }) => { + server.connect() + + server.addEventListener('close', (event) => { + event.stopImmediatePropagation() + serverCloseListener(1) + + process.nextTick(() => client.close()) + }) + + server.addEventListener('close', () => { + serverCloseListener(2) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('close', () => { + serverCloseListener(3) + }) + }), + + service.addEventListener('connection', ({ server }) => { + server.addEventListener('close', () => { + serverCloseListener(4) + }) + }), + ) + + const ws = new WebSocket(originalServer.url) + + await vi.waitFor(() => { + expect(ws.readyState).toBe(WebSocket.CLOSED) + }) + + expect(serverCloseListener).toHaveBeenNthCalledWith(1, 1) + expect(serverCloseListener).toHaveBeenCalledOnce() +}) From 92f1d532f5a43b0d9ff761322c07c1197a5e4135 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 7 Oct 2024 12:32:39 +0200 Subject: [PATCH 099/105] chore(release): v2.3.0-ws.rc-12 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6b169f544..edf501b05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-11", + "version": "2.3.0-ws.rc-12", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From 8ea8487c0199dc6a76da443338d115f03b5776b1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 28 Oct 2024 15:41:08 +0100 Subject: [PATCH 100/105] test(modules): remove `process.exit(0)` from runtime scripts --- test/modules/node/esm-node.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/modules/node/esm-node.test.ts b/test/modules/node/esm-node.test.ts index e8619916e..5ecf123bc 100644 --- a/test/modules/node/esm-node.test.ts +++ b/test/modules/node/esm-node.test.ts @@ -32,7 +32,6 @@ const server = setupServer( http.get('/resource', () => new Response()) ) console.log(typeof server.listen) -process.exit(0) `, }) @@ -84,7 +83,6 @@ const server = setupServer( http.get('/resource', () => new Response()) ) console.log(typeof server.listen) -process.exit(0) `, }) From dba8d205c3d2d45169610708c5834e38b3809854 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 28 Oct 2024 15:44:38 +0100 Subject: [PATCH 101/105] chore: revert version to upstream --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b1ba770ba..432b512aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "2.3.0-ws.rc-12", + "version": "2.5.2", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", From 4847e7577954f2f36b50209efc010538df1f712b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 28 Oct 2024 15:49:30 +0100 Subject: [PATCH 102/105] fix: add "msw/core/ws" export --- package.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/package.json b/package.json index 432b512aa..b0528bfcb 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,12 @@ "import": "./lib/core/graphql.mjs", "default": "./lib/core/graphql.js" }, + "./core/ws": { + "types": "./lib/core/ws.d.ts", + "require": "./lib/core/ws.js", + "import": "./lib/core/ws.mjs", + "default": "./lib/core/ws.js" + }, "./mockServiceWorker.js": "./lib/mockServiceWorker.js", "./package.json": "./package.json" }, From d82bef600e7187133c0ca796dfb956b75eb63358 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 28 Oct 2024 16:24:10 +0100 Subject: [PATCH 103/105] fix: export `WebSocketHandlerConnection` type --- src/core/handlers/WebSocketHandler.ts | 2 +- src/core/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/handlers/WebSocketHandler.ts b/src/core/handlers/WebSocketHandler.ts index 55f66def0..26d443872 100644 --- a/src/core/handlers/WebSocketHandler.ts +++ b/src/core/handlers/WebSocketHandler.ts @@ -17,7 +17,7 @@ export type WebSocketHandlerEventMap = { connection: [args: WebSocketHandlerConnection] } -interface WebSocketHandlerConnection extends WebSocketConnectionData { +export interface WebSocketHandlerConnection extends WebSocketConnectionData { params: PathParams } diff --git a/src/core/index.ts b/src/core/index.ts index 770b58b37..9cd723080 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -14,6 +14,7 @@ export { ws, type WebSocketLink } from './ws' export { WebSocketHandler, type WebSocketHandlerEventMap, + type WebSocketHandlerConnection, } from './handlers/WebSocketHandler' /* Utils */ From bbc98ca33cfeb7c460536019fb8f52bcee81c82d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 28 Oct 2024 16:24:18 +0100 Subject: [PATCH 104/105] test: add type tests --- test/typings/ws.test-d.ts | 156 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 test/typings/ws.test-d.ts diff --git a/test/typings/ws.test-d.ts b/test/typings/ws.test-d.ts new file mode 100644 index 000000000..612369390 --- /dev/null +++ b/test/typings/ws.test-d.ts @@ -0,0 +1,156 @@ +import { it, expectTypeOf } from 'vitest' +import { + WebSocketData, + WebSocketLink, + WebSocketHandlerConnection, + ws, +} from 'msw' +import { WebSocketClientConnectionProtocol } from '@mswjs/interceptors/WebSocket' + +it('supports URL as the link argument', () => { + expectTypeOf(ws.link('ws://localhost')).toEqualTypeOf() +}) + +it('supports RegExp as the link argument', () => { + expectTypeOf(ws.link(/\/ws$/)).toEqualTypeOf() +}) + +it('exposes root-level link APIs', () => { + const link = ws.link('ws://localhost') + + expectTypeOf(link.addEventListener).toBeFunction() + expectTypeOf(link.broadcast).toBeFunction() + expectTypeOf(link.broadcastExcept).toBeFunction() + expectTypeOf(link.clients).toEqualTypeOf< + Set + >() +}) + +it('supports "connection" event listener', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', (connection) => { + expectTypeOf(connection).toEqualTypeOf() + }) +}) + +it('errors on arbitrary event names passed to the link', () => { + const link = ws.link('ws://localhost') + + link.addEventListener( + // @ts-expect-error Unknown event name "abc". + 'abc', + () => {}, + ) +}) + +/** + * Client API. + */ + +it('exposes root-level "client" APIs', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ client }) => { + expectTypeOf(client.id).toBeString() + expectTypeOf(client.socket).toEqualTypeOf() + expectTypeOf(client.url).toEqualTypeOf() + + expectTypeOf(client.addEventListener).toBeFunction() + expectTypeOf(client.send).toBeFunction() + expectTypeOf(client.removeEventListener).toBeFunction() + expectTypeOf(client.close).toBeFunction() + }) +}) + +it('supports "message" event listener on the client', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + expectTypeOf(event).toEqualTypeOf>() + }) + }) +}) + +it('supports "close" event listener on the client', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ client }) => { + client.addEventListener('close', (event) => { + expectTypeOf(event).toMatchTypeOf() + }) + }) +}) + +it('errors on arbitrary event names passed to the client', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ client }) => { + client.addEventListener( + // @ts-expect-error Unknown event name "abc". + 'abc', + () => {}, + ) + }) +}) + +/** + * Server API. + */ + +it('exposes root-level "server" APIs', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ server }) => { + expectTypeOf(server.socket).toEqualTypeOf() + + expectTypeOf(server.connect).toEqualTypeOf<() => void>() + expectTypeOf(server.addEventListener).toBeFunction() + expectTypeOf(server.send).toBeFunction() + expectTypeOf(server.removeEventListener).toBeFunction() + expectTypeOf(server.close).toBeFunction() + }) +}) + +it('supports "message" event listener on the server', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ server }) => { + server.addEventListener('message', (event) => { + expectTypeOf(event).toEqualTypeOf>() + }) + }) +}) + +it('supports "open" event listener on the server', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ server }) => { + server.addEventListener('open', (event) => { + expectTypeOf(event).toMatchTypeOf() + }) + }) +}) + +it('supports "close" event listener on the server', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ server }) => { + server.addEventListener('close', (event) => { + expectTypeOf(event).toMatchTypeOf() + }) + }) +}) + +it('errors on arbitrary event names passed to the server', () => { + const link = ws.link('ws://localhost') + + link.addEventListener('connection', ({ server }) => { + server.addEventListener( + // @ts-expect-error Unknown event name "abc". + 'abc', + () => {}, + ) + }) +}) From 773a6d28770ac5fd1a567399fdc522db4ae6f297 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 28 Oct 2024 17:19:25 +0100 Subject: [PATCH 105/105] test(WebSocketClientManager): increase max listeners count --- src/core/ws/WebSocketClientManager.test.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/core/ws/WebSocketClientManager.test.ts b/src/core/ws/WebSocketClientManager.test.ts index b94569126..6740b2113 100644 --- a/src/core/ws/WebSocketClientManager.test.ts +++ b/src/core/ws/WebSocketClientManager.test.ts @@ -1,4 +1,5 @@ // @vitest-environment node-websocket +import { setMaxListeners } from 'node:events' import { WebSocketClientConnection, WebSocketData, @@ -10,6 +11,14 @@ import { } from './WebSocketClientManager' const channel = new BroadcastChannel('test:channel') + +/** + * @note Increase the number of maximum event listeners + * because the same channel is shared between different + * manager instances in different tests. + */ +setMaxListeners(Number.MAX_SAFE_INTEGER, channel) + vi.spyOn(channel, 'postMessage') const socket = new WebSocket('ws://localhost') @@ -24,7 +33,7 @@ afterEach(() => { }) it('adds a client from this runtime to the list of clients', async () => { - const manager = new WebSocketClientManager(channel, '*') + const manager = new WebSocketClientManager(channel) const connection = new WebSocketClientConnection( socket, new TestWebSocketTransport(), @@ -37,7 +46,7 @@ it('adds a client from this runtime to the list of clients', async () => { }) it('adds multiple clients from this runtime to the list of clients', async () => { - const manager = new WebSocketClientManager(channel, '*') + const manager = new WebSocketClientManager(channel) const connectionOne = new WebSocketClientConnection( socket, new TestWebSocketTransport(), @@ -61,7 +70,7 @@ it('adds multiple clients from this runtime to the list of clients', async () => }) it('replays a "send" event coming from another runtime', async () => { - const manager = new WebSocketClientManager(channel, '*') + const manager = new WebSocketClientManager(channel) const connection = new WebSocketClientConnection( socket, new TestWebSocketTransport(), @@ -90,7 +99,7 @@ it('replays a "send" event coming from another runtime', async () => { }) it('replays a "close" event coming from another runtime', async () => { - const manager = new WebSocketClientManager(channel, '*') + const manager = new WebSocketClientManager(channel) const connection = new WebSocketClientConnection( socket, new TestWebSocketTransport(), @@ -120,7 +129,7 @@ it('replays a "close" event coming from another runtime', async () => { }) it('removes the extraneous message listener when the connection closes', async () => { - const manager = new WebSocketClientManager(channel, '*') + const manager = new WebSocketClientManager(channel) const transport = new TestWebSocketTransport() const connection = new WebSocketClientConnection(socket, transport) vi.spyOn(connection, 'close').mockImplementationOnce(() => {