diff --git a/.nvmrc b/.nvmrc index 85aee5a53..9a2a0e219 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20 \ No newline at end of file +v20 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/package.json b/package.json index 93accad44..f6a6203e4 100644 --- a/package.json +++ b/package.json @@ -259,13 +259,14 @@ "devDependencies": { "@commitlint/cli": "^18.4.4", "@commitlint/config-conventional": "^18.4.4", + "@epic-web/test-server": "^0.1.6", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.34.0", "@fastify/websocket": "^8.3.1", "@graphql-typed-document-node/core": "^3.2.0", "@open-draft/test-server": "^0.4.2", + "@playwright/test": "^1.50.1", "@ossjs/release": "^0.10.0", - "@playwright/test": "^1.48.0", "@swc/core": "^1.13.5", "@types/eslint__js": "^8.42.3", "@types/express": "^4.17.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c75b39ff..ac9c27eed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: '@commitlint/config-conventional': specifier: ^18.4.4 version: 18.6.3 + '@epic-web/test-server': + specifier: ^0.1.6 + version: 0.1.6 '@eslint/eslintrc': specifier: ^3.3.1 version: 3.3.1 @@ -88,8 +91,8 @@ importers: specifier: ^0.10.0 version: 0.10.0 '@playwright/test': - specifier: ^1.48.0 - version: 1.49.0 + specifier: ^1.50.1 + version: 1.56.1 '@swc/core': specifier: ^1.13.5 version: 1.13.5 @@ -419,6 +422,10 @@ packages: resolution: {integrity: sha512-tpyc+7i6bPG9mvaBbtKUeghfyZSDgWquIDfMgqYtTbmZ9Y9VzEm2je9EYcQ0aoz5o7NvGS+rcDec93yO08MHYA==} engines: {node: '>=v18'} + '@epic-web/test-server@0.1.6': + resolution: {integrity: sha512-n6+dZKI2I3J8f3vjIFAYh5trgAJl0crji76+K1aoiRJqEYjdsCX/nVgBrkBCwPpBqhv9NZ1XlMqzfOafkqY5EQ==} + engines: {node: '>=20'} + '@esbuild/aix-ppc64@0.20.2': resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} engines: {node: '>=12'} @@ -917,6 +924,19 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + '@hono/node-server@1.19.6': + resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@hono/node-ws@1.2.0': + resolution: {integrity: sha512-OBPQ8OSHBw29mj00wT/xGYtB6HY54j0fNSdVZ7gZM3TUeq0So11GXaWtFf1xWxQNfumKIsj0wRuLKWfVsO5GgQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + '@hono/node-server': ^1.11.1 + hono: ^4.6.0 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1086,8 +1106,8 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} 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.56.1': + resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} engines: {node: '>=18'} hasBin: true @@ -1445,6 +1465,9 @@ packages: '@types/ws@7.4.7': resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -3063,6 +3086,10 @@ packages: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} + hono@4.10.4: + resolution: {integrity: sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg==} + engines: {node: '>=16.9.0'} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -4069,8 +4096,8 @@ packages: engines: {node: '>=16'} hasBin: true - playwright-core@1.49.0: - resolution: {integrity: sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==} + playwright-core@1.56.1: + resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} engines: {node: '>=18'} hasBin: true @@ -4079,8 +4106,8 @@ packages: engines: {node: '>=16'} hasBin: true - playwright@1.49.0: - resolution: {integrity: sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==} + playwright@1.56.1: + resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} engines: {node: '>=18'} hasBin: true @@ -5227,6 +5254,18 @@ packages: utf-8-validate: optional: true + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -5594,6 +5633,18 @@ snapshots: chalk: 5.3.0 optional: true + '@epic-web/test-server@0.1.6': + dependencies: + '@hono/node-server': 1.19.6(hono@4.10.4) + '@hono/node-ws': 1.2.0(@hono/node-server@1.19.6(hono@4.10.4))(hono@4.10.4) + '@open-draft/deferred-promise': 2.2.0 + '@types/ws': 8.18.1 + hono: 4.10.4 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@esbuild/aix-ppc64@0.20.2': optional: true @@ -5888,6 +5939,19 @@ snapshots: dependencies: graphql: 16.8.2 + '@hono/node-server@1.19.6(hono@4.10.4)': + dependencies: + hono: 4.10.4 + + '@hono/node-ws@1.2.0(@hono/node-server@1.19.6(hono@4.10.4))(hono@4.10.4)': + dependencies: + '@hono/node-server': 1.19.6(hono@4.10.4) + hono: 4.10.4 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -6142,9 +6206,9 @@ snapshots: '@pkgr/core@0.2.9': {} - '@playwright/test@1.49.0': + '@playwright/test@1.56.1': dependencies: - playwright: 1.49.0 + playwright: 1.56.1 '@pnpm/config.env-replace@1.1.0': {} @@ -6460,6 +6524,10 @@ snapshots: dependencies: '@types/node': 18.19.28 + '@types/ws@8.18.1': + dependencies: + '@types/node': 18.19.28 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.32': @@ -8454,6 +8522,8 @@ snapshots: dependencies: parse-passwd: 1.0.0 + hono@4.10.4: {} + hosted-git-info@2.8.9: {} hosted-git-info@4.1.0: @@ -9461,7 +9531,7 @@ snapshots: playwright-core@1.42.1: {} - playwright-core@1.49.0: {} + playwright-core@1.56.1: {} playwright@1.42.1: dependencies: @@ -9469,9 +9539,9 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - playwright@1.49.0: + playwright@1.56.1: dependencies: - playwright-core: 1.49.0 + playwright-core: 1.56.1 optionalDependencies: fsevents: 2.3.2 @@ -10701,6 +10771,8 @@ snapshots: ws@8.18.0: {} + ws@8.18.3: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} 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/index.ts b/src/core/index.ts index 44f03bcdf..c81c7cc0b 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -17,6 +17,15 @@ export { type WebSocketHandlerConnection, } from './handlers/WebSocketHandler' +/* Server-Sent Events */ +export { + sse, + type ServerSentEventRequestHandler, + type ServerSentEventResolver, + type ServerSentEventResolverExtras, + type ServerSentEventMessage, +} from './sse' + /* Utils */ export { matchRequestUrl } from './utils/matching/matchRequestUrl' export * from './utils/handleRequest' diff --git a/src/core/sse.ts b/src/core/sse.ts new file mode 100644 index 000000000..9b58e3c8d --- /dev/null +++ b/src/core/sse.ts @@ -0,0 +1,897 @@ +import { invariant } from 'outvariant' +import { Emitter } from 'strict-event-emitter' +import type { ResponseResolver } from './handlers/RequestHandler' +import { + HttpHandler, + type HttpRequestResolverExtras, + type HttpRequestParsedResult, +} 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 + [key: string]: unknown + [key: symbol | number]: never +} + +export type ServerSentEventResolverExtras< + EventMap extends EventMapConstraint, + Params extends PathParams, +> = HttpRequestResolverExtras & { + client: ServerSentEventClient + server: ServerSentEventServer +} + +export type ServerSentEventResolver< + EventMap extends EventMapConstraint, + Params extends PathParams, +> = ResponseResolver, any, any> + +export type ServerSentEventRequestHandler = < + EventMap extends EventMapConstraint = { message: unknown }, + Params extends PathParams = PathParams, + RequestPath extends Path = Path, +>( + path: RequestPath, + resolver: ServerSentEventResolver, +) => HttpHandler + +export type ServerSentEventMessage< + EventMap extends EventMapConstraint = { message: unknown }, +> = + | ToEventDiscriminatedUnion + | { + id?: never + event?: never + data?: never + retry: number + } + +/** + * 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/api/sse `sse()` API reference} + */ +export const sse: ServerSentEventRequestHandler = (path, resolver) => { + return new ServerSentEventHandler(path, resolver) +} + +class ServerSentEventHandler< + EventMap extends EventMapConstraint, +> extends HttpHandler { + constructor(path: Path, resolver: ServerSentEventResolver) { + invariant( + typeof EventSource !== 'undefined', + '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, 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({ + async start(controller) { + const client = new ServerSentEventClient({ + controller, + emitter: clientEmitter, + }) + const server = new ServerSentEventServer({ + request: info.request, + client, + }) + + await resolver({ + ...info, + client, + server, + }) + }, + }) + + return new Response(stream, responseInit) + }) + } + + async predicate(args: { + request: Request + parsedResult: HttpRequestParsedResult + }) { + if (args.request.headers.get('accept') !== 'text/event-stream') { + return false + } + + return super.predicate(args) + } + + 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 + } + + #attachClientLogger( + request: Request, + emitter: Emitter, + ): void { + const publicUrl = toPublicUrl(request.url) + + /* eslint-disable no-console */ + emitter.on('message', (payload) => { + console.groupCollapsed( + devUtils.formatMessage( + `${getTimestamp()} SSE %s %c⇣%c ${payload.event}`, + ), + publicUrl, + `color:${colors.mocked}`, + 'color:inherit', + ) + console.log(payload.frames) + console.groupEnd() + }) + + emitter.on('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.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 */ + } +} + +type Values = T[keyof T] +type Identity = { [K in keyof T]: T[K] } & unknown +type ToEventDiscriminatedUnion = Values<{ + [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 }) + > +}> + +type ServerSentEventClientEventMap = { + message: [ + payload: { + id?: string + event: string + data?: unknown + frames: Array + }, + ] + error: [] + close: [] +} + +class ServerSentEventClient< + EventMap extends EventMapConstraint = { message: unknown }, +> { + #encoder: TextEncoder + #controller: ReadableStreamDefaultController + #emitter: Emitter + + constructor(args: { + controller: ReadableStreamDefaultController + emitter: Emitter + }) { + this.#encoder = new TextEncoder() + this.#controller = args.controller + this.#emitter = args.emitter + } + + /** + * Sends the given payload to the intercepted `EventSource`. + */ + public send(payload: ServerSentEventMessage): void { + if ('retry' in payload && payload.retry != null) { + this.#sendRetry(payload.retry) + return + } + + this.#sendMessage({ + id: payload.id, + event: payload.event, + data: + typeof payload.data === 'object' + ? JSON.stringify(payload.data) + : payload.data, + }) + } + + /** + * Dispatches the given event on the intercepted `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.#sendMessage({ + id: event.lastEventId || undefined, + event: event.type === 'message' ? undefined : event.type, + data: event.data, + }) + return + } + + if (event.type === 'error') { + this.error() + return + } + + if (event.type === 'close') { + this.close() + return + } + } + + /** + * Errors the underlying `EventSource`, closing the connection with an error. + * 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') + } + + /** + * Closes the underlying `EventSource`, closing the connection. + */ + public close(): void { + 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`)) + } + } + + #sendMessage(message: { + id?: string + event?: unknown + data: unknown | undefined + }): void { + 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: message.id, + event: message.event?.toString() || 'message', + data: message.data, + frames, + }) + } +} + +class ServerSentEventServer { + #request: Request + #client: ServerSentEventClient + + constructor(args: { request: Request; client: ServerSentEventClient }) { + this.#request = args.request + this.#client = args.client + } + + /** + * Establishes the 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 passthrough so it doesn't trigger + * an infinite loop matching against the existing request handler. + */ + accept: 'msw/passthrough', + }, + }) + + 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) + } + }) + } + + // 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) + } + }) + }) + + return source + } +} + +interface ObservableEventSourceInit extends EventSourceInit { + headers?: HeadersInit +} + +type EventHandler = ( + this: EventSource, + event: EventType, +) => 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.append('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 { + 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) + }, + abort: () => { + throw new Error('Stream abort is not implemented') + }, + close: () => { + this.failConnection() + }, + }) + + response + .body!.pipeTo(parsingStream) + .then(() => { + this.processResponseEndOfBody(response) + }) + .catch(() => { + this.failConnection() + }) + } + + 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 + 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() + 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 + break + } + + 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/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/attachWebSocketLogger.ts b/src/core/ws/utils/attachWebSocketLogger.ts index 64631a181..91ecd92f5 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', diff --git a/src/core/ws/utils/getMessageLength.ts b/src/core/ws/utils/getMessageLength.ts index fd88caa77..931d497b9 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/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 af3db0d8b..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,7 +9,7 @@ export async function getPublicData(data: WebSocketData): Promise { } // Handle all ArrayBuffer-like objects. - if (typeof data === 'object' && 'byteLength' in 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.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.client.send.test.ts b/test/browser/sse-api/sse.client.send.test.ts new file mode 100644 index 000000000..42baf1b30 --- /dev/null +++ b/test/browser/sse-api/sse.client.send.test.ts @@ -0,0 +1,150 @@ +import { sse } from 'msw' +import { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare namespace window { + export const msw: { + setupWorker: typeof setupWorker + sse: typeof sse + } +} + +const EXAMPLE_URL = new URL('./sse.mocks.ts', import.meta.url) + +test('sends a mock message event', async ({ loadExample, page }) => { + await loadExample(EXAMPLE_URL, { + 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.onerror = () => reject() + + source.addEventListener('message', (event) => { + resolve(`${event.type}:${event.data}`) + }) + }) + }) + + expect(message).toBe('message:{"username":"john"}') +}) + +test('sends a mock custom event', async ({ loadExample, page }) => { + await loadExample(EXAMPLE_URL, { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + 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) => { + 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(EXAMPLE_URL, { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + 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) => { + const source = new EventSource('http://localhost/stream') + source.addEventListener('userconnect', (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 }) => { + await loadExample(EXAMPLE_URL, { + skipActivation: true, + }) + + await page.evaluate(async () => { + const { setupWorker, sse } = window.msw + + const worker = setupWorker( + sse('http://localhost/stream-error', ({ client }) => { + queueMicrotask(() => client.error()) + }), + ) + await worker.start() + }) + + const readyState = await page.evaluate(() => { + 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) + + /** + * @note That erroring the stream does not throw any errors. + */ +}) diff --git a/test/browser/sse-api/sse.mocks.ts b/test/browser/sse-api/sse.mocks.ts new file mode 100644 index 000000000..013257888 --- /dev/null +++ b/test/browser/sse-api/sse.mocks.ts @@ -0,0 +1,9 @@ +import { http, sse } from 'msw' +import { setupWorker } from 'msw/browser' + +window.msw = { + setupWorker, + // @ts-ignore + http, + sse, +} 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..13374fe03 --- /dev/null +++ b/test/browser/sse-api/sse.retry.test.ts @@ -0,0 +1,58 @@ +import { sse } from 'msw' +import { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare namespace window { + export const msw: { + setupWorker: typeof setupWorker + sse: typeof sse + } +} + +const EXAMPLE_URL = new URL('./sse.mocks.ts', import.meta.url) + +test('supports mocking the retry time', async ({ loadExample, page }) => { + await loadExample(EXAMPLE_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 new file mode 100644 index 000000000..21eb1203a --- /dev/null +++ b/test/browser/sse-api/sse.server.connect.test.ts @@ -0,0 +1,297 @@ +import { sse } from 'msw' +import { setupWorker } 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 + } +} + +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!') + 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, +}) => { + await loadExample(EXAMPLE_URL, { + 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'), + ) + controller.close() + }, + }) + + 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 }) => { + // 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(EXAMPLE_URL, { + 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<{ message: string }>((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) + + expect(message).toEqual({ message: 'hello' }) +}) + +test('forwards custom event from the server to the client automatically', async ({ + loadExample, + page, +}) => { + await loadExample(EXAMPLE_URL, { + 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, ({ server }) => { + server.connect() + }), + ) + await worker.start() + }, url) + + const message = await page.evaluate((url) => { + return new Promise<{ message: string }>((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) + + expect(message).toEqual({ message: 'hello' }) +}) + +test('forwards error event from the server to the client automatically', async ({ + loadExample, + page, +}) => { + await loadExample(EXAMPLE_URL, { + 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: { + '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 = () => resolve() + source.onmessage = () => reject(new Error('Must not receive a message')) + }) + }, url) + + await expect(errorPromise).resolves.toBeUndefined() +}) + +test('forward custom stream errors from the original server to the client automatically', async ({ + loadExample, + page, +}) => { + await loadExample(EXAMPLE_URL, { + 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 = () => resolve() + source.onmessage = () => { + reject(new Error('Must not receive a message')) + } + }) + }, 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..346416604 --- /dev/null +++ b/test/browser/sse-api/sse.use.test.ts @@ -0,0 +1,61 @@ +import { sse } from 'msw' +import { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare namespace window { + export const msw: { + setupWorker: typeof setupWorker + sse: typeof sse + } +} + +const EXAMPLE_URL = new URL('./sse.mocks.ts', import.meta.url) + +test('supports server-sent event handler overrides', async ({ + loadExample, + page, +}) => { + await loadExample(EXAMPLE_URL, { + 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..98f9cee8c --- /dev/null +++ b/test/browser/sse-api/sse.with-credentials.test.ts @@ -0,0 +1,56 @@ +import { sse } from 'msw' +import { setupWorker } from 'msw/browser' +import { test, expect } from '../playwright.extend' + +declare namespace window { + export const msw: { + setupWorker: typeof setupWorker + sse: typeof sse + } +} + +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(EXAMPLE_URL, { + 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..990de4972 --- /dev/null +++ b/test/typings/sse.test-d.ts @@ -0,0 +1,182 @@ +import { it } from 'vitest' +import { sse } from 'msw' + +/** + * @note Define the global property to simulate an EventSource-compatible environment. + * MSW checks for that and throws to prevent incorrect usage. + */ +Object.defineProperty(global, 'EventSource', { value: () => {} }) + +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', + data: 'hello', + }) + + client.send({ + // @ts-expect-error Unknown event type "unknown". + 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', + }) + }) +}) + +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 + 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, + }) + + // 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, + }) + }) +}) + +it('supports optional event data', () => { + sse<{ maybe?: string }>('/stream', ({ client }) => { + // No data is fine because data is optional. + client.send({ event: 'maybe' }) + + // Allows explicit undefined as optional data. + client.send({ event: 'maybe', data: undefined }) + + // 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, + }) + }) +}) + +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: 1000 }) + + // @ts-expect-error Cannot use message properties with "retry". + client.send({ retry: 1000, id: '1' }) + // @ts-expect-error Cannot use message properties with "retry". + client.send({ retry: 1000, data: 'hello' }) + // @ts-expect-error Cannot use message properties with "retry". + client.send({ retry: 1000, event: 'custom', data: 'goodbye' }) + // prettier-ignore-end + }) +})