From daec32bfb861412dd8700d9e9d050b55b57bd1a9 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 30 Sep 2024 15:00:05 +0200 Subject: [PATCH 01/49] feat: support server-sent events --- src/browser/index.ts | 7 ++ src/browser/sse.ts | 111 +++++++++++++++++++ test/browser/sse-api/sse.mocks.ts | 7 ++ test/browser/sse-api/sse.node.test.ts | 149 ++++++++++++++++++++++++++ 4 files changed, 274 insertions(+) create mode 100644 src/browser/sse.ts create mode 100644 test/browser/sse-api/sse.mocks.ts create mode 100644 test/browser/sse-api/sse.node.test.ts diff --git a/src/browser/index.ts b/src/browser/index.ts index 0eafbe76f..ba272ddb5 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -1,3 +1,10 @@ export { setupWorker } from './setupWorker/setupWorker' export type { SetupWorker, StartOptions } from './setupWorker/glossary' export { SetupWorkerApi } from './setupWorker/setupWorker' + +/* Server-Sent Events */ +export { + sse, + type ServerSentEventRequestHandler, + type ServerSentEventResolver, +} from './sse' diff --git a/src/browser/sse.ts b/src/browser/sse.ts new file mode 100644 index 000000000..a3033902f --- /dev/null +++ b/src/browser/sse.ts @@ -0,0 +1,111 @@ +import type { ResponseResolver } from '~/core/handlers/RequestHandler' +import { + HttpHandler, + type HttpRequestResolverExtras, + type HttpRequestParsedResult, +} from '~/core/handlers/HttpHandler' +import { HttpResponse } from '~/core/HttpResponse' +import type { Path, PathParams } from '~/core/utils/matching/matchRequestUrl' + +export type ServerSentEventResolverExtras = + HttpRequestResolverExtras & { + source: ServerSentEventSource + } + +export type ServerSentEventResolver = + ResponseResolver, any, any> + +export type ServerSentEventRequestHandler = < + Params extends PathParams = PathParams, + RequestPath extends Path = Path, +>( + path: RequestPath, + resolver: ServerSentEventResolver, +) => HttpHandler + +/** + * Request handler for Server-Sent Events (SSE). + * + * @example + * sse('http://localhost:4321', ({ source }) => { + * source.send({ data: 'hello world' }) + * }) + * + * @see {@link https://mswjs.io/docs/api/sse `sse()` API reference} + */ +export const sse: ServerSentEventRequestHandler = (path, resolver) => { + return new ServerSentEventHandler(path, resolver) +} + +class ServerSentEventHandler extends HttpHandler { + constructor(path: Path, resolver: ServerSentEventResolver) { + super('GET', path, (info) => { + const stream = new ReadableStream({ + start(controller) { + resolver({ + ...info, + source: new ServerSentEventSource(controller), + }) + }, + }) + + return new HttpResponse(stream, { + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }, + }) + }) + } + + predicate(args: { + request: Request + parsedResult: HttpRequestParsedResult + }): boolean { + // Ignore non-SSE requests. + if ( + args.request.headers.get('accept')?.toLowerCase() !== 'text/event-stream' + ) { + return false + } + + // If it is a SSE request, match it against its method and path. + return super.predicate(args) + } +} + +class ServerSentEventSource { + private encoder: TextEncoder + + constructor(protected controller: ReadableStreamDefaultController) { + this.encoder = new TextEncoder() + } + + /** + * Dispatches the given event to the underlying `EventSource`. + */ + public send(payload: { id?: string; event?: string; data: unknown }): void { + const frames: Array = [] + + if (payload.event) { + frames.push(`event:${payload.event}`) + } + + frames.push(`data:${JSON.stringify(payload.data)}`) + + if (payload.id) { + frames.push(`id:${payload.id}`) + } + + frames.push('', '') + this.controller.enqueue(this.encoder.encode(frames.join('\n'))) + } + + /** + * Errors the underlying `EventSource`, closing the connection. + */ + public error(): void { + this.controller.error() + } +} diff --git a/test/browser/sse-api/sse.mocks.ts b/test/browser/sse-api/sse.mocks.ts new file mode 100644 index 000000000..29525730c --- /dev/null +++ b/test/browser/sse-api/sse.mocks.ts @@ -0,0 +1,7 @@ +import { setupWorker, sse } from 'msw/browser' + +// @ts-ignore +window.msw = { + setupWorker, + sse, +} diff --git a/test/browser/sse-api/sse.node.test.ts b/test/browser/sse-api/sse.node.test.ts new file mode 100644 index 000000000..9e346411b --- /dev/null +++ b/test/browser/sse-api/sse.node.test.ts @@ -0,0 +1,149 @@ +import { setupWorker, sse } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare namespace window { + export const msw: { + setupWorker: typeof setupWorker + sse: typeof sse + } +} + +test('sends a mock message event', async ({ loadExample, page }) => { + await loadExample(require.resolve('./sse.mocks.ts'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + sse('http://localhost/stream', ({ source }) => { + source.send({ + data: { username: 'john' }, + }) + }), + ) + await worker.start() + }) + + const message = await page.evaluate(() => { + return new Promise((resolve, reject) => { + const source = new EventSource('http://localhost/stream') + source.addEventListener('message', (event) => { + resolve(`${event.type}:${event.data}`) + }) + source.onerror = reject + }) + }) + + expect(message).toBe('message:{"username":"john"}') +}) + +test('sends a mock custom event', async ({ loadExample, page }) => { + await loadExample(require.resolve('./sse.mocks.ts'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + sse('http://localhost/stream', ({ source }) => { + source.send({ + event: 'userconnect', + data: { username: 'john' }, + }) + }), + ) + await worker.start() + }) + + const message = await page.evaluate(() => { + return new Promise((resolve, reject) => { + const source = new EventSource('http://localhost/stream') + source.addEventListener('userconnect', (event) => { + resolve(`${event.type}:${event.data}`) + }) + source.onerror = reject + }) + }) + + expect(message).toEqual('userconnect:{"username":"john"}') +}) + +test('sends a mock message event with custom id', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./sse.mocks.ts'), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + sse('http://localhost/stream', ({ source }) => { + source.send({ + id: 'abc-123', + event: 'userconnect', + data: { username: 'john' }, + }) + }), + ) + await worker.start() + }) + + const message = await page.evaluate(() => { + return new Promise((resolve, reject) => { + const source = new EventSource('http://localhost/stream') + source.addEventListener('userconnect', (event) => { + console.log(event) + resolve(`${event.type}:${event.lastEventId}:${event.data}`) + }) + source.onerror = reject + }) + }) + + expect(message).toBe('userconnect:abc-123:{"username":"john"}') +}) + +test('errors the connected source', async ({ loadExample, page, waitFor }) => { + await loadExample(require.resolve('./sse.mocks.ts'), { + skipActivation: true, + }) + + const pageErrors: Array = [] + page.on('pageerror', (error) => { + pageErrors.push(error.message) + }) + + await page.evaluate(async () => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + sse('http://localhost/stream', ({ source }) => { + queueMicrotask(() => source.error()) + }), + ) + await worker.start() + }) + + const readyState = await page.evaluate(() => { + return new Promise((resolve) => { + const source = new EventSource('http://localhost/stream') + source.addEventListener('error', (event) => { + console.log('ERROR!', event, source.readyState) + resolve(source.readyState) + }) + }) + }) + + // EventSource must be closed. + expect(readyState).toBe(2) + + await waitFor(() => { + // Must error with "Failed to fetch" (default EventSource behavior). + expect(pageErrors).toContain('Failed to fetch') + }) +}) From 8c37fe0dda72ac9e4e9a4505444a8505a5942659 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 30 Sep 2024 22:56:53 +0200 Subject: [PATCH 02/49] fix: support passthrough via `server` --- src/browser/sse.ts | 123 ++++++++++++++++++++++++-- src/core/utils/handleRequest.ts | 5 +- test/browser/sse-api/sse.node.test.ts | 71 +++++++++++++-- 3 files changed, 184 insertions(+), 15 deletions(-) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index a3033902f..b80ff9afc 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -1,15 +1,16 @@ +import type { Constructor } from 'type-fest' import type { ResponseResolver } from '~/core/handlers/RequestHandler' import { HttpHandler, type HttpRequestResolverExtras, type HttpRequestParsedResult, } from '~/core/handlers/HttpHandler' -import { HttpResponse } from '~/core/HttpResponse' import type { Path, PathParams } from '~/core/utils/matching/matchRequestUrl' export type ServerSentEventResolverExtras = HttpRequestResolverExtras & { - source: ServerSentEventSource + client: ServerSentEventClient + server: ServerSentEventServer } export type ServerSentEventResolver = @@ -42,14 +43,23 @@ class ServerSentEventHandler extends HttpHandler { super('GET', path, (info) => { const stream = new ReadableStream({ start(controller) { + const client = new ServerSentEventClient({ + controller, + }) + const server = new ServerSentEventServer({ + request: info.request, + client, + }) + resolver({ ...info, - source: new ServerSentEventSource(controller), + client, + server, }) }, }) - return new HttpResponse(stream, { + return new Response(stream, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', @@ -75,11 +85,13 @@ class ServerSentEventHandler extends HttpHandler { } } -class ServerSentEventSource { +class ServerSentEventClient { private encoder: TextEncoder + protected controller: ReadableStreamDefaultController - constructor(protected controller: ReadableStreamDefaultController) { + constructor(args: { controller: ReadableStreamDefaultController }) { this.encoder = new TextEncoder() + this.controller = args.controller } /** @@ -109,3 +121,102 @@ class ServerSentEventSource { this.controller.error() } } + +const kListener = Symbol('kListener') + +class ServerSentEventServer { + protected request: Request + protected client: ServerSentEventClient + + constructor(args: { request: Request; client: ServerSentEventClient }) { + this.request = args.request + this.client = args.client + } + + /** + * Establishes an actual connection for this SSE request + * and returns the `EventSource` instance. + */ + public connect(): EventSource { + const requestUrl = new URL(this.request.url) + requestUrl.searchParams.set('x-msw-intention', 'bypass') + + let source = new EventSource(requestUrl, { + withCredentials: this.request.credentials === 'include', + }) + + const addEventListener = source.addEventListener.bind(source) + source.addEventListener = (event: any, listener: any, options: any) => { + const wrappedListener = this.wrapEventListener(listener).bind(source) + Object.defineProperty(listener, kListener, { + value: wrappedListener, + }) + addEventListener(event, wrappedListener, options) + } + + const removeEventListener = source.removeEventListener.bind(source) + source.removeEventListener = (event: any, listener: any, options: any) => { + const wrappedListener = listener[kListener] || listener + removeEventListener(event, wrappedListener, options) + } + + source = new Proxy(source, { + set: (target, property, value) => { + switch (property) { + case 'onopen': + case 'onmessage': + case 'onerror': { + return Reflect.set(target, property, this.wrapEventListener(value)) + } + } + + return Reflect.set(target, property, value) + }, + }) + + return source + } + + private wrapEventListener(listener: (event: Event) => void) { + const { client } = this + + return function (this: EventSource, event: Event) { + const EventConstructor = event.constructor as Constructor + const cancelableEvent = new EventConstructor(event.type, { + ...event, + cancelable: true, + }) + + listener.call(this, cancelableEvent) + + if (event.type === 'open') { + return + } + + if (!cancelableEvent.defaultPrevented) { + switch (event.type) { + case 'error': { + client.error() + break + } + + default: { + if (event instanceof MessageEvent) { + client.send({ + id: event.lastEventId, + event: event.type === 'message' ? undefined : event.type, + /** + * @fixme Data will already be stringified. + * `client.send()` will stringify it again. + */ + data: event.data, + }) + } + + break + } + } + } + } + } +} diff --git a/src/core/utils/handleRequest.ts b/src/core/utils/handleRequest.ts index 766b22ce6..ab2fcb88b 100644 --- a/src/core/utils/handleRequest.ts +++ b/src/core/utils/handleRequest.ts @@ -53,7 +53,10 @@ export async function handleRequest( emitter.emit('request:start', { request, requestId }) // Perform bypassed requests (i.e. wrapped in "bypass()") as-is. - if (request.headers.get('x-msw-intention') === 'bypass') { + if ( + request.headers.get('x-msw-intention') === 'bypass' || + new URL(request.url).searchParams.get('x-msw-intention') === 'bypass' + ) { emitter.emit('request:end', { request, requestId }) handleRequestOptions?.onPassthroughResponse?.(request) return diff --git a/test/browser/sse-api/sse.node.test.ts b/test/browser/sse-api/sse.node.test.ts index 9e346411b..a4607f0f3 100644 --- a/test/browser/sse-api/sse.node.test.ts +++ b/test/browser/sse-api/sse.node.test.ts @@ -1,4 +1,5 @@ import { setupWorker, sse } from 'msw/browser' +import { HttpServer } from '@open-draft/test-server/http' import { test, expect } from '../playwright.extend' declare namespace window { @@ -8,6 +9,26 @@ declare namespace window { } } +const httpServer = new HttpServer((app) => { + app.get('/stream', (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + res.write('data: {"message": "hello"}\n\n') + }) +}) + +test.beforeAll(async () => { + await httpServer.listen() +}) + +test.afterAll(async () => { + await httpServer.close() +}) + test('sends a mock message event', async ({ loadExample, page }) => { await loadExample(require.resolve('./sse.mocks.ts'), { skipActivation: true, @@ -17,8 +38,8 @@ test('sends a mock message event', async ({ loadExample, page }) => { const { setupWorker, sse } = window.msw const worker = setupWorker( - sse('http://localhost/stream', ({ source }) => { - source.send({ + sse('http://localhost/stream', ({ client }) => { + client.send({ data: { username: 'john' }, }) }), @@ -48,8 +69,8 @@ test('sends a mock custom event', async ({ loadExample, page }) => { const { setupWorker, sse } = window.msw const worker = setupWorker( - sse('http://localhost/stream', ({ source }) => { - source.send({ + sse('http://localhost/stream', ({ client }) => { + client.send({ event: 'userconnect', data: { username: 'john' }, }) @@ -83,8 +104,8 @@ test('sends a mock message event with custom id', async ({ const { setupWorker, sse } = window.msw const worker = setupWorker( - sse('http://localhost/stream', ({ source }) => { - source.send({ + sse('http://localhost/stream', ({ client }) => { + client.send({ id: 'abc-123', event: 'userconnect', data: { username: 'john' }, @@ -122,8 +143,8 @@ test('errors the connected source', async ({ loadExample, page, waitFor }) => { const { setupWorker, sse } = window.msw const worker = setupWorker( - sse('http://localhost/stream', ({ source }) => { - queueMicrotask(() => source.error()) + sse('http://localhost/stream', ({ client }) => { + queueMicrotask(() => client.error()) }), ) await worker.start() @@ -147,3 +168,37 @@ test('errors the connected source', async ({ loadExample, page, waitFor }) => { expect(pageErrors).toContain('Failed to fetch') }) }) + +test.only('forwards original server message events to the client', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./sse.mocks.ts'), { + skipActivation: true, + }) + const url = httpServer.http.url('/stream') + + await page.evaluate(async (url) => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + sse(url, async ({ server }) => { + const source = server.connect() + + source.addEventListener('message', (event) => { + event.preventDefault() + }) + }), + ) + await worker.start() + }, url) + + await page.evaluate((url) => { + const source = new EventSource(url) + source.addEventListener('message', (event) => { + console.warn('client received:', event) + }) + }, url) + + await page.pause() +}) From 70756861036fad4bb23ee32d31a74279ad1485be Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 30 Sep 2024 23:09:13 +0200 Subject: [PATCH 03/49] fix: prevent double data serialization --- src/browser/sse.ts | 56 ++++++++++++++++++--------- test/browser/sse-api/sse.node.test.ts | 4 +- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index b80ff9afc..d76c89018 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -1,4 +1,3 @@ -import type { Constructor } from 'type-fest' import type { ResponseResolver } from '~/core/handlers/RequestHandler' import { HttpHandler, @@ -85,6 +84,8 @@ class ServerSentEventHandler extends HttpHandler { } } +const kSend = Symbol('kSend') + class ServerSentEventClient { private encoder: TextEncoder protected controller: ReadableStreamDefaultController @@ -98,13 +99,32 @@ class ServerSentEventClient { * Dispatches the given event to the underlying `EventSource`. */ public send(payload: { id?: string; event?: string; data: unknown }): void { + this[kSend]({ + id: payload.id, + event: payload.event, + data: JSON.stringify(payload.data), + }) + } + + /** + * Errors the underlying `EventSource`, closing the connection. + */ + public error(): void { + this.controller.error() + } + + private [kSend](payload: { + id?: string + event?: string + data: string + }): void { const frames: Array = [] if (payload.event) { frames.push(`event:${payload.event}`) } - frames.push(`data:${JSON.stringify(payload.data)}`) + frames.push(`data:${payload.data}`) if (payload.id) { frames.push(`id:${payload.id}`) @@ -113,13 +133,6 @@ class ServerSentEventClient { frames.push('', '') this.controller.enqueue(this.encoder.encode(frames.join('\n'))) } - - /** - * Errors the underlying `EventSource`, closing the connection. - */ - public error(): void { - this.controller.error() - } } const kListener = Symbol('kListener') @@ -181,14 +194,24 @@ class ServerSentEventServer { const { client } = this return function (this: EventSource, event: Event) { - const EventConstructor = event.constructor as Constructor - const cancelableEvent = new EventConstructor(event.type, { - ...event, - cancelable: true, + const cancelableEvent = + event instanceof MessageEvent + ? new MessageEvent(event.type, { + data: event.data, + cancelable: true, + }) + : new Event(event.type, { + cancelable: true, + }) + Object.defineProperties(cancelableEvent, { + target: { enumerable: true, value: this, writable: false }, + currentTarget: { enumerable: true, value: this, writable: false }, + srcElement: { enumerable: true, value: this, writable: false }, }) listener.call(this, cancelableEvent) + // The "open" event cannot be prevented so ignore it. if (event.type === 'open') { return } @@ -202,13 +225,10 @@ class ServerSentEventServer { default: { if (event instanceof MessageEvent) { - client.send({ + // Use the internal send method to avoid data serialization. + client[kSend]({ id: event.lastEventId, event: event.type === 'message' ? undefined : event.type, - /** - * @fixme Data will already be stringified. - * `client.send()` will stringify it again. - */ data: event.data, }) } diff --git a/test/browser/sse-api/sse.node.test.ts b/test/browser/sse-api/sse.node.test.ts index a4607f0f3..c7ed9fb64 100644 --- a/test/browser/sse-api/sse.node.test.ts +++ b/test/browser/sse-api/sse.node.test.ts @@ -169,7 +169,7 @@ test('errors the connected source', async ({ loadExample, page, waitFor }) => { }) }) -test.only('forwards original server message events to the client', async ({ +test('forwards original server message events to the client', async ({ loadExample, page, }) => { @@ -186,7 +186,7 @@ test.only('forwards original server message events to the client', async ({ const source = server.connect() source.addEventListener('message', (event) => { - event.preventDefault() + console.log(event) }) }), ) From 775c2d6918391cdc56571f2bb9a7411df7113d8c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 30 Sep 2024 23:09:32 +0200 Subject: [PATCH 04/49] chore: rename sse test --- test/browser/sse-api/{sse.node.test.ts => sse.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/browser/sse-api/{sse.node.test.ts => sse.test.ts} (100%) diff --git a/test/browser/sse-api/sse.node.test.ts b/test/browser/sse-api/sse.test.ts similarity index 100% rename from test/browser/sse-api/sse.node.test.ts rename to test/browser/sse-api/sse.test.ts From c53e13e0e4e40fe13a267ff3b771c88e08622d21 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 1 Oct 2024 17:42:55 +0200 Subject: [PATCH 05/49] fix: make forwarding manual --- package.json | 3 +- pnpm-lock.yaml | 64 +++++ src/browser/sse.ts | 117 +++------ .../sse-api/sse.server.connect.test.ts | 233 ++++++++++++++++++ 4 files changed, 329 insertions(+), 88 deletions(-) create mode 100644 test/browser/sse-api/sse.server.connect.test.ts diff --git a/package.json b/package.json index 73dc51602..778d8cbbd 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "test:unit": "vitest", "test:node": "vitest run --config=./test/node/vitest.config.mts", "test:native": "vitest --config=./test/native/vitest.config.mts", - "test:browser": "playwright test -c ./test/browser/playwright.config.ts", + "test:browser": "NODE_OPTIONS=--experimental-require-module npx playwright test -c ./test/browser/playwright.config.ts", "test:modules:node": "vitest --config=./test/modules/node/vitest.config.mts", "test:modules:browser": "playwright test -c ./test/modules/browser/playwright.config.ts", "test:ts": "vitest --typecheck --config=./test/typings/vitest.config.mts", @@ -154,6 +154,7 @@ "devDependencies": { "@commitlint/cli": "^18.4.4", "@commitlint/config-conventional": "^18.4.4", + "@epic-web/test-server": "^0.1.0", "@open-draft/test-server": "^0.4.2", "@ossjs/release": "^0.8.1", "@playwright/test": "^1.40.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 611143cee..c4c3597a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,6 +64,9 @@ devDependencies: '@commitlint/config-conventional': specifier: ^18.4.4 version: 18.6.3 + '@epic-web/test-server': + specifier: ^0.1.0 + version: 0.1.0 '@open-draft/test-server': specifier: ^0.4.2 version: 0.4.2 @@ -677,6 +680,21 @@ packages: '@jridgewell/trace-mapping': 0.3.9 dev: true + /@epic-web/test-server@0.1.0: + resolution: {integrity: sha512-RjJ2WvFepiXynHqm6svriviySVmpCA2QxJmy0DHYwy/9qjeSZvTAIttNqCLLryrV6hL+RsSpHS9CN4MSn/4DpA==} + engines: {node: '>=20'} + dependencies: + '@hono/node-server': 1.13.1(hono@4.6.3) + '@hono/node-ws': 1.0.4(@hono/node-server@1.13.1) + '@open-draft/deferred-promise': 2.2.0 + '@types/ws': 8.5.12 + hono: 4.6.3 + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + /@esbuild/aix-ppc64@0.19.12: resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} engines: {node: '>=12'} @@ -1138,6 +1156,28 @@ packages: engines: {node: '>=14'} dev: true + /@hono/node-server@1.13.1(hono@4.6.3): + resolution: {integrity: sha512-TSxE6cT5RHnawbjnveexVN7H2Dpn1YaLxQrCOLCUwD+hFbqbFsnJBgdWcYtASqtWVjA+Qgi8uqFug39GsHjo5A==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + dependencies: + hono: 4.6.3 + dev: true + + /@hono/node-ws@1.0.4(@hono/node-server@1.13.1): + resolution: {integrity: sha512-0j1TMp67U5ym0CIlvPKcKtD0f2ZjaS/EnhOxFLs3bVfV+/4WInBE7hVe2x/7PLEsNIUK9+jVL8lPd28rzTAcZg==} + engines: {node: '>=18.14.1'} + peerDependencies: + '@hono/node-server': ^1.11.1 + dependencies: + '@hono/node-server': 1.13.1(hono@4.6.3) + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + /@humanwhocodes/config-array@0.11.14: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -2134,6 +2174,12 @@ packages: '@types/node': 18.19.28 dev: true + /@types/ws@8.5.12: + resolution: {integrity: sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==} + dependencies: + '@types/node': 18.19.28 + dev: true + /@types/yargs-parser@21.0.3: resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} dev: true @@ -5049,6 +5095,11 @@ packages: parse-passwd: 1.0.0 dev: true + /hono@4.6.3: + resolution: {integrity: sha512-0LeEuBNFeSHGqZ9sNVVgZjB1V5fmhkBSB0hZrpqStSMLOWgfLy0dHOvrjbJh0H2khsjet6rbHfWTHY0kpYThKQ==} + engines: {node: '>=16.9.0'} + dev: true + /hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} dev: true @@ -8672,6 +8723,19 @@ packages: optional: true dev: true + /ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + 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 + /xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} diff --git a/src/browser/sse.ts b/src/browser/sse.ts index d76c89018..b87576470 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -47,7 +47,6 @@ class ServerSentEventHandler extends HttpHandler { }) const server = new ServerSentEventServer({ request: info.request, - client, }) resolver({ @@ -96,7 +95,7 @@ class ServerSentEventClient { } /** - * Dispatches the given event to the underlying `EventSource`. + * Sends the given payload to the underlying `EventSource`. */ public send(payload: { id?: string; event?: string; data: unknown }): void { this[kSend]({ @@ -106,6 +105,29 @@ class ServerSentEventClient { }) } + /** + * Dispatches the given event to the underlying `EventSource`. + */ + public dispatchEvent(event: Event) { + if (event instanceof MessageEvent) { + /** + * @note Use the internal send mechanism to skip normalization + * of the message data (already normalized by the server). + */ + this[kSend]({ + id: event.lastEventId || undefined, + event: event.type === 'message' ? undefined : event.type, + data: event.data, + }) + return + } + + if (event.type === 'error') { + this.error() + return + } + } + /** * Errors the underlying `EventSource`, closing the connection. */ @@ -135,15 +157,11 @@ class ServerSentEventClient { } } -const kListener = Symbol('kListener') - class ServerSentEventServer { protected request: Request - protected client: ServerSentEventClient - constructor(args: { request: Request; client: ServerSentEventClient }) { + constructor(args: { request: Request }) { this.request = args.request - this.client = args.client } /** @@ -152,91 +170,16 @@ class ServerSentEventServer { */ public connect(): EventSource { const requestUrl = new URL(this.request.url) + /** + * @todo @fixme Explore if there's a different way to bypass this request. + * It has to be bypassed not to cause an infinite loop of it being intercepted. + */ requestUrl.searchParams.set('x-msw-intention', 'bypass') - let source = new EventSource(requestUrl, { + const source = new EventSource(requestUrl, { withCredentials: this.request.credentials === 'include', }) - const addEventListener = source.addEventListener.bind(source) - source.addEventListener = (event: any, listener: any, options: any) => { - const wrappedListener = this.wrapEventListener(listener).bind(source) - Object.defineProperty(listener, kListener, { - value: wrappedListener, - }) - addEventListener(event, wrappedListener, options) - } - - const removeEventListener = source.removeEventListener.bind(source) - source.removeEventListener = (event: any, listener: any, options: any) => { - const wrappedListener = listener[kListener] || listener - removeEventListener(event, wrappedListener, options) - } - - source = new Proxy(source, { - set: (target, property, value) => { - switch (property) { - case 'onopen': - case 'onmessage': - case 'onerror': { - return Reflect.set(target, property, this.wrapEventListener(value)) - } - } - - return Reflect.set(target, property, value) - }, - }) - return source } - - private wrapEventListener(listener: (event: Event) => void) { - const { client } = this - - return function (this: EventSource, event: Event) { - const cancelableEvent = - event instanceof MessageEvent - ? new MessageEvent(event.type, { - data: event.data, - cancelable: true, - }) - : new Event(event.type, { - cancelable: true, - }) - Object.defineProperties(cancelableEvent, { - target: { enumerable: true, value: this, writable: false }, - currentTarget: { enumerable: true, value: this, writable: false }, - srcElement: { enumerable: true, value: this, writable: false }, - }) - - listener.call(this, cancelableEvent) - - // The "open" event cannot be prevented so ignore it. - if (event.type === 'open') { - return - } - - if (!cancelableEvent.defaultPrevented) { - switch (event.type) { - case 'error': { - client.error() - break - } - - default: { - if (event instanceof MessageEvent) { - // Use the internal send method to avoid data serialization. - client[kSend]({ - id: event.lastEventId, - event: event.type === 'message' ? undefined : event.type, - data: event.data, - }) - } - - break - } - } - } - } - } } diff --git a/test/browser/sse-api/sse.server.connect.test.ts b/test/browser/sse-api/sse.server.connect.test.ts new file mode 100644 index 000000000..07ea0ad89 --- /dev/null +++ b/test/browser/sse-api/sse.server.connect.test.ts @@ -0,0 +1,233 @@ +import { setupWorker, sse } from 'msw/browser' +import { createTestHttpServer } from '@epic-web/test-server/http' +import { test, expect } from '../playwright.extend' + +declare namespace window { + export const msw: { + setupWorker: typeof setupWorker + sse: typeof sse + } +} + +test('makes the actual request when called "server.connect()"', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./sse.mocks.ts'), { + skipActivation: true, + }) + + await using server = await createTestHttpServer({ + defineRoutes(routes) { + routes.get('/stream', () => { + return new Response(null, { + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }, + }) + }) + }, + }) + const url = server.http.url('/stream').href + + await page.evaluate(async (url) => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + sse(url, ({ server }) => { + // Calling "server.connect()" establishes the actual connection. + server.connect() + }), + ) + await worker.start() + }, url) + + const openPromise = page.evaluate((url) => { + return new Promise((resolve, reject) => { + const source = new EventSource(url) + source.onopen = () => resolve() + source.onerror = () => reject(new Error('EventSource connection failed')) + }) + }, url) + + await expect(openPromise).resolves.toBeUndefined() +}) + +test('forwards message event from the original server to the client', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./sse.mocks.ts'), { + skipActivation: true, + }) + + await using server = await createTestHttpServer({ + defineRoutes(routes) { + routes.get('/stream', () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode('data: {"message": "hello"}\n\n'), + ) + }, + }) + + return new Response(stream, { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }, + }) + }) + }, + }) + const url = server.http.url('/stream').href + + await page.evaluate(async (url) => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + sse(url, ({ client, server }) => { + const source = server.connect() + source.addEventListener('message', (event) => { + client.dispatchEvent(event) + }) + }), + ) + await worker.start() + }, url) + + const message = await page.evaluate((url) => { + return new Promise((resolve, reject) => { + const source = new EventSource(url) + source.addEventListener('message', (event) => { + resolve(JSON.parse(event.data)) + }) + source.onerror = () => reject(new Error('EventSource connection failed')) + }) + }, url) + + await page.pause() + + expect(message).toEqual({ message: 'hello' }) +}) + +test('forwards custom event from the original server to the client', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./sse.mocks.ts'), { + skipActivation: true, + }) + + await using server = await createTestHttpServer({ + defineRoutes(routes) { + routes.get('/stream', () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'event: custom\ndata: {"message": "hello"}\n\n', + ), + ) + }, + }) + + return new Response(stream, { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }, + }) + }) + }, + }) + const url = server.http.url('/stream').href + + await page.evaluate(async (url) => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + sse(url, ({ client, server }) => { + const source = server.connect() + source.addEventListener('custom', (event) => { + client.dispatchEvent(event) + }) + }), + ) + await worker.start() + }, url) + + const message = await page.evaluate((url) => { + return new Promise((resolve, reject) => { + const source = new EventSource(url) + source.addEventListener('custom', (event) => { + resolve(JSON.parse(event.data)) + }) + source.onerror = () => reject(new Error('EventSource connection failed')) + }) + }, url) + + await page.pause() + + expect(message).toEqual({ message: 'hello' }) +}) + +test('forwards the original server error to the client', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./sse.mocks.ts'), { + skipActivation: true, + }) + + await using server = await createTestHttpServer({ + defineRoutes(routes) { + routes.get('/stream', () => { + const stream = new ReadableStream({ + start(controller) { + controller.error() + }, + }) + + return new Response(stream, { + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }, + }) + }) + }, + }) + const url = server.http.url('/stream').href + + await page.evaluate(async (url) => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + sse(url, ({ client, server }) => { + const source = server.connect() + source.addEventListener('error', (event) => { + client.dispatchEvent(event) + }) + }), + ) + await worker.start() + }, url) + + const errorPromise = page.evaluate((url) => { + return new Promise((resolve) => { + const source = new EventSource(url) + source.onerror = () => resolve() + }) + }, url) + + await expect(errorPromise).resolves.toBeUndefined() +}) From 2edf2a6a8da39e9f59f3310449062c605ad550cf Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 1 Oct 2024 17:55:26 +0200 Subject: [PATCH 06/49] chore: use node v20 for ci --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/typescript-nightly.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0fa59640..238d0356a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 20 - uses: pnpm/action-setup@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ed8349f9..3fb85b0e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 20 always-auth: true registry-url: https://registry.npmjs.org diff --git a/.github/workflows/typescript-nightly.yml b/.github/workflows/typescript-nightly.yml index 9449cd3ba..031f0c64d 100644 --- a/.github/workflows/typescript-nightly.yml +++ b/.github/workflows/typescript-nightly.yml @@ -13,7 +13,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 20 - name: Get latest TypeScript version id: get-versions @@ -42,7 +42,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 20 - name: Set up pnpm uses: pnpm/action-setup@v4 From 81cc17e251deca0471a7920dc5d4967b8c84c323 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 1 Oct 2024 18:01:07 +0200 Subject: [PATCH 07/49] test: add "withCredentials" test --- .../sse-api/sse.with-credentials.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 test/browser/sse-api/sse.with-credentials.test.ts diff --git a/test/browser/sse-api/sse.with-credentials.test.ts b/test/browser/sse-api/sse.with-credentials.test.ts new file mode 100644 index 000000000..bc6c1c4bc --- /dev/null +++ b/test/browser/sse-api/sse.with-credentials.test.ts @@ -0,0 +1,53 @@ +import { setupWorker, sse } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare namespace window { + export const msw: { + setupWorker: typeof setupWorker + sse: typeof sse + } +} + +test('forwards document cookies on the request when "withCredentials" is set to true', async ({ + loadExample, + page, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(require.resolve('./sse.mocks.ts'), { + skipActivation: true, + }) + + await page.evaluate(() => { + document.cookie = 'foo=bar' + }) + + await page.evaluate(async () => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + sse('http://localhost/stream', ({ cookies }) => { + console.log(JSON.stringify({ cookies })) + }), + ) + await worker.start() + }) + + await page.evaluate((url) => { + return new Promise((resolve, reject) => { + const source = new EventSource('http://localhost/stream', { + withCredentials: true, + }) + source.onopen = () => resolve() + source.onerror = () => reject(new Error('EventSource connection failed')) + }) + }) + + expect(consoleSpy.get('log')).toContain( + JSON.stringify({ + cookies: { + foo: 'bar', + }, + }), + ) +}) From 122661205a7074c550e2b8c5f9111bd0cafbfbfd Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 1 Oct 2024 18:02:55 +0200 Subject: [PATCH 08/49] fix: throw if EventSource is undefined --- src/browser/sse.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index b87576470..95400413c 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -1,3 +1,4 @@ +import { invariant } from 'outvariant' import type { ResponseResolver } from '~/core/handlers/RequestHandler' import { HttpHandler, @@ -39,6 +40,12 @@ export const sse: ServerSentEventRequestHandler = (path, resolver) => { class ServerSentEventHandler extends HttpHandler { constructor(path: Path, resolver: ServerSentEventResolver) { + invariant( + typeof EventSource !== 'undefined', + 'Failed to construct a Server-Sent Event handler for path "%s": your environment does not support the EventSource API', + path, + ) + super('GET', path, (info) => { const stream = new ReadableStream({ start(controller) { From 4c49a14d2d75fffed0f13909fae9b80d95e05a40 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 1 Oct 2024 18:30:10 +0200 Subject: [PATCH 09/49] feat: support `EventMap` type argument --- src/browser/sse.ts | 49 +++++++++++++------ .../{sse.test.ts => sse.client.send.test.ts} | 0 test/typings/sse.test-d.ts | 39 +++++++++++++++ test/typings/vitest.config.mts | 4 +- 4 files changed, 75 insertions(+), 17 deletions(-) rename test/browser/sse-api/{sse.test.ts => sse.client.send.test.ts} (100%) create mode 100644 test/typings/sse.test-d.ts diff --git a/src/browser/sse.ts b/src/browser/sse.ts index 95400413c..7a1cbf3e4 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -7,21 +7,26 @@ import { } from '~/core/handlers/HttpHandler' import type { Path, PathParams } from '~/core/utils/matching/matchRequestUrl' -export type ServerSentEventResolverExtras = - HttpRequestResolverExtras & { - client: ServerSentEventClient - server: ServerSentEventServer - } +export type ServerSentEventResolverExtras< + EventMap extends Record, + Params extends PathParams, +> = HttpRequestResolverExtras & { + client: ServerSentEventClient + server: ServerSentEventServer +} -export type ServerSentEventResolver = - ResponseResolver, any, any> +export type ServerSentEventResolver< + EventMap extends Record, + Params extends PathParams, +> = ResponseResolver, any, any> export type ServerSentEventRequestHandler = < + EventMap extends Record, Params extends PathParams = PathParams, RequestPath extends Path = Path, >( path: RequestPath, - resolver: ServerSentEventResolver, + resolver: ServerSentEventResolver, ) => HttpHandler /** @@ -38,8 +43,10 @@ export const sse: ServerSentEventRequestHandler = (path, resolver) => { return new ServerSentEventHandler(path, resolver) } -class ServerSentEventHandler extends HttpHandler { - constructor(path: Path, resolver: ServerSentEventResolver) { +class ServerSentEventHandler< + EventMap extends Record, +> extends HttpHandler { + constructor(path: Path, resolver: ServerSentEventResolver) { invariant( typeof EventSource !== 'undefined', 'Failed to construct a Server-Sent Event handler for path "%s": your environment does not support the EventSource API', @@ -49,7 +56,7 @@ class ServerSentEventHandler extends HttpHandler { super('GET', path, (info) => { const stream = new ReadableStream({ start(controller) { - const client = new ServerSentEventClient({ + const client = new ServerSentEventClient({ controller, }) const server = new ServerSentEventServer({ @@ -92,7 +99,7 @@ class ServerSentEventHandler extends HttpHandler { const kSend = Symbol('kSend') -class ServerSentEventClient { +class ServerSentEventClient> { private encoder: TextEncoder protected controller: ReadableStreamDefaultController @@ -104,7 +111,19 @@ class ServerSentEventClient { /** * Sends the given payload to the underlying `EventSource`. */ - public send(payload: { id?: string; event?: string; data: unknown }): void { + public send( + payload: + | { + id?: string + event: EventType + data: EventMap[EventType] + } + | { + id?: string + event?: never + data?: EventMap['message'] + }, + ): void { this[kSend]({ id: payload.id, event: payload.event, @@ -142,9 +161,9 @@ class ServerSentEventClient { this.controller.error() } - private [kSend](payload: { + private [kSend](payload: { id?: string - event?: string + event?: EventType data: string }): void { const frames: Array = [] diff --git a/test/browser/sse-api/sse.test.ts b/test/browser/sse-api/sse.client.send.test.ts similarity index 100% rename from test/browser/sse-api/sse.test.ts rename to test/browser/sse-api/sse.client.send.test.ts diff --git a/test/typings/sse.test-d.ts b/test/typings/sse.test-d.ts new file mode 100644 index 000000000..59ce96238 --- /dev/null +++ b/test/typings/sse.test-d.ts @@ -0,0 +1,39 @@ +import { it } from 'vitest' +import { sse } from 'msw/browser' + +it('supports custom event map type argument', () => { + sse<{ myevent: string }>('/stream', ({ client }) => { + client.send({ + event: 'myevent', + data: 'hello', + }) + + client.send({ + // @ts-expect-error Unknown event type "unknown". + event: 'unknown', + data: 'hello', + }) + }) +}) + +it('supports event map type argument for unnamed events', () => { + sse<{ message: number; custom: 'goodbye' }>('/stream', ({ client }) => { + client.send({ + data: 123, + }) + client.send({ + // @ts-expect-error Unexpected data type for "message" event. + data: 'goodbye', + }) + + client.send({ + event: 'custom', + data: 'goodbye', + }) + client.send({ + event: 'custom', + // @ts-expect-error Unexpected data type for "custom" event. + data: 'invalid', + }) + }) +}) diff --git a/test/typings/vitest.config.mts b/test/typings/vitest.config.mts index 4f879ab8e..b5db2bcc8 100644 --- a/test/typings/vitest.config.mts +++ b/test/typings/vitest.config.mts @@ -1,8 +1,8 @@ import * as path from 'node:path' +import * as fs from 'fs' import { defineConfig } from 'vitest/config' -import tsPackageJson from 'typescript/package.json' assert { type: 'json' } import { invariant } from 'outvariant' -import * as fs from 'fs' +import tsPackageJson from 'typescript/package.json' assert { type: 'json' } const LIB_DIR = path.resolve(__dirname, '../../lib') From 4471412bc98b327c17a3063fe626a6428ce3a338 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 2 Oct 2024 15:09:48 +0200 Subject: [PATCH 10/49] feat: support server-to-client forwarding --- .nvmrc | 2 +- src/browser/sse.ts | 488 +++++++++++++++++- src/browser/tsconfig.browser.json | 2 +- src/core/utils/handleRequest.ts | 5 +- .../sse-api/sse.server.connect.test.ts | 23 +- 5 files changed, 489 insertions(+), 31 deletions(-) diff --git a/.nvmrc b/.nvmrc index 3462e8c26..92f279e3e 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v18.19.0 \ No newline at end of file +v22 \ No newline at end of file diff --git a/src/browser/sse.ts b/src/browser/sse.ts index 7a1cbf3e4..90a103a59 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -6,6 +6,7 @@ import { type HttpRequestParsedResult, } from '~/core/handlers/HttpHandler' import type { Path, PathParams } from '~/core/utils/matching/matchRequestUrl' +import { delay } from '~/core/delay' export type ServerSentEventResolverExtras< EventMap extends Record, @@ -61,6 +62,7 @@ class ServerSentEventHandler< }) const server = new ServerSentEventServer({ request: info.request, + client, }) resolver({ @@ -184,10 +186,12 @@ class ServerSentEventClient> { } class ServerSentEventServer { - protected request: Request + private request: Request + private client: ServerSentEventClient - constructor(args: { request: Request }) { + constructor(args: { request: Request; client: ServerSentEventClient }) { this.request = args.request + this.client = args.client } /** @@ -195,17 +199,481 @@ class ServerSentEventServer { * and returns the `EventSource` instance. */ public connect(): EventSource { - const requestUrl = new URL(this.request.url) - /** - * @todo @fixme Explore if there's a different way to bypass this request. - * It has to be bypassed not to cause an infinite loop of it being intercepted. - */ - requestUrl.searchParams.set('x-msw-intention', 'bypass') - - const source = new EventSource(requestUrl, { + const source = new ObservableEventSource(this.request.url, { withCredentials: this.request.credentials === 'include', + headers: { + /** + * @note Mark this request as bypassed so it doesn't trigger + * an infinite loop matching the existing request handler. + */ + 'x-msw-intention': 'bypass', + }, }) + source[kOnAnyMessage] = (event) => { + // Schedule the server-to-client forwarding for the next tick + // so the user can prevent the message event. + queueMicrotask(() => { + if (!event.defaultPrevented) { + this.client.dispatchEvent(event) + } + }) + } + return source } } + +interface ObservableEventSourceInit extends EventSourceInit { + headers?: HeadersInit +} + +type EventHandler = (this: EventSource, event: E) => any + +const kRequest = Symbol('kRequest') +const kReconnectionTime = Symbol('kReconnectionTime') +const kLastEventId = Symbol('kLastEventId') +const kAbortController = Symbol('kAbortController') +const kOnOpen = Symbol('kOnOpen') +const kOnMessage = Symbol('kOnMessage') +const kOnAnyMessage = Symbol('kOnAnyMessage') +const kOnError = Symbol('kOnError') + +class ObservableEventSource extends EventTarget implements EventSource { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSED = 2 + + public readonly CONNECTING = ObservableEventSource.CONNECTING + public readonly OPEN = ObservableEventSource.OPEN + public readonly CLOSED = ObservableEventSource.CLOSED + + public readyState: number + public url: string + public withCredentials: boolean + + private [kRequest]: Request + private [kReconnectionTime]: number + private [kLastEventId]: string + private [kAbortController]: AbortController + private [kOnOpen]: EventHandler | null = null + private [kOnMessage]: EventHandler | null = null + private [kOnAnyMessage]: EventHandler | null = null + private [kOnError]: EventHandler | null = null + + constructor(url: string | URL, init?: ObservableEventSourceInit) { + super() + + this.url = new URL(url).href + this.withCredentials = init?.withCredentials ?? false + + this.readyState = this.CONNECTING + + // Support custom request init. + const headers = new Headers(init?.headers || {}) + headers.set('accept', 'text/event-stream') + + this[kAbortController] = new AbortController() + this[kReconnectionTime] = 2000 + this[kLastEventId] = '' + this[kRequest] = new Request(this.url, { + method: 'GET', + headers, + credentials: this.withCredentials ? 'include' : 'omit', + signal: this[kAbortController].signal, + }) + + this.connect() + } + + get onopen(): EventHandler | null { + return this[kOnOpen] + } + + set onopen(handler: EventHandler) { + if (this[kOnOpen]) { + this.removeEventListener('open', this[kOnOpen]) + } + this[kOnOpen] = handler.bind(this) + this.addEventListener('open', this[kOnOpen]) + } + + get onmessage(): EventHandler | null { + return this[kOnMessage] + } + set onmessage(handler: EventHandler) { + if (this[kOnMessage]) { + this.removeEventListener('message', { handleEvent: this[kOnMessage] }) + } + this[kOnMessage] = handler.bind(this) + this.addEventListener('message', { handleEvent: this[kOnMessage] }) + } + + get onerror(): EventHandler | null { + return this[kOnError] + } + set oneerror(handler: EventHandler) { + if (this[kOnError]) { + this.removeEventListener('error', { handleEvent: this[kOnError] }) + } + this[kOnError] = handler.bind(this) + this.addEventListener('error', { handleEvent: this[kOnError] }) + } + + public addEventListener( + type: K, + listener: EventHandler, + options?: boolean | AddEventListenerOptions, + ): void + public addEventListener( + type: string, + listener: EventHandler, + options?: boolean | AddEventListenerOptions, + ): void + public addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void + + public addEventListener( + type: string, + listener: EventHandler | EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void { + super.addEventListener( + type, + listener as EventListenerOrEventListenerObject, + options, + ) + } + + public removeEventListener( + type: K, + listener: (this: EventSource, ev: EventSourceEventMap[K]) => any, + options?: boolean | EventListenerOptions, + ): void + public removeEventListener( + type: string, + listener: (this: EventSource, event: MessageEvent) => any, + options?: boolean | EventListenerOptions, + ): void + public removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void + + public removeEventListener( + type: string, + listener: EventHandler | EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void { + super.removeEventListener( + type, + listener as EventListenerOrEventListenerObject, + options, + ) + } + + public dispatchEvent(event: Event): boolean { + if (this.readyState === this.CLOSED) { + return false + } + + return super.dispatchEvent(event) + } + + public close(): void { + this[kAbortController].abort() + this.readyState = this.CLOSED + } + + private async connect() { + const response = await fetch(this[kRequest]) + this.processResponse(response) + } + + private processResponse(response: Response): void { + if (!response.body) { + this.failConnection() + return + } + + if (isNetworkError(response)) { + this.reestablishConnection() + return + } + + if ( + response.status !== 200 || + response.headers.get('content-type') !== 'text/event-stream' + ) { + this.failConnection() + return + } + + this.announceConnection() + this.interpretResponseBody(response) + } + + private announceConnection(): void { + queueMicrotask(() => { + if (this.readyState !== this.CLOSED) { + this.readyState = this.OPEN + this.dispatchEvent(new Event('open')) + } + }) + } + + private interpretResponseBody(response: Response): void { + const parsingStream = new EventSourceParsingStream({ + message: (message) => { + if (message.id) { + this[kLastEventId] = message.id + } + + if (message.retry) { + this[kReconnectionTime] = message.retry + } + + const messageEvent = new MessageEvent( + message.event ? message.event : 'message', + { + data: message.data, + origin: this[kRequest].url, + lastEventId: this[kLastEventId], + cancelable: true, + }, + ) + + this[kOnAnyMessage]?.(messageEvent) + this.dispatchEvent(messageEvent) + }, + }) + + response.body!.pipeTo(parsingStream).then(() => { + this.processResponseEndOfBody(response) + }) + } + + private processResponseEndOfBody(response: Response): void { + if (!isNetworkError(response)) { + this.reestablishConnection() + } + } + + private async reestablishConnection(): Promise { + queueMicrotask(() => { + if (this.readyState === this.CLOSED) { + return + } + + this.readyState = this.CONNECTING + this.dispatchEvent(new Event('error')) + }) + + await delay(this[kReconnectionTime]) + + queueMicrotask(async () => { + if (this.readyState !== this.CONNECTING) { + return + } + + if (this[kLastEventId] !== '') { + this[kRequest].headers.set('last-event-id', this[kLastEventId]) + } + + await this.connect() + }) + } + + private failConnection(): void { + queueMicrotask(() => { + if (this.readyState !== this.CLOSED) { + this.readyState = this.CLOSED + this.dispatchEvent(new Event('error')) + } + }) + } +} + +/** + * Checks if the given `Response` instance is a network error. + * @see https://fetch.spec.whatwg.org/#concept-network-error + */ +function isNetworkError(response: Response): boolean { + return ( + response.type === 'error' && + response.status === 0 && + response.statusText === '' && + Array.from(response.headers.entries()).length === 0 && + response.body === null + ) +} + +const enum ControlCharacters { + NewLine = 10, + CarriageReturn = 13, + Space = 32, + Colon = 58, +} + +interface EventSourceMessage { + id?: string + event?: string + data?: string + retry?: number +} + +class EventSourceParsingStream extends WritableStream { + private decoder: TextDecoder + + private buffer?: Uint8Array + private position: number + private fieldLength?: number + private discardTrailingNewline = false + + private message: EventSourceMessage = { + id: undefined, + event: undefined, + data: undefined, + retry: undefined, + } + + constructor( + private underlyingSink: { + message: (message: EventSourceMessage) => void + }, + ) { + super({ + write: (chunk) => { + this.processResponseBodyChunk(chunk) + }, + }) + + this.decoder = new TextDecoder() + this.position = 0 + } + + private resetMessage(): void { + this.message = { + id: undefined, + event: undefined, + data: undefined, + retry: undefined, + } + } + + private processResponseBodyChunk(chunk: Uint8Array): void { + if (this.buffer == null) { + this.buffer = chunk + this.position = 0 + this.fieldLength = -1 + } else { + const nextBuffer = new Uint8Array(this.buffer.length + chunk.length) + nextBuffer.set(this.buffer) + nextBuffer.set(chunk, this.buffer.length) + this.buffer = nextBuffer + } + + const bufferLength = this.buffer.length + let lineStart = 0 + + while (this.position < bufferLength) { + if (this.discardTrailingNewline) { + if (this.buffer[this.position] === ControlCharacters.NewLine) { + lineStart = ++this.position + } + + this.discardTrailingNewline = false + } + + let lineEnd = -1 + + for (; this.position < bufferLength && lineEnd === -1; ++this.position) { + switch (this.buffer[this.position]) { + case ControlCharacters.Colon: { + if (this.fieldLength === -1) { + this.fieldLength = this.position - lineStart + } + break + } + + case ControlCharacters.CarriageReturn: { + this.discardTrailingNewline = true + } + case ControlCharacters.NewLine: { + lineEnd = this.position + break + } + } + } + + if (lineEnd === -1) { + break + } + + this.processLine( + this.buffer.subarray(lineStart, lineEnd), + this.fieldLength!, + ) + + lineStart = this.position + this.fieldLength = -1 + } + + if (lineStart === bufferLength) { + this.buffer = undefined + } else if (lineStart !== 0) { + this.buffer = this.buffer.subarray(lineStart) + this.position -= lineStart + } + } + + private processLine(line: Uint8Array, fieldLength: number): void { + // New line indicates the end of the message. Dispatch it. + if (line.length === 0) { + this.underlyingSink.message(this.message) + this.resetMessage() + return + } + + // Otherwise, keep accumulating message fields until the new line. + if (fieldLength > 0) { + const field = this.decoder.decode(line.subarray(0, fieldLength)) + const valueOffset = + fieldLength + + (line[fieldLength + 1] === ControlCharacters.Space ? 2 : 1) + const value = this.decoder.decode(line.subarray(valueOffset)) + + switch (field) { + case 'data': { + this.message.data = this.message.data + ? this.message.data + '\n' + value + : value + break + } + + case 'event': { + this.message.event = value + break + } + + case 'id': { + this.message.id = value + break + } + + case 'retry': { + const retry = parseInt(value, 10) + + if (!isNaN(retry)) { + this.message.retry = retry + } + break + } + } + } + } +} diff --git a/src/browser/tsconfig.browser.json b/src/browser/tsconfig.browser.json index e998a8e8e..6a44514da 100644 --- a/src/browser/tsconfig.browser.json +++ b/src/browser/tsconfig.browser.json @@ -3,7 +3,7 @@ "compilerOptions": { // Expose browser-specific libraries only for the // source code under the "src/browser" directory. - "lib": ["DOM", "WebWorker"] + "lib": ["DOM", "WebWorker", "DOM.Iterable"] }, "include": ["../../global.d.ts", "./global.browser.d.ts", "./**/*.ts"] } diff --git a/src/core/utils/handleRequest.ts b/src/core/utils/handleRequest.ts index ab2fcb88b..766b22ce6 100644 --- a/src/core/utils/handleRequest.ts +++ b/src/core/utils/handleRequest.ts @@ -53,10 +53,7 @@ export async function handleRequest( emitter.emit('request:start', { request, requestId }) // Perform bypassed requests (i.e. wrapped in "bypass()") as-is. - if ( - request.headers.get('x-msw-intention') === 'bypass' || - new URL(request.url).searchParams.get('x-msw-intention') === 'bypass' - ) { + if (request.headers.get('x-msw-intention') === 'bypass') { emitter.emit('request:end', { request, requestId }) handleRequestOptions?.onPassthroughResponse?.(request) return diff --git a/test/browser/sse-api/sse.server.connect.test.ts b/test/browser/sse-api/sse.server.connect.test.ts index 07ea0ad89..f031bab3b 100644 --- a/test/browser/sse-api/sse.server.connect.test.ts +++ b/test/browser/sse-api/sse.server.connect.test.ts @@ -55,7 +55,7 @@ test('makes the actual request when called "server.connect()"', async ({ await expect(openPromise).resolves.toBeUndefined() }) -test('forwards message event from the original server to the client', async ({ +test('forwards message event from the server to the client automatically', async ({ loadExample, page, }) => { @@ -92,10 +92,7 @@ test('forwards message event from the original server to the client', async ({ const worker = setupWorker( sse(url, ({ client, server }) => { - const source = server.connect() - source.addEventListener('message', (event) => { - client.dispatchEvent(event) - }) + server.connect() }), ) await worker.start() @@ -105,6 +102,8 @@ test('forwards message event from the original server to the client', async ({ return new Promise((resolve, reject) => { const source = new EventSource(url) source.addEventListener('message', (event) => { + console.log('client got:', event.type, event.data) + resolve(JSON.parse(event.data)) }) source.onerror = () => reject(new Error('EventSource connection failed')) @@ -116,7 +115,7 @@ test('forwards message event from the original server to the client', async ({ expect(message).toEqual({ message: 'hello' }) }) -test('forwards custom event from the original server to the client', async ({ +test('forwards custom event from the server to the client automatically', async ({ loadExample, page, }) => { @@ -155,10 +154,7 @@ test('forwards custom event from the original server to the client', async ({ const worker = setupWorker( sse(url, ({ client, server }) => { - const source = server.connect() - source.addEventListener('custom', (event) => { - client.dispatchEvent(event) - }) + server.connect() }), ) await worker.start() @@ -179,7 +175,7 @@ test('forwards custom event from the original server to the client', async ({ expect(message).toEqual({ message: 'hello' }) }) -test('forwards the original server error to the client', async ({ +test('forwards error event from the server to the client automatically', async ({ loadExample, page, }) => { @@ -213,10 +209,7 @@ test('forwards the original server error to the client', async ({ const worker = setupWorker( sse(url, ({ client, server }) => { - const source = server.connect() - source.addEventListener('error', (event) => { - client.dispatchEvent(event) - }) + server.connect() }), ) await worker.start() From 3fdf314ba3360c7857f176c7fec97fc2e38be31b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 4 Oct 2024 15:19:30 +0200 Subject: [PATCH 11/49] test(axios-upload): wrap request promise in expect --- test/node/third-party/axios-upload.node.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/node/third-party/axios-upload.node.test.ts b/test/node/third-party/axios-upload.node.test.ts index e63e352bb..f7f2c068a 100644 --- a/test/node/third-party/axios-upload.node.test.ts +++ b/test/node/third-party/axios-upload.node.test.ts @@ -45,12 +45,13 @@ it('responds with a mocked response to an upload request', async () => { const file = new Blob(['Hello', 'world'], { type: 'text/plain' }) formData.set('file', file, 'doc.txt') - const response = await request.post('/upload', formData) - - expect(response.data).toEqual({ - message: 'Successfully uploaded "doc.txt"!', - content: 'Helloworld', + await expect(request.post('/upload', formData)).resolves.toMatchObject({ + data: { + message: 'Successfully uploaded "doc.txt"!', + content: 'Helloworld', + }, }) + expect(onUploadProgress.mock.calls.length).toBeGreaterThan(0) expect(onUploadProgress).toHaveBeenNthCalledWith( 1, From b11ba9b06b33bd20828f2213b6f58262a88d8936 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 4 Oct 2024 15:33:05 +0200 Subject: [PATCH 12/49] fix: skip stringifying non-objects --- src/browser/sse.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index 90a103a59..4f7987bb1 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -103,7 +103,7 @@ const kSend = Symbol('kSend') class ServerSentEventClient> { private encoder: TextEncoder - protected controller: ReadableStreamDefaultController + private controller: ReadableStreamDefaultController constructor(args: { controller: ReadableStreamDefaultController }) { this.encoder = new TextEncoder() @@ -129,7 +129,10 @@ class ServerSentEventClient> { this[kSend]({ id: payload.id, event: payload.event, - data: JSON.stringify(payload.data), + data: + typeof payload.data === 'object' + ? JSON.stringify(payload.data) + : payload.data, }) } @@ -166,7 +169,7 @@ class ServerSentEventClient> { private [kSend](payload: { id?: string event?: EventType - data: string + data: string | EventMap[EventType] | EventMap['message'] | undefined }): void { const frames: Array = [] From 852870a4a0e7e469a02607f1691b579caffda57c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 4 Oct 2024 15:33:16 +0200 Subject: [PATCH 13/49] test: add runtime request handler test --- .../sse-api/sse.server.connect.test.ts | 4 +- test/browser/sse-api/sse.use.test.ts | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 test/browser/sse-api/sse.use.test.ts diff --git a/test/browser/sse-api/sse.server.connect.test.ts b/test/browser/sse-api/sse.server.connect.test.ts index f031bab3b..b7a4dbc5e 100644 --- a/test/browser/sse-api/sse.server.connect.test.ts +++ b/test/browser/sse-api/sse.server.connect.test.ts @@ -91,7 +91,7 @@ test('forwards message event from the server to the client automatically', async const { setupWorker, sse } = window.msw const worker = setupWorker( - sse(url, ({ client, server }) => { + sse(url, ({ server }) => { server.connect() }), ) @@ -102,8 +102,6 @@ test('forwards message event from the server to the client automatically', async return new Promise((resolve, reject) => { const source = new EventSource(url) source.addEventListener('message', (event) => { - console.log('client got:', event.type, event.data) - resolve(JSON.parse(event.data)) }) source.onerror = () => reject(new Error('EventSource connection failed')) diff --git a/test/browser/sse-api/sse.use.test.ts b/test/browser/sse-api/sse.use.test.ts new file mode 100644 index 000000000..325acee37 --- /dev/null +++ b/test/browser/sse-api/sse.use.test.ts @@ -0,0 +1,58 @@ +import { setupWorker, sse } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare namespace window { + export const msw: { + setupWorker: typeof setupWorker + sse: typeof sse + } +} + +test('supports server-sent event handler overrides', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./sse.mocks.ts'), { + skipActivation: true, + }) + + const workerRef = await page.evaluateHandle(async () => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + sse('http://localhost/stream', ({ client }) => { + client.send({ data: 'happy-path' }) + }), + ) + await worker.start() + return worker + }) + + await page.evaluate((worker) => { + const { sse } = window.msw + + worker.use( + // Adding this runtime request handler will make it + // take precedence over the happy path handler above. + sse('http://localhost/stream', ({ client }) => { + // Queue the data for the next tick to rule out + // the happy path handler from executing. + queueMicrotask(() => { + client.send({ data: 'override' }) + }) + }), + ) + }, workerRef) + + const message = await page.evaluate(() => { + return new Promise((resolve, reject) => { + const source = new EventSource('http://localhost/stream') + source.addEventListener('message', (event) => { + resolve(event.data) + }) + source.onerror = reject + }) + }) + + expect(message).toBe('override') +}) From 4d6ccfe93e2ece0be016d3a351de252678a17f2d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 4 Oct 2024 15:52:47 +0200 Subject: [PATCH 14/49] chore: use v22 for ci only --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/typescript-nightly.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 238d0356a..1730987dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v3 with: - node-version: 20 + node-version: 22 - uses: pnpm/action-setup@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fb85b0e1..7ed8349f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version: 20 + node-version: 18 always-auth: true registry-url: https://registry.npmjs.org diff --git a/.github/workflows/typescript-nightly.yml b/.github/workflows/typescript-nightly.yml index 031f0c64d..70b81abb6 100644 --- a/.github/workflows/typescript-nightly.yml +++ b/.github/workflows/typescript-nightly.yml @@ -13,7 +13,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v3 with: - node-version: 20 + node-version: 18 - name: Get latest TypeScript version id: get-versions From 8afa887b63086edaea0e15e3862c657d43022cc5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 4 Oct 2024 15:53:07 +0200 Subject: [PATCH 15/49] test: improve axios errors in test --- test/node/third-party/axios-upload.node.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/node/third-party/axios-upload.node.test.ts b/test/node/third-party/axios-upload.node.test.ts index f7f2c068a..ca5089a4e 100644 --- a/test/node/third-party/axios-upload.node.test.ts +++ b/test/node/third-party/axios-upload.node.test.ts @@ -45,11 +45,13 @@ it('responds with a mocked response to an upload request', async () => { const file = new Blob(['Hello', 'world'], { type: 'text/plain' }) formData.set('file', file, 'doc.txt') - await expect(request.post('/upload', formData)).resolves.toMatchObject({ - data: { - message: 'Successfully uploaded "doc.txt"!', - content: 'Helloworld', - }, + const response = await request.post('/upload', formData).catch((error) => { + throw error.response.data + }) + + expect(response.data).toEqual({ + message: 'Successfully uploaded "doc.txt"!', + content: 'Helloworld', }) expect(onUploadProgress.mock.calls.length).toBeGreaterThan(0) From bbc45532afb2c149c3072de3f4f5216681183b37 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 10 Oct 2024 18:56:47 +0200 Subject: [PATCH 16/49] fix: fail connection on fetch failure --- src/browser/sse.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index 4f7987bb1..f2472e7c3 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -393,8 +393,15 @@ class ObservableEventSource extends EventTarget implements EventSource { } private async connect() { - const response = await fetch(this[kRequest]) - this.processResponse(response) + await fetch(this[kRequest]) + .then((response) => { + this.processResponse(response) + }) + .catch(() => { + // Fail the connection on request errors instead of + // throwing a generic "Failed to fetch" error. + this.failConnection() + }) } private processResponse(response: Response): void { From 2afd608a9dc044f20f18bcbc97a6b1e0b4e2e98d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 10 Oct 2024 18:57:14 +0200 Subject: [PATCH 17/49] fix: prevent dispatching message if data is empty --- src/browser/sse.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index f2472e7c3..1269ebf32 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -644,6 +644,13 @@ class EventSourceParsingStream extends WritableStream { private processLine(line: Uint8Array, fieldLength: number): void { // New line indicates the end of the message. Dispatch it. if (line.length === 0) { + // Prevent dispatching the message if the data is an empty string. + // That is a no-op per spec. + if (this.message.data === undefined) { + this.message.event = undefined + return + } + this.underlyingSink.message(this.message) this.resetMessage() return From 913f4c76f7f717b15bd81a6a1c51fddeaf1d705e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 11 Feb 2025 21:49:59 +0100 Subject: [PATCH 18/49] chore: update @playwright/test --- package.json | 2 +- pnpm-lock.yaml | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 08389c1cf..a6a79e713 100644 --- a/package.json +++ b/package.json @@ -166,7 +166,7 @@ "@fastify/websocket": "^8.3.1", "@open-draft/test-server": "^0.4.2", "@ossjs/release": "^0.8.1", - "@playwright/test": "^1.48.0", + "@playwright/test": "^1.50.1", "@types/express": "^4.17.21", "@types/fs-extra": "^11.0.4", "@types/glob": "^8.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37350a3cb..e39a30d85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,8 +82,8 @@ importers: specifier: ^0.8.1 version: 0.8.1 '@playwright/test': - specifier: ^1.48.0 - version: 1.49.0 + specifier: ^1.50.1 + version: 1.50.1 '@types/express': specifier: ^4.17.21 version: 4.17.21 @@ -1059,8 +1059,8 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.49.0': - resolution: {integrity: sha512-DMulbwQURa8rNIQrf94+jPJQ4FmOVdpE5ZppRNvWVjvhC+6sOeo28r8MgIpQRYouXRtt/FCCXU7zn20jnHR4Qw==} + '@playwright/test@1.50.1': + resolution: {integrity: sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==} engines: {node: '>=18'} hasBin: true @@ -3991,8 +3991,8 @@ packages: engines: {node: '>=16'} hasBin: true - playwright-core@1.49.0: - resolution: {integrity: sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==} + playwright-core@1.50.1: + resolution: {integrity: sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==} engines: {node: '>=18'} hasBin: true @@ -4001,8 +4001,8 @@ packages: engines: {node: '>=16'} hasBin: true - playwright@1.49.0: - resolution: {integrity: sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==} + playwright@1.50.1: + resolution: {integrity: sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==} engines: {node: '>=18'} hasBin: true @@ -6030,9 +6030,9 @@ snapshots: '@pkgr/core@0.1.1': {} - '@playwright/test@1.49.0': + '@playwright/test@1.50.1': dependencies: - playwright: 1.49.0 + playwright: 1.50.1 '@rollup/plugin-node-resolve@15.3.0(rollup@4.27.4)': dependencies: @@ -9273,7 +9273,7 @@ snapshots: playwright-core@1.42.1: {} - playwright-core@1.49.0: {} + playwright-core@1.50.1: {} playwright@1.42.1: dependencies: @@ -9281,9 +9281,9 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - playwright@1.49.0: + playwright@1.50.1: dependencies: - playwright-core: 1.49.0 + playwright-core: 1.50.1 optionalDependencies: fsevents: 2.3.2 From 665560e8b778f8b99674a76cfa4d4ce21b6c0162 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 11 Feb 2025 21:58:25 +0100 Subject: [PATCH 19/49] chore: fix type tests --- test/typings/sse.test-d.ts | 6 ++++++ test/typings/vitest.config.mts | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/test/typings/sse.test-d.ts b/test/typings/sse.test-d.ts index 59ce96238..502546681 100644 --- a/test/typings/sse.test-d.ts +++ b/test/typings/sse.test-d.ts @@ -1,6 +1,12 @@ import { it } from 'vitest' import { sse } from 'msw/browser' +/** + * @note Define the global property to simulate an EventSource-compatible environment. + * MSW checks for that and throws to prevent incorrect usage. + */ +Object.defineProperty(globalThis, 'EventSource', { value: () => {} }) + it('supports custom event map type argument', () => { sse<{ myevent: string }>('/stream', ({ client }) => { client.send({ diff --git a/test/typings/vitest.config.mts b/test/typings/vitest.config.mts index 428df2307..4eee86927 100644 --- a/test/typings/vitest.config.mts +++ b/test/typings/vitest.config.mts @@ -2,7 +2,13 @@ import * as path from 'node:path' import * as fs from 'node:fs' import { defineConfig } from 'vitest/config' import { invariant } from 'outvariant' -import tsPackageJson from 'typescript/package.json' assert { type: 'json' } + +/** + * @note Use `createRequire` because "assert/with { type: 'json' }" is finicky. + */ +import { createRequire } from 'node:module' +const require = createRequire(import.meta.url) +const tsPackageJson = require('typescript/package.json') const LIB_DIR = path.resolve(__dirname, '../../lib') From 1960ee2cfe4e0036a601ce7381e6c97830babe68 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 11 Feb 2025 22:01:39 +0100 Subject: [PATCH 20/49] chore: remove NODE_OPTIONS from browser test args --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a6a79e713..14d410f99 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "test:unit": "vitest", "test:node": "vitest --config=./test/node/vitest.config.mts", "test:native": "vitest --config=./test/native/vitest.config.mts", - "test:browser": "NODE_OPTIONS=--experimental-require-module npx playwright test -c ./test/browser/playwright.config.ts", + "test:browser": "playwright test -c ./test/browser/playwright.config.ts", "test:modules:node": "vitest --config=./test/modules/node/vitest.config.mts", "test:modules:browser": "playwright test -c ./test/modules/browser/playwright.config.ts", "test:e2e": "vitest run --config=./test/e2e/vitest.config.mts", From e0ba1ebbde2abdeae18f0fde13ed92f59a313716 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 20 Feb 2025 21:40:22 +0100 Subject: [PATCH 21/49] fix: set `accept` for bypassing the request --- src/browser/sse.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index 1269ebf32..19656e248 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -198,7 +198,7 @@ class ServerSentEventServer { } /** - * Establishes an actual connection for this SSE request + * Establishes the actual connection for this SSE request * and returns the `EventSource` instance. */ public connect(): EventSource { @@ -209,7 +209,7 @@ class ServerSentEventServer { * @note Mark this request as bypassed so it doesn't trigger * an infinite loop matching the existing request handler. */ - 'x-msw-intention': 'bypass', + accept: 'msw/passthrough', }, }) @@ -274,7 +274,7 @@ class ObservableEventSource extends EventTarget implements EventSource { // Support custom request init. const headers = new Headers(init?.headers || {}) - headers.set('accept', 'text/event-stream') + headers.append('accept', 'text/event-stream') this[kAbortController] = new AbortController() this[kReconnectionTime] = 2000 From e9688869dd5019d00fbc501e91cbe39aad61d6d8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 22 Feb 2025 12:11:19 +0100 Subject: [PATCH 22/49] test: fix promise return types --- test/browser/sse-api/sse.server.connect.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/test/browser/sse-api/sse.server.connect.test.ts b/test/browser/sse-api/sse.server.connect.test.ts index b7a4dbc5e..5859d594f 100644 --- a/test/browser/sse-api/sse.server.connect.test.ts +++ b/test/browser/sse-api/sse.server.connect.test.ts @@ -99,7 +99,7 @@ test('forwards message event from the server to the client automatically', async }, url) const message = await page.evaluate((url) => { - return new Promise((resolve, reject) => { + return new Promise<{ message: string }>((resolve, reject) => { const source = new EventSource(url) source.addEventListener('message', (event) => { resolve(JSON.parse(event.data)) @@ -108,9 +108,9 @@ test('forwards message event from the server to the client automatically', async }) }, url) - await page.pause() - expect(message).toEqual({ message: 'hello' }) + + await page.pause() }) test('forwards custom event from the server to the client automatically', async ({ @@ -159,7 +159,7 @@ test('forwards custom event from the server to the client automatically', async }, url) const message = await page.evaluate((url) => { - return new Promise((resolve, reject) => { + return new Promise<{ message: string }>((resolve, reject) => { const source = new EventSource(url) source.addEventListener('custom', (event) => { resolve(JSON.parse(event.data)) @@ -168,8 +168,6 @@ test('forwards custom event from the server to the client automatically', async }) }, url) - await page.pause() - expect(message).toEqual({ message: 'hello' }) }) From b9777b3a0335462d95f0577547a415b165c8ef70 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 27 Feb 2025 18:30:12 +0100 Subject: [PATCH 23/49] chore: use node@22 for browser tests --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f77f6f929..175d74f11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,7 +206,7 @@ jobs: test-browser: name: test (browser) - needs: build-18 + needs: build-20 runs-on: macos-latest steps: - name: Checkout @@ -220,16 +220,16 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 cache: 'pnpm' - name: Restore build cache uses: actions/cache@v4 with: path: ./lib - key: ${{ runner.os }}-node-18-build-${{ github.sha }} + key: ${{ runner.os }}-node-20-build-${{ github.sha }} restore-keys: | - ${{ runner.os }}-node-18-build- + ${{ runner.os }}-node-20-build- - name: Install dependencies run: pnpm install From 83b840f6a3549b84c03352ad1a3d9f08363e26b4 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 27 Feb 2025 18:44:47 +0100 Subject: [PATCH 24/49] test(sse): add cors middleware for testing --- test/browser/playwright.config.ts | 2 +- test/browser/sse-api/sse.server.connect.test.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/test/browser/playwright.config.ts b/test/browser/playwright.config.ts index 23684b0e9..f70f30fdf 100644 --- a/test/browser/playwright.config.ts +++ b/test/browser/playwright.config.ts @@ -9,7 +9,7 @@ const config: PlaywrightTestConfig = { }, ], timeout: 10_000, - retries: 1, + retries: process.env.PWDEBUG ? 0 : 1, use: { trace: 'on-first-retry', launchOptions: { diff --git a/test/browser/sse-api/sse.server.connect.test.ts b/test/browser/sse-api/sse.server.connect.test.ts index 5859d594f..d135b521b 100644 --- a/test/browser/sse-api/sse.server.connect.test.ts +++ b/test/browser/sse-api/sse.server.connect.test.ts @@ -1,5 +1,8 @@ import { setupWorker, sse } from 'msw/browser' -import { createTestHttpServer } from '@epic-web/test-server/http' +import { + createTestHttpServer, + TestHttpServerOptions, +} from '@epic-web/test-server/http' import { test, expect } from '../playwright.extend' declare namespace window { @@ -151,7 +154,7 @@ test('forwards custom event from the server to the client automatically', async const { setupWorker, sse } = window.msw const worker = setupWorker( - sse(url, ({ client, server }) => { + sse(url, ({ server }) => { server.connect() }), ) @@ -190,6 +193,7 @@ test('forwards error event from the server to the client automatically', async ( return new Response(stream, { headers: { + 'access-control-allow-origin': '*', 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive', @@ -204,7 +208,7 @@ test('forwards error event from the server to the client automatically', async ( const { setupWorker, sse } = window.msw const worker = setupWorker( - sse(url, ({ client, server }) => { + sse(url, ({ server }) => { server.connect() }), ) @@ -212,9 +216,10 @@ test('forwards error event from the server to the client automatically', async ( }, url) const errorPromise = page.evaluate((url) => { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const source = new EventSource(url) source.onerror = () => resolve() + source.onmessage = () => reject(new Error('Must not receive a message')) }) }, url) From aced92e583b594dc5140b25de2d5c0594df8ad73 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 27 Feb 2025 19:25:02 +0100 Subject: [PATCH 25/49] fix(sse): forward original server errors to the client --- src/browser/sse.ts | 39 +++++++++++++++---- .../sse-api/sse.server.connect.test.ts | 10 ++++- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index 19656e248..cdf5b275f 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -223,6 +223,16 @@ class ServerSentEventServer { }) } + // Forward stream errors from the actual server to the client. + source.addEventListener('error', (event) => { + queueMicrotask(() => { + // Allow the user to opt-out from this forwarding. + if (!event.defaultPrevented) { + this.client.dispatchEvent(event) + } + }) + }) + return source } } @@ -380,10 +390,6 @@ class ObservableEventSource extends EventTarget implements EventSource { } public dispatchEvent(event: Event): boolean { - if (this.readyState === this.CLOSED) { - return false - } - return super.dispatchEvent(event) } @@ -460,11 +466,22 @@ class ObservableEventSource extends EventTarget implements EventSource { this[kOnAnyMessage]?.(messageEvent) this.dispatchEvent(messageEvent) }, + abort: () => { + throw new Error('Stream abort is not implemented') + }, + close: () => { + this.failConnection() + }, }) - response.body!.pipeTo(parsingStream).then(() => { - this.processResponseEndOfBody(response) - }) + response + .body!.pipeTo(parsingStream) + .then(() => { + this.processResponseEndOfBody(response) + }) + .catch(() => { + this.failConnection() + }) } private processResponseEndOfBody(response: Response): void { @@ -554,12 +571,20 @@ class EventSourceParsingStream extends WritableStream { constructor( private underlyingSink: { message: (message: EventSourceMessage) => void + abort?: (reason: any) => void + close?: () => void }, ) { super({ write: (chunk) => { this.processResponseBodyChunk(chunk) }, + abort: (reason) => { + this.underlyingSink.abort?.(reason) + }, + close: () => { + this.underlyingSink.close?.() + }, }) this.decoder = new TextDecoder() diff --git a/test/browser/sse-api/sse.server.connect.test.ts b/test/browser/sse-api/sse.server.connect.test.ts index d135b521b..d00128b10 100644 --- a/test/browser/sse-api/sse.server.connect.test.ts +++ b/test/browser/sse-api/sse.server.connect.test.ts @@ -12,6 +12,14 @@ declare namespace window { } } +globalThis.console = new Proxy(globalThis.console, { + apply(target, thisArg, argArray) { + process.stdout.write('console called!') + process.stdout.write(new Error().stack!) + return Reflect.apply(target as any, thisArg, argArray) + }, +}) + test('makes the actual request when called "server.connect()"', async ({ loadExample, page, @@ -112,8 +120,6 @@ test('forwards message event from the server to the client automatically', async }, url) expect(message).toEqual({ message: 'hello' }) - - await page.pause() }) test('forwards custom event from the server to the client automatically', async ({ From a6b3534a57d55b3fca2f6b817e16080550262b94 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 27 Feb 2025 19:27:07 +0100 Subject: [PATCH 26/49] test(sse): add custom stream error forwarding test --- .../sse-api/sse.server.connect.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/test/browser/sse-api/sse.server.connect.test.ts b/test/browser/sse-api/sse.server.connect.test.ts index d00128b10..db8cabba3 100644 --- a/test/browser/sse-api/sse.server.connect.test.ts +++ b/test/browser/sse-api/sse.server.connect.test.ts @@ -231,3 +231,58 @@ test('forwards error event from the server to the client automatically', async ( await expect(errorPromise).resolves.toBeUndefined() }) + +test('forward custom stream errors from the original server to the client automatically', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./sse.mocks.ts'), { + skipActivation: true, + }) + + await using server = await createTestHttpServer({ + defineRoutes(routes) { + routes.get('/stream', () => { + const stream = new ReadableStream({ + start(controller) { + controller.error(new Error('Custom stream error')) + }, + }) + + return new Response(stream, { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }, + }) + }) + }, + }) + const url = server.http.url('/stream').href + + await page.evaluate(async (url) => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + sse(url, ({ server }) => { + server.connect() + }), + ) + await worker.start() + }, url) + + const errorPromise = page.evaluate((url) => { + return new Promise((resolve, reject) => { + const source = new EventSource(url) + source.onerror = (error) => { + console.log(error) + resolve() + } + source.onmessage = () => reject(new Error('Must not receive a message')) + }) + }, url) + + await expect(errorPromise).resolves.toBeUndefined() +}) From eef28274a8de04487f124b095090351ac387f697 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 1 Jun 2025 13:18:49 +0200 Subject: [PATCH 27/49] feat: support `retry` --- src/browser/sse.ts | 101 +++++++++++++----- src/core/utils/internal/isObject.ts | 2 +- src/core/ws/utils/getMessageLength.ts | 5 +- src/core/ws/utils/getPublicData.ts | 7 +- test/browser/sse-api/sse.client.send.test.ts | 65 ++++++----- test/browser/sse-api/sse.retry.test.ts | 55 ++++++++++ .../sse-api/sse.server.connect.test.ts | 5 +- 7 files changed, 167 insertions(+), 73 deletions(-) create mode 100644 test/browser/sse-api/sse.retry.test.ts diff --git a/src/browser/sse.ts b/src/browser/sse.ts index cdf5b275f..8682a2956 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -87,19 +87,43 @@ class ServerSentEventHandler< request: Request parsedResult: HttpRequestParsedResult }): boolean { - // Ignore non-SSE requests. - if ( - args.request.headers.get('accept')?.toLowerCase() !== 'text/event-stream' - ) { + // Ignore requests that are not SSE. + if (args.request.headers.get('accept') !== 'text/event-stream') { return false } - // If it is a SSE request, match it against its method and path. return super.predicate(args) } } -const kSend = Symbol('kSend') +type ServerSentEventMessagePayload> = { + id?: string + event?: never + data?: EventMap['message'] +} + +type ServerSentEventStrictMessagePayload< + EventMap extends Record, + EventType extends keyof EventMap, +> = { + id?: string + event: EventType + data: EventMap[EventType] +} + +type ServerSentEventRetryPayload = { + /** + * The reconnection time. If the connection to the server is lost, the browser + * will wait for the specified time before attempting to reconnect. This must be + * an integer, specifying the reconnection time in milliseconds. If a non-integer + * value is specified, the field is ignored. + */ + retry: number + + event?: never + id?: never + data?: never +} class ServerSentEventClient> { private encoder: TextEncoder @@ -111,22 +135,23 @@ class ServerSentEventClient> { } /** - * Sends the given payload to the underlying `EventSource`. + * Sends the given payload to the intercepted `EventSource`. */ public send( payload: - | { - id?: string - event: EventType - data: EventMap[EventType] - } - | { - id?: string - event?: never - data?: EventMap['message'] - }, + | ServerSentEventMessagePayload + | ServerSentEventStrictMessagePayload + | ServerSentEventRetryPayload, ): void { - this[kSend]({ + /** + * @note Retry is not a part of the SSE message block. + */ + if ('retry' in payload && payload.retry != null) { + this.#sendRetry(payload.retry) + return + } + + this.#sendMessage({ id: payload.id, event: payload.event, data: @@ -137,7 +162,7 @@ class ServerSentEventClient> { } /** - * Dispatches the given event to the underlying `EventSource`. + * Dispatches the given event on the intercepted `EventSource`. */ public dispatchEvent(event: Event) { if (event instanceof MessageEvent) { @@ -145,7 +170,7 @@ class ServerSentEventClient> { * @note Use the internal send mechanism to skip normalization * of the message data (already normalized by the server). */ - this[kSend]({ + this.#sendMessage({ id: event.lastEventId || undefined, event: event.type === 'message' ? undefined : event.type, data: event.data, @@ -160,29 +185,44 @@ class ServerSentEventClient> { } /** - * Errors the underlying `EventSource`, closing the connection. + * Errors the underlying `EventSource`, closing the connection with an error. + * Erroring the connection with an error will not trigger a reconnect from the client. */ public error(): void { this.controller.error() } - private [kSend](payload: { + /** + * Closes the underlying `EventSource`, closing the connection. + * Closing the connection will trigger a reconnect from the client. + */ + public close(): void { + this.controller.close() + } + + #sendRetry(retry: number): void { + if (typeof retry === 'number') { + this.controller.enqueue(this.encoder.encode(`retry:${retry}\n\n`)) + } + } + + #sendMessage(payload: { id?: string event?: EventType data: string | EventMap[EventType] | EventMap['message'] | undefined }): void { const frames: Array = [] + if (payload.id) { + frames.push(`id:${payload.id}`) + } + if (payload.event) { frames.push(`event:${payload.event}`) } frames.push(`data:${payload.data}`) - if (payload.id) { - frames.push(`id:${payload.id}`) - } - frames.push('', '') this.controller.enqueue(this.encoder.encode(frames.join('\n'))) } @@ -206,8 +246,8 @@ class ServerSentEventServer { withCredentials: this.request.credentials === 'include', headers: { /** - * @note Mark this request as bypassed so it doesn't trigger - * an infinite loop matching the existing request handler. + * @note Mark this request as passthrough so it doesn't trigger + * an infinite loop matching against the existing request handler. */ accept: 'msw/passthrough', }, @@ -241,7 +281,10 @@ interface ObservableEventSourceInit extends EventSourceInit { headers?: HeadersInit } -type EventHandler = (this: EventSource, event: E) => any +type EventHandler = ( + this: EventSource, + event: EventType, +) => any const kRequest = Symbol('kRequest') const kReconnectionTime = Symbol('kReconnectionTime') diff --git a/src/core/utils/internal/isObject.ts b/src/core/utils/internal/isObject.ts index e4b730fc8..29fa8fbfa 100644 --- a/src/core/utils/internal/isObject.ts +++ b/src/core/utils/internal/isObject.ts @@ -1,6 +1,6 @@ /** * Determines if the given value is an object. */ -export function isObject(value: any): boolean { +export function isObject(value: any): value is Record { return value != null && typeof value === 'object' && !Array.isArray(value) } diff --git a/src/core/ws/utils/getMessageLength.ts b/src/core/ws/utils/getMessageLength.ts index a8e041955..84100fb37 100644 --- a/src/core/ws/utils/getMessageLength.ts +++ b/src/core/ws/utils/getMessageLength.ts @@ -1,4 +1,5 @@ -import type { WebSocketData } from '@mswjs/interceptors/lib/browser/interceptors/WebSocket' +import type { WebSocketData } from '@mswjs/interceptors/WebSocket' +import { isObject } from '../../utils/internal/isObject' /** * Returns the byte length of the given WebSocket message. @@ -11,7 +12,7 @@ export function getMessageLength(data: WebSocketData): number { return data.size } - if (data instanceof ArrayBuffer) { + if (isObject(data) && 'byteLength' in data) { return data.byteLength } diff --git a/src/core/ws/utils/getPublicData.ts b/src/core/ws/utils/getPublicData.ts index 8fd41b606..6f4f97a96 100644 --- a/src/core/ws/utils/getPublicData.ts +++ b/src/core/ws/utils/getPublicData.ts @@ -1,4 +1,5 @@ -import { WebSocketData } from '@mswjs/interceptors/WebSocket' +import type { WebSocketData } from '@mswjs/interceptors/WebSocket' +import { isObject } from '../../utils/internal/isObject' import { truncateMessage } from './truncateMessage' export async function getPublicData(data: WebSocketData): Promise { @@ -8,8 +9,8 @@ export async function getPublicData(data: WebSocketData): Promise { } // Handle all ArrayBuffer-like objects. - if (typeof data === 'object' && 'byteLength' in data) { - const text = new TextDecoder().decode(data) + if (isObject(data)) { + const text = new TextDecoder().decode(data as ArrayBuffer) return `ArrayBuffer(${truncateMessage(text)})` } diff --git a/test/browser/sse-api/sse.client.send.test.ts b/test/browser/sse-api/sse.client.send.test.ts index c7ed9fb64..7c593804d 100644 --- a/test/browser/sse-api/sse.client.send.test.ts +++ b/test/browser/sse-api/sse.client.send.test.ts @@ -50,10 +50,11 @@ test('sends a mock message event', async ({ loadExample, page }) => { const message = await page.evaluate(() => { return new Promise((resolve, reject) => { const source = new EventSource('http://localhost/stream') + source.onerror = () => reject() + source.addEventListener('message', (event) => { resolve(`${event.type}:${event.data}`) }) - source.onerror = reject }) }) @@ -85,7 +86,7 @@ test('sends a mock custom event', async ({ loadExample, page }) => { source.addEventListener('userconnect', (event) => { resolve(`${event.type}:${event.data}`) }) - source.onerror = reject + source.onerror = () => reject() }) }) @@ -119,10 +120,9 @@ test('sends a mock message event with custom id', async ({ return new Promise((resolve, reject) => { const source = new EventSource('http://localhost/stream') source.addEventListener('userconnect', (event) => { - console.log(event) resolve(`${event.type}:${event.lastEventId}:${event.data}`) }) - source.onerror = reject + source.onerror = () => reject() }) }) @@ -154,7 +154,6 @@ test('errors the connected source', async ({ loadExample, page, waitFor }) => { return new Promise((resolve) => { const source = new EventSource('http://localhost/stream') source.addEventListener('error', (event) => { - console.log('ERROR!', event, source.readyState) resolve(source.readyState) }) }) @@ -169,36 +168,34 @@ test('errors the connected source', async ({ loadExample, page, waitFor }) => { }) }) -test('forwards original server message events to the client', async ({ - loadExample, - page, -}) => { - await loadExample(require.resolve('./sse.mocks.ts'), { - skipActivation: true, - }) - const url = httpServer.http.url('/stream') +test.fixme( + 'forwards original server message events to the client', + async ({ loadExample, page }) => { + await loadExample(require.resolve('./sse.mocks.ts'), { + skipActivation: true, + }) + const url = httpServer.http.url('/stream') - await page.evaluate(async (url) => { - const { setupWorker, sse } = window.msw + await page.evaluate(async (url) => { + const { setupWorker, sse } = window.msw - const worker = setupWorker( - sse(url, async ({ server }) => { - const source = server.connect() + const worker = setupWorker( + sse(url, async ({ server }) => { + const source = server.connect() - source.addEventListener('message', (event) => { - console.log(event) - }) - }), - ) - await worker.start() - }, url) + source.addEventListener('message', (event) => { + console.log(event) + }) + }), + ) + await worker.start() + }, url) - await page.evaluate((url) => { - const source = new EventSource(url) - source.addEventListener('message', (event) => { - console.warn('client received:', event) - }) - }, url) - - await page.pause() -}) + await page.evaluate((url) => { + const source = new EventSource(url) + source.addEventListener('message', (event) => { + console.warn('client received:', event) + }) + }, url) + }, +) diff --git a/test/browser/sse-api/sse.retry.test.ts b/test/browser/sse-api/sse.retry.test.ts new file mode 100644 index 000000000..3a4027f06 --- /dev/null +++ b/test/browser/sse-api/sse.retry.test.ts @@ -0,0 +1,55 @@ +import { setupWorker, sse } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare namespace window { + export const msw: { + setupWorker: typeof setupWorker + sse: typeof sse + } +} + +test('supports mocking the retry time', async ({ loadExample, page }) => { + await loadExample(new URL('./sse.mocks.ts', import.meta.url), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + sse('http://localhost/stream', ({ client }) => { + client.send({ + retry: 2000, + }) + + // Simulate the connection closure. + // This will trigger the client to reconnect. + queueMicrotask(() => client.close()) + }), + ) + await worker.start() + }) + + const reconnectionTime = await page.evaluate(() => { + return new Promise((resolve) => { + const source = new EventSource('http://localhost/stream') + + source.onerror = () => { + const errorAt = Date.now() + + console.assert( + source.readyState === EventSource.CONNECTING, + 'Expected the connection to be in CONNECTING state but got %d', + source.readyState, + ) + + source.addEventListener('open', () => { + const reconnectAt = Date.now() + resolve(reconnectAt - errorAt) + }) + } + }) + }) + + expect(reconnectionTime).toBeGreaterThanOrEqual(2000) +}) diff --git a/test/browser/sse-api/sse.server.connect.test.ts b/test/browser/sse-api/sse.server.connect.test.ts index db8cabba3..e6a82d9fa 100644 --- a/test/browser/sse-api/sse.server.connect.test.ts +++ b/test/browser/sse-api/sse.server.connect.test.ts @@ -1,8 +1,5 @@ import { setupWorker, sse } from 'msw/browser' -import { - createTestHttpServer, - TestHttpServerOptions, -} from '@epic-web/test-server/http' +import { createTestHttpServer } from '@epic-web/test-server/http' import { test, expect } from '../playwright.extend' declare namespace window { From 7169df231a98e1f5a96317b8fd737d6e913af9e2 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 1 Jun 2025 17:49:54 +0200 Subject: [PATCH 28/49] test(ts): `client.send()` call signature --- src/browser/sse.ts | 43 +++++++++----------------------------- test/typings/sse.test-d.ts | 33 ++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 34 deletions(-) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index 8682a2956..8672211d3 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -96,35 +96,6 @@ class ServerSentEventHandler< } } -type ServerSentEventMessagePayload> = { - id?: string - event?: never - data?: EventMap['message'] -} - -type ServerSentEventStrictMessagePayload< - EventMap extends Record, - EventType extends keyof EventMap, -> = { - id?: string - event: EventType - data: EventMap[EventType] -} - -type ServerSentEventRetryPayload = { - /** - * The reconnection time. If the connection to the server is lost, the browser - * will wait for the specified time before attempting to reconnect. This must be - * an integer, specifying the reconnection time in milliseconds. If a non-integer - * value is specified, the field is ignored. - */ - retry: number - - event?: never - id?: never - data?: never -} - class ServerSentEventClient> { private encoder: TextEncoder private controller: ReadableStreamDefaultController @@ -137,11 +108,16 @@ class ServerSentEventClient> { /** * Sends the given payload to the intercepted `EventSource`. */ - public send( + public send( payload: - | ServerSentEventMessagePayload - | ServerSentEventStrictMessagePayload - | ServerSentEventRetryPayload, + | { + id?: string + event?: EventType extends keyof EventMap ? EventType : 'message' + data: EventType extends keyof EventMap + ? EventMap[EventType] + : EventMap['message'] + } + | { retry: number }, ): void { /** * @note Retry is not a part of the SSE message block. @@ -154,6 +130,7 @@ class ServerSentEventClient> { this.#sendMessage({ id: payload.id, event: payload.event, + // @ts-expect-error data: typeof payload.data === 'object' ? JSON.stringify(payload.data) diff --git a/test/typings/sse.test-d.ts b/test/typings/sse.test-d.ts index 502546681..cba79b930 100644 --- a/test/typings/sse.test-d.ts +++ b/test/typings/sse.test-d.ts @@ -5,7 +5,7 @@ import { sse } from 'msw/browser' * @note Define the global property to simulate an EventSource-compatible environment. * MSW checks for that and throws to prevent incorrect usage. */ -Object.defineProperty(globalThis, 'EventSource', { value: () => {} }) +Object.defineProperty(global, 'EventSource', { value: () => {} }) it('supports custom event map type argument', () => { sse<{ myevent: string }>('/stream', ({ client }) => { @@ -28,6 +28,23 @@ it('supports event map type argument for unnamed events', () => { data: 123, }) client.send({ + // When no explicit "event" key is provided, + // threat it as if event was set to "message". + // @ts-expect-error Unexpected data type for "message" event. + data: 'goodbye', + }) + + client.send({ + event: 'message', + data: 123, + }) + client.send({ + event: 'message', + // @ts-expect-error Unexpected data type for "message" event. + data: 'invalid', + }) + client.send({ + event: 'message', // @ts-expect-error Unexpected data type for "message" event. data: 'goodbye', }) @@ -41,5 +58,19 @@ it('supports event map type argument for unnamed events', () => { // @ts-expect-error Unexpected data type for "custom" event. data: 'invalid', }) + client.send({ + event: 'custom', + // @ts-expect-error Unexpected data type for "custom" event + data: 123, + }) + + client.send({ + // Sending unknown events must be forbidden + // if the EventMap type argument was provided. + // @ts-expect-error Unknown event type "unknown". + event: 'invalid', + // @ts-expect-error Unknown data type for unknown event. + data: 123, + }) }) }) From 8382780c0230e1e427f21d7431af358396b8be8a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 1 Jun 2025 18:23:05 +0200 Subject: [PATCH 29/49] chore: silence ts errors temporarily --- src/browser/sse.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index 8672211d3..664c514c3 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -128,13 +128,17 @@ class ServerSentEventClient> { } this.#sendMessage({ + // @ts-expect-error id: payload.id, - event: payload.event, // @ts-expect-error + event: payload.event, data: + // @ts-expect-error typeof payload.data === 'object' - ? JSON.stringify(payload.data) - : payload.data, + ? // @ts-expect-error + JSON.stringify(payload.data) + : // @ts-expect-error + payload.data, }) } From 2ea5fcbc6ce786e8da2f820a016da355c553f906 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 1 Jun 2025 19:03:55 +0200 Subject: [PATCH 30/49] fix: rework types slightly (#2520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mateusz Burzyński --- src/browser/sse.ts | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index 664c514c3..e18e715b8 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -9,7 +9,10 @@ import type { Path, PathParams } from '~/core/utils/matching/matchRequestUrl' import { delay } from '~/core/delay' export type ServerSentEventResolverExtras< - EventMap extends Record, + EventMap extends { + message?: unknown + [key: string]: unknown + }, Params extends PathParams, > = HttpRequestResolverExtras & { client: ServerSentEventClient @@ -17,12 +20,18 @@ export type ServerSentEventResolverExtras< } export type ServerSentEventResolver< - EventMap extends Record, + EventMap extends { + message?: unknown + [key: string]: unknown + }, Params extends PathParams, > = ResponseResolver, any, any> export type ServerSentEventRequestHandler = < - EventMap extends Record, + EventMap extends { + message?: unknown + [key: string]: unknown + }, Params extends PathParams = PathParams, RequestPath extends Path = Path, >( @@ -45,7 +54,10 @@ export const sse: ServerSentEventRequestHandler = (path, resolver) => { } class ServerSentEventHandler< - EventMap extends Record, + EventMap extends { + message?: unknown + [key: string]: unknown + }, > extends HttpHandler { constructor(path: Path, resolver: ServerSentEventResolver) { invariant( @@ -96,7 +108,12 @@ class ServerSentEventHandler< } } -class ServerSentEventClient> { +class ServerSentEventClient< + EventMap extends { + message?: unknown + [key: string]: unknown + }, +> { private encoder: TextEncoder private controller: ReadableStreamDefaultController @@ -108,14 +125,12 @@ class ServerSentEventClient> { /** * Sends the given payload to the intercepted `EventSource`. */ - public send( + public send( payload: | { id?: string - event?: EventType extends keyof EventMap ? EventType : 'message' - data: EventType extends keyof EventMap - ? EventMap[EventType] - : EventMap['message'] + event?: EventType + data: EventMap[EventType] } | { retry: number }, ): void { From 20b812a338e220b9f6fb39272c5017a1e793537f Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 1 Jun 2025 19:23:36 +0200 Subject: [PATCH 31/49] fix: discriminate event and data types --- src/browser/sse.ts | 7 ++++- test/typings/sse.test-d.ts | 61 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index e18e715b8..90080cfd6 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -127,10 +127,15 @@ class ServerSentEventClient< */ public send( payload: + | { + id?: string + event?: 'message' + data?: EventMap['message'] + } | { id?: string event?: EventType - data: EventMap[EventType] + data?: EventMap[EventType] } | { retry: number }, ): void { diff --git a/test/typings/sse.test-d.ts b/test/typings/sse.test-d.ts index cba79b930..b4dc9706a 100644 --- a/test/typings/sse.test-d.ts +++ b/test/typings/sse.test-d.ts @@ -7,7 +7,30 @@ import { sse } from 'msw/browser' */ Object.defineProperty(global, 'EventSource', { value: () => {} }) -it('supports custom event map type argument', () => { +it('supports sending anything without an explicit event map type', () => { + sse('/stream', ({ client }) => { + client.send({ data: 123 }) + client.send({ data: 'hello' }) + }) +}) + +it('supports an optional "id" property', () => { + sse('/stream', ({ client }) => { + client.send({ id: '1', data: 'hello' }) + }) + sse<{ message: 'greeting' }>('/stream', ({ client }) => { + client.send({ id: '2', data: 'greeting' }) + }) + sse<{ custom: 'goodbye' }>('/stream', ({ client }) => { + client.send({ + id: '2', + event: 'custom', + data: 'goodbye', + }) + }) +}) + +it('supports custom event map type', () => { sse<{ myevent: string }>('/stream', ({ client }) => { client.send({ event: 'myevent', @@ -19,6 +42,15 @@ it('supports custom event map type argument', () => { event: 'unknown', data: 'hello', }) + + client.send({ + /** + * @note Sending anonymous events ("message" events) + * must still accept any data type unless narrowed down + * in the event map by the "message" key. + */ + data: 'anything', + }) }) }) @@ -69,8 +101,33 @@ it('supports event map type argument for unnamed events', () => { // if the EventMap type argument was provided. // @ts-expect-error Unknown event type "unknown". event: 'invalid', - // @ts-expect-error Unknown data type for unknown event. data: 123, }) }) }) + +it('supports sending custom retry duration', () => { + sse<{ custom: 'goodbye' }>('/stream', ({ client }) => { + client.send({ + // "retry" must be the only property on the message. + retry: 1000, + }) + + client.send({ + retry: 1000, + // @ts-expect-error No properties are allowed. + id: '1', + }) + client.send({ + retry: 1000, + // @ts-expect-error No properties are allowed. + data: 'hello', + }) + client.send({ + retry: 1000, + event: 'custom', + // @ts-expect-error No properties are allowed. + data: 'goodbye', + }) + }) +}) From 4f4aab5fe316d716ca178ef6b25be7c8666b2f8a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 1 Jun 2025 19:26:16 +0200 Subject: [PATCH 32/49] fix: discriminate against the `retry` property --- src/browser/sse.ts | 14 +++++++------- test/typings/sse.test-d.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index 90080cfd6..9c464c256 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -131,13 +131,17 @@ class ServerSentEventClient< id?: string event?: 'message' data?: EventMap['message'] + retry?: never } | { id?: string event?: EventType data?: EventMap[EventType] + retry?: never } - | { retry: number }, + | { + retry: number + }, ): void { /** * @note Retry is not a part of the SSE message block. @@ -148,17 +152,13 @@ class ServerSentEventClient< } this.#sendMessage({ - // @ts-expect-error id: payload.id, // @ts-expect-error event: payload.event, data: - // @ts-expect-error typeof payload.data === 'object' - ? // @ts-expect-error - JSON.stringify(payload.data) - : // @ts-expect-error - payload.data, + ? JSON.stringify(payload.data) + : payload.data, }) } diff --git a/test/typings/sse.test-d.ts b/test/typings/sse.test-d.ts index b4dc9706a..ed3a5a5a3 100644 --- a/test/typings/sse.test-d.ts +++ b/test/typings/sse.test-d.ts @@ -125,8 +125,8 @@ it('supports sending custom retry duration', () => { }) client.send({ retry: 1000, - event: 'custom', // @ts-expect-error No properties are allowed. + event: 'custom', data: 'goodbye', }) }) From c12097b407ed895c236ea260c7bb83cf62845cde Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 1 Jun 2025 19:27:54 +0200 Subject: [PATCH 33/49] fix: correct `EventType` for #sendMessage --- src/browser/sse.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index 9c464c256..4b21ce311 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -153,7 +153,6 @@ class ServerSentEventClient< this.#sendMessage({ id: payload.id, - // @ts-expect-error event: payload.event, data: typeof payload.data === 'object' @@ -207,7 +206,7 @@ class ServerSentEventClient< } } - #sendMessage(payload: { + #sendMessage(payload: { id?: string event?: EventType data: string | EventMap[EventType] | EventMap['message'] | undefined @@ -219,7 +218,7 @@ class ServerSentEventClient< } if (payload.event) { - frames.push(`event:${payload.event}`) + frames.push(`event:${payload.event?.toString()}`) } frames.push(`data:${payload.data}`) From 631fb2592ba0a5c10914e16a837be1666adbe2f1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 1 Jun 2025 22:13:29 +0200 Subject: [PATCH 34/49] chore: update @epic-web/test-server --- package.json | 2 +- pnpm-lock.yaml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 66edec15e..f8911a2c2 100644 --- a/package.json +++ b/package.json @@ -259,7 +259,7 @@ "devDependencies": { "@commitlint/cli": "^18.4.4", "@commitlint/config-conventional": "^18.4.4", - "@epic-web/test-server": "^0.1.0", + "@epic-web/test-server": "^0.1.6", "@fastify/websocket": "^8.3.1", "@open-draft/test-server": "^0.4.2", "@ossjs/release": "^0.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7cdc0ae5..370fcd7f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,8 +70,8 @@ importers: specifier: ^18.4.4 version: 18.6.3 '@epic-web/test-server': - specifier: ^0.1.0 - version: 0.1.5 + specifier: ^0.1.6 + version: 0.1.6 '@fastify/websocket': specifier: ^8.3.1 version: 8.3.1 @@ -407,8 +407,8 @@ packages: resolution: {integrity: sha512-tpyc+7i6bPG9mvaBbtKUeghfyZSDgWquIDfMgqYtTbmZ9Y9VzEm2je9EYcQ0aoz5o7NvGS+rcDec93yO08MHYA==} engines: {node: '>=v18'} - '@epic-web/test-server@0.1.5': - resolution: {integrity: sha512-e1zv+jZ32Q7sKzC7EUYEvGkZzm5UwPIs31zHOvKB5dYLmNMdivWBR0vbr5hDVOY2h8VYfrsfUzvsaOYkhx8YPw==} + '@epic-web/test-server@0.1.6': + resolution: {integrity: sha512-n6+dZKI2I3J8f3vjIFAYh5trgAJl0crji76+K1aoiRJqEYjdsCX/nVgBrkBCwPpBqhv9NZ1XlMqzfOafkqY5EQ==} engines: {node: '>=20'} '@esbuild/aix-ppc64@0.20.2': @@ -5599,7 +5599,7 @@ snapshots: chalk: 5.3.0 optional: true - '@epic-web/test-server@0.1.5': + '@epic-web/test-server@0.1.6': dependencies: '@hono/node-server': 1.14.1(hono@4.7.10) '@hono/node-ws': 1.1.4(@hono/node-server@1.14.1(hono@4.7.10))(hono@4.7.10) @@ -6064,7 +6064,7 @@ snapshots: '@miniflare/core': 2.14.4 '@miniflare/shared': 2.14.4 undici: 5.28.4 - ws: 8.18.0 + ws: 8.18.2 transitivePeerDependencies: - bufferutil - utf-8-validate From 147dcd13653839a4974c8491550da7865cf58c1f Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 1 Jun 2025 22:13:37 +0200 Subject: [PATCH 35/49] test: remove redundant test case --- test/browser/sse-api/sse.client.send.test.ts | 53 -------------------- 1 file changed, 53 deletions(-) diff --git a/test/browser/sse-api/sse.client.send.test.ts b/test/browser/sse-api/sse.client.send.test.ts index 7c593804d..30073f0eb 100644 --- a/test/browser/sse-api/sse.client.send.test.ts +++ b/test/browser/sse-api/sse.client.send.test.ts @@ -1,5 +1,4 @@ import { setupWorker, sse } from 'msw/browser' -import { HttpServer } from '@open-draft/test-server/http' import { test, expect } from '../playwright.extend' declare namespace window { @@ -9,26 +8,6 @@ declare namespace window { } } -const httpServer = new HttpServer((app) => { - app.get('/stream', (req, res) => { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }) - - res.write('data: {"message": "hello"}\n\n') - }) -}) - -test.beforeAll(async () => { - await httpServer.listen() -}) - -test.afterAll(async () => { - await httpServer.close() -}) - test('sends a mock message event', async ({ loadExample, page }) => { await loadExample(require.resolve('./sse.mocks.ts'), { skipActivation: true, @@ -167,35 +146,3 @@ test('errors the connected source', async ({ loadExample, page, waitFor }) => { expect(pageErrors).toContain('Failed to fetch') }) }) - -test.fixme( - 'forwards original server message events to the client', - async ({ loadExample, page }) => { - await loadExample(require.resolve('./sse.mocks.ts'), { - skipActivation: true, - }) - const url = httpServer.http.url('/stream') - - await page.evaluate(async (url) => { - const { setupWorker, sse } = window.msw - - const worker = setupWorker( - sse(url, async ({ server }) => { - const source = server.connect() - - source.addEventListener('message', (event) => { - console.log(event) - }) - }), - ) - await worker.start() - }, url) - - await page.evaluate((url) => { - const source = new EventSource(url) - source.addEventListener('message', (event) => { - console.warn('client received:', event) - }) - }, url) - }, -) From eb839067d208c6b8076c6ff785c9f830d51f74e5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 1 Jun 2025 22:16:39 +0200 Subject: [PATCH 36/49] chore: migrate sse tests to esm --- test/browser/sse-api/sse.client.send.test.ts | 10 ++++++---- test/browser/sse-api/sse.retry.test.ts | 4 +++- test/browser/sse-api/sse.server.connect.test.ts | 12 +++++++----- test/browser/sse-api/sse.use.test.ts | 4 +++- test/browser/sse-api/sse.with-credentials.test.ts | 4 +++- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/test/browser/sse-api/sse.client.send.test.ts b/test/browser/sse-api/sse.client.send.test.ts index 30073f0eb..c2dab2b22 100644 --- a/test/browser/sse-api/sse.client.send.test.ts +++ b/test/browser/sse-api/sse.client.send.test.ts @@ -8,8 +8,10 @@ declare namespace window { } } +const EXAMPLE_URL = new URL('./sse.mocks.ts', import.meta.url) + test('sends a mock message event', async ({ loadExample, page }) => { - await loadExample(require.resolve('./sse.mocks.ts'), { + await loadExample(EXAMPLE_URL, { skipActivation: true, }) @@ -41,7 +43,7 @@ test('sends a mock message event', async ({ loadExample, page }) => { }) test('sends a mock custom event', async ({ loadExample, page }) => { - await loadExample(require.resolve('./sse.mocks.ts'), { + await loadExample(EXAMPLE_URL, { skipActivation: true, }) @@ -76,7 +78,7 @@ test('sends a mock message event with custom id', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./sse.mocks.ts'), { + await loadExample(EXAMPLE_URL, { skipActivation: true, }) @@ -109,7 +111,7 @@ test('sends a mock message event with custom id', async ({ }) test('errors the connected source', async ({ loadExample, page, waitFor }) => { - await loadExample(require.resolve('./sse.mocks.ts'), { + await loadExample(EXAMPLE_URL, { skipActivation: true, }) diff --git a/test/browser/sse-api/sse.retry.test.ts b/test/browser/sse-api/sse.retry.test.ts index 3a4027f06..ee9ee7768 100644 --- a/test/browser/sse-api/sse.retry.test.ts +++ b/test/browser/sse-api/sse.retry.test.ts @@ -8,8 +8,10 @@ declare namespace window { } } +const EXAMPLE_URL = new URL('./sse.mocks.ts', import.meta.url) + test('supports mocking the retry time', async ({ loadExample, page }) => { - await loadExample(new URL('./sse.mocks.ts', import.meta.url), { + await loadExample(EXAMPLE_URL, { skipActivation: true, }) diff --git a/test/browser/sse-api/sse.server.connect.test.ts b/test/browser/sse-api/sse.server.connect.test.ts index e6a82d9fa..18e03e08b 100644 --- a/test/browser/sse-api/sse.server.connect.test.ts +++ b/test/browser/sse-api/sse.server.connect.test.ts @@ -9,6 +9,8 @@ declare namespace window { } } +const EXAMPLE_URL = new URL('./sse.mocks.ts', import.meta.url) + globalThis.console = new Proxy(globalThis.console, { apply(target, thisArg, argArray) { process.stdout.write('console called!') @@ -21,7 +23,7 @@ test('makes the actual request when called "server.connect()"', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./sse.mocks.ts'), { + await loadExample(EXAMPLE_URL, { skipActivation: true, }) @@ -67,7 +69,7 @@ test('forwards message event from the server to the client automatically', async loadExample, page, }) => { - await loadExample(require.resolve('./sse.mocks.ts'), { + await loadExample(EXAMPLE_URL, { skipActivation: true, }) @@ -123,7 +125,7 @@ test('forwards custom event from the server to the client automatically', async loadExample, page, }) => { - await loadExample(require.resolve('./sse.mocks.ts'), { + await loadExample(EXAMPLE_URL, { skipActivation: true, }) @@ -181,7 +183,7 @@ test('forwards error event from the server to the client automatically', async ( loadExample, page, }) => { - await loadExample(require.resolve('./sse.mocks.ts'), { + await loadExample(EXAMPLE_URL, { skipActivation: true, }) @@ -233,7 +235,7 @@ test('forward custom stream errors from the original server to the client automa loadExample, page, }) => { - await loadExample(require.resolve('./sse.mocks.ts'), { + await loadExample(EXAMPLE_URL, { skipActivation: true, }) diff --git a/test/browser/sse-api/sse.use.test.ts b/test/browser/sse-api/sse.use.test.ts index 325acee37..942cf1a57 100644 --- a/test/browser/sse-api/sse.use.test.ts +++ b/test/browser/sse-api/sse.use.test.ts @@ -8,11 +8,13 @@ declare namespace window { } } +const EXAMPLE_URL = new URL('./sse.mocks.ts', import.meta.url) + test('supports server-sent event handler overrides', async ({ loadExample, page, }) => { - await loadExample(require.resolve('./sse.mocks.ts'), { + await loadExample(EXAMPLE_URL, { skipActivation: true, }) diff --git a/test/browser/sse-api/sse.with-credentials.test.ts b/test/browser/sse-api/sse.with-credentials.test.ts index bc6c1c4bc..c8fb59509 100644 --- a/test/browser/sse-api/sse.with-credentials.test.ts +++ b/test/browser/sse-api/sse.with-credentials.test.ts @@ -8,13 +8,15 @@ declare namespace window { } } +const EXAMPLE_URL = new URL('./sse.mocks.ts', import.meta.url) + test('forwards document cookies on the request when "withCredentials" is set to true', async ({ loadExample, page, spyOnConsole, }) => { const consoleSpy = spyOnConsole() - await loadExample(require.resolve('./sse.mocks.ts'), { + await loadExample(EXAMPLE_URL, { skipActivation: true, }) From 465e03e840fd5d9439ccda5bcd250b4d9b486660 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 1 Jun 2025 22:21:56 +0200 Subject: [PATCH 37/49] chore: polish the server connect test --- .../browser/sse-api/sse.server.connect.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test/browser/sse-api/sse.server.connect.test.ts b/test/browser/sse-api/sse.server.connect.test.ts index 18e03e08b..d1149c8d3 100644 --- a/test/browser/sse-api/sse.server.connect.test.ts +++ b/test/browser/sse-api/sse.server.connect.test.ts @@ -30,7 +30,16 @@ test('makes the actual request when called "server.connect()"', async ({ await using server = await createTestHttpServer({ defineRoutes(routes) { routes.get('/stream', () => { - return new Response(null, { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode('data: {"message": "hello"}\n\n'), + ) + controller.close() + }, + }) + + return new Response(stream, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', @@ -275,11 +284,12 @@ test('forward custom stream errors from the original server to the client automa const errorPromise = page.evaluate((url) => { return new Promise((resolve, reject) => { const source = new EventSource(url) - source.onerror = (error) => { - console.log(error) + source.onerror = () => { resolve() } - source.onmessage = () => reject(new Error('Must not receive a message')) + source.onmessage = () => { + reject(new Error('Must not receive a message')) + } }) }, url) From 0e8311b6ddf9d291f2bfc42b61de6ca54561d0ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 3 Jun 2025 11:29:24 +0200 Subject: [PATCH 38/49] Use discriminated union more properly (#2521) --- src/browser/sse.ts | 66 +++++++++++++------------- test/typings/sse.test-d.ts | 95 ++++++++++++++++++-------------------- 2 files changed, 78 insertions(+), 83 deletions(-) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index 4b21ce311..367201298 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -8,11 +8,14 @@ import { import type { Path, PathParams } from '~/core/utils/matching/matchRequestUrl' import { delay } from '~/core/delay' +type EventMapConstraint = { + message?: unknown + [key: string]: unknown + [key: symbol | number]: never +} + export type ServerSentEventResolverExtras< - EventMap extends { - message?: unknown - [key: string]: unknown - }, + EventMap extends EventMapConstraint, Params extends PathParams, > = HttpRequestResolverExtras & { client: ServerSentEventClient @@ -20,18 +23,12 @@ export type ServerSentEventResolverExtras< } export type ServerSentEventResolver< - EventMap extends { - message?: unknown - [key: string]: unknown - }, + EventMap extends EventMapConstraint, Params extends PathParams, > = ResponseResolver, any, any> export type ServerSentEventRequestHandler = < - EventMap extends { - message?: unknown - [key: string]: unknown - }, + EventMap extends EventMapConstraint = { message: unknown }, Params extends PathParams = PathParams, RequestPath extends Path = Path, >( @@ -54,10 +51,7 @@ export const sse: ServerSentEventRequestHandler = (path, resolver) => { } class ServerSentEventHandler< - EventMap extends { - message?: unknown - [key: string]: unknown - }, + EventMap extends EventMapConstraint, > extends HttpHandler { constructor(path: Path, resolver: ServerSentEventResolver) { invariant( @@ -108,11 +102,25 @@ class ServerSentEventHandler< } } +type Values = T[keyof T] +type ToEventDiscriminatedUnion = Values<{ + [K in keyof T]: K extends 'message' + ? { + id?: string + event?: K + data?: T[K] + retry?: never + } + : { + id?: string + event: K + data?: T[K] + retry?: never + } +}> + class ServerSentEventClient< - EventMap extends { - message?: unknown - [key: string]: unknown - }, + EventMap extends EventMapConstraint = { message: unknown }, > { private encoder: TextEncoder private controller: ReadableStreamDefaultController @@ -125,21 +133,13 @@ class ServerSentEventClient< /** * Sends the given payload to the intercepted `EventSource`. */ - public send( + public send( payload: + | ToEventDiscriminatedUnion | { - id?: string - event?: 'message' - data?: EventMap['message'] - retry?: never - } - | { - id?: string - event?: EventType - data?: EventMap[EventType] - retry?: never - } - | { + id?: never + event?: never + data?: never retry: number }, ): void { diff --git a/test/typings/sse.test-d.ts b/test/typings/sse.test-d.ts index ed3a5a5a3..4956888df 100644 --- a/test/typings/sse.test-d.ts +++ b/test/typings/sse.test-d.ts @@ -55,79 +55,74 @@ it('supports custom event map type', () => { }) it('supports event map type argument for unnamed events', () => { - sse<{ message: number; custom: 'goodbye' }>('/stream', ({ client }) => { + sse<{ message: number; custom: string; other: boolean }>('/stream', ({ client }) => { + /** + * @note TS 5.0 reports errors at different locations + * to satisfy a wide range of TS versions in type tests formatting gets disabled here + * so that @ts-expect-error can assert the reported errors regardless of the location + * see: https://github.com/mswjs/msw/pull/2521#issuecomment-2931733565 + */ + // prettier-ignore-start client.send({ data: 123, }) - client.send({ - // When no explicit "event" key is provided, - // threat it as if event was set to "message". - // @ts-expect-error Unexpected data type for "message" event. - data: 'goodbye', - }) + // When no explicit "event" key is provided, + // threat it as if event was set to "message". + // @ts-expect-error Unexpected data type for "message" event. + client.send({ data: 'goodbye' }) client.send({ event: 'message', data: 123, }) - client.send({ - event: 'message', - // @ts-expect-error Unexpected data type for "message" event. - data: 'invalid', - }) - client.send({ - event: 'message', - // @ts-expect-error Unexpected data type for "message" event. - data: 'goodbye', - }) + // @ts-expect-error Unexpected data type for "message" event. + client.send({ event: 'message', data: 'invalid' }) + // @ts-expect-error Unexpected data type for "message" event. + client.send({ event: 'message', data: 'goodbye' }) client.send({ event: 'custom', data: 'goodbye', }) - client.send({ - event: 'custom', - // @ts-expect-error Unexpected data type for "custom" event. - data: 'invalid', - }) - client.send({ - event: 'custom', - // @ts-expect-error Unexpected data type for "custom" event - data: 123, - }) + // @ts-expect-error Unexpected data type for "custom" event + client.send({ event: 'custom', data: 123 }) - client.send({ - // Sending unknown events must be forbidden - // if the EventMap type argument was provided. - // @ts-expect-error Unknown event type "unknown". - event: 'invalid', - data: 123, - }) + // Sending unknown events must be forbidden + // if the EventMap type argument was provided. + // @ts-expect-error Unknown event type "unknown". + client.send({ event: 'invalid', data: 123 }) + + // boolean is only allowed for "other" event + // @ts-expect-error + client.send({ event: 'custom', data: true }) + + // boolean is only allowed for "other" event + // @ts-expect-error + client.send({ event: 'custom' as 'custom' | 'other', data: true }) + // prettier-ignore-end }) }) it('supports sending custom retry duration', () => { sse<{ custom: 'goodbye' }>('/stream', ({ client }) => { + /** + * @note TS 5.0 reports errors at different locations + * to satisfy a wide range of TS versions in type tests formatting gets disabled here + * so that @ts-expect-error can assert the reported errors regardless of the location + * see: https://github.com/mswjs/msw/pull/2521#issuecomment-2931733565 + */ + // prettier-ignore-start client.send({ // "retry" must be the only property on the message. retry: 1000, }) - client.send({ - retry: 1000, - // @ts-expect-error No properties are allowed. - id: '1', - }) - client.send({ - retry: 1000, - // @ts-expect-error No properties are allowed. - data: 'hello', - }) - client.send({ - retry: 1000, - // @ts-expect-error No properties are allowed. - event: 'custom', - data: 'goodbye', - }) + // @ts-expect-error No properties are allowed. + client.send({ retry: 1000, id: '1' }) + // @ts-expect-error No properties are allowed. + client.send({ retry: 1000, data: 'hello' }) + // @ts-expect-error No properties are allowed. + client.send({ retry: 1000, event: 'custom', data: 'goodbye' }) + // prettier-ignore-end }) }) From 00053c243e3c968e6f80b654de2429fe6ca7b069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Wed, 4 Jun 2025 09:49:27 +0200 Subject: [PATCH 39/49] fix(sse): error on missing required data (#2522) Co-authored-by: Artem Zakharchenko --- src/browser/sse.ts | 31 +++++---- test/typings/sse.test-d.ts | 136 ++++++++++++++++++++++++++----------- 2 files changed, 113 insertions(+), 54 deletions(-) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index 367201298..0db82447d 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -103,20 +103,25 @@ class ServerSentEventHandler< } type Values = T[keyof T] +type Identity = { [K in keyof T]: T[K] } & unknown type ToEventDiscriminatedUnion = Values<{ - [K in keyof T]: K extends 'message' - ? { - id?: string - event?: K - data?: T[K] - retry?: never - } - : { - id?: string - event: K - data?: T[K] - retry?: never - } + [K in keyof T]: Identity< + (K extends 'message' + ? { + id?: string + event?: K + data?: T[K] + retry?: never + } + : { + id?: string + event: K + data?: T[K] + retry?: never + }) & + // make the `data` field conditionally required through an intersection + (undefined extends T[K] ? unknown : { data: unknown }) + > }> class ServerSentEventClient< diff --git a/test/typings/sse.test-d.ts b/test/typings/sse.test-d.ts index 4956888df..8ff5c43dc 100644 --- a/test/typings/sse.test-d.ts +++ b/test/typings/sse.test-d.ts @@ -55,51 +55,108 @@ it('supports custom event map type', () => { }) it('supports event map type argument for unnamed events', () => { - sse<{ message: number; custom: string; other: boolean }>('/stream', ({ client }) => { - /** - * @note TS 5.0 reports errors at different locations - * to satisfy a wide range of TS versions in type tests formatting gets disabled here - * so that @ts-expect-error can assert the reported errors regardless of the location - * see: https://github.com/mswjs/msw/pull/2521#issuecomment-2931733565 - */ - // prettier-ignore-start + sse<{ message: number; custom: string; other: boolean }>( + '/stream', + ({ client }) => { + /** + * @note TS 5.0 reports errors at different locations + * to satisfy a wide range of TS versions in type tests formatting gets disabled here + * so that @ts-expect-error can assert the reported errors regardless of the location + * see: https://github.com/mswjs/msw/pull/2521#issuecomment-2931733565 + */ + // prettier-ignore-start + client.send({ + data: 123, + }) + // When no explicit "event" key is provided, + // threat it as if event was set to "message". + // @ts-expect-error Unexpected data type for "message" event. + client.send({ data: 'goodbye' }) + + client.send({ + event: 'message', + data: 123, + }) + // @ts-expect-error Unexpected data type for "message" event. + client.send({ event: 'message', data: 'invalid' }) + // @ts-expect-error Unexpected data type for "message" event. + client.send({ event: 'message', data: 'goodbye' }) + + client.send({ + event: 'custom', + data: 'goodbye', + }) + // @ts-expect-error Unexpected data type for "custom" event + client.send({ event: 'custom', data: 123 }) + + // Sending unknown events must be forbidden + // if the EventMap type argument was provided. + // @ts-expect-error Unknown event type "unknown". + client.send({ event: 'invalid', data: 123 }) + + // boolean is only allowed for "other" event + // @ts-expect-error + client.send({ event: 'custom', data: true }) + + // boolean is only allowed for "other" event + // @ts-expect-error + client.send({ event: 'custom' as 'custom' | 'other', data: true }) + + // should error when required data is missing + // @ts-expect-error + client.send({ event: 'other' }) + + // prettier-ignore-end + }, + ) +}) + +it('supports optional mnessage data', () => { + sse<{ message?: string }>('/stream', ({ client }) => { + // No data is fine because data is optional. + client.send({}) + + // Allows explicit undefined as optional data. + client.send({ data: undefined }) + + // Data is still validated even if optional. + client.send({ data: 'hello' }) client.send({ + // @ts-expect-error Invalid data type. data: 123, }) - // When no explicit "event" key is provided, - // threat it as if event was set to "message". - // @ts-expect-error Unexpected data type for "message" event. - client.send({ data: 'goodbye' }) + // No data is fine because data is optional. + client.send({ event: 'message' }) + + // Allows explicit undefined as optional data. + client.send({ event: 'message', data: undefined }) + + // Data is still validated even if optional. + client.send({ event: 'message', data: 'hello' }) client.send({ event: 'message', + // @ts-expect-error Invalid data type. data: 123, }) - // @ts-expect-error Unexpected data type for "message" event. - client.send({ event: 'message', data: 'invalid' }) - // @ts-expect-error Unexpected data type for "message" event. - client.send({ event: 'message', data: 'goodbye' }) - - client.send({ - event: 'custom', - data: 'goodbye', - }) - // @ts-expect-error Unexpected data type for "custom" event - client.send({ event: 'custom', data: 123 }) + }) +}) - // Sending unknown events must be forbidden - // if the EventMap type argument was provided. - // @ts-expect-error Unknown event type "unknown". - client.send({ event: 'invalid', data: 123 }) +it('supports optional event data', () => { + sse<{ maybe?: string }>('/stream', ({ client }) => { + // No data is fine because data is optional. + client.send({ event: 'maybe' }) - // boolean is only allowed for "other" event - // @ts-expect-error - client.send({ event: 'custom', data: true }) + // Allows explicit undefined as optional data. + client.send({ event: 'maybe', data: undefined }) - // boolean is only allowed for "other" event - // @ts-expect-error - client.send({ event: 'custom' as 'custom' | 'other', data: true }) - // prettier-ignore-end + // Data is still validated even if optional. + client.send({ event: 'maybe', data: 'hello' }) + client.send({ + event: 'maybe', + // @ts-expect-error Invalid data type. + data: 123, + }) }) }) @@ -112,16 +169,13 @@ it('supports sending custom retry duration', () => { * see: https://github.com/mswjs/msw/pull/2521#issuecomment-2931733565 */ // prettier-ignore-start - client.send({ - // "retry" must be the only property on the message. - retry: 1000, - }) + client.send({ retry: 1000 }) - // @ts-expect-error No properties are allowed. + // @ts-expect-error Cannot use message properties with "retry". client.send({ retry: 1000, id: '1' }) - // @ts-expect-error No properties are allowed. + // @ts-expect-error Cannot use message properties with "retry". client.send({ retry: 1000, data: 'hello' }) - // @ts-expect-error No properties are allowed. + // @ts-expect-error Cannot use message properties with "retry". client.send({ retry: 1000, event: 'custom', data: 'goodbye' }) // prettier-ignore-end }) From 4753cb1c5e5b4ca71533c23b01f286293a0a472e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 12 Jun 2025 13:10:19 +0200 Subject: [PATCH 40/49] fix: fix sse connection logging --- src/browser/sse.ts | 17 +++++++++++++++++ test/browser/sse-api/sse.server.connect.test.ts | 1 + 2 files changed, 18 insertions(+) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index 0db82447d..424a5d60c 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -100,6 +100,23 @@ class ServerSentEventHandler< return super.predicate(args) } + + async log(args: { request: Request; response: Response }): Promise { + super.log({ + request: args.request, + + /** + * @note Construct a placeholder response since SSE response + * is being streamed and cannot be cloned/consumed at this point. + * This also allows us to rely on the same logging logic as in HTTP handlers. + */ + response: new Response('[streaming]', { + status: args.response.status, + statusText: args.response.statusText, + headers: args.response.headers, + }), + }) + } } type Values = T[keyof T] diff --git a/test/browser/sse-api/sse.server.connect.test.ts b/test/browser/sse-api/sse.server.connect.test.ts index d1149c8d3..34d088b5d 100644 --- a/test/browser/sse-api/sse.server.connect.test.ts +++ b/test/browser/sse-api/sse.server.connect.test.ts @@ -41,6 +41,7 @@ test('makes the actual request when called "server.connect()"', async ({ return new Response(stream, { headers: { + 'access-control-allow-origin': '*', 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive', From f0e063f42ce5bf753cfe97b76494504c9034e274 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 20 Jun 2025 10:16:03 +0200 Subject: [PATCH 41/49] feat: logger for sse messages --- src/browser/sse.ts | 131 +++++++++++++++++---- src/core/ws/utils/attachWebSocketLogger.ts | 2 +- 2 files changed, 108 insertions(+), 25 deletions(-) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index 424a5d60c..13383d4d2 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -1,4 +1,5 @@ import { invariant } from 'outvariant' +import { Emitter } from 'strict-event-emitter' import type { ResponseResolver } from '~/core/handlers/RequestHandler' import { HttpHandler, @@ -7,6 +8,9 @@ import { } from '~/core/handlers/HttpHandler' import type { Path, PathParams } from '~/core/utils/matching/matchRequestUrl' import { delay } from '~/core/delay' +import { getTimestamp } from '~/core/utils/logging/getTimestamp' +import { devUtils } from '~/core/utils/internal/devUtils' +import { colors } from '~/core/ws/utils/attachWebSocketLogger' type EventMapConstraint = { message?: unknown @@ -40,10 +44,11 @@ export type ServerSentEventRequestHandler = < * Request handler for Server-Sent Events (SSE). * * @example - * sse('http://localhost:4321', ({ source }) => { - * source.send({ data: 'hello world' }) + * sse('http://localhost:4321', ({ client }) => { + * client.send({ data: 'hello world' }) * }) * + * @see {@link https://mswjs.io/docs/sse Mocking Server-Sent Events} * @see {@link https://mswjs.io/docs/api/sse `sse()` API reference} */ export const sse: ServerSentEventRequestHandler = (path, resolver) => { @@ -60,11 +65,14 @@ class ServerSentEventHandler< path, ) + const clientEmitter = new Emitter() + super('GET', path, (info) => { const stream = new ReadableStream({ start(controller) { const client = new ServerSentEventClient({ controller, + emitter: clientEmitter, }) const server = new ServerSentEventServer({ request: info.request, @@ -87,13 +95,14 @@ class ServerSentEventHandler< }, }) }) + + this.attachClientLogger(clientEmitter) } predicate(args: { request: Request parsedResult: HttpRequestParsedResult }): boolean { - // Ignore requests that are not SSE. if (args.request.headers.get('accept') !== 'text/event-stream') { return false } @@ -117,6 +126,38 @@ class ServerSentEventHandler< }), }) } + + private attachClientLogger( + emitter: Emitter, + ): void { + /* eslint-disable no-console */ + emitter.on('message', (payload) => { + console.groupCollapsed( + devUtils.formatMessage(`${getTimestamp()} %c⇣%c ${payload.event}`), + `color:${colors.mocked}`, + 'color:inherit', + ) + console.log(payload.frames.join('\n')) + console.groupEnd() + }) + + emitter.on('error', () => { + console.log( + devUtils.formatMessage(`${getTimestamp()} %c\u00D7%c error`), + `color: ${colors.system}`, + 'color:inherit', + ) + }) + + emitter.on('close', () => { + console.log( + devUtils.formatMessage(`${getTimestamp()} %c■%c close`), + `colors:${colors.system}`, + 'color:inherit', + ) + }) + /* eslint-enable no-console */ + } } type Values = T[keyof T] @@ -136,20 +177,38 @@ type ToEventDiscriminatedUnion = Values<{ data?: T[K] retry?: never }) & - // make the `data` field conditionally required through an intersection + // Make the `data` field conditionally required through an intersection. (undefined extends T[K] ? unknown : { data: unknown }) > }> +type ServerSentEventClientEventMap = { + message: [ + payload: { + id?: string + event: string + data?: unknown + frames: Array + }, + ] + error: [] + close: [] +} + class ServerSentEventClient< EventMap extends EventMapConstraint = { message: unknown }, > { - private encoder: TextEncoder - private controller: ReadableStreamDefaultController + #encoder: TextEncoder + #controller: ReadableStreamDefaultController + #emitter?: Emitter - constructor(args: { controller: ReadableStreamDefaultController }) { - this.encoder = new TextEncoder() - this.controller = args.controller + constructor(args: { + controller: ReadableStreamDefaultController + emitter?: Emitter + }) { + this.#encoder = new TextEncoder() + this.#controller = args.controller + this.#emitter = args.emitter } /** @@ -165,9 +224,6 @@ class ServerSentEventClient< retry: number }, ): void { - /** - * @note Retry is not a part of the SSE message block. - */ if ('retry' in payload && payload.retry != null) { this.#sendRetry(payload.retry) return @@ -211,7 +267,8 @@ class ServerSentEventClient< * Erroring the connection with an error will not trigger a reconnect from the client. */ public error(): void { - this.controller.error() + this.#controller.error() + this.#emitter?.emit('error') } /** @@ -219,12 +276,13 @@ class ServerSentEventClient< * Closing the connection will trigger a reconnect from the client. */ public close(): void { - this.controller.close() + this.#controller.close() + this.#emitter?.emit('close') } #sendRetry(retry: number): void { if (typeof retry === 'number') { - this.controller.enqueue(this.encoder.encode(`retry:${retry}\n\n`)) + this.#controller.enqueue(this.#encoder.encode(`retry:${retry}\n\n`)) } } @@ -246,17 +304,24 @@ class ServerSentEventClient< frames.push(`data:${payload.data}`) frames.push('', '') - this.controller.enqueue(this.encoder.encode(frames.join('\n'))) + this.#controller.enqueue(this.#encoder.encode(frames.join('\n'))) + + this.#emitter?.emit('message', { + id: payload.id, + event: payload.event?.toString() || 'message', + data: payload.data, + frames, + }) } } class ServerSentEventServer { - private request: Request - private client: ServerSentEventClient + #request: Request + #client: ServerSentEventClient constructor(args: { request: Request; client: ServerSentEventClient }) { - this.request = args.request - this.client = args.client + this.#request = args.request + this.#client = args.client } /** @@ -264,8 +329,8 @@ class ServerSentEventServer { * and returns the `EventSource` instance. */ public connect(): EventSource { - const source = new ObservableEventSource(this.request.url, { - withCredentials: this.request.credentials === 'include', + const source = new ObservableEventSource(this.#request.url, { + withCredentials: this.#request.credentials === 'include', headers: { /** * @note Mark this request as passthrough so it doesn't trigger @@ -276,21 +341,39 @@ class ServerSentEventServer { }) source[kOnAnyMessage] = (event) => { + Object.defineProperties(event, { + target: { + value: this, + enumerable: true, + writable: true, + configurable: true, + }, + }) + // Schedule the server-to-client forwarding for the next tick // so the user can prevent the message event. queueMicrotask(() => { if (!event.defaultPrevented) { - this.client.dispatchEvent(event) + this.#client.dispatchEvent(event) } }) } // Forward stream errors from the actual server to the client. source.addEventListener('error', (event) => { + Object.defineProperties(event, { + target: { + value: this, + enumerable: true, + writable: true, + configurable: true, + }, + }) + queueMicrotask(() => { // Allow the user to opt-out from this forwarding. if (!event.defaultPrevented) { - this.client.dispatchEvent(event) + this.#client.dispatchEvent(event) } }) }) diff --git a/src/core/ws/utils/attachWebSocketLogger.ts b/src/core/ws/utils/attachWebSocketLogger.ts index 7e416a639..1b456f204 100644 --- a/src/core/ws/utils/attachWebSocketLogger.ts +++ b/src/core/ws/utils/attachWebSocketLogger.ts @@ -9,7 +9,7 @@ import { toPublicUrl } from '../../utils/request/toPublicUrl' import { getMessageLength } from './getMessageLength' import { getPublicData } from './getPublicData' -const colors = { +export const colors = { system: '#3b82f6', outgoing: '#22c55e', incoming: '#ef4444', From cc3ca6b403df3a3b9afd9587eea2ff9199603cd9 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 12 Jul 2025 12:17:46 +0200 Subject: [PATCH 42/49] chore: fix `client.error()` test --- src/browser/sse.ts | 106 ++++++++++++------- test/browser/sse-api/sse.client.send.test.ts | 63 ++++++----- 2 files changed, 96 insertions(+), 73 deletions(-) diff --git a/src/browser/sse.ts b/src/browser/sse.ts index 13383d4d2..f5a3fb227 100644 --- a/src/browser/sse.ts +++ b/src/browser/sse.ts @@ -11,6 +11,7 @@ import { delay } from '~/core/delay' import { getTimestamp } from '~/core/utils/logging/getTimestamp' import { devUtils } from '~/core/utils/internal/devUtils' import { colors } from '~/core/ws/utils/attachWebSocketLogger' +import { toPublicUrl } from '~/core/utils/request/toPublicUrl' type EventMapConstraint = { message?: unknown @@ -61,15 +62,38 @@ class ServerSentEventHandler< constructor(path: Path, resolver: ServerSentEventResolver) { invariant( typeof EventSource !== 'undefined', - 'Failed to construct a Server-Sent Event handler for path "%s": your environment does not support the EventSource API', + 'Failed to construct a Server-Sent Event handler for path "%s": the EventSource API is not supported in this environment', path, ) const clientEmitter = new Emitter() - super('GET', path, (info) => { + super('GET', path, async (info) => { + const responseInit: ResponseInit = { + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }, + } + + /** + * @note Log the intercepted request early. + * Normally, the `this.log()` method is called when the handler returns a response. + * For SSE, call that method earlier so the logs are in correct order. + */ + await super.log({ + request: info.request, + /** + * @note Construct a placeholder response since SSE response + * is being streamed and cannot be cloned/consumed for logging. + */ + response: new Response('[streaming]', responseInit), + }) + this.#attachClientLogger(info.request, clientEmitter) + const stream = new ReadableStream({ - start(controller) { + async start(controller) { const client = new ServerSentEventClient({ controller, emitter: clientEmitter, @@ -79,7 +103,7 @@ class ServerSentEventHandler< client, }) - resolver({ + await resolver({ ...info, client, server, @@ -87,16 +111,8 @@ class ServerSentEventHandler< }, }) - return new Response(stream, { - headers: { - 'content-type': 'text/event-stream', - 'cache-control': 'no-cache', - connection: 'keep-alive', - }, - }) + return new Response(stream, responseInit) }) - - this.attachClientLogger(clientEmitter) } predicate(args: { @@ -110,30 +126,27 @@ class ServerSentEventHandler< return super.predicate(args) } - async log(args: { request: Request; response: Response }): Promise { - super.log({ - request: args.request, - - /** - * @note Construct a placeholder response since SSE response - * is being streamed and cannot be cloned/consumed at this point. - * This also allows us to rely on the same logging logic as in HTTP handlers. - */ - response: new Response('[streaming]', { - status: args.response.status, - statusText: args.response.statusText, - headers: args.response.headers, - }), - }) + async log(_args: { request: Request; response: Response }): Promise { + /** + * @note Skip the default `this.log()` logic so that when this handler is logged + * upon handling the request, nothing is printed (we log SSE requests early). + */ + return } - private attachClientLogger( + #attachClientLogger( + request: Request, emitter: Emitter, ): void { + const publicUrl = toPublicUrl(request.url) + /* eslint-disable no-console */ emitter.on('message', (payload) => { console.groupCollapsed( - devUtils.formatMessage(`${getTimestamp()} %c⇣%c ${payload.event}`), + devUtils.formatMessage( + `${getTimestamp()} SSE %s %c⇣%c ${payload.event}`, + ), + publicUrl, `color:${colors.mocked}`, 'color:inherit', ) @@ -142,19 +155,25 @@ class ServerSentEventHandler< }) emitter.on('error', () => { - console.log( - devUtils.formatMessage(`${getTimestamp()} %c\u00D7%c error`), + console.groupCollapsed( + devUtils.formatMessage(`${getTimestamp()} SSE %s %c\u00D7%c error`), + publicUrl, `color: ${colors.system}`, 'color:inherit', ) + console.log('Handler:', this) + console.groupEnd() }) emitter.on('close', () => { - console.log( - devUtils.formatMessage(`${getTimestamp()} %c■%c close`), + console.groupCollapsed( + devUtils.formatMessage(`${getTimestamp()} SSE %s %c■%c close`), + publicUrl, `colors:${colors.system}`, 'color:inherit', ) + console.log('Handler:', this) + console.groupEnd() }) /* eslint-enable no-console */ } @@ -200,11 +219,11 @@ class ServerSentEventClient< > { #encoder: TextEncoder #controller: ReadableStreamDefaultController - #emitter?: Emitter + #emitter: Emitter constructor(args: { controller: ReadableStreamDefaultController - emitter?: Emitter + emitter: Emitter }) { this.#encoder = new TextEncoder() this.#controller = args.controller @@ -260,24 +279,29 @@ class ServerSentEventClient< this.error() return } + + if (event.type === 'close') { + this.close() + return + } } /** * Errors the underlying `EventSource`, closing the connection with an error. - * Erroring the connection with an error will not trigger a reconnect from the client. + * This is equivalent to aborting the connection and will produce a `TypeError: Failed to fetch` + * error. */ public error(): void { this.#controller.error() - this.#emitter?.emit('error') + this.#emitter.emit('error') } /** * Closes the underlying `EventSource`, closing the connection. - * Closing the connection will trigger a reconnect from the client. */ public close(): void { this.#controller.close() - this.#emitter?.emit('close') + this.#emitter.emit('close') } #sendRetry(retry: number): void { @@ -306,7 +330,7 @@ class ServerSentEventClient< frames.push('', '') this.#controller.enqueue(this.#encoder.encode(frames.join('\n'))) - this.#emitter?.emit('message', { + this.#emitter.emit('message', { id: payload.id, event: payload.event?.toString() || 'message', data: payload.data, diff --git a/test/browser/sse-api/sse.client.send.test.ts b/test/browser/sse-api/sse.client.send.test.ts index c2dab2b22..57c2fb1d6 100644 --- a/test/browser/sse-api/sse.client.send.test.ts +++ b/test/browser/sse-api/sse.client.send.test.ts @@ -29,7 +29,7 @@ test('sends a mock message event', async ({ loadExample, page }) => { }) const message = await page.evaluate(() => { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const source = new EventSource('http://localhost/stream') source.onerror = () => reject() @@ -51,18 +51,21 @@ test('sends a mock custom event', async ({ loadExample, page }) => { const { setupWorker, sse } = window.msw const worker = setupWorker( - sse('http://localhost/stream', ({ client }) => { - client.send({ - event: 'userconnect', - data: { username: 'john' }, - }) - }), + sse<{ userconnect: { username: string } }>( + 'http://localhost/stream', + ({ client }) => { + client.send({ + event: 'userconnect', + data: { username: 'john' }, + }) + }, + ), ) await worker.start() }) const message = await page.evaluate(() => { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const source = new EventSource('http://localhost/stream') source.addEventListener('userconnect', (event) => { resolve(`${event.type}:${event.data}`) @@ -86,19 +89,22 @@ test('sends a mock message event with custom id', async ({ const { setupWorker, sse } = window.msw const worker = setupWorker( - sse('http://localhost/stream', ({ client }) => { - client.send({ - id: 'abc-123', - event: 'userconnect', - data: { username: 'john' }, - }) - }), + sse<{ userconnect: { username: string } }>( + 'http://localhost/stream', + ({ client }) => { + client.send({ + id: 'abc-123', + event: 'userconnect', + data: { username: 'john' }, + }) + }, + ), ) await worker.start() }) const message = await page.evaluate(() => { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const source = new EventSource('http://localhost/stream') source.addEventListener('userconnect', (event) => { resolve(`${event.type}:${event.lastEventId}:${event.data}`) @@ -110,21 +116,16 @@ test('sends a mock message event with custom id', async ({ expect(message).toBe('userconnect:abc-123:{"username":"john"}') }) -test('errors the connected source', async ({ loadExample, page, waitFor }) => { +test('errors the connected source', async ({ loadExample, page }) => { await loadExample(EXAMPLE_URL, { skipActivation: true, }) - const pageErrors: Array = [] - page.on('pageerror', (error) => { - pageErrors.push(error.message) - }) - await page.evaluate(async () => { const { setupWorker, sse } = window.msw const worker = setupWorker( - sse('http://localhost/stream', ({ client }) => { + sse('http://localhost/stream-error', ({ client }) => { queueMicrotask(() => client.error()) }), ) @@ -132,19 +133,17 @@ test('errors the connected source', async ({ loadExample, page, waitFor }) => { }) const readyState = await page.evaluate(() => { - return new Promise((resolve) => { - const source = new EventSource('http://localhost/stream') - source.addEventListener('error', (event) => { - resolve(source.readyState) - }) + return new Promise((resolve) => { + const source = new EventSource('http://localhost/stream-error') + source.onerror = () => resolve(source.readyState) + source.onopen = () => console.log('OPEN?') }) }) // EventSource must be closed. expect(readyState).toBe(2) - await waitFor(() => { - // Must error with "Failed to fetch" (default EventSource behavior). - expect(pageErrors).toContain('Failed to fetch') - }) + /** + * @note That erroring the stream does not throw any errors. + */ }) From 5ed58db99712b147ff17b8c40861feafcccd82bd Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 12 Jul 2025 12:40:15 +0200 Subject: [PATCH 43/49] chore: move `sse` to the root --- src/browser/index.ts | 7 ------- src/core/index.ts | 8 ++++++++ src/{browser => core}/sse.ts | 16 ++++++++-------- test/browser/sse-api/sse.client.send.test.ts | 3 ++- test/browser/sse-api/sse.mocks.ts | 3 ++- test/browser/sse-api/sse.retry.test.ts | 3 ++- test/browser/sse-api/sse.server.connect.test.ts | 7 +++---- test/browser/sse-api/sse.use.test.ts | 3 ++- .../browser/sse-api/sse.with-credentials.test.ts | 3 ++- test/typings/sse.test-d.ts | 2 +- 10 files changed, 30 insertions(+), 25 deletions(-) rename src/{browser => core}/sse.ts (97%) diff --git a/src/browser/index.ts b/src/browser/index.ts index ba272ddb5..0eafbe76f 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -1,10 +1,3 @@ export { setupWorker } from './setupWorker/setupWorker' export type { SetupWorker, StartOptions } from './setupWorker/glossary' export { SetupWorkerApi } from './setupWorker/setupWorker' - -/* Server-Sent Events */ -export { - sse, - type ServerSentEventRequestHandler, - type ServerSentEventResolver, -} from './sse' diff --git a/src/core/index.ts b/src/core/index.ts index 4c079a284..da03f2f23 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -17,6 +17,14 @@ export { type WebSocketHandlerConnection, } from './handlers/WebSocketHandler' +/* Server-Sent Events */ +export { + sse, + type ServerSentEventRequestHandler, + type ServerSentEventResolver, + type ServerSentEventResolverExtras, +} from './sse' + /* Utils */ export { matchRequestUrl } from './utils/matching/matchRequestUrl' export * from './utils/handleRequest' diff --git a/src/browser/sse.ts b/src/core/sse.ts similarity index 97% rename from src/browser/sse.ts rename to src/core/sse.ts index f5a3fb227..69433a349 100644 --- a/src/browser/sse.ts +++ b/src/core/sse.ts @@ -1,17 +1,17 @@ import { invariant } from 'outvariant' import { Emitter } from 'strict-event-emitter' -import type { ResponseResolver } from '~/core/handlers/RequestHandler' +import type { ResponseResolver } from './handlers/RequestHandler' import { HttpHandler, type HttpRequestResolverExtras, type HttpRequestParsedResult, -} from '~/core/handlers/HttpHandler' -import type { Path, PathParams } from '~/core/utils/matching/matchRequestUrl' -import { delay } from '~/core/delay' -import { getTimestamp } from '~/core/utils/logging/getTimestamp' -import { devUtils } from '~/core/utils/internal/devUtils' -import { colors } from '~/core/ws/utils/attachWebSocketLogger' -import { toPublicUrl } from '~/core/utils/request/toPublicUrl' +} from './handlers/HttpHandler' +import type { Path, PathParams } from './utils/matching/matchRequestUrl' +import { delay } from './delay' +import { getTimestamp } from './utils/logging/getTimestamp' +import { devUtils } from './utils/internal/devUtils' +import { colors } from './ws/utils/attachWebSocketLogger' +import { toPublicUrl } from './utils/request/toPublicUrl' type EventMapConstraint = { message?: unknown diff --git a/test/browser/sse-api/sse.client.send.test.ts b/test/browser/sse-api/sse.client.send.test.ts index 57c2fb1d6..42baf1b30 100644 --- a/test/browser/sse-api/sse.client.send.test.ts +++ b/test/browser/sse-api/sse.client.send.test.ts @@ -1,4 +1,5 @@ -import { setupWorker, sse } from 'msw/browser' +import { sse } from 'msw' +import { setupWorker } from 'msw/browser' import { test, expect } from '../playwright.extend' declare namespace window { diff --git a/test/browser/sse-api/sse.mocks.ts b/test/browser/sse-api/sse.mocks.ts index 29525730c..11d63248a 100644 --- a/test/browser/sse-api/sse.mocks.ts +++ b/test/browser/sse-api/sse.mocks.ts @@ -1,4 +1,5 @@ -import { setupWorker, sse } from 'msw/browser' +import { sse } from 'msw' +import { setupWorker } from 'msw/browser' // @ts-ignore window.msw = { diff --git a/test/browser/sse-api/sse.retry.test.ts b/test/browser/sse-api/sse.retry.test.ts index ee9ee7768..13374fe03 100644 --- a/test/browser/sse-api/sse.retry.test.ts +++ b/test/browser/sse-api/sse.retry.test.ts @@ -1,4 +1,5 @@ -import { setupWorker, sse } from 'msw/browser' +import { sse } from 'msw' +import { setupWorker } from 'msw/browser' import { test, expect } from '../playwright.extend' declare namespace window { diff --git a/test/browser/sse-api/sse.server.connect.test.ts b/test/browser/sse-api/sse.server.connect.test.ts index 34d088b5d..21eb1203a 100644 --- a/test/browser/sse-api/sse.server.connect.test.ts +++ b/test/browser/sse-api/sse.server.connect.test.ts @@ -1,4 +1,5 @@ -import { setupWorker, sse } from 'msw/browser' +import { sse } from 'msw' +import { setupWorker } from 'msw/browser' import { createTestHttpServer } from '@epic-web/test-server/http' import { test, expect } from '../playwright.extend' @@ -285,9 +286,7 @@ test('forward custom stream errors from the original server to the client automa const errorPromise = page.evaluate((url) => { return new Promise((resolve, reject) => { const source = new EventSource(url) - source.onerror = () => { - resolve() - } + source.onerror = () => resolve() source.onmessage = () => { reject(new Error('Must not receive a message')) } diff --git a/test/browser/sse-api/sse.use.test.ts b/test/browser/sse-api/sse.use.test.ts index 942cf1a57..346416604 100644 --- a/test/browser/sse-api/sse.use.test.ts +++ b/test/browser/sse-api/sse.use.test.ts @@ -1,4 +1,5 @@ -import { setupWorker, sse } from 'msw/browser' +import { sse } from 'msw' +import { setupWorker } from 'msw/browser' import { test, expect } from '../playwright.extend' declare namespace window { diff --git a/test/browser/sse-api/sse.with-credentials.test.ts b/test/browser/sse-api/sse.with-credentials.test.ts index c8fb59509..98f9cee8c 100644 --- a/test/browser/sse-api/sse.with-credentials.test.ts +++ b/test/browser/sse-api/sse.with-credentials.test.ts @@ -1,4 +1,5 @@ -import { setupWorker, sse } from 'msw/browser' +import { sse } from 'msw' +import { setupWorker } from 'msw/browser' import { test, expect } from '../playwright.extend' declare namespace window { diff --git a/test/typings/sse.test-d.ts b/test/typings/sse.test-d.ts index 8ff5c43dc..990de4972 100644 --- a/test/typings/sse.test-d.ts +++ b/test/typings/sse.test-d.ts @@ -1,5 +1,5 @@ import { it } from 'vitest' -import { sse } from 'msw/browser' +import { sse } from 'msw' /** * @note Define the global property to simulate an EventSource-compatible environment. From bdaca690280fc9a8055ede65913e13a9cb5ba5f0 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 31 Jul 2025 17:07:14 +0200 Subject: [PATCH 44/49] feat(sse): add `sse.createMessage()` utility --- src/core/sse.ts | 63 ++++++++++++++------- test/browser/sse-api/sse.extraneous.test.ts | 49 ++++++++++++++++ test/browser/sse-api/sse.mocks.ts | 3 +- 3 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 test/browser/sse-api/sse.extraneous.test.ts diff --git a/src/core/sse.ts b/src/core/sse.ts index 69433a349..165ab09e7 100644 --- a/src/core/sse.ts +++ b/src/core/sse.ts @@ -32,14 +32,28 @@ export type ServerSentEventResolver< Params extends PathParams, > = ResponseResolver, any, any> -export type ServerSentEventRequestHandler = < +export interface ServerSentEventRequestHandler< EventMap extends EventMapConstraint = { message: unknown }, Params extends PathParams = PathParams, RequestPath extends Path = Path, ->( - path: RequestPath, - resolver: ServerSentEventResolver, -) => HttpHandler +> { + ( + path: RequestPath, + resolver: ServerSentEventResolver, + ): HttpHandler + + createMessage: < + EventMap extends EventMapConstraint = { message: unknown }, + EventType extends keyof EventMap = 'message', + >(payload: { + id?: string + event?: EventType + data: string | EventMap[EventType] | EventMap['message'] | undefined + }) => Uint8Array +} + +const encoder = new TextEncoder() +const decoder = new TextDecoder() /** * Request handler for Server-Sent Events (SSE). @@ -56,6 +70,23 @@ export const sse: ServerSentEventRequestHandler = (path, resolver) => { return new ServerSentEventHandler(path, resolver) } +sse.createMessage = function createMessage(payload) { + const frames: Array = [] + + if (payload.id) { + frames.push(`id:${payload.id}`) + } + + if (payload.event) { + frames.push(`event:${payload.event?.toString()}`) + } + + frames.push(`data:${payload.data}`) + frames.push('', '') + + return encoder.encode(frames.join('\n')) +} + class ServerSentEventHandler< EventMap extends EventMapConstraint, > extends HttpHandler { @@ -150,7 +181,7 @@ class ServerSentEventHandler< `color:${colors.mocked}`, 'color:inherit', ) - console.log(payload.frames.join('\n')) + console.log(decoder.decode(payload.message)) console.groupEnd() }) @@ -207,7 +238,7 @@ type ServerSentEventClientEventMap = { id?: string event: string data?: unknown - frames: Array + message: Uint8Array }, ] error: [] @@ -315,26 +346,14 @@ class ServerSentEventClient< event?: EventType data: string | EventMap[EventType] | EventMap['message'] | undefined }): void { - const frames: Array = [] - - if (payload.id) { - frames.push(`id:${payload.id}`) - } - - if (payload.event) { - frames.push(`event:${payload.event?.toString()}`) - } - - frames.push(`data:${payload.data}`) - - frames.push('', '') - this.#controller.enqueue(this.#encoder.encode(frames.join('\n'))) + const message = sse.createMessage(payload) + this.#controller.enqueue(message) this.#emitter.emit('message', { id: payload.id, event: payload.event?.toString() || 'message', data: payload.data, - frames, + message, }) } } diff --git a/test/browser/sse-api/sse.extraneous.test.ts b/test/browser/sse-api/sse.extraneous.test.ts new file mode 100644 index 000000000..22ff6b77d --- /dev/null +++ b/test/browser/sse-api/sse.extraneous.test.ts @@ -0,0 +1,49 @@ +import { http, sse } from 'msw' +import { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare namespace window { + export const msw: { + setupWorker: typeof setupWorker + http: typeof http + sse: typeof sse + } +} + +const EXAMPLE_URL = new URL('./sse.mocks.ts', import.meta.url) + +test('supports triggerring events from another handlers', async ({ + loadExample, + page, +}) => { + await loadExample(EXAMPLE_URL, { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, http, sse } = window.msw + + const target = new EventTarget() + + const worker = setupWorker( + sse('/stream', ({ client }) => { + target.addEventListener('message', (event) => { + client.send(event.data) + }) + }), + http.get('/trigger', () => { + target.dispatchEvent( + new MessageEvent('message', { + data: sse.createMessage({ + data: 'hello world', + }), + }), + ) + }), + ) + + await worker.start() + }) + + // ... +}) diff --git a/test/browser/sse-api/sse.mocks.ts b/test/browser/sse-api/sse.mocks.ts index 11d63248a..fd1f07de3 100644 --- a/test/browser/sse-api/sse.mocks.ts +++ b/test/browser/sse-api/sse.mocks.ts @@ -1,8 +1,9 @@ -import { sse } from 'msw' +import { http, sse } from 'msw' import { setupWorker } from 'msw/browser' // @ts-ignore window.msw = { setupWorker, + http, sse, } From 6ad5d68ce850dac65c3a3c22645d363e378c8f5b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 31 Jul 2025 17:30:40 +0200 Subject: [PATCH 45/49] feat(sse): export `ServerSentEventMessage` type --- src/core/index.ts | 1 + src/core/sse.ts | 95 ++++++++----------- .../sse.client.send.extraneous.test.ts | 70 ++++++++++++++ test/browser/sse-api/sse.extraneous.test.ts | 49 ---------- 4 files changed, 110 insertions(+), 105 deletions(-) create mode 100644 test/browser/sse-api/sse.client.send.extraneous.test.ts delete mode 100644 test/browser/sse-api/sse.extraneous.test.ts diff --git a/src/core/index.ts b/src/core/index.ts index da03f2f23..cd4f56502 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -23,6 +23,7 @@ export { type ServerSentEventRequestHandler, type ServerSentEventResolver, type ServerSentEventResolverExtras, + type ServerSentEventMessage, } from './sse' /* Utils */ diff --git a/src/core/sse.ts b/src/core/sse.ts index 165ab09e7..3eadd5cc3 100644 --- a/src/core/sse.ts +++ b/src/core/sse.ts @@ -32,28 +32,25 @@ export type ServerSentEventResolver< Params extends PathParams, > = ResponseResolver, any, any> -export interface ServerSentEventRequestHandler< +export type ServerSentEventRequestHandler = < EventMap extends EventMapConstraint = { message: unknown }, Params extends PathParams = PathParams, RequestPath extends Path = Path, -> { - ( - path: RequestPath, - resolver: ServerSentEventResolver, - ): HttpHandler - - createMessage: < - EventMap extends EventMapConstraint = { message: unknown }, - EventType extends keyof EventMap = 'message', - >(payload: { - id?: string - event?: EventType - data: string | EventMap[EventType] | EventMap['message'] | undefined - }) => Uint8Array -} +>( + path: RequestPath, + resolver: ServerSentEventResolver, +) => HttpHandler -const encoder = new TextEncoder() -const decoder = new TextDecoder() +export type ServerSentEventMessage< + EventMap extends EventMapConstraint = { message: unknown }, +> = + | ToEventDiscriminatedUnion + | { + id?: never + event?: never + data?: never + retry: number + } /** * Request handler for Server-Sent Events (SSE). @@ -70,23 +67,6 @@ export const sse: ServerSentEventRequestHandler = (path, resolver) => { return new ServerSentEventHandler(path, resolver) } -sse.createMessage = function createMessage(payload) { - const frames: Array = [] - - if (payload.id) { - frames.push(`id:${payload.id}`) - } - - if (payload.event) { - frames.push(`event:${payload.event?.toString()}`) - } - - frames.push(`data:${payload.data}`) - frames.push('', '') - - return encoder.encode(frames.join('\n')) -} - class ServerSentEventHandler< EventMap extends EventMapConstraint, > extends HttpHandler { @@ -181,7 +161,7 @@ class ServerSentEventHandler< `color:${colors.mocked}`, 'color:inherit', ) - console.log(decoder.decode(payload.message)) + console.log(payload.frames) console.groupEnd() }) @@ -238,7 +218,7 @@ type ServerSentEventClientEventMap = { id?: string event: string data?: unknown - message: Uint8Array + frames: Array }, ] error: [] @@ -264,16 +244,7 @@ class ServerSentEventClient< /** * Sends the given payload to the intercepted `EventSource`. */ - public send( - payload: - | ToEventDiscriminatedUnion - | { - id?: never - event?: never - data?: never - retry: number - }, - ): void { + public send(payload: ServerSentEventMessage): void { if ('retry' in payload && payload.retry != null) { this.#sendRetry(payload.retry) return @@ -341,19 +312,31 @@ class ServerSentEventClient< } } - #sendMessage(payload: { + #sendMessage(message: { id?: string - event?: EventType - data: string | EventMap[EventType] | EventMap['message'] | undefined + event?: unknown + data: unknown | undefined }): void { - const message = sse.createMessage(payload) - this.#controller.enqueue(message) + const frames: Array = [] + + if (message.id) { + frames.push(`id:${message.id}`) + } + + if (message.event) { + frames.push(`event:${message.event?.toString()}`) + } + + frames.push(`data:${message.data}`) + frames.push('', '') + + this.#controller.enqueue(this.#encoder.encode(frames.join('\n'))) this.#emitter.emit('message', { - id: payload.id, - event: payload.event?.toString() || 'message', - data: payload.data, - message, + id: message.id, + event: message.event?.toString() || 'message', + data: message.data, + frames, }) } } diff --git a/test/browser/sse-api/sse.client.send.extraneous.test.ts b/test/browser/sse-api/sse.client.send.extraneous.test.ts new file mode 100644 index 000000000..3cf05396d --- /dev/null +++ b/test/browser/sse-api/sse.client.send.extraneous.test.ts @@ -0,0 +1,70 @@ +import { http, sse, type ServerSentEventMessage } from 'msw' +import { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare namespace window { + export const msw: { + setupWorker: typeof setupWorker + http: typeof http + sse: typeof sse + } +} + +test('supports triggerring events from another handlers', async ({ + loadExample, + page, +}) => { + await loadExample(new URL('./sse.mocks.ts', import.meta.url), { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, http, sse } = window.msw + const target = new EventTarget() + + const worker = setupWorker( + sse('/stream', ({ client }) => { + // Listen to the event target dispatching message events + // to trigger server-sent events to the client. + target.addEventListener('message', (event) => { + if (event instanceof MessageEvent) { + client.send(event.data) + } + }) + }), + http.get('/trigger', () => { + // Dispatch a message event onto the event target + // to trigger a server-sent event to the client. + target.dispatchEvent( + new MessageEvent('message', { + data: { + data: 'hello world', + } satisfies ServerSentEventMessage, + }), + ) + + return new Response() + }), + ) + + await worker.start() + }) + + const message = await page.evaluate(() => { + return new Promise(async (resolve, reject) => { + const source = new EventSource('/stream') + source.addEventListener('message', (event) => { + resolve(event.data) + }) + source.addEventListener('error', () => { + reject(new Error('EventSource errored')) + }) + + source.addEventListener('open', async () => { + await fetch('/trigger').catch((error) => reject(error)) + }) + }) + }) + + expect(message).toBe('hello world') +}) diff --git a/test/browser/sse-api/sse.extraneous.test.ts b/test/browser/sse-api/sse.extraneous.test.ts deleted file mode 100644 index 22ff6b77d..000000000 --- a/test/browser/sse-api/sse.extraneous.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { http, sse } from 'msw' -import { setupWorker } from 'msw/browser' -import { test, expect } from '../playwright.extend' - -declare namespace window { - export const msw: { - setupWorker: typeof setupWorker - http: typeof http - sse: typeof sse - } -} - -const EXAMPLE_URL = new URL('./sse.mocks.ts', import.meta.url) - -test('supports triggerring events from another handlers', async ({ - loadExample, - page, -}) => { - await loadExample(EXAMPLE_URL, { - skipActivation: true, - }) - - await page.evaluate(async () => { - const { setupWorker, http, sse } = window.msw - - const target = new EventTarget() - - const worker = setupWorker( - sse('/stream', ({ client }) => { - target.addEventListener('message', (event) => { - client.send(event.data) - }) - }), - http.get('/trigger', () => { - target.dispatchEvent( - new MessageEvent('message', { - data: sse.createMessage({ - data: 'hello world', - }), - }), - ) - }), - ) - - await worker.start() - }) - - // ... -}) From 59416c60a074dee2f409c8f44202bc9f344d4545 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 31 Aug 2025 17:19:32 +0200 Subject: [PATCH 46/49] fix(sse): adjust `ServerSentEventHandler` to new `predicate` return type --- src/core/sse.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/sse.ts b/src/core/sse.ts index 3eadd5cc3..c877d101d 100644 --- a/src/core/sse.ts +++ b/src/core/sse.ts @@ -126,10 +126,10 @@ class ServerSentEventHandler< }) } - predicate(args: { + async predicate(args: { request: Request parsedResult: HttpRequestParsedResult - }): boolean { + }) { if (args.request.headers.get('accept') !== 'text/event-stream') { return false } From 6921dc440a66863ba9214dc53b740cd29ec87fb7 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 4 Nov 2025 14:29:07 +0100 Subject: [PATCH 47/49] docs: polish `sse` jsdoc --- src/core/sse.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/sse.ts b/src/core/sse.ts index c877d101d..848f12d19 100644 --- a/src/core/sse.ts +++ b/src/core/sse.ts @@ -53,14 +53,14 @@ export type ServerSentEventMessage< } /** - * Request handler for Server-Sent Events (SSE). + * Intercept Server-Sent Events (SSE). * * @example * sse('http://localhost:4321', ({ client }) => { * client.send({ data: 'hello world' }) * }) * - * @see {@link https://mswjs.io/docs/sse Mocking Server-Sent Events} + * @see {@link https://mswjs.io/docs/sse/ Mocking Server-Sent Events} * @see {@link https://mswjs.io/docs/api/sse `sse()` API reference} */ export const sse: ServerSentEventRequestHandler = (path, resolver) => { From ad293bb17febd52173841b0a9e007bc7f0cdb90a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 4 Nov 2025 14:29:19 +0100 Subject: [PATCH 48/49] fix(sse): add missing `break` in response body processing --- src/core/sse.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/sse.ts b/src/core/sse.ts index 848f12d19..9b58e3c8d 100644 --- a/src/core/sse.ts +++ b/src/core/sse.ts @@ -811,7 +811,9 @@ class EventSourceParsingStream extends WritableStream { case ControlCharacters.CarriageReturn: { this.discardTrailingNewline = true + break } + case ControlCharacters.NewLine: { lineEnd = this.position break From cd9ef5a21b6b03dad5f2f82ee757799c64a2dfa2 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 5 Nov 2025 13:28:41 +0100 Subject: [PATCH 49/49] chore: allow @ts-ignore in test modules --- eslint.config.mjs | 3 ++- test/browser/sse-api/sse.mocks.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index ffdfa012f..617d6fda2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,7 +16,7 @@ export default tseslint.config( sourceType: 'module', }, }, - ignores: ['/lib', '/node', '/native', '/config', 'test'], + ignores: ['/lib', '/node', '/native', '/config', '/test'], rules: { 'no-console': [ 'error', @@ -60,6 +60,7 @@ export default tseslint.config( ], rules: { 'no-console': 'off', + '@typescript-eslint/prefer-ts-expect-error': 'off', '@typescript-eslint/no-unused-vars': 'off', }, }, diff --git a/test/browser/sse-api/sse.mocks.ts b/test/browser/sse-api/sse.mocks.ts index fd1f07de3..013257888 100644 --- a/test/browser/sse-api/sse.mocks.ts +++ b/test/browser/sse-api/sse.mocks.ts @@ -1,9 +1,9 @@ import { http, sse } from 'msw' import { setupWorker } from 'msw/browser' -// @ts-ignore window.msw = { setupWorker, + // @ts-ignore http, sse, }