diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 312b5e3b4..0248e6ccd 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@v4 with: - node-version: 18 + node-version: 22 - uses: pnpm/action-setup@v4 with: diff --git a/.github/workflows/typescript-nightly.yml b/.github/workflows/typescript-nightly.yml index a292f12bb..1d5fb2f4e 100644 --- a/.github/workflows/typescript-nightly.yml +++ b/.github/workflows/typescript-nightly.yml @@ -42,7 +42,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 - name: Set up pnpm uses: pnpm/action-setup@v4 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/package.json b/package.json index c0d11508d..d1445f023 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,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", @@ -161,6 +161,7 @@ "devDependencies": { "@commitlint/cli": "^18.4.4", "@commitlint/config-conventional": "^18.4.4", + "@epic-web/test-server": "^0.1.0", "@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 252e22306..98f82390e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,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 '@fastify/websocket': specifier: ^8.3.1 version: 8.3.1 @@ -675,6 +678,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.7(hono@4.6.10) + '@hono/node-ws': 1.0.4(@hono/node-server@1.13.7) + '@open-draft/deferred-promise': 2.2.0 + '@types/ws': 8.5.13 + hono: 4.6.10 + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + /@esbuild/aix-ppc64@0.20.2: resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} engines: {node: '>=12'} @@ -1597,6 +1615,28 @@ packages: - utf-8-validate dev: true + /@hono/node-server@1.13.7(hono@4.6.10): + resolution: {integrity: sha512-kTfUMsoloVKtRA2fLiGSd9qBddmru9KadNyhJCwgKBxTiNkaAJEwkVN9KV/rS4HtmmNRtUh6P+YpmjRMl0d9vQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + dependencies: + hono: 4.6.10 + dev: true + + /@hono/node-ws@1.0.4(@hono/node-server@1.13.7): + 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.7(hono@4.6.10) + 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'} @@ -2579,6 +2619,12 @@ packages: '@types/node': 18.19.28 dev: true + /@types/ws@8.5.13: + resolution: {integrity: sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==} + dependencies: + '@types/node': 18.19.28 + dev: true + /@types/yargs-parser@21.0.3: resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} dev: true @@ -5677,6 +5723,11 @@ packages: parse-passwd: 1.0.0 dev: true + /hono@4.6.10: + resolution: {integrity: sha512-IXXNfRAZEahFnWBhUUlqKEGF9upeE6hZoRZszvNkyAz/CYp+iVbxm3viMvStlagRJohjlBRGOQ7f4jfcV0XMGg==} + engines: {node: '>=16.9.0'} + dev: true + /hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} dev: true 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..1269ebf32 --- /dev/null +++ b/src/browser/sse.ts @@ -0,0 +1,696 @@ +import { invariant } from 'outvariant' +import type { ResponseResolver } from '~/core/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' + +export type ServerSentEventResolverExtras< + EventMap extends Record, + Params extends PathParams, +> = HttpRequestResolverExtras & { + client: ServerSentEventClient + server: ServerSentEventServer +} + +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, +) => 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< + 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', + path, + ) + + 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, + client, + server, + }) + }, + }) + + return new Response(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) + } +} + +const kSend = Symbol('kSend') + +class ServerSentEventClient> { + private encoder: TextEncoder + private controller: ReadableStreamDefaultController + + constructor(args: { controller: ReadableStreamDefaultController }) { + this.encoder = new TextEncoder() + this.controller = args.controller + } + + /** + * Sends the given payload to the underlying `EventSource`. + */ + 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, + data: + typeof payload.data === 'object' + ? JSON.stringify(payload.data) + : payload.data, + }) + } + + /** + * 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. + */ + public error(): void { + this.controller.error() + } + + private [kSend](payload: { + id?: string + event?: EventType + data: string | EventMap[EventType] | EventMap['message'] | undefined + }): void { + const frames: Array = [] + + 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'))) + } +} + +class ServerSentEventServer { + private request: Request + private 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 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() { + 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 { + 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) { + // 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 + } + + // 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/test/browser/sse-api/sse.client.send.test.ts b/test/browser/sse-api/sse.client.send.test.ts new file mode 100644 index 000000000..c7ed9fb64 --- /dev/null +++ b/test/browser/sse-api/sse.client.send.test.ts @@ -0,0 +1,204 @@ +import { setupWorker, sse } from 'msw/browser' +import { HttpServer } from '@open-draft/test-server/http' +import { test, expect } from '../playwright.extend' + +declare namespace window { + export const msw: { + setupWorker: typeof setupWorker + sse: typeof sse + } +} + +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, + }) + + await page.evaluate(async () => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + sse('http://localhost/stream', ({ client }) => { + client.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', ({ client }) => { + client.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', ({ client }) => { + client.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', ({ client }) => { + queueMicrotask(() => client.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') + }) +}) + +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') + + 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) + + await page.pause() +}) 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.server.connect.test.ts b/test/browser/sse-api/sse.server.connect.test.ts new file mode 100644 index 000000000..b7a4dbc5e --- /dev/null +++ b/test/browser/sse-api/sse.server.connect.test.ts @@ -0,0 +1,224 @@ +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 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.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, ({ server }) => { + server.connect() + }), + ) + 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 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.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 }) => { + server.connect() + }), + ) + 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 error event from the 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() + }, + }) + + 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 }) => { + server.connect() + }), + ) + 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() +}) 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') +}) 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', + }, + }), + ) +}) diff --git a/test/node/third-party/axios-upload.node.test.ts b/test/node/third-party/axios-upload.node.test.ts index e63e352bb..ca5089a4e 100644 --- a/test/node/third-party/axios-upload.node.test.ts +++ b/test/node/third-party/axios-upload.node.test.ts @@ -45,12 +45,15 @@ 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) + 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) expect(onUploadProgress).toHaveBeenNthCalledWith( 1, 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')