msw 2.14.6 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +5 -5
  2. package/lib/browser/index.js +233 -93
  3. package/lib/browser/index.js.map +1 -1
  4. package/lib/browser/index.mjs +233 -93
  5. package/lib/browser/index.mjs.map +1 -1
  6. package/lib/core/{HttpResponse-CQwYpuKo.d.mts → HttpResponse-BE_Y2Ak9.d.mts} +19 -1
  7. package/lib/core/{HttpResponse-BFS34nkx.d.ts → HttpResponse-DL-P1EeG.d.ts} +19 -1
  8. package/lib/core/HttpResponse.d.mts +1 -1
  9. package/lib/core/HttpResponse.d.ts +1 -1
  10. package/lib/core/HttpResponse.js +2 -2
  11. package/lib/core/HttpResponse.js.map +1 -1
  12. package/lib/core/HttpResponse.mjs +2 -2
  13. package/lib/core/HttpResponse.mjs.map +1 -1
  14. package/lib/core/experimental/compat.d.mts +2 -2
  15. package/lib/core/experimental/compat.d.ts +2 -2
  16. package/lib/core/experimental/define-network.d.mts +2 -2
  17. package/lib/core/experimental/define-network.d.ts +2 -2
  18. package/lib/core/experimental/frames/http-frame.d.mts +2 -2
  19. package/lib/core/experimental/frames/http-frame.d.ts +2 -2
  20. package/lib/core/experimental/frames/network-frame.d.mts +2 -2
  21. package/lib/core/experimental/frames/network-frame.d.ts +2 -2
  22. package/lib/core/experimental/frames/network-frame.js +2 -0
  23. package/lib/core/experimental/frames/network-frame.js.map +1 -1
  24. package/lib/core/experimental/frames/network-frame.mjs +2 -0
  25. package/lib/core/experimental/frames/network-frame.mjs.map +1 -1
  26. package/lib/core/experimental/frames/websocket-frame.d.mts +2 -2
  27. package/lib/core/experimental/frames/websocket-frame.d.ts +2 -2
  28. package/lib/core/experimental/handlers-controller.d.mts +1 -1
  29. package/lib/core/experimental/handlers-controller.d.ts +1 -1
  30. package/lib/core/experimental/index.d.mts +2 -2
  31. package/lib/core/experimental/index.d.ts +2 -2
  32. package/lib/core/experimental/on-unhandled-frame.d.mts +2 -2
  33. package/lib/core/experimental/on-unhandled-frame.d.ts +2 -2
  34. package/lib/core/experimental/setup-api.d.mts +1 -1
  35. package/lib/core/experimental/setup-api.d.ts +1 -1
  36. package/lib/core/experimental/sources/interceptor-source.d.mts +2 -2
  37. package/lib/core/experimental/sources/interceptor-source.d.ts +2 -2
  38. package/lib/core/experimental/sources/network-source.d.mts +2 -2
  39. package/lib/core/experimental/sources/network-source.d.ts +2 -2
  40. package/lib/core/getResponse.d.mts +1 -1
  41. package/lib/core/getResponse.d.ts +1 -1
  42. package/lib/core/graphql.d.mts +1 -1
  43. package/lib/core/graphql.d.ts +1 -1
  44. package/lib/core/handlers/GraphQLHandler.d.mts +1 -1
  45. package/lib/core/handlers/GraphQLHandler.d.ts +1 -1
  46. package/lib/core/handlers/HttpHandler.d.mts +1 -1
  47. package/lib/core/handlers/HttpHandler.d.ts +1 -1
  48. package/lib/core/handlers/RequestHandler.d.mts +1 -1
  49. package/lib/core/handlers/RequestHandler.d.ts +1 -1
  50. package/lib/core/handlers/RequestHandler.js +88 -23
  51. package/lib/core/handlers/RequestHandler.js.map +1 -1
  52. package/lib/core/handlers/RequestHandler.mjs +88 -23
  53. package/lib/core/handlers/RequestHandler.mjs.map +1 -1
  54. package/lib/core/handlers/WebSocketHandler.js +7 -6
  55. package/lib/core/handlers/WebSocketHandler.js.map +1 -1
  56. package/lib/core/handlers/WebSocketHandler.mjs +7 -6
  57. package/lib/core/handlers/WebSocketHandler.mjs.map +1 -1
  58. package/lib/core/http.d.mts +1 -1
  59. package/lib/core/http.d.ts +1 -1
  60. package/lib/core/index.d.mts +1 -1
  61. package/lib/core/index.d.ts +1 -1
  62. package/lib/core/{on-unhandled-frame-BBR-P3kV.d.ts → on-unhandled-frame-0KmwUmeV.d.ts} +1 -1
  63. package/lib/core/{on-unhandled-frame-Cr1KOZ0I.d.mts → on-unhandled-frame-gkokW4Rr.d.mts} +1 -1
  64. package/lib/core/passthrough.d.mts +1 -1
  65. package/lib/core/passthrough.d.ts +1 -1
  66. package/lib/core/sse.d.mts +2 -2
  67. package/lib/core/sse.d.ts +2 -2
  68. package/lib/core/sse.js +83 -60
  69. package/lib/core/sse.js.map +1 -1
  70. package/lib/core/sse.mjs +83 -60
  71. package/lib/core/sse.mjs.map +1 -1
  72. package/lib/core/utils/HttpResponse/decorators.d.mts +8 -3
  73. package/lib/core/utils/HttpResponse/decorators.d.ts +8 -3
  74. package/lib/core/utils/HttpResponse/decorators.js +14 -1
  75. package/lib/core/utils/HttpResponse/decorators.js.map +1 -1
  76. package/lib/core/utils/HttpResponse/decorators.mjs +14 -1
  77. package/lib/core/utils/HttpResponse/decorators.mjs.map +1 -1
  78. package/lib/core/utils/executeHandlers.d.mts +1 -1
  79. package/lib/core/utils/executeHandlers.d.ts +1 -1
  80. package/lib/core/utils/handleRequest.d.mts +1 -1
  81. package/lib/core/utils/handleRequest.d.ts +1 -1
  82. package/lib/core/utils/internal/attachSiblingHandlers.d.mts +1 -1
  83. package/lib/core/utils/internal/attachSiblingHandlers.d.ts +1 -1
  84. package/lib/core/utils/internal/attachSiblingHandlers.js +1 -1
  85. package/lib/core/utils/internal/attachSiblingHandlers.js.map +1 -1
  86. package/lib/core/utils/internal/attachSiblingHandlers.mjs +1 -1
  87. package/lib/core/utils/internal/attachSiblingHandlers.mjs.map +1 -1
  88. package/lib/core/utils/internal/isHandlerKind.d.mts +1 -1
  89. package/lib/core/utils/internal/isHandlerKind.d.ts +1 -1
  90. package/lib/core/utils/internal/observe-response-body-stream.d.mts +18 -0
  91. package/lib/core/utils/internal/observe-response-body-stream.d.ts +18 -0
  92. package/lib/core/utils/internal/observe-response-body-stream.js +64 -0
  93. package/lib/core/utils/internal/observe-response-body-stream.js.map +1 -0
  94. package/lib/core/utils/internal/observe-response-body-stream.mjs +44 -0
  95. package/lib/core/utils/internal/observe-response-body-stream.mjs.map +1 -0
  96. package/lib/core/utils/internal/parseGraphQLRequest.d.mts +1 -1
  97. package/lib/core/utils/internal/parseGraphQLRequest.d.ts +1 -1
  98. package/lib/core/utils/internal/parseMultipartData.d.mts +1 -1
  99. package/lib/core/utils/internal/parseMultipartData.d.ts +1 -1
  100. package/lib/core/ws/WebSocketClientManager.js +4 -0
  101. package/lib/core/ws/WebSocketClientManager.js.map +1 -1
  102. package/lib/core/ws/WebSocketClientManager.mjs +4 -0
  103. package/lib/core/ws/WebSocketClientManager.mjs.map +1 -1
  104. package/lib/core/ws/handleWebSocketEvent.d.mts +1 -1
  105. package/lib/core/ws/handleWebSocketEvent.d.ts +1 -1
  106. package/lib/iife/index.js +4668 -1105
  107. package/lib/iife/index.js.map +1 -1
  108. package/lib/mockServiceWorker.js +21 -9
  109. package/package.json +4 -5
  110. package/src/core/handlers/HttpHandler.test.ts +89 -0
  111. package/src/core/handlers/RequestHandler.ts +142 -32
  112. package/src/core/sse.ts +100 -57
  113. package/src/core/utils/HttpResponse/decorators.ts +23 -2
  114. package/src/core/utils/internal/observe-response-body-stream.test.ts +64 -0
  115. package/src/core/utils/internal/observe-response-body-stream.ts +77 -0
  116. package/src/mockServiceWorker.js +19 -7
@@ -7,8 +7,8 @@
7
7
  * - Please do NOT modify this file.
8
8
  */
9
9
 
10
- const PACKAGE_VERSION = '2.14.6'
11
- const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
10
+ const PACKAGE_VERSION = '2.15.0'
11
+ const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
12
12
  const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
13
13
  const activeClientIds = new Set()
14
14
 
@@ -137,8 +137,18 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
137
137
  if (client && activeClientIds.has(client.id)) {
138
138
  const serializedRequest = await serializeRequest(requestCloneForEvents)
139
139
 
140
+ // Omit the body of server-sent event stream responses.
141
+ // Cloning such responses would prevent client-side stream cancelations
142
+ // from reaching the original stream (a teed stream only cancels its
143
+ // source once both of its branches cancel) and would buffer the
144
+ // entire stream into the unconsumed clone indefinitely.
145
+ const isEventStreamResponse = response.headers
146
+ .get('content-type')
147
+ ?.toLowerCase()
148
+ .startsWith('text/event-stream')
149
+
140
150
  // Clone the response so both the client and the library could consume it.
141
- const responseClone = response.clone()
151
+ const responseClone = isEventStreamResponse ? null : response.clone()
142
152
 
143
153
  sendToClient(
144
154
  client,
@@ -151,15 +161,17 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
151
161
  ...serializedRequest,
152
162
  },
153
163
  response: {
154
- type: responseClone.type,
155
- status: responseClone.status,
156
- statusText: responseClone.statusText,
157
- headers: Object.fromEntries(responseClone.headers.entries()),
158
- body: responseClone.body,
164
+ type: response.type,
165
+ status: response.status,
166
+ statusText: response.statusText,
167
+ headers: Object.fromEntries(response.headers.entries()),
168
+ body: responseClone ? responseClone.body : null,
159
169
  },
160
170
  },
161
171
  },
162
- responseClone.body ? [serializedRequest.body, responseClone.body] : [],
172
+ responseClone && responseClone.body
173
+ ? [serializedRequest.body, responseClone.body]
174
+ : [],
163
175
  )
164
176
  }
165
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "msw",
3
- "version": "2.14.6",
3
+ "version": "2.15.0",
4
4
  "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.",
5
5
  "type": "commonjs",
6
6
  "main": "./lib/core/index.js",
@@ -248,7 +248,7 @@
248
248
  "@fastify/websocket": "^11.2.0",
249
249
  "@graphql-typed-document-node/core": "^3.2.0",
250
250
  "@open-draft/test-server": "^0.4.2",
251
- "@ossjs/release": "^0.10.1",
251
+ "@ossjs/release": "^0.11.1",
252
252
  "@playwright/test": "^1.59.1",
253
253
  "@types/express": "^5.0.6",
254
254
  "@types/json-bigint": "^1.0.4",
@@ -280,13 +280,12 @@
280
280
  "simple-git-hooks": "^2.13.1",
281
281
  "tsup": "^8.5.1",
282
282
  "typescript": "^5.9.3",
283
- "undici": "^8.1.0",
283
+ "undici": "^7.28.0",
284
284
  "url-loader": "^4.1.1",
285
285
  "vitest": "^4.1.4",
286
- "vitest-environment-miniflare": "^2.14.4",
287
286
  "webpack": "^5.106.2",
288
287
  "webpack-http-server": "^0.5.0",
289
- "msw": "^2.14.6"
288
+ "msw": "^2.15.0"
290
289
  },
291
290
  "peerDependencies": {
292
291
  "typescript": ">= 4.8.x"
@@ -252,3 +252,92 @@ describe('run', () => {
252
252
  await expect(run()).resolves.toBe('complete')
253
253
  })
254
254
  })
255
+
256
+ describe('finalize', () => {
257
+ it('returns the exact response instance if the resolver never accesses finalize', async () => {
258
+ const response = new HttpResponse(
259
+ new ReadableStream({
260
+ start(controller) {
261
+ controller.enqueue(new TextEncoder().encode('hello'))
262
+ controller.close()
263
+ },
264
+ }),
265
+ )
266
+ const handler = new HttpHandler('GET', '/resource', () => {
267
+ return response
268
+ })
269
+
270
+ const result = await handler.run({
271
+ request: new Request(new URL('/resource', location.href)),
272
+ requestId: createRequestId(),
273
+ })
274
+
275
+ expect(result?.response).toBe(response)
276
+ })
277
+
278
+ it('returns the exact response instance if finalize is accessed but never called', async () => {
279
+ const response = new HttpResponse(
280
+ new ReadableStream({
281
+ start(controller) {
282
+ controller.close()
283
+ },
284
+ }),
285
+ )
286
+ const handler = new HttpHandler('GET', '/resource', ({ finalize }) => {
287
+ expect(finalize).toBeInstanceOf(Function)
288
+ return response
289
+ })
290
+
291
+ const result = await handler.run({
292
+ request: new Request(new URL('/resource', location.href)),
293
+ requestId: createRequestId(),
294
+ })
295
+
296
+ expect(result?.response).toBe(response)
297
+ })
298
+
299
+ it('defers the cleanup until the response stream settles', async () => {
300
+ const cleanup = vi.fn()
301
+ const response = new HttpResponse(
302
+ new ReadableStream({
303
+ start(controller) {
304
+ controller.enqueue(new TextEncoder().encode('hello'))
305
+ controller.close()
306
+ },
307
+ }),
308
+ )
309
+ const handler = new HttpHandler('GET', '/resource', ({ finalize }) => {
310
+ finalize(cleanup)
311
+ return response
312
+ })
313
+
314
+ const result = await handler.run({
315
+ request: new Request(new URL('/resource', location.href)),
316
+ requestId: createRequestId(),
317
+ })
318
+
319
+ // The observed response is a copy of the original response.
320
+ expect(result?.response).not.toBe(response)
321
+ expect(cleanup).not.toHaveBeenCalled()
322
+
323
+ await expect(result?.response?.text()).resolves.toBe('hello')
324
+ await vi.waitFor(() => {
325
+ expect(cleanup).toHaveBeenCalledOnce()
326
+ })
327
+ })
328
+
329
+ it('runs the cleanup immediately for responses without a body', async () => {
330
+ const cleanup = vi.fn()
331
+ const handler = new HttpHandler('GET', '/resource', ({ finalize }) => {
332
+ finalize(cleanup)
333
+ return new HttpResponse(null)
334
+ })
335
+
336
+ await handler.run({
337
+ request: new Request(new URL('/resource', location.href)),
338
+ requestId: createRequestId(),
339
+ })
340
+
341
+ expect(cleanup).toHaveBeenCalledOnce()
342
+ })
343
+ })
@@ -15,6 +15,7 @@ import {
15
15
  import type { GraphQLRequestBody } from './GraphQLHandler'
16
16
  import { devUtils } from '../utils/internal/devUtils'
17
17
  import { getRawSetCookie } from '../utils/HttpResponse/decorators'
18
+ import { observeResponseBodyStream } from '../utils/internal/observe-response-body-stream'
18
19
 
19
20
  export type DefaultRequestMultipartBody = Record<
20
21
  string,
@@ -31,12 +32,7 @@ export type DefaultBodyType =
31
32
  | undefined
32
33
 
33
34
  export type JsonBodyType =
34
- | Record<string, any>
35
- | string
36
- | number
37
- | boolean
38
- | null
39
- | undefined
35
+ Record<string, any> | string | number | boolean | null | undefined
40
36
 
41
37
  export interface RequestHandlerDefaultInfo {
42
38
  header: string
@@ -94,6 +90,10 @@ export type ResponseResolverInfo<
94
90
  /**
95
91
  * Schedule a callback to run after this response resolver completes.
96
92
  * Handy for cleaning up the side effects introduced in the resolver.
93
+ *
94
+ * For responses with a `ReadableStream` body (including `sse()` handlers),
95
+ * the callback runs once the response stream settles: it is read to
96
+ * completion, errored, or canceled, or the request is aborted.
97
97
  * @example
98
98
  * sse('/', ({ client, finalize }) => {
99
99
  * const interval = setInterval(() => client.send({ data: 'ping' }))
@@ -355,20 +355,54 @@ export abstract class RequestHandler<
355
355
 
356
356
  const listenerController = new AbortController()
357
357
 
358
- args.request.signal.addEventListener(
359
- 'abort',
360
- () => this.runScheduledCleanups(args.requestId),
361
- {
362
- once: true,
363
- signal: listenerController.signal,
364
- },
365
- )
358
+ /**
359
+ * @note Initialize the `finalize` machinery lazily, on the first
360
+ * access of the `finalize` property by the resolver. If the resolver
361
+ * never accesses it, the handler behaves as if `finalize` never
362
+ * existed: no abort listeners, no scheduled cleanups, and no response
363
+ * body stream observation (see `this.complete()`).
364
+ */
365
+ let finalizeFunction: ResponseResolverFinalizeFunction | undefined
366
+
367
+ const getFinalize = (): ResponseResolverFinalizeFunction => {
368
+ if (finalizeFunction == null) {
369
+ // Run any scheduled cleanups if the request gets aborted
370
+ // while the resolver is still executing.
371
+ if (!args.request.signal.aborted) {
372
+ args.request.signal.addEventListener(
373
+ 'abort',
374
+ () => this.runScheduledCleanups(args.requestId),
375
+ {
376
+ once: true,
377
+ signal: listenerController.signal,
378
+ },
379
+ )
380
+ }
381
+
382
+ finalizeFunction = (callback) => {
383
+ this.scheduleCleanup(args.requestId, callback)
384
+
385
+ /**
386
+ * @note Run the cleanup immediately if the request has already
387
+ * been aborted. The "abort" listener above never fires for an
388
+ * already-aborted signal (and fires at most once), while
389
+ * long-lived resolvers (streams, generators) may never settle
390
+ * to run the cleanups on completion.
391
+ */
392
+ if (args.request.signal.aborted) {
393
+ void this.runScheduledCleanups(args.requestId)
394
+ }
395
+ }
396
+ }
397
+
398
+ return finalizeFunction
399
+ }
366
400
 
367
401
  const mockedResponsePromise = (
368
402
  executeResolver({
369
403
  ...resolverExtras,
370
- finalize: (callback) => {
371
- this.scheduleCleanup(args.requestId, callback)
404
+ get finalize(): ResponseResolverFinalizeFunction {
405
+ return getFinalize()
372
406
  },
373
407
  requestId: args.requestId,
374
408
  request: args.request,
@@ -423,9 +457,13 @@ export abstract class RequestHandler<
423
457
  }
424
458
 
425
459
  if (!isIterable(result)) {
426
- // Otherwise, run the cleanup immediately if it's a plain response resolver.
427
- await this.runScheduledCleanups(info.requestId)
428
- return result
460
+ // Otherwise, run the cleanups if it's a plain response resolver
461
+ // (deferred until the response stream settles for streamed responses).
462
+ return this.complete({
463
+ request: info.request,
464
+ requestId: info.requestId,
465
+ response: result,
466
+ })
429
467
  }
430
468
 
431
469
  /**
@@ -465,11 +503,13 @@ export abstract class RequestHandler<
465
503
  // only after it's been completely exhausted.
466
504
  this.isUsed = true
467
505
 
468
- await this.runScheduledCleanups(info.requestId)
469
-
470
506
  // Clone the previously stored response so it can be read
471
507
  // when receiving it repeatedly from the "done" generator.
472
- return this.resolverIteratorResult?.clone()
508
+ return this.complete({
509
+ request: info.request,
510
+ requestId: info.requestId,
511
+ response: this.resolverIteratorResult?.clone(),
512
+ })
473
513
  }
474
514
 
475
515
  return nextResponse
@@ -532,28 +572,98 @@ export abstract class RequestHandler<
532
572
  }
533
573
  }
534
574
 
535
- private async runScheduledCleanups(requestId: string): Promise<void> {
575
+ /**
576
+ * Remove and return the cleanups scheduled for the given request
577
+ * (or the pending iterator cleanups for generator resolvers).
578
+ */
579
+ private takeScheduledCleanups(
580
+ requestId: string,
581
+ ): Array<() => MaybePromise<void>> | undefined {
536
582
  if (
537
583
  this.resolverIterator &&
538
584
  this.resolverIteratorCleanups != null &&
539
585
  this.resolverIteratorCleanups.length > 0
540
586
  ) {
541
- try {
542
- await this.exhaustCleanups(this.resolverIteratorCleanups)
543
- } finally {
544
- this.resolverIteratorCleanups = undefined
545
- }
546
- return
587
+ const cleanups = this.resolverIteratorCleanups
588
+ this.resolverIteratorCleanups = undefined
589
+ return cleanups
547
590
  }
548
591
 
549
592
  const cleanups = this.scheduledCleanups.get(requestId)
550
593
 
551
- if (!cleanups || cleanups.length == 0) {
552
- return
594
+ if (!cleanups || cleanups.length === 0) {
595
+ return undefined
553
596
  }
554
597
 
555
- await this.exhaustCleanups(cleanups)
556
598
  this.scheduledCleanups.delete(requestId)
599
+ return cleanups
600
+ }
601
+
602
+ private async runScheduledCleanups(requestId: string): Promise<void> {
603
+ const cleanups = this.takeScheduledCleanups(requestId)
604
+
605
+ if (cleanups) {
606
+ await this.exhaustCleanups(cleanups)
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Conclude the response resolution for the given request.
612
+ * Runs the scheduled cleanups immediately for responses without a
613
+ * `ReadableStream` body. For streamed responses, returns an observed
614
+ * copy of the response and defers the cleanups until its body settles
615
+ * (is read to completion, errored, or canceled) or the request is
616
+ * aborted, whichever comes first.
617
+ */
618
+ private async complete(args: {
619
+ request: StrictRequest<any>
620
+ requestId: string
621
+ response: Response | undefined | void
622
+ }): Promise<Response | undefined | void> {
623
+ const cleanups = this.takeScheduledCleanups(args.requestId)
624
+
625
+ if (!cleanups) {
626
+ return args.response
627
+ }
628
+
629
+ const observedResponse = args.response
630
+ ? observeResponseBodyStream(args.response)
631
+ : null
632
+
633
+ if (!observedResponse) {
634
+ await this.exhaustCleanups(cleanups)
635
+ return args.response
636
+ }
637
+
638
+ const listenerController = new AbortController()
639
+
640
+ const runCleanupsOnce = (): void => {
641
+ if (listenerController.signal.aborted) {
642
+ return
643
+ }
644
+
645
+ listenerController.abort()
646
+ void this.exhaustCleanups(cleanups)
647
+ }
648
+
649
+ void observedResponse.settled.then(runCleanupsOnce)
650
+
651
+ /**
652
+ * @note Also run the cleanups when the request is aborted.
653
+ * Stream cancellation does not always propagate to the observed
654
+ * response (e.g. if an unconsumed clone of the response exists),
655
+ * while the request abort reliably means the response is unused.
656
+ */
657
+ if (args.request.signal.aborted) {
658
+ runCleanupsOnce()
659
+ } else {
660
+ args.request.signal.addEventListener('abort', runCleanupsOnce, {
661
+ once: true,
662
+ signal: listenerController.signal,
663
+ })
664
+ }
665
+
666
+ return observedResponse.response
557
667
  }
558
668
  }
559
669
 
package/src/core/sse.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { invariant } from 'outvariant'
2
2
  import { DeferredPromise } from '@open-draft/deferred-promise'
3
- import { Emitter } from 'strict-event-emitter'
3
+ import { Emitter, TypedEvent } from 'rettime'
4
4
  import type { ResponseResolver } from './handlers/RequestHandler'
5
5
  import {
6
6
  HttpHandler,
@@ -80,7 +80,7 @@ const SSE_RESPONSE_INIT: ResponseInit = {
80
80
  class ServerSentEventHandler<
81
81
  EventMap extends EventMapConstraint,
82
82
  > extends HttpHandler {
83
- #emitter: Emitter<ServerSentEventClientEventMap>
83
+ #loggableRequests: WeakSet<Request>
84
84
 
85
85
  constructor(path: Path, resolver: ServerSentEventResolver<EventMap, any>) {
86
86
  invariant(
@@ -94,18 +94,33 @@ class ServerSentEventHandler<
94
94
  info.request,
95
95
  )
96
96
 
97
- client[kClientEmitter] = this.#emitter
97
+ /**
98
+ * @note Scope the emitter to this connection so its logging
99
+ * listeners don't accumulate on the handler across connections.
100
+ */
101
+ const emitter = new Emitter<ServerSentEventClientEventMap>()
102
+ client[kClientEmitter] = emitter
98
103
 
99
- await resolver({
100
- ...info,
101
- client,
102
- server,
103
- })
104
+ if (this.#loggableRequests.has(info.request)) {
105
+ this.#attachClientLogger(info.request, emitter)
106
+ }
107
+
108
+ /**
109
+ * @note Extend the resolver info via assignment instead of a spread.
110
+ * Spreading the info would invoke its lazy `finalize` getter,
111
+ * initializing the finalize machinery for resolvers that never use it.
112
+ */
113
+ await resolver(
114
+ Object.assign(info, {
115
+ client,
116
+ server,
117
+ }),
118
+ )
104
119
 
105
120
  return response
106
121
  })
107
122
 
108
- this.#emitter = new Emitter<ServerSentEventClientEventMap>()
123
+ this.#loggableRequests = new WeakSet()
109
124
  }
110
125
 
111
126
  async predicate(args: {
@@ -134,13 +149,22 @@ class ServerSentEventHandler<
134
149
  response: new Response('[streaming]', SSE_RESPONSE_INIT),
135
150
  })
136
151
 
137
- this.#attachClientLogger(args.request, this.#emitter)
152
+ // Defer attaching the client logger until the resolver runs
153
+ // and creates a connection-scoped emitter for this request.
154
+ this.#loggableRequests.add(args.request)
138
155
  }
139
156
 
140
157
  return matches
141
158
  }
142
159
 
143
- async log(_args: { request: Request; response: Response }): Promise<void> {
160
+ async log(args: { request: Request; response: Response }): Promise<void> {
161
+ /**
162
+ * @note Cancel the response stream because it's not needed for logging.
163
+ * Otherwise, this cloned response remains unconsumed and its original
164
+ * doesn't propagate stream cancelations at all.
165
+ */
166
+ args.response.body?.cancel()
167
+
144
168
  /**
145
169
  * @note Skip the default `this.log()` logic so that when this handler is logged
146
170
  * upon handling the request, nothing is printed (we log SSE requests early).
@@ -155,16 +179,14 @@ class ServerSentEventHandler<
155
179
  const publicUrl = toPublicUrl(request.url)
156
180
 
157
181
  /* eslint-disable no-console */
158
- emitter.on('message', (payload) => {
182
+ emitter.on('message', ({ data }) => {
159
183
  console.groupCollapsed(
160
- devUtils.formatMessage(
161
- `${getTimestamp()} SSE %s %c⇣%c ${payload.event}`,
162
- ),
184
+ devUtils.formatMessage(`${getTimestamp()} SSE %s %c⇣%c ${data.event}`),
163
185
  publicUrl,
164
186
  `color:${colors.mocked}`,
165
187
  'color:inherit',
166
188
  )
167
- console.log(payload.frames)
189
+ console.log(data.frames)
168
190
  console.groupEnd()
169
191
  })
170
192
 
@@ -216,9 +238,9 @@ type ToEventDiscriminatedUnion<T> = Values<{
216
238
  }>
217
239
 
218
240
  type ServerSentEventClientEventMap = {
219
- message: [payload: EventStreamMessage]
220
- error: []
221
- close: []
241
+ message: TypedEvent<EventStreamMessage>
242
+ error: TypedEvent
243
+ close: TypedEvent
222
244
  }
223
245
 
224
246
  const kClientEmitter = Symbol.for('kClientEmitter')
@@ -229,11 +251,13 @@ class ServerSentEventClient<
229
251
  private [kClientEmitter]?: Emitter<ServerSentEventClientEventMap>
230
252
 
231
253
  #encoder: TextEncoder
232
- #writer: WritableStreamDefaultWriter
254
+ #controller: ReadableStreamDefaultController<Uint8Array>
255
+ #closed: DeferredPromise<void>
233
256
 
234
- constructor(writable: WritableStream) {
257
+ constructor(controller: ReadableStreamDefaultController<Uint8Array>) {
235
258
  this.#encoder = new TextEncoder()
236
- this.#writer = writable.getWriter()
259
+ this.#controller = controller
260
+ this.#closed = new DeferredPromise()
237
261
  }
238
262
 
239
263
  /**
@@ -289,37 +313,50 @@ class ServerSentEventClient<
289
313
  * error.
290
314
  */
291
315
  public error(): void {
292
- this.#writer.abort().catch((error) => {
293
- console.error(error)
294
- devUtils.error(
295
- 'Failed to abort server-side EventSource. Please see the original error above.',
296
- )
297
- })
298
- this[kClientEmitter]?.emit('error')
316
+ if (this.#closed.state !== 'pending') {
317
+ return
318
+ }
319
+
320
+ this.#controller.error()
321
+ this.#closed.resolve()
322
+ this[kClientEmitter]?.emit(new TypedEvent('error'))
299
323
  }
300
324
 
301
325
  /**
302
326
  * Closes the underlying `EventSource`, closing the connection.
303
327
  */
304
328
  public close(): void {
305
- this.#writer.close().catch((error) => {
329
+ if (this.#closed.state !== 'pending') {
330
+ return
331
+ }
332
+
333
+ try {
334
+ this.#controller.close()
335
+ this.#closed.resolve()
336
+ } catch {
337
+ //
338
+ }
339
+
340
+ this[kClientEmitter]?.emit(new TypedEvent('close'))
341
+ }
342
+
343
+ #enqueue(chunk: Uint8Array): void {
344
+ if (this.#closed.state !== 'pending') {
345
+ return
346
+ }
347
+
348
+ try {
349
+ this.#controller.enqueue(chunk)
350
+ } catch (error) {
306
351
  console.error(error)
307
352
  devUtils.error(
308
- 'Failed to close server-side EventSource. Please see the original error above.',
353
+ 'Failed to write to server-side EventSource. Please see the original error above.',
309
354
  )
310
- })
311
- this[kClientEmitter]?.emit('close')
355
+ }
312
356
  }
313
357
 
314
358
  #sendRetry(retry: number): void {
315
- this.#writer
316
- .write(this.#encoder.encode(`retry:${retry}\n\n`))
317
- .catch((error) => {
318
- console.error(error)
319
- devUtils.error(
320
- 'Failed to send a retry packet to server-side EventSource. Please see the original error above.',
321
- )
322
- })
359
+ this.#enqueue(this.#encoder.encode(`retry:${retry}\n\n`))
323
360
  }
324
361
 
325
362
  #sendMessage(message: {
@@ -350,21 +387,18 @@ class ServerSentEventClient<
350
387
 
351
388
  frames.push('', '')
352
389
 
353
- this.#writer
354
- .write(this.#encoder.encode(frames.join('\n')))
355
- .catch((error) => {
356
- console.error(error)
357
- devUtils.error(
358
- 'Failed to send a message to server-side EventSource. Please see the original error above.',
359
- )
360
- })
390
+ this.#enqueue(this.#encoder.encode(frames.join('\n')))
361
391
 
362
- this[kClientEmitter]?.emit('message', {
363
- id: message.id,
364
- event: message.event?.toString() || 'message',
365
- data: message.data,
366
- frames,
367
- })
392
+ this[kClientEmitter]?.emit(
393
+ new TypedEvent('message', {
394
+ data: {
395
+ id: message.id,
396
+ event: message.event?.toString() || 'message',
397
+ data: message.data,
398
+ frames,
399
+ },
400
+ }),
401
+ )
368
402
  }
369
403
  }
370
404
 
@@ -991,9 +1025,18 @@ function createEventStream<EventMap extends EventMapConstraint>(
991
1025
  request.url,
992
1026
  )
993
1027
 
994
- const { readable, writable } = new TransformStream()
1028
+ let controller!: ReadableStreamDefaultController<Uint8Array>
1029
+
1030
+ const readable = new ReadableStream<Uint8Array>({
1031
+ start(defaultController) {
1032
+ controller = defaultController
1033
+ },
1034
+ cancel() {
1035
+ client.close()
1036
+ },
1037
+ })
995
1038
 
996
- const client = new ServerSentEventClient<EventMap>(writable)
1039
+ const client = new ServerSentEventClient<EventMap>(controller)
997
1040
  const server = new ServerSentEventServer({
998
1041
  request,
999
1042
  client,