msw 2.11.6 → 2.12.1
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.
- package/lib/browser/index.js +1 -1
- package/lib/browser/index.js.map +1 -1
- package/lib/browser/index.mjs +1 -1
- package/lib/browser/index.mjs.map +1 -1
- package/lib/core/index.d.mts +1 -0
- package/lib/core/index.d.ts +1 -0
- package/lib/core/index.js +2 -0
- package/lib/core/index.js.map +1 -1
- package/lib/core/index.mjs +4 -0
- package/lib/core/index.mjs.map +1 -1
- package/lib/core/sse.d.mts +116 -0
- package/lib/core/sse.d.ts +116 -0
- package/lib/core/sse.js +599 -0
- package/lib/core/sse.js.map +1 -0
- package/lib/core/sse.mjs +581 -0
- package/lib/core/sse.mjs.map +1 -0
- package/lib/core/utils/internal/isObject.d.mts +1 -1
- package/lib/core/utils/internal/isObject.d.ts +1 -1
- package/lib/core/utils/internal/isObject.js.map +1 -1
- package/lib/core/utils/internal/isObject.mjs.map +1 -1
- package/lib/core/ws/utils/attachWebSocketLogger.d.mts +7 -1
- package/lib/core/ws/utils/attachWebSocketLogger.d.ts +7 -1
- package/lib/core/ws/utils/attachWebSocketLogger.js +1 -0
- package/lib/core/ws/utils/attachWebSocketLogger.js.map +1 -1
- package/lib/core/ws/utils/attachWebSocketLogger.mjs +1 -0
- package/lib/core/ws/utils/attachWebSocketLogger.mjs.map +1 -1
- package/lib/core/ws/utils/getMessageLength.js +2 -1
- package/lib/core/ws/utils/getMessageLength.js.map +1 -1
- package/lib/core/ws/utils/getMessageLength.mjs +2 -1
- package/lib/core/ws/utils/getMessageLength.mjs.map +1 -1
- package/lib/core/ws/utils/getPublicData.js +2 -1
- package/lib/core/ws/utils/getPublicData.js.map +1 -1
- package/lib/core/ws/utils/getPublicData.mjs +2 -1
- package/lib/core/ws/utils/getPublicData.mjs.map +1 -1
- package/lib/iife/index.js +816 -253
- package/lib/iife/index.js.map +1 -1
- package/lib/mockServiceWorker.js +1 -1
- package/package.json +4 -3
- package/src/browser/setupWorker/start/createResponseListener.ts +1 -1
- package/src/browser/tsconfig.browser.json +1 -1
- package/src/core/index.ts +9 -0
- package/src/core/sse.ts +897 -0
- package/src/core/utils/internal/isObject.ts +1 -1
- package/src/core/ws/utils/attachWebSocketLogger.ts +1 -1
- package/src/core/ws/utils/getMessageLength.ts +2 -1
- package/src/core/ws/utils/getPublicData.ts +3 -2
package/src/core/sse.ts
ADDED
|
@@ -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
|
+
}
|