undici 6.4.0 → 6.5.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.
package/README.md CHANGED
@@ -20,6 +20,7 @@ npm i undici
20
20
  The benchmark is a simple `hello world` [example](benchmarks/benchmark.js) using a
21
21
  50 TCP connections with a pipelining depth of 10 running on Node 20.10.0.
22
22
 
23
+ ```
23
24
  │ Tests │ Samples │ Result │ Tolerance │ Difference with slowest │
24
25
  |─────────────────────|─────────|─────────────────|───────────|─────────────────────────|
25
26
  │ got │ 45 │ 1661.71 req/sec │ ± 2.93 % │ - │
@@ -33,6 +34,7 @@ The benchmark is a simple `hello world` [example](benchmarks/benchmark.js) using
33
34
  │ undici - request │ 55 │ 7773.98 req/sec │ ± 2.93 % │ + 367.83 % │
34
35
  │ undici - stream │ 70 │ 8425.96 req/sec │ ± 2.91 % │ + 407.07 % │
35
36
  │ undici - dispatch │ 50 │ 9488.99 req/sec │ ± 2.85 % │ + 471.04 % │
37
+ ```
36
38
 
37
39
  ## Quick Start
38
40
 
@@ -0,0 +1,21 @@
1
+ # EventSource
2
+
3
+ Undici exposes a WHATWG spec-compliant implementation of [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
4
+ for [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events).
5
+
6
+ ## Instantiating EventSource
7
+
8
+ Undici exports a EventSource class. You can instantiate the EventSource as
9
+ follows:
10
+
11
+ ```mjs
12
+ import { EventSource } from 'undici'
13
+
14
+ const evenSource = new EventSource('http://localhost:3000')
15
+ evenSource.onmessage = (event) => {
16
+ console.log(event.data)
17
+ }
18
+ ```
19
+
20
+ More information about the EventSource API can be found on
21
+ [MDN](https://developer.mozilla.org/en-US/docs/Web/API/EventSource).
package/index.js CHANGED
@@ -149,3 +149,7 @@ module.exports.MockClient = MockClient
149
149
  module.exports.MockPool = MockPool
150
150
  module.exports.MockAgent = MockAgent
151
151
  module.exports.mockErrors = mockErrors
152
+
153
+ const { EventSource } = require('./lib/eventsource/eventsource')
154
+
155
+ module.exports.EventSource = EventSource
@@ -94,7 +94,7 @@ module.exports = class BodyReadable extends Readable {
94
94
  }
95
95
 
96
96
  push (chunk) {
97
- if (this[kConsume] && chunk !== null && this.readableLength === 0) {
97
+ if (this[kConsume] && chunk !== null) {
98
98
  consumePush(this[kConsume], chunk)
99
99
  return this[kReading] ? super.push(chunk) : true
100
100
  }
@@ -215,26 +215,28 @@ async function consume (stream, type) {
215
215
  reject(rState.errored ?? new TypeError('unusable'))
216
216
  }
217
217
  } else {
218
- stream[kConsume] = {
219
- type,
220
- stream,
221
- resolve,
222
- reject,
223
- length: 0,
224
- body: []
225
- }
218
+ queueMicrotask(() => {
219
+ stream[kConsume] = {
220
+ type,
221
+ stream,
222
+ resolve,
223
+ reject,
224
+ length: 0,
225
+ body: []
226
+ }
226
227
 
227
- stream
228
- .on('error', function (err) {
229
- consumeFinish(this[kConsume], err)
230
- })
231
- .on('close', function () {
232
- if (this[kConsume].body !== null) {
233
- consumeFinish(this[kConsume], new RequestAbortedError())
234
- }
235
- })
228
+ stream
229
+ .on('error', function (err) {
230
+ consumeFinish(this[kConsume], err)
231
+ })
232
+ .on('close', function () {
233
+ if (this[kConsume].body !== null) {
234
+ consumeFinish(this[kConsume], new RequestAbortedError())
235
+ }
236
+ })
236
237
 
237
- queueMicrotask(() => consumeStart(stream[kConsume]))
238
+ consumeStart(stream[kConsume])
239
+ })
238
240
  }
239
241
  })
240
242
  }
@@ -246,8 +248,16 @@ function consumeStart (consume) {
246
248
 
247
249
  const { _readableState: state } = consume.stream
248
250
 
249
- for (const chunk of state.buffer) {
250
- consumePush(consume, chunk)
251
+ if (state.bufferIndex) {
252
+ const start = state.bufferIndex
253
+ const end = state.buffer.length
254
+ for (let n = start; n < end; n++) {
255
+ consumePush(consume, state.buffer[n])
256
+ }
257
+ } else {
258
+ for (const chunk of state.buffer) {
259
+ consumePush(consume, chunk)
260
+ }
251
261
  }
252
262
 
253
263
  if (state.endEmitted) {
@@ -0,0 +1,398 @@
1
+ 'use strict'
2
+ const { Transform } = require('node:stream')
3
+ const { isASCIINumber, isValidLastEventId } = require('./util')
4
+
5
+ /**
6
+ * @type {number[]} BOM
7
+ */
8
+ const BOM = [0xEF, 0xBB, 0xBF]
9
+ /**
10
+ * @type {10} LF
11
+ */
12
+ const LF = 0x0A
13
+ /**
14
+ * @type {13} CR
15
+ */
16
+ const CR = 0x0D
17
+ /**
18
+ * @type {58} COLON
19
+ */
20
+ const COLON = 0x3A
21
+ /**
22
+ * @type {32} SPACE
23
+ */
24
+ const SPACE = 0x20
25
+
26
+ /**
27
+ * @typedef {object} EventSourceStreamEvent
28
+ * @type {object}
29
+ * @property {string} [event] The event type.
30
+ * @property {string} [data] The data of the message.
31
+ * @property {string} [id] A unique ID for the event.
32
+ * @property {string} [retry] The reconnection time, in milliseconds.
33
+ */
34
+
35
+ /**
36
+ * @typedef eventSourceSettings
37
+ * @type {object}
38
+ * @property {string} lastEventId The last event ID received from the server.
39
+ * @property {string} origin The origin of the event source.
40
+ * @property {number} reconnectionTime The reconnection time, in milliseconds.
41
+ */
42
+
43
+ class EventSourceStream extends Transform {
44
+ /**
45
+ * @type {eventSourceSettings}
46
+ */
47
+ state = null
48
+
49
+ /**
50
+ * Leading byte-order-mark check.
51
+ * @type {boolean}
52
+ */
53
+ checkBOM = true
54
+
55
+ /**
56
+ * @type {boolean}
57
+ */
58
+ crlfCheck = false
59
+
60
+ /**
61
+ * @type {boolean}
62
+ */
63
+ eventEndCheck = false
64
+
65
+ /**
66
+ * @type {Buffer}
67
+ */
68
+ buffer = null
69
+
70
+ pos = 0
71
+
72
+ event = {
73
+ data: undefined,
74
+ event: undefined,
75
+ id: undefined,
76
+ retry: undefined
77
+ }
78
+
79
+ /**
80
+ * @param {object} options
81
+ * @param {eventSourceSettings} options.eventSourceSettings
82
+ * @param {Function} [options.push]
83
+ */
84
+ constructor (options = {}) {
85
+ // Enable object mode as EventSourceStream emits objects of shape
86
+ // EventSourceStreamEvent
87
+ options.readableObjectMode = true
88
+
89
+ super(options)
90
+
91
+ this.state = options.eventSourceSettings || {}
92
+ if (options.push) {
93
+ this.push = options.push
94
+ }
95
+ }
96
+
97
+ /**
98
+ * @param {Buffer} chunk
99
+ * @param {string} _encoding
100
+ * @param {Function} callback
101
+ * @returns {void}
102
+ */
103
+ _transform (chunk, _encoding, callback) {
104
+ if (chunk.length === 0) {
105
+ callback()
106
+ return
107
+ }
108
+
109
+ // Cache the chunk in the buffer, as the data might not be complete while
110
+ // processing it
111
+ // TODO: Investigate if there is a more performant way to handle
112
+ // incoming chunks
113
+ // see: https://github.com/nodejs/undici/issues/2630
114
+ if (this.buffer) {
115
+ this.buffer = Buffer.concat([this.buffer, chunk])
116
+ } else {
117
+ this.buffer = chunk
118
+ }
119
+
120
+ // Strip leading byte-order-mark if we opened the stream and started
121
+ // the processing of the incoming data
122
+ if (this.checkBOM) {
123
+ switch (this.buffer.length) {
124
+ case 1:
125
+ // Check if the first byte is the same as the first byte of the BOM
126
+ if (this.buffer[0] === BOM[0]) {
127
+ // If it is, we need to wait for more data
128
+ callback()
129
+ return
130
+ }
131
+ // Set the checkBOM flag to false as we don't need to check for the
132
+ // BOM anymore
133
+ this.checkBOM = false
134
+
135
+ // The buffer only contains one byte so we need to wait for more data
136
+ callback()
137
+ return
138
+ case 2:
139
+ // Check if the first two bytes are the same as the first two bytes
140
+ // of the BOM
141
+ if (
142
+ this.buffer[0] === BOM[0] &&
143
+ this.buffer[1] === BOM[1]
144
+ ) {
145
+ // If it is, we need to wait for more data, because the third byte
146
+ // is needed to determine if it is the BOM or not
147
+ callback()
148
+ return
149
+ }
150
+
151
+ // Set the checkBOM flag to false as we don't need to check for the
152
+ // BOM anymore
153
+ this.checkBOM = false
154
+ break
155
+ case 3:
156
+ // Check if the first three bytes are the same as the first three
157
+ // bytes of the BOM
158
+ if (
159
+ this.buffer[0] === BOM[0] &&
160
+ this.buffer[1] === BOM[1] &&
161
+ this.buffer[2] === BOM[2]
162
+ ) {
163
+ // If it is, we can drop the buffered data, as it is only the BOM
164
+ this.buffer = Buffer.alloc(0)
165
+ // Set the checkBOM flag to false as we don't need to check for the
166
+ // BOM anymore
167
+ this.checkBOM = false
168
+
169
+ // Await more data
170
+ callback()
171
+ return
172
+ }
173
+ // If it is not the BOM, we can start processing the data
174
+ this.checkBOM = false
175
+ break
176
+ default:
177
+ // The buffer is longer than 3 bytes, so we can drop the BOM if it is
178
+ // present
179
+ if (
180
+ this.buffer[0] === BOM[0] &&
181
+ this.buffer[1] === BOM[1] &&
182
+ this.buffer[2] === BOM[2]
183
+ ) {
184
+ // Remove the BOM from the buffer
185
+ this.buffer = this.buffer.subarray(3)
186
+ }
187
+
188
+ // Set the checkBOM flag to false as we don't need to check for the
189
+ this.checkBOM = false
190
+ break
191
+ }
192
+ }
193
+
194
+ while (this.pos < this.buffer.length) {
195
+ // If the previous line ended with an end-of-line, we need to check
196
+ // if the next character is also an end-of-line.
197
+ if (this.eventEndCheck) {
198
+ // If the the current character is an end-of-line, then the event
199
+ // is finished and we can process it
200
+
201
+ // If the previous line ended with a carriage return, we need to
202
+ // check if the current character is a line feed and remove it
203
+ // from the buffer.
204
+ if (this.crlfCheck) {
205
+ // If the current character is a line feed, we can remove it
206
+ // from the buffer and reset the crlfCheck flag
207
+ if (this.buffer[this.pos] === LF) {
208
+ this.buffer = this.buffer.subarray(this.pos + 1)
209
+ this.pos = 0
210
+ this.crlfCheck = false
211
+
212
+ // It is possible that the line feed is not the end of the
213
+ // event. We need to check if the next character is an
214
+ // end-of-line character to determine if the event is
215
+ // finished. We simply continue the loop to check the next
216
+ // character.
217
+
218
+ // As we removed the line feed from the buffer and set the
219
+ // crlfCheck flag to false, we basically don't make any
220
+ // distinction between a line feed and a carriage return.
221
+ continue
222
+ }
223
+ this.crlfCheck = false
224
+ }
225
+
226
+ if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) {
227
+ // If the current character is a carriage return, we need to
228
+ // set the crlfCheck flag to true, as we need to check if the
229
+ // next character is a line feed so we can remove it from the
230
+ // buffer
231
+ if (this.buffer[this.pos] === CR) {
232
+ this.crlfCheck = true
233
+ }
234
+
235
+ this.buffer = this.buffer.subarray(this.pos + 1)
236
+ this.pos = 0
237
+ if (
238
+ this.event.data !== undefined || this.event.event || this.event.id || this.event.retry) {
239
+ this.processEvent(this.event)
240
+ }
241
+ this.clearEvent()
242
+ continue
243
+ }
244
+ // If the current character is not an end-of-line, then the event
245
+ // is not finished and we have to reset the eventEndCheck flag
246
+ this.eventEndCheck = false
247
+ continue
248
+ }
249
+
250
+ // If the current character is an end-of-line, we can process the
251
+ // line
252
+ if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) {
253
+ // If the current character is a carriage return, we need to
254
+ // set the crlfCheck flag to true, as we need to check if the
255
+ // next character is a line feed
256
+ if (this.buffer[this.pos] === CR) {
257
+ this.crlfCheck = true
258
+ }
259
+
260
+ // In any case, we can process the line as we reached an
261
+ // end-of-line character
262
+ this.parseLine(this.buffer.subarray(0, this.pos), this.event)
263
+
264
+ // Remove the processed line from the buffer
265
+ this.buffer = this.buffer.subarray(this.pos + 1)
266
+ // Reset the position as we removed the processed line from the buffer
267
+ this.pos = 0
268
+ // A line was processed and this could be the end of the event. We need
269
+ // to check if the next line is empty to determine if the event is
270
+ // finished.
271
+ this.eventEndCheck = true
272
+ continue
273
+ }
274
+
275
+ this.pos++
276
+ }
277
+
278
+ callback()
279
+ }
280
+
281
+ /**
282
+ * @param {Buffer} line
283
+ * @param {EventStreamEvent} event
284
+ */
285
+ parseLine (line, event) {
286
+ // If the line is empty (a blank line)
287
+ // Dispatch the event, as defined below.
288
+ // This will be handled in the _transform method
289
+ if (line.length === 0) {
290
+ return
291
+ }
292
+
293
+ // If the line starts with a U+003A COLON character (:)
294
+ // Ignore the line.
295
+ const colonPosition = line.indexOf(COLON)
296
+ if (colonPosition === 0) {
297
+ return
298
+ }
299
+
300
+ let field = ''
301
+ let value = ''
302
+
303
+ // If the line contains a U+003A COLON character (:)
304
+ if (colonPosition !== -1) {
305
+ // Collect the characters on the line before the first U+003A COLON
306
+ // character (:), and let field be that string.
307
+ // TODO: Investigate if there is a more performant way to extract the
308
+ // field
309
+ // see: https://github.com/nodejs/undici/issues/2630
310
+ field = line.subarray(0, colonPosition).toString('utf8')
311
+
312
+ // Collect the characters on the line after the first U+003A COLON
313
+ // character (:), and let value be that string.
314
+ // If value starts with a U+0020 SPACE character, remove it from value.
315
+ let valueStart = colonPosition + 1
316
+ if (line[valueStart] === SPACE) {
317
+ ++valueStart
318
+ }
319
+ // TODO: Investigate if there is a more performant way to extract the
320
+ // value
321
+ // see: https://github.com/nodejs/undici/issues/2630
322
+ value = line.subarray(valueStart).toString('utf8')
323
+
324
+ // Otherwise, the string is not empty but does not contain a U+003A COLON
325
+ // character (:)
326
+ } else {
327
+ // Process the field using the steps described below, using the whole
328
+ // line as the field name, and the empty string as the field value.
329
+ field = line.toString('utf8')
330
+ value = ''
331
+ }
332
+
333
+ // Modify the event with the field name and value. The value is also
334
+ // decoded as UTF-8
335
+ switch (field) {
336
+ case 'data':
337
+ if (event[field] === undefined) {
338
+ event[field] = value
339
+ } else {
340
+ event[field] += '\n' + value
341
+ }
342
+ break
343
+ case 'retry':
344
+ if (isASCIINumber(value)) {
345
+ event[field] = value
346
+ }
347
+ break
348
+ case 'id':
349
+ if (isValidLastEventId(value)) {
350
+ event[field] = value
351
+ }
352
+ break
353
+ case 'event':
354
+ if (value.length > 0) {
355
+ event[field] = value
356
+ }
357
+ break
358
+ }
359
+ }
360
+
361
+ /**
362
+ * @param {EventSourceStreamEvent} event
363
+ */
364
+ processEvent (event) {
365
+ if (event.retry && isASCIINumber(event.retry)) {
366
+ this.state.reconnectionTime = parseInt(event.retry, 10)
367
+ }
368
+
369
+ if (event.id && isValidLastEventId(event.id)) {
370
+ this.state.lastEventId = event.id
371
+ }
372
+
373
+ // only dispatch event, when data is provided
374
+ if (event.data !== undefined) {
375
+ this.push({
376
+ type: event.event || 'message',
377
+ options: {
378
+ data: event.data,
379
+ lastEventId: this.state.lastEventId,
380
+ origin: this.state.origin
381
+ }
382
+ })
383
+ }
384
+ }
385
+
386
+ clearEvent () {
387
+ this.event = {
388
+ data: undefined,
389
+ event: undefined,
390
+ id: undefined,
391
+ retry: undefined
392
+ }
393
+ }
394
+ }
395
+
396
+ module.exports = {
397
+ EventSourceStream
398
+ }
@@ -0,0 +1,473 @@
1
+ 'use strict'
2
+
3
+ const { setTimeout } = require('node:timers/promises')
4
+ const { pipeline } = require('node:stream')
5
+ const { fetching } = require('../fetch')
6
+ const { makeRequest } = require('../fetch/request')
7
+ const { getGlobalOrigin } = require('../fetch/global')
8
+ const { webidl } = require('../fetch/webidl')
9
+ const { EventSourceStream } = require('./eventsource-stream')
10
+ const { parseMIMEType } = require('../fetch/dataURL')
11
+ const { MessageEvent } = require('../websocket/events')
12
+ const { isNetworkError } = require('../fetch/response')
13
+ const { getGlobalDispatcher } = require('../global')
14
+
15
+ let experimentalWarned = false
16
+
17
+ /**
18
+ * A reconnection time, in milliseconds. This must initially be an implementation-defined value,
19
+ * probably in the region of a few seconds.
20
+ *
21
+ * In Comparison:
22
+ * - Chrome uses 3000ms.
23
+ * - Deno uses 5000ms.
24
+ *
25
+ * @type {3000}
26
+ */
27
+ const defaultReconnectionTime = 3000
28
+
29
+ /**
30
+ * The readyState attribute represents the state of the connection.
31
+ * @enum
32
+ * @readonly
33
+ * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#dom-eventsource-readystate-dev
34
+ */
35
+
36
+ /**
37
+ * The connection has not yet been established, or it was closed and the user
38
+ * agent is reconnecting.
39
+ * @type {0}
40
+ */
41
+ const CONNECTING = 0
42
+
43
+ /**
44
+ * The user agent has an open connection and is dispatching events as it
45
+ * receives them.
46
+ * @type {1}
47
+ */
48
+ const OPEN = 1
49
+
50
+ /**
51
+ * The connection is not open, and the user agent is not trying to reconnect.
52
+ * @type {2}
53
+ */
54
+ const CLOSED = 2
55
+
56
+ /**
57
+ * Requests for the element will have their mode set to "cors" and their credentials mode set to "same-origin".
58
+ * @type {'anonymous'}
59
+ */
60
+ const ANONYMOUS = 'anonymous'
61
+
62
+ /**
63
+ * Requests for the element will have their mode set to "cors" and their credentials mode set to "include".
64
+ * @type {'use-credentials'}
65
+ */
66
+ const USE_CREDENTIALS = 'use-credentials'
67
+
68
+ /**
69
+ * @typedef {object} EventSourceInit
70
+ * @property {boolean} [withCredentials] indicates whether the request
71
+ * should include credentials.
72
+ */
73
+
74
+ /**
75
+ * The EventSource interface is used to receive server-sent events. It
76
+ * connects to a server over HTTP and receives events in text/event-stream
77
+ * format without closing the connection.
78
+ * @extends {EventTarget}
79
+ * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events
80
+ * @api public
81
+ */
82
+ class EventSource extends EventTarget {
83
+ #events = {
84
+ open: null,
85
+ error: null,
86
+ message: null
87
+ }
88
+
89
+ #url = null
90
+ #withCredentials = false
91
+
92
+ #readyState = CONNECTING
93
+
94
+ #request = null
95
+ #controller = null
96
+
97
+ /**
98
+ * @type {object}
99
+ * @property {string} lastEventId
100
+ * @property {number} reconnectionTime
101
+ * @property {any} reconnectionTimer
102
+ */
103
+ #settings = null
104
+
105
+ /**
106
+ * Creates a new EventSource object.
107
+ * @param {string} url
108
+ * @param {EventSourceInit} [eventSourceInitDict]
109
+ * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface
110
+ */
111
+ constructor (url, eventSourceInitDict = {}) {
112
+ // 1. Let ev be a new EventSource object.
113
+ super()
114
+
115
+ webidl.argumentLengthCheck(arguments, 1, { header: 'EventSource constructor' })
116
+
117
+ if (!experimentalWarned) {
118
+ experimentalWarned = true
119
+ process.emitWarning('EventSource is experimental, expect them to change at any time.', {
120
+ code: 'UNDICI-ES'
121
+ })
122
+ }
123
+
124
+ url = webidl.converters.USVString(url)
125
+ eventSourceInitDict = webidl.converters.EventSourceInitDict(eventSourceInitDict)
126
+
127
+ // 2. Let settings be ev's relevant settings object.
128
+ // https://html.spec.whatwg.org/multipage/webappapis.html#environment-settings-object
129
+ this.#settings = {
130
+ origin: getGlobalOrigin(),
131
+ policyContainer: {
132
+ referrerPolicy: 'no-referrer'
133
+ },
134
+ lastEventId: '',
135
+ reconnectionTime: defaultReconnectionTime
136
+ }
137
+
138
+ let urlRecord
139
+
140
+ try {
141
+ // 3. Let urlRecord be the result of encoding-parsing a URL given url, relative to settings.
142
+ urlRecord = new URL(url, this.#settings.origin)
143
+ this.#settings.origin = urlRecord.origin
144
+ } catch (e) {
145
+ // 4. If urlRecord is failure, then throw a "SyntaxError" DOMException.
146
+ throw new DOMException(e, 'SyntaxError')
147
+ }
148
+
149
+ // 5. Set ev's url to urlRecord.
150
+ this.#url = urlRecord.href
151
+
152
+ // 6. Let corsAttributeState be Anonymous.
153
+ let corsAttributeState = ANONYMOUS
154
+
155
+ // 7. If the value of eventSourceInitDict's withCredentials member is true,
156
+ // then set corsAttributeState to Use Credentials and set ev's
157
+ // withCredentials attribute to true.
158
+ if (eventSourceInitDict.withCredentials) {
159
+ corsAttributeState = USE_CREDENTIALS
160
+ this.#withCredentials = true
161
+ }
162
+
163
+ // 8. Let request be the result of creating a potential-CORS request given
164
+ // urlRecord, the empty string, and corsAttributeState.
165
+ const initRequest = {
166
+ redirect: 'follow',
167
+ keepalive: true,
168
+ // @see https://html.spec.whatwg.org/multipage/urls-and-fetching.html#cors-settings-attributes
169
+ mode: 'cors',
170
+ credentials: corsAttributeState === 'anonymous'
171
+ ? 'same-origin'
172
+ : 'omit',
173
+ referrer: 'no-referrer'
174
+ }
175
+
176
+ // 9. Set request's client to settings.
177
+ initRequest.client = this.#settings
178
+
179
+ // 10. User agents may set (`Accept`, `text/event-stream`) in request's header list.
180
+ initRequest.headersList = [['accept', { name: 'accept', value: 'text/event-stream' }]]
181
+
182
+ // 11. Set request's cache mode to "no-store".
183
+ initRequest.cache = 'no-store'
184
+
185
+ // 12. Set request's initiator type to "other".
186
+ initRequest.initiator = 'other'
187
+
188
+ initRequest.urlList = [new URL(this.#url)]
189
+
190
+ // 13. Set ev's request to request.
191
+ this.#request = makeRequest(initRequest)
192
+
193
+ this.#connect()
194
+ }
195
+
196
+ /**
197
+ * Returns the state of this EventSource object's connection. It can have the
198
+ * values described below.
199
+ * @returns {0|1|2}
200
+ * @readonly
201
+ */
202
+ get readyState () {
203
+ return this.#readyState
204
+ }
205
+
206
+ /**
207
+ * Returns the URL providing the event stream.
208
+ * @readonly
209
+ * @returns {string}
210
+ */
211
+ get url () {
212
+ return this.#url
213
+ }
214
+
215
+ /**
216
+ * Returns a boolean indicating whether the EventSource object was
217
+ * instantiated with CORS credentials set (true), or not (false, the default).
218
+ */
219
+ get withCredentials () {
220
+ return this.#withCredentials
221
+ }
222
+
223
+ #connect () {
224
+ if (this.#readyState === CLOSED) return
225
+
226
+ this.#readyState = CONNECTING
227
+
228
+ const fetchParam = {
229
+ request: this.#request
230
+ }
231
+
232
+ // 14. Let processEventSourceEndOfBody given response res be the following step: if res is not a network error, then reestablish the connection.
233
+ const processEventSourceEndOfBody = (response) => {
234
+ if (isNetworkError(response)) {
235
+ this.dispatchEvent(new Event('error'))
236
+ this.close()
237
+ }
238
+
239
+ this.#reconnect()
240
+ }
241
+
242
+ // 15. Fetch request, with processResponseEndOfBody set to processEventSourceEndOfBody...
243
+ fetchParam.processResponseEndOfBody = processEventSourceEndOfBody
244
+
245
+ // and processResponse set to the following steps given response res:
246
+ fetchParam.processResponse = (response) => {
247
+ // 1. If res is an aborted network error, then fail the connection.
248
+
249
+ if (isNetworkError(response)) {
250
+ // 1. When a user agent is to fail the connection, the user agent
251
+ // must queue a task which, if the readyState attribute is set to a
252
+ // value other than CLOSED, sets the readyState attribute to CLOSED
253
+ // and fires an event named error at the EventSource object. Once the
254
+ // user agent has failed the connection, it does not attempt to
255
+ // reconnect.
256
+ if (response.aborted) {
257
+ this.close()
258
+ this.dispatchEvent(new Event('error'))
259
+ return
260
+ // 2. Otherwise, if res is a network error, then reestablish the
261
+ // connection, unless the user agent knows that to be futile, in
262
+ // which case the user agent may fail the connection.
263
+ } else {
264
+ this.#reconnect()
265
+ return
266
+ }
267
+ }
268
+
269
+ // 3. Otherwise, if res's status is not 200, or if res's `Content-Type`
270
+ // is not `text/event-stream`, then fail the connection.
271
+ const contentType = response.headersList.get('content-type', true)
272
+ const mimeType = contentType !== null ? parseMIMEType(contentType) : 'failure'
273
+ const contentTypeValid = mimeType !== 'failure' && mimeType.essence === 'text/event-stream'
274
+ if (
275
+ response.status !== 200 ||
276
+ contentTypeValid === false
277
+ ) {
278
+ this.close()
279
+ this.dispatchEvent(new Event('error'))
280
+ return
281
+ }
282
+
283
+ // 4. Otherwise, announce the connection and interpret res's body
284
+ // line by line.
285
+
286
+ // When a user agent is to announce the connection, the user agent
287
+ // must queue a task which, if the readyState attribute is set to a
288
+ // value other than CLOSED, sets the readyState attribute to OPEN
289
+ // and fires an event named open at the EventSource object.
290
+ // @see https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model
291
+ this.#readyState = OPEN
292
+ this.dispatchEvent(new Event('open'))
293
+
294
+ // If redirected to a different origin, set the origin to the new origin.
295
+ this.#settings.origin = response.urlList[response.urlList.length - 1].origin
296
+
297
+ const eventSourceStream = new EventSourceStream({
298
+ eventSourceSettings: this.#settings,
299
+ push: (event) => {
300
+ this.dispatchEvent(new MessageEvent(
301
+ event.type,
302
+ event.options
303
+ ))
304
+ }
305
+ })
306
+
307
+ pipeline(response.body.stream,
308
+ eventSourceStream,
309
+ (error) => {
310
+ if (
311
+ error?.aborted === false
312
+ ) {
313
+ this.close()
314
+ this.dispatchEvent(new Event('error'))
315
+ }
316
+ })
317
+ }
318
+
319
+ this.#controller = fetching({
320
+ ...fetchParam,
321
+ dispatcher: getGlobalDispatcher()
322
+ })
323
+ }
324
+
325
+ /**
326
+ * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model
327
+ * @returns {Promise<void>}
328
+ */
329
+ async #reconnect () {
330
+ // When a user agent is to reestablish the connection, the user agent must
331
+ // run the following steps. These steps are run in parallel, not as part of
332
+ // a task. (The tasks that it queues, of course, are run like normal tasks
333
+ // and not themselves in parallel.)
334
+
335
+ // 1. Queue a task to run the following steps:
336
+
337
+ // 1. If the readyState attribute is set to CLOSED, abort the task.
338
+ if (this.#readyState === CLOSED) return
339
+
340
+ // 2. Set the readyState attribute to CONNECTING.
341
+ this.#readyState = CONNECTING
342
+
343
+ // 3. Fire an event named error at the EventSource object.
344
+ this.dispatchEvent(new Event('error'))
345
+
346
+ // 2. Wait a delay equal to the reconnection time of the event source.
347
+ await setTimeout(this.#settings.reconnectionTime, { ref: false })
348
+
349
+ // 5. Queue a task to run the following steps:
350
+
351
+ // 1. If the EventSource object's readyState attribute is not set to
352
+ // CONNECTING, then return.
353
+ if (this.#readyState !== CONNECTING) return
354
+
355
+ // 2. Let request be the EventSource object's request.
356
+ // 3. If the EventSource object's last event ID string is not the empty
357
+ // string, then:
358
+ // 1. Let lastEventIDValue be the EventSource object's last event ID
359
+ // string, encoded as UTF-8.
360
+ // 2. Set (`Last-Event-ID`, lastEventIDValue) in request's header
361
+ // list.
362
+ if (this.#settings.lastEventId !== '') {
363
+ this.#request.headersList.set('last-event-id', this.#settings.lastEventId, true)
364
+ }
365
+
366
+ // 4. Fetch request and process the response obtained in this fashion, if any, as described earlier in this section.
367
+ this.#connect()
368
+ }
369
+
370
+ /**
371
+ * Closes the connection, if any, and sets the readyState attribute to
372
+ * CLOSED.
373
+ */
374
+ close () {
375
+ webidl.brandCheck(this, EventSource)
376
+
377
+ if (this.#readyState === CLOSED) return
378
+ this.#readyState = CLOSED
379
+ clearTimeout(this.#settings.reconnectionTimer)
380
+ this.#controller.abort()
381
+
382
+ if (this.#request) {
383
+ this.#request = null
384
+ }
385
+ }
386
+
387
+ get onopen () {
388
+ return this.#events.open
389
+ }
390
+
391
+ set onopen (fn) {
392
+ if (this.#events.open) {
393
+ this.removeEventListener('open', this.#events.open)
394
+ }
395
+
396
+ if (typeof fn === 'function') {
397
+ this.#events.open = fn
398
+ this.addEventListener('open', fn)
399
+ } else {
400
+ this.#events.open = null
401
+ }
402
+ }
403
+
404
+ get onmessage () {
405
+ return this.#events.message
406
+ }
407
+
408
+ set onmessage (fn) {
409
+ if (this.#events.message) {
410
+ this.removeEventListener('message', this.#events.message)
411
+ }
412
+
413
+ if (typeof fn === 'function') {
414
+ this.#events.message = fn
415
+ this.addEventListener('message', fn)
416
+ } else {
417
+ this.#events.message = null
418
+ }
419
+ }
420
+
421
+ get onerror () {
422
+ return this.#events.error
423
+ }
424
+
425
+ set onerror (fn) {
426
+ if (this.#events.error) {
427
+ this.removeEventListener('error', this.#events.error)
428
+ }
429
+
430
+ if (typeof fn === 'function') {
431
+ this.#events.error = fn
432
+ this.addEventListener('error', fn)
433
+ } else {
434
+ this.#events.error = null
435
+ }
436
+ }
437
+ }
438
+
439
+ const constantsPropertyDescriptors = {
440
+ CONNECTING: {
441
+ __proto__: null,
442
+ configurable: false,
443
+ enumerable: true,
444
+ value: CONNECTING,
445
+ writable: false
446
+ },
447
+ OPEN: {
448
+ __proto__: null,
449
+ configurable: false,
450
+ enumerable: true,
451
+ value: OPEN,
452
+ writable: false
453
+ },
454
+ CLOSED: {
455
+ __proto__: null,
456
+ configurable: false,
457
+ enumerable: true,
458
+ value: CLOSED,
459
+ writable: false
460
+ }
461
+ }
462
+
463
+ Object.defineProperties(EventSource, constantsPropertyDescriptors)
464
+ Object.defineProperties(EventSource.prototype, constantsPropertyDescriptors)
465
+
466
+ webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([
467
+ { key: 'withCredentials', converter: webidl.converters.boolean, defaultValue: false }
468
+ ])
469
+
470
+ module.exports = {
471
+ EventSource,
472
+ defaultReconnectionTime
473
+ }
@@ -0,0 +1,29 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Checks if the given value is a valid LastEventId.
5
+ * @param {string} value
6
+ * @returns {boolean}
7
+ */
8
+ function isValidLastEventId (value) {
9
+ // LastEventId should not contain U+0000 NULL
10
+ return value.indexOf('\u0000') === -1
11
+ }
12
+
13
+ /**
14
+ * Checks if the given value is a base 10 digit.
15
+ * @param {string} value
16
+ * @returns {boolean}
17
+ */
18
+ function isASCIINumber (value) {
19
+ if (value.length === 0) return false
20
+ for (let i = 0; i < value.length; i++) {
21
+ if (value.charCodeAt(i) < 0x30 || value.charCodeAt(i) > 0x39) return false
22
+ }
23
+ return true
24
+ }
25
+
26
+ module.exports = {
27
+ isValidLastEventId,
28
+ isASCIINumber
29
+ }
@@ -374,6 +374,9 @@ function fetching ({
374
374
  useParallelQueue = false,
375
375
  dispatcher // undici
376
376
  }) {
377
+ // This has bitten me in the ass more times than I'd like to admit.
378
+ assert(dispatcher)
379
+
377
380
  // 1. Let taskDestination be null.
378
381
  let taskDestination = null
379
382
 
@@ -364,6 +364,16 @@ function makeNetworkError (reason) {
364
364
  })
365
365
  }
366
366
 
367
+ // @see https://fetch.spec.whatwg.org/#concept-network-error
368
+ function isNetworkError (response) {
369
+ return (
370
+ // A network error is a response whose type is "error",
371
+ response.type === 'error' &&
372
+ // status is 0
373
+ response.status === 0
374
+ )
375
+ }
376
+
367
377
  function makeFilteredResponse (response, state) {
368
378
  state = {
369
379
  internalResponse: response,
@@ -572,6 +582,7 @@ webidl.converters.ResponseInit = webidl.dictionaryConverter([
572
582
  ])
573
583
 
574
584
  module.exports = {
585
+ isNetworkError,
575
586
  makeNetworkError,
576
587
  makeResponse,
577
588
  makeAppropriateNetworkError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "6.4.0",
3
+ "version": "6.5.0",
4
4
  "description": "An HTTP/1.1 client, written from scratch for Node.js",
5
5
  "homepage": "https://undici.nodejs.org",
6
6
  "bugs": {
@@ -75,9 +75,10 @@
75
75
  "build:wasm": "node build/wasm.js --docker",
76
76
  "lint": "standard | snazzy",
77
77
  "lint:fix": "standard --fix | snazzy",
78
- "test": "node scripts/generate-pem && npm run test:tap && npm run test:node-fetch && npm run test:fetch && npm run test:cookies && npm run test:wpt && npm run test:websocket && npm run test:jest && npm run test:typescript && npm run test:node-test",
78
+ "test": "node scripts/generate-pem && npm run test:tap && npm run test:node-fetch && npm run test:fetch && npm run test:cookies && npm run test:eventsource && npm run test:wpt && npm run test:websocket && npm run test:jest && npm run test:typescript && npm run test:node-test",
79
79
  "test:cookies": "borp --coverage -p \"test/cookie/*.js\"",
80
80
  "test:node-fetch": "mocha --exit test/node-fetch",
81
+ "test:eventsource": "npm run build:node && borp --expose-gc --coverage -p \"test/eventsource/*.js\"",
81
82
  "test:fetch": "npm run build:node && borp --expose-gc --coverage -p \"test/fetch/*.js\" && borp --coverage -p \"test/webidl/*.js\"",
82
83
  "test:jest": "jest",
83
84
  "test:tap": "tap test/*.js",
@@ -86,7 +87,7 @@
86
87
  "test:tdd:node-test": "borp -p \"test/node-test/**/*.js\" -w",
87
88
  "test:typescript": "tsd && tsc --skipLibCheck test/imports/undici-import.ts",
88
89
  "test:websocket": "borp --coverage -p \"test/websocket/*.js\"",
89
- "test:wpt": "node test/wpt/start-fetch.mjs && node test/wpt/start-FileAPI.mjs && node test/wpt/start-mimesniff.mjs && node test/wpt/start-xhr.mjs && node test/wpt/start-websockets.mjs && node test/wpt/start-cacheStorage.mjs",
90
+ "test:wpt": "node test/wpt/start-fetch.mjs && node test/wpt/start-FileAPI.mjs && node test/wpt/start-mimesniff.mjs && node test/wpt/start-xhr.mjs && node test/wpt/start-websockets.mjs && node test/wpt/start-cacheStorage.mjs && node test/wpt/start-eventsource.mjs",
90
91
  "coverage": "nyc --reporter=text --reporter=html npm run test",
91
92
  "coverage:ci": "nyc --reporter=lcov npm run test",
92
93
  "bench": "PORT=3042 concurrently -k -s first npm:bench:server npm:bench:run",
@@ -119,7 +120,7 @@
119
120
  "husky": "^8.0.1",
120
121
  "import-fresh": "^3.3.0",
121
122
  "jest": "^29.0.2",
122
- "jsdom": "^23.0.0",
123
+ "jsdom": "^24.0.0",
123
124
  "jsfuzz": "^1.0.15",
124
125
  "mitata": "^0.1.6",
125
126
  "mocha": "^10.0.0",
@@ -0,0 +1,61 @@
1
+ import { MessageEvent, ErrorEvent } from './websocket'
2
+
3
+ import {
4
+ EventTarget,
5
+ Event,
6
+ EventListenerOptions,
7
+ AddEventListenerOptions,
8
+ EventListenerOrEventListenerObject
9
+ } from './patch'
10
+
11
+ interface EventSourceEventMap {
12
+ error: ErrorEvent
13
+ message: MessageEvent
14
+ open: Event
15
+ }
16
+
17
+ interface EventSource extends EventTarget {
18
+ close(): void
19
+ readonly CLOSED: 2
20
+ readonly CONNECTING: 0
21
+ readonly OPEN: 1
22
+ onerror: (this: EventSource, ev: ErrorEvent) => any
23
+ onmessage: (this: EventSource, ev: MessageEvent) => any
24
+ onopen: (this: EventSource, ev: Event) => any
25
+ readonly readyState: 0 | 1 | 2
26
+ readonly url: string
27
+ readonly withCredentials: boolean
28
+
29
+ addEventListener<K extends keyof EventSourceEventMap>(
30
+ type: K,
31
+ listener: (this: EventSource, ev: EventSourceEventMap[K]) => any,
32
+ options?: boolean | AddEventListenerOptions
33
+ ): void
34
+ addEventListener(
35
+ type: string,
36
+ listener: EventListenerOrEventListenerObject,
37
+ options?: boolean | AddEventListenerOptions
38
+ ): void
39
+ removeEventListener<K extends keyof EventSourceEventMap>(
40
+ type: K,
41
+ listener: (this: EventSource, ev: EventSourceEventMap[K]) => any,
42
+ options?: boolean | EventListenerOptions
43
+ ): void
44
+ removeEventListener(
45
+ type: string,
46
+ listener: EventListenerOrEventListenerObject,
47
+ options?: boolean | EventListenerOptions
48
+ ): void
49
+ }
50
+
51
+ export declare const EventSource: {
52
+ prototype: EventSource
53
+ new (url: string | URL, init: EventSourceInit): EventSource
54
+ readonly CLOSED: 2
55
+ readonly CONNECTING: 0
56
+ readonly OPEN: 1
57
+ }
58
+
59
+ interface EventSourceInit {
60
+ withCredentials?: boolean
61
+ }
package/types/index.d.ts CHANGED
@@ -19,6 +19,7 @@ import { request, pipeline, stream, connect, upgrade } from './api'
19
19
 
20
20
  export * from './util'
21
21
  export * from './cookies'
22
+ export * from './eventsource'
22
23
  export * from './fetch'
23
24
  export * from './file'
24
25
  export * from './filereader'
@@ -17,7 +17,7 @@ export type BinaryType = 'blob' | 'arraybuffer'
17
17
 
18
18
  interface WebSocketEventMap {
19
19
  close: CloseEvent
20
- error: Event
20
+ error: ErrorEvent
21
21
  message: MessageEvent
22
22
  open: Event
23
23
  }
@@ -124,6 +124,27 @@ export declare const MessageEvent: {
124
124
  new<T>(type: string, eventInitDict?: MessageEventInit<T>): MessageEvent<T>
125
125
  }
126
126
 
127
+ interface ErrorEventInit extends EventInit {
128
+ message?: string
129
+ filename?: string
130
+ lineno?: number
131
+ colno?: number
132
+ error?: any
133
+ }
134
+
135
+ interface ErrorEvent extends Event {
136
+ readonly message: string
137
+ readonly filename: string
138
+ readonly lineno: number
139
+ readonly colno: number
140
+ readonly error: any
141
+ }
142
+
143
+ export declare const ErrorEvent: {
144
+ prototype: ErrorEvent
145
+ new (type: string, eventInitDict?: ErrorEventInit): ErrorEvent
146
+ }
147
+
127
148
  interface WebSocketInit {
128
149
  protocols?: string | string[],
129
150
  dispatcher?: Dispatcher,