msw 2.11.5 → 2.12.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 (45) hide show
  1. package/lib/browser/index.js +365 -308
  2. package/lib/browser/index.js.map +1 -1
  3. package/lib/browser/index.mjs +365 -308
  4. package/lib/browser/index.mjs.map +1 -1
  5. package/lib/core/index.d.mts +1 -0
  6. package/lib/core/index.d.ts +1 -0
  7. package/lib/core/index.js +2 -0
  8. package/lib/core/index.js.map +1 -1
  9. package/lib/core/index.mjs +4 -0
  10. package/lib/core/index.mjs.map +1 -1
  11. package/lib/core/sse.d.mts +116 -0
  12. package/lib/core/sse.d.ts +116 -0
  13. package/lib/core/sse.js +599 -0
  14. package/lib/core/sse.js.map +1 -0
  15. package/lib/core/sse.mjs +581 -0
  16. package/lib/core/sse.mjs.map +1 -0
  17. package/lib/core/utils/internal/isObject.d.mts +1 -1
  18. package/lib/core/utils/internal/isObject.d.ts +1 -1
  19. package/lib/core/utils/internal/isObject.js.map +1 -1
  20. package/lib/core/utils/internal/isObject.mjs.map +1 -1
  21. package/lib/core/ws/utils/attachWebSocketLogger.d.mts +7 -1
  22. package/lib/core/ws/utils/attachWebSocketLogger.d.ts +7 -1
  23. package/lib/core/ws/utils/attachWebSocketLogger.js +1 -0
  24. package/lib/core/ws/utils/attachWebSocketLogger.js.map +1 -1
  25. package/lib/core/ws/utils/attachWebSocketLogger.mjs +1 -0
  26. package/lib/core/ws/utils/attachWebSocketLogger.mjs.map +1 -1
  27. package/lib/core/ws/utils/getMessageLength.js +2 -1
  28. package/lib/core/ws/utils/getMessageLength.js.map +1 -1
  29. package/lib/core/ws/utils/getMessageLength.mjs +2 -1
  30. package/lib/core/ws/utils/getMessageLength.mjs.map +1 -1
  31. package/lib/core/ws/utils/getPublicData.js +2 -1
  32. package/lib/core/ws/utils/getPublicData.js.map +1 -1
  33. package/lib/core/ws/utils/getPublicData.mjs +2 -1
  34. package/lib/core/ws/utils/getPublicData.mjs.map +1 -1
  35. package/lib/iife/index.js +1367 -746
  36. package/lib/iife/index.js.map +1 -1
  37. package/lib/mockServiceWorker.js +1 -1
  38. package/package.json +6 -4
  39. package/src/browser/tsconfig.browser.json +1 -1
  40. package/src/core/index.ts +9 -0
  41. package/src/core/sse.ts +897 -0
  42. package/src/core/utils/internal/isObject.ts +1 -1
  43. package/src/core/ws/utils/attachWebSocketLogger.ts +1 -1
  44. package/src/core/ws/utils/getMessageLength.ts +2 -1
  45. package/src/core/ws/utils/getPublicData.ts +3 -2
@@ -0,0 +1,897 @@
1
+ import { invariant } from 'outvariant'
2
+ import { Emitter } from 'strict-event-emitter'
3
+ import type { ResponseResolver } from './handlers/RequestHandler'
4
+ import {
5
+ HttpHandler,
6
+ type HttpRequestResolverExtras,
7
+ type HttpRequestParsedResult,
8
+ } from './handlers/HttpHandler'
9
+ import type { Path, PathParams } from './utils/matching/matchRequestUrl'
10
+ import { delay } from './delay'
11
+ import { getTimestamp } from './utils/logging/getTimestamp'
12
+ import { devUtils } from './utils/internal/devUtils'
13
+ import { colors } from './ws/utils/attachWebSocketLogger'
14
+ import { toPublicUrl } from './utils/request/toPublicUrl'
15
+
16
+ type EventMapConstraint = {
17
+ message?: unknown
18
+ [key: string]: unknown
19
+ [key: symbol | number]: never
20
+ }
21
+
22
+ export type ServerSentEventResolverExtras<
23
+ EventMap extends EventMapConstraint,
24
+ Params extends PathParams,
25
+ > = HttpRequestResolverExtras<Params> & {
26
+ client: ServerSentEventClient<EventMap>
27
+ server: ServerSentEventServer
28
+ }
29
+
30
+ export type ServerSentEventResolver<
31
+ EventMap extends EventMapConstraint,
32
+ Params extends PathParams,
33
+ > = ResponseResolver<ServerSentEventResolverExtras<EventMap, Params>, any, any>
34
+
35
+ export type ServerSentEventRequestHandler = <
36
+ EventMap extends EventMapConstraint = { message: unknown },
37
+ Params extends PathParams<keyof Params> = PathParams,
38
+ RequestPath extends Path = Path,
39
+ >(
40
+ path: RequestPath,
41
+ resolver: ServerSentEventResolver<EventMap, Params>,
42
+ ) => HttpHandler
43
+
44
+ export type ServerSentEventMessage<
45
+ EventMap extends EventMapConstraint = { message: unknown },
46
+ > =
47
+ | ToEventDiscriminatedUnion<EventMap & { message: unknown }>
48
+ | {
49
+ id?: never
50
+ event?: never
51
+ data?: never
52
+ retry: number
53
+ }
54
+
55
+ /**
56
+ * Intercept Server-Sent Events (SSE).
57
+ *
58
+ * @example
59
+ * sse('http://localhost:4321', ({ client }) => {
60
+ * client.send({ data: 'hello world' })
61
+ * })
62
+ *
63
+ * @see {@link https://mswjs.io/docs/sse/ Mocking Server-Sent Events}
64
+ * @see {@link https://mswjs.io/docs/api/sse `sse()` API reference}
65
+ */
66
+ export const sse: ServerSentEventRequestHandler = (path, resolver) => {
67
+ return new ServerSentEventHandler(path, resolver)
68
+ }
69
+
70
+ class ServerSentEventHandler<
71
+ EventMap extends EventMapConstraint,
72
+ > extends HttpHandler {
73
+ constructor(path: Path, resolver: ServerSentEventResolver<EventMap, any>) {
74
+ invariant(
75
+ typeof EventSource !== 'undefined',
76
+ 'Failed to construct a Server-Sent Event handler for path "%s": the EventSource API is not supported in this environment',
77
+ path,
78
+ )
79
+
80
+ const clientEmitter = new Emitter<ServerSentEventClientEventMap>()
81
+
82
+ super('GET', path, async (info) => {
83
+ const responseInit: ResponseInit = {
84
+ headers: {
85
+ 'content-type': 'text/event-stream',
86
+ 'cache-control': 'no-cache',
87
+ connection: 'keep-alive',
88
+ },
89
+ }
90
+
91
+ /**
92
+ * @note Log the intercepted request early.
93
+ * Normally, the `this.log()` method is called when the handler returns a response.
94
+ * For SSE, call that method earlier so the logs are in correct order.
95
+ */
96
+ await super.log({
97
+ request: info.request,
98
+ /**
99
+ * @note Construct a placeholder response since SSE response
100
+ * is being streamed and cannot be cloned/consumed for logging.
101
+ */
102
+ response: new Response('[streaming]', responseInit),
103
+ })
104
+ this.#attachClientLogger(info.request, clientEmitter)
105
+
106
+ const stream = new ReadableStream({
107
+ async start(controller) {
108
+ const client = new ServerSentEventClient<EventMap>({
109
+ controller,
110
+ emitter: clientEmitter,
111
+ })
112
+ const server = new ServerSentEventServer({
113
+ request: info.request,
114
+ client,
115
+ })
116
+
117
+ await resolver({
118
+ ...info,
119
+ client,
120
+ server,
121
+ })
122
+ },
123
+ })
124
+
125
+ return new Response(stream, responseInit)
126
+ })
127
+ }
128
+
129
+ async predicate(args: {
130
+ request: Request
131
+ parsedResult: HttpRequestParsedResult
132
+ }) {
133
+ if (args.request.headers.get('accept') !== 'text/event-stream') {
134
+ return false
135
+ }
136
+
137
+ return super.predicate(args)
138
+ }
139
+
140
+ async log(_args: { request: Request; response: Response }): Promise<void> {
141
+ /**
142
+ * @note Skip the default `this.log()` logic so that when this handler is logged
143
+ * upon handling the request, nothing is printed (we log SSE requests early).
144
+ */
145
+ return
146
+ }
147
+
148
+ #attachClientLogger(
149
+ request: Request,
150
+ emitter: Emitter<ServerSentEventClientEventMap>,
151
+ ): void {
152
+ const publicUrl = toPublicUrl(request.url)
153
+
154
+ /* eslint-disable no-console */
155
+ emitter.on('message', (payload) => {
156
+ console.groupCollapsed(
157
+ devUtils.formatMessage(
158
+ `${getTimestamp()} SSE %s %c⇣%c ${payload.event}`,
159
+ ),
160
+ publicUrl,
161
+ `color:${colors.mocked}`,
162
+ 'color:inherit',
163
+ )
164
+ console.log(payload.frames)
165
+ console.groupEnd()
166
+ })
167
+
168
+ emitter.on('error', () => {
169
+ console.groupCollapsed(
170
+ devUtils.formatMessage(`${getTimestamp()} SSE %s %c\u00D7%c error`),
171
+ publicUrl,
172
+ `color: ${colors.system}`,
173
+ 'color:inherit',
174
+ )
175
+ console.log('Handler:', this)
176
+ console.groupEnd()
177
+ })
178
+
179
+ emitter.on('close', () => {
180
+ console.groupCollapsed(
181
+ devUtils.formatMessage(`${getTimestamp()} SSE %s %c■%c close`),
182
+ publicUrl,
183
+ `colors:${colors.system}`,
184
+ 'color:inherit',
185
+ )
186
+ console.log('Handler:', this)
187
+ console.groupEnd()
188
+ })
189
+ /* eslint-enable no-console */
190
+ }
191
+ }
192
+
193
+ type Values<T> = T[keyof T]
194
+ type Identity<T> = { [K in keyof T]: T[K] } & unknown
195
+ type ToEventDiscriminatedUnion<T> = Values<{
196
+ [K in keyof T]: Identity<
197
+ (K extends 'message'
198
+ ? {
199
+ id?: string
200
+ event?: K
201
+ data?: T[K]
202
+ retry?: never
203
+ }
204
+ : {
205
+ id?: string
206
+ event: K
207
+ data?: T[K]
208
+ retry?: never
209
+ }) &
210
+ // Make the `data` field conditionally required through an intersection.
211
+ (undefined extends T[K] ? unknown : { data: unknown })
212
+ >
213
+ }>
214
+
215
+ type ServerSentEventClientEventMap = {
216
+ message: [
217
+ payload: {
218
+ id?: string
219
+ event: string
220
+ data?: unknown
221
+ frames: Array<string>
222
+ },
223
+ ]
224
+ error: []
225
+ close: []
226
+ }
227
+
228
+ class ServerSentEventClient<
229
+ EventMap extends EventMapConstraint = { message: unknown },
230
+ > {
231
+ #encoder: TextEncoder
232
+ #controller: ReadableStreamDefaultController
233
+ #emitter: Emitter<ServerSentEventClientEventMap>
234
+
235
+ constructor(args: {
236
+ controller: ReadableStreamDefaultController
237
+ emitter: Emitter<ServerSentEventClientEventMap>
238
+ }) {
239
+ this.#encoder = new TextEncoder()
240
+ this.#controller = args.controller
241
+ this.#emitter = args.emitter
242
+ }
243
+
244
+ /**
245
+ * Sends the given payload to the intercepted `EventSource`.
246
+ */
247
+ public send(payload: ServerSentEventMessage<EventMap>): void {
248
+ if ('retry' in payload && payload.retry != null) {
249
+ this.#sendRetry(payload.retry)
250
+ return
251
+ }
252
+
253
+ this.#sendMessage({
254
+ id: payload.id,
255
+ event: payload.event,
256
+ data:
257
+ typeof payload.data === 'object'
258
+ ? JSON.stringify(payload.data)
259
+ : payload.data,
260
+ })
261
+ }
262
+
263
+ /**
264
+ * Dispatches the given event on the intercepted `EventSource`.
265
+ */
266
+ public dispatchEvent(event: Event) {
267
+ if (event instanceof MessageEvent) {
268
+ /**
269
+ * @note Use the internal send mechanism to skip normalization
270
+ * of the message data (already normalized by the server).
271
+ */
272
+ this.#sendMessage({
273
+ id: event.lastEventId || undefined,
274
+ event: event.type === 'message' ? undefined : event.type,
275
+ data: event.data,
276
+ })
277
+ return
278
+ }
279
+
280
+ if (event.type === 'error') {
281
+ this.error()
282
+ return
283
+ }
284
+
285
+ if (event.type === 'close') {
286
+ this.close()
287
+ return
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Errors the underlying `EventSource`, closing the connection with an error.
293
+ * This is equivalent to aborting the connection and will produce a `TypeError: Failed to fetch`
294
+ * error.
295
+ */
296
+ public error(): void {
297
+ this.#controller.error()
298
+ this.#emitter.emit('error')
299
+ }
300
+
301
+ /**
302
+ * Closes the underlying `EventSource`, closing the connection.
303
+ */
304
+ public close(): void {
305
+ this.#controller.close()
306
+ this.#emitter.emit('close')
307
+ }
308
+
309
+ #sendRetry(retry: number): void {
310
+ if (typeof retry === 'number') {
311
+ this.#controller.enqueue(this.#encoder.encode(`retry:${retry}\n\n`))
312
+ }
313
+ }
314
+
315
+ #sendMessage(message: {
316
+ id?: string
317
+ event?: unknown
318
+ data: unknown | undefined
319
+ }): void {
320
+ const frames: Array<string> = []
321
+
322
+ if (message.id) {
323
+ frames.push(`id:${message.id}`)
324
+ }
325
+
326
+ if (message.event) {
327
+ frames.push(`event:${message.event?.toString()}`)
328
+ }
329
+
330
+ frames.push(`data:${message.data}`)
331
+ frames.push('', '')
332
+
333
+ this.#controller.enqueue(this.#encoder.encode(frames.join('\n')))
334
+
335
+ this.#emitter.emit('message', {
336
+ id: message.id,
337
+ event: message.event?.toString() || 'message',
338
+ data: message.data,
339
+ frames,
340
+ })
341
+ }
342
+ }
343
+
344
+ class ServerSentEventServer {
345
+ #request: Request
346
+ #client: ServerSentEventClient<ServerSentEventClientEventMap>
347
+
348
+ constructor(args: { request: Request; client: ServerSentEventClient<any> }) {
349
+ this.#request = args.request
350
+ this.#client = args.client
351
+ }
352
+
353
+ /**
354
+ * Establishes the actual connection for this SSE request
355
+ * and returns the `EventSource` instance.
356
+ */
357
+ public connect(): EventSource {
358
+ const source = new ObservableEventSource(this.#request.url, {
359
+ withCredentials: this.#request.credentials === 'include',
360
+ headers: {
361
+ /**
362
+ * @note Mark this request as passthrough so it doesn't trigger
363
+ * an infinite loop matching against the existing request handler.
364
+ */
365
+ accept: 'msw/passthrough',
366
+ },
367
+ })
368
+
369
+ source[kOnAnyMessage] = (event) => {
370
+ Object.defineProperties(event, {
371
+ target: {
372
+ value: this,
373
+ enumerable: true,
374
+ writable: true,
375
+ configurable: true,
376
+ },
377
+ })
378
+
379
+ // Schedule the server-to-client forwarding for the next tick
380
+ // so the user can prevent the message event.
381
+ queueMicrotask(() => {
382
+ if (!event.defaultPrevented) {
383
+ this.#client.dispatchEvent(event)
384
+ }
385
+ })
386
+ }
387
+
388
+ // Forward stream errors from the actual server to the client.
389
+ source.addEventListener('error', (event) => {
390
+ Object.defineProperties(event, {
391
+ target: {
392
+ value: this,
393
+ enumerable: true,
394
+ writable: true,
395
+ configurable: true,
396
+ },
397
+ })
398
+
399
+ queueMicrotask(() => {
400
+ // Allow the user to opt-out from this forwarding.
401
+ if (!event.defaultPrevented) {
402
+ this.#client.dispatchEvent(event)
403
+ }
404
+ })
405
+ })
406
+
407
+ return source
408
+ }
409
+ }
410
+
411
+ interface ObservableEventSourceInit extends EventSourceInit {
412
+ headers?: HeadersInit
413
+ }
414
+
415
+ type EventHandler<EventType extends Event> = (
416
+ this: EventSource,
417
+ event: EventType,
418
+ ) => any
419
+
420
+ const kRequest = Symbol('kRequest')
421
+ const kReconnectionTime = Symbol('kReconnectionTime')
422
+ const kLastEventId = Symbol('kLastEventId')
423
+ const kAbortController = Symbol('kAbortController')
424
+ const kOnOpen = Symbol('kOnOpen')
425
+ const kOnMessage = Symbol('kOnMessage')
426
+ const kOnAnyMessage = Symbol('kOnAnyMessage')
427
+ const kOnError = Symbol('kOnError')
428
+
429
+ class ObservableEventSource extends EventTarget implements EventSource {
430
+ static readonly CONNECTING = 0
431
+ static readonly OPEN = 1
432
+ static readonly CLOSED = 2
433
+
434
+ public readonly CONNECTING = ObservableEventSource.CONNECTING
435
+ public readonly OPEN = ObservableEventSource.OPEN
436
+ public readonly CLOSED = ObservableEventSource.CLOSED
437
+
438
+ public readyState: number
439
+ public url: string
440
+ public withCredentials: boolean
441
+
442
+ private [kRequest]: Request
443
+ private [kReconnectionTime]: number
444
+ private [kLastEventId]: string
445
+ private [kAbortController]: AbortController
446
+ private [kOnOpen]: EventHandler<Event> | null = null
447
+ private [kOnMessage]: EventHandler<MessageEvent> | null = null
448
+ private [kOnAnyMessage]: EventHandler<MessageEvent> | null = null
449
+ private [kOnError]: EventHandler<Event> | null = null
450
+
451
+ constructor(url: string | URL, init?: ObservableEventSourceInit) {
452
+ super()
453
+
454
+ this.url = new URL(url).href
455
+ this.withCredentials = init?.withCredentials ?? false
456
+
457
+ this.readyState = this.CONNECTING
458
+
459
+ // Support custom request init.
460
+ const headers = new Headers(init?.headers || {})
461
+ headers.append('accept', 'text/event-stream')
462
+
463
+ this[kAbortController] = new AbortController()
464
+ this[kReconnectionTime] = 2000
465
+ this[kLastEventId] = ''
466
+ this[kRequest] = new Request(this.url, {
467
+ method: 'GET',
468
+ headers,
469
+ credentials: this.withCredentials ? 'include' : 'omit',
470
+ signal: this[kAbortController].signal,
471
+ })
472
+
473
+ this.connect()
474
+ }
475
+
476
+ get onopen(): EventHandler<Event> | null {
477
+ return this[kOnOpen]
478
+ }
479
+
480
+ set onopen(handler: EventHandler<Event>) {
481
+ if (this[kOnOpen]) {
482
+ this.removeEventListener('open', this[kOnOpen])
483
+ }
484
+ this[kOnOpen] = handler.bind(this)
485
+ this.addEventListener('open', this[kOnOpen])
486
+ }
487
+
488
+ get onmessage(): EventHandler<MessageEvent> | null {
489
+ return this[kOnMessage]
490
+ }
491
+ set onmessage(handler: EventHandler<MessageEvent>) {
492
+ if (this[kOnMessage]) {
493
+ this.removeEventListener('message', { handleEvent: this[kOnMessage] })
494
+ }
495
+ this[kOnMessage] = handler.bind(this)
496
+ this.addEventListener('message', { handleEvent: this[kOnMessage] })
497
+ }
498
+
499
+ get onerror(): EventHandler<Event> | null {
500
+ return this[kOnError]
501
+ }
502
+ set oneerror(handler: EventHandler<Event>) {
503
+ if (this[kOnError]) {
504
+ this.removeEventListener('error', { handleEvent: this[kOnError] })
505
+ }
506
+ this[kOnError] = handler.bind(this)
507
+ this.addEventListener('error', { handleEvent: this[kOnError] })
508
+ }
509
+
510
+ public addEventListener<K extends keyof EventSourceEventMap>(
511
+ type: K,
512
+ listener: EventHandler<EventSourceEventMap[K]>,
513
+ options?: boolean | AddEventListenerOptions,
514
+ ): void
515
+ public addEventListener(
516
+ type: string,
517
+ listener: EventHandler<MessageEvent>,
518
+ options?: boolean | AddEventListenerOptions,
519
+ ): void
520
+ public addEventListener(
521
+ type: string,
522
+ listener: EventListenerOrEventListenerObject,
523
+ options?: boolean | AddEventListenerOptions,
524
+ ): void
525
+
526
+ public addEventListener(
527
+ type: string,
528
+ listener: EventHandler<MessageEvent> | EventListenerOrEventListenerObject,
529
+ options?: boolean | AddEventListenerOptions,
530
+ ): void {
531
+ super.addEventListener(
532
+ type,
533
+ listener as EventListenerOrEventListenerObject,
534
+ options,
535
+ )
536
+ }
537
+
538
+ public removeEventListener<K extends keyof EventSourceEventMap>(
539
+ type: K,
540
+ listener: (this: EventSource, ev: EventSourceEventMap[K]) => any,
541
+ options?: boolean | EventListenerOptions,
542
+ ): void
543
+ public removeEventListener(
544
+ type: string,
545
+ listener: (this: EventSource, event: MessageEvent) => any,
546
+ options?: boolean | EventListenerOptions,
547
+ ): void
548
+ public removeEventListener(
549
+ type: string,
550
+ listener: EventListenerOrEventListenerObject,
551
+ options?: boolean | EventListenerOptions,
552
+ ): void
553
+
554
+ public removeEventListener(
555
+ type: string,
556
+ listener: EventHandler<MessageEvent> | EventListenerOrEventListenerObject,
557
+ options?: boolean | AddEventListenerOptions,
558
+ ): void {
559
+ super.removeEventListener(
560
+ type,
561
+ listener as EventListenerOrEventListenerObject,
562
+ options,
563
+ )
564
+ }
565
+
566
+ public dispatchEvent(event: Event): boolean {
567
+ return super.dispatchEvent(event)
568
+ }
569
+
570
+ public close(): void {
571
+ this[kAbortController].abort()
572
+ this.readyState = this.CLOSED
573
+ }
574
+
575
+ private async connect() {
576
+ await fetch(this[kRequest])
577
+ .then((response) => {
578
+ this.processResponse(response)
579
+ })
580
+ .catch(() => {
581
+ // Fail the connection on request errors instead of
582
+ // throwing a generic "Failed to fetch" error.
583
+ this.failConnection()
584
+ })
585
+ }
586
+
587
+ private processResponse(response: Response): void {
588
+ if (!response.body) {
589
+ this.failConnection()
590
+ return
591
+ }
592
+
593
+ if (isNetworkError(response)) {
594
+ this.reestablishConnection()
595
+ return
596
+ }
597
+
598
+ if (
599
+ response.status !== 200 ||
600
+ response.headers.get('content-type') !== 'text/event-stream'
601
+ ) {
602
+ this.failConnection()
603
+ return
604
+ }
605
+
606
+ this.announceConnection()
607
+ this.interpretResponseBody(response)
608
+ }
609
+
610
+ private announceConnection(): void {
611
+ queueMicrotask(() => {
612
+ if (this.readyState !== this.CLOSED) {
613
+ this.readyState = this.OPEN
614
+ this.dispatchEvent(new Event('open'))
615
+ }
616
+ })
617
+ }
618
+
619
+ private interpretResponseBody(response: Response): void {
620
+ const parsingStream = new EventSourceParsingStream({
621
+ message: (message) => {
622
+ if (message.id) {
623
+ this[kLastEventId] = message.id
624
+ }
625
+
626
+ if (message.retry) {
627
+ this[kReconnectionTime] = message.retry
628
+ }
629
+
630
+ const messageEvent = new MessageEvent(
631
+ message.event ? message.event : 'message',
632
+ {
633
+ data: message.data,
634
+ origin: this[kRequest].url,
635
+ lastEventId: this[kLastEventId],
636
+ cancelable: true,
637
+ },
638
+ )
639
+
640
+ this[kOnAnyMessage]?.(messageEvent)
641
+ this.dispatchEvent(messageEvent)
642
+ },
643
+ abort: () => {
644
+ throw new Error('Stream abort is not implemented')
645
+ },
646
+ close: () => {
647
+ this.failConnection()
648
+ },
649
+ })
650
+
651
+ response
652
+ .body!.pipeTo(parsingStream)
653
+ .then(() => {
654
+ this.processResponseEndOfBody(response)
655
+ })
656
+ .catch(() => {
657
+ this.failConnection()
658
+ })
659
+ }
660
+
661
+ private processResponseEndOfBody(response: Response): void {
662
+ if (!isNetworkError(response)) {
663
+ this.reestablishConnection()
664
+ }
665
+ }
666
+
667
+ private async reestablishConnection(): Promise<void> {
668
+ queueMicrotask(() => {
669
+ if (this.readyState === this.CLOSED) {
670
+ return
671
+ }
672
+
673
+ this.readyState = this.CONNECTING
674
+ this.dispatchEvent(new Event('error'))
675
+ })
676
+
677
+ await delay(this[kReconnectionTime])
678
+
679
+ queueMicrotask(async () => {
680
+ if (this.readyState !== this.CONNECTING) {
681
+ return
682
+ }
683
+
684
+ if (this[kLastEventId] !== '') {
685
+ this[kRequest].headers.set('last-event-id', this[kLastEventId])
686
+ }
687
+
688
+ await this.connect()
689
+ })
690
+ }
691
+
692
+ private failConnection(): void {
693
+ queueMicrotask(() => {
694
+ if (this.readyState !== this.CLOSED) {
695
+ this.readyState = this.CLOSED
696
+ this.dispatchEvent(new Event('error'))
697
+ }
698
+ })
699
+ }
700
+ }
701
+
702
+ /**
703
+ * Checks if the given `Response` instance is a network error.
704
+ * @see https://fetch.spec.whatwg.org/#concept-network-error
705
+ */
706
+ function isNetworkError(response: Response): boolean {
707
+ return (
708
+ response.type === 'error' &&
709
+ response.status === 0 &&
710
+ response.statusText === '' &&
711
+ Array.from(response.headers.entries()).length === 0 &&
712
+ response.body === null
713
+ )
714
+ }
715
+
716
+ const enum ControlCharacters {
717
+ NewLine = 10,
718
+ CarriageReturn = 13,
719
+ Space = 32,
720
+ Colon = 58,
721
+ }
722
+
723
+ interface EventSourceMessage {
724
+ id?: string
725
+ event?: string
726
+ data?: string
727
+ retry?: number
728
+ }
729
+
730
+ class EventSourceParsingStream extends WritableStream {
731
+ private decoder: TextDecoder
732
+
733
+ private buffer?: Uint8Array
734
+ private position: number
735
+ private fieldLength?: number
736
+ private discardTrailingNewline = false
737
+
738
+ private message: EventSourceMessage = {
739
+ id: undefined,
740
+ event: undefined,
741
+ data: undefined,
742
+ retry: undefined,
743
+ }
744
+
745
+ constructor(
746
+ private underlyingSink: {
747
+ message: (message: EventSourceMessage) => void
748
+ abort?: (reason: any) => void
749
+ close?: () => void
750
+ },
751
+ ) {
752
+ super({
753
+ write: (chunk) => {
754
+ this.processResponseBodyChunk(chunk)
755
+ },
756
+ abort: (reason) => {
757
+ this.underlyingSink.abort?.(reason)
758
+ },
759
+ close: () => {
760
+ this.underlyingSink.close?.()
761
+ },
762
+ })
763
+
764
+ this.decoder = new TextDecoder()
765
+ this.position = 0
766
+ }
767
+
768
+ private resetMessage(): void {
769
+ this.message = {
770
+ id: undefined,
771
+ event: undefined,
772
+ data: undefined,
773
+ retry: undefined,
774
+ }
775
+ }
776
+
777
+ private processResponseBodyChunk(chunk: Uint8Array): void {
778
+ if (this.buffer == null) {
779
+ this.buffer = chunk
780
+ this.position = 0
781
+ this.fieldLength = -1
782
+ } else {
783
+ const nextBuffer = new Uint8Array(this.buffer.length + chunk.length)
784
+ nextBuffer.set(this.buffer)
785
+ nextBuffer.set(chunk, this.buffer.length)
786
+ this.buffer = nextBuffer
787
+ }
788
+
789
+ const bufferLength = this.buffer.length
790
+ let lineStart = 0
791
+
792
+ while (this.position < bufferLength) {
793
+ if (this.discardTrailingNewline) {
794
+ if (this.buffer[this.position] === ControlCharacters.NewLine) {
795
+ lineStart = ++this.position
796
+ }
797
+
798
+ this.discardTrailingNewline = false
799
+ }
800
+
801
+ let lineEnd = -1
802
+
803
+ for (; this.position < bufferLength && lineEnd === -1; ++this.position) {
804
+ switch (this.buffer[this.position]) {
805
+ case ControlCharacters.Colon: {
806
+ if (this.fieldLength === -1) {
807
+ this.fieldLength = this.position - lineStart
808
+ }
809
+ break
810
+ }
811
+
812
+ case ControlCharacters.CarriageReturn: {
813
+ this.discardTrailingNewline = true
814
+ break
815
+ }
816
+
817
+ case ControlCharacters.NewLine: {
818
+ lineEnd = this.position
819
+ break
820
+ }
821
+ }
822
+ }
823
+
824
+ if (lineEnd === -1) {
825
+ break
826
+ }
827
+
828
+ this.processLine(
829
+ this.buffer.subarray(lineStart, lineEnd),
830
+ this.fieldLength!,
831
+ )
832
+
833
+ lineStart = this.position
834
+ this.fieldLength = -1
835
+ }
836
+
837
+ if (lineStart === bufferLength) {
838
+ this.buffer = undefined
839
+ } else if (lineStart !== 0) {
840
+ this.buffer = this.buffer.subarray(lineStart)
841
+ this.position -= lineStart
842
+ }
843
+ }
844
+
845
+ private processLine(line: Uint8Array, fieldLength: number): void {
846
+ // New line indicates the end of the message. Dispatch it.
847
+ if (line.length === 0) {
848
+ // Prevent dispatching the message if the data is an empty string.
849
+ // That is a no-op per spec.
850
+ if (this.message.data === undefined) {
851
+ this.message.event = undefined
852
+ return
853
+ }
854
+
855
+ this.underlyingSink.message(this.message)
856
+ this.resetMessage()
857
+ return
858
+ }
859
+
860
+ // Otherwise, keep accumulating message fields until the new line.
861
+ if (fieldLength > 0) {
862
+ const field = this.decoder.decode(line.subarray(0, fieldLength))
863
+ const valueOffset =
864
+ fieldLength +
865
+ (line[fieldLength + 1] === ControlCharacters.Space ? 2 : 1)
866
+ const value = this.decoder.decode(line.subarray(valueOffset))
867
+
868
+ switch (field) {
869
+ case 'data': {
870
+ this.message.data = this.message.data
871
+ ? this.message.data + '\n' + value
872
+ : value
873
+ break
874
+ }
875
+
876
+ case 'event': {
877
+ this.message.event = value
878
+ break
879
+ }
880
+
881
+ case 'id': {
882
+ this.message.id = value
883
+ break
884
+ }
885
+
886
+ case 'retry': {
887
+ const retry = parseInt(value, 10)
888
+
889
+ if (!isNaN(retry)) {
890
+ this.message.retry = retry
891
+ }
892
+ break
893
+ }
894
+ }
895
+ }
896
+ }
897
+ }