undici 6.28.0 → 6.29.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.
@@ -40,7 +40,8 @@ diagnosticsChannel.channel('undici:request:bodySent').subscribe(({ request }) =>
40
40
 
41
41
  ## `undici:request:headers`
42
42
 
43
- This message is published after the response headers have been received, i.e. the response has been completed.
43
+ This message is published after the response headers have been received. This includes a successful CONNECT or
44
+ protocol upgrade response.
44
45
 
45
46
  ```js
46
47
  import diagnosticsChannel from 'diagnostics_channel'
@@ -57,6 +58,7 @@ diagnosticsChannel.channel('undici:request:headers').subscribe(({ request, respo
57
58
  ## `undici:request:trailers`
58
59
 
59
60
  This message is published after the response body and trailers have been received, i.e. the response has been completed.
61
+ After an upgraded socket is passed to the request handler, this message is published with an empty `trailers` array.
60
62
 
61
63
  ```js
62
64
  import diagnosticsChannel from 'diagnostics_channel'
@@ -263,11 +263,77 @@ class Request {
263
263
  }
264
264
  }
265
265
 
266
- onUpgrade (statusCode, headers, socket) {
266
+ /**
267
+ * @param {number|null} statusCode
268
+ * @param {Buffer[]|null} headers
269
+ * @param {import('node:stream').Duplex} socket
270
+ * @param {string} [statusText]
271
+ */
272
+ onUpgrade (statusCode, headers, socket, statusText = '') {
273
+ this.onFinally()
274
+
267
275
  assert(!this.aborted)
268
276
  assert(!this.completed)
269
277
 
270
- return this[kHandler].onUpgrade(statusCode, headers, socket)
278
+ if (statusCode !== null) {
279
+ this.#publishUpgradeHeaders(statusCode, headers, statusText)
280
+ }
281
+
282
+ const result = this[kHandler].onUpgrade(statusCode, headers, socket)
283
+
284
+ if (!this.aborted) {
285
+ this.completed = true
286
+ if (statusCode !== null) {
287
+ this.#publishUpgradeTrailers()
288
+ }
289
+ }
290
+
291
+ return result
292
+ }
293
+
294
+ /**
295
+ * @param {number} statusCode
296
+ * @param {import('node:http2').IncomingHttpHeaders} headers
297
+ * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders
298
+ * @param {string} [statusText]
299
+ */
300
+ onUpgradeResponse (statusCode, headers, parseHeaders, statusText = '') {
301
+ assert(!this.aborted)
302
+ assert(this.completed)
303
+
304
+ if (channels.headers.hasSubscribers) {
305
+ this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText)
306
+ }
307
+ this.#publishUpgradeTrailers()
308
+ }
309
+
310
+ /**
311
+ * @param {Error} error
312
+ */
313
+ onUpgradeError (error) {
314
+ assert(!this.aborted)
315
+ assert(this.completed)
316
+
317
+ if (channels.error.hasSubscribers) {
318
+ channels.error.publish({ request: this, error })
319
+ }
320
+ }
321
+
322
+ /**
323
+ * @param {number} statusCode
324
+ * @param {Buffer[]} headers
325
+ * @param {string} statusText
326
+ */
327
+ #publishUpgradeHeaders (statusCode, headers, statusText) {
328
+ if (channels.headers.hasSubscribers) {
329
+ channels.headers.publish({ request: this, response: { statusCode, headers, statusText } })
330
+ }
331
+ }
332
+
333
+ #publishUpgradeTrailers () {
334
+ if (channels.trailers.hasSubscribers) {
335
+ channels.trailers.publish({ request: this, trailers: [] })
336
+ }
271
337
  }
272
338
 
273
339
  onComplete (trailers) {
@@ -432,7 +432,7 @@ class Parser {
432
432
  }
433
433
 
434
434
  onUpgrade (head) {
435
- const { upgrade, client, socket, headers, statusCode } = this
435
+ const { upgrade, client, socket, headers, statusCode, statusText } = this
436
436
 
437
437
  assert(upgrade)
438
438
  assert(client[kSocket] === socket)
@@ -467,9 +467,10 @@ class Parser {
467
467
  client.emit('disconnect', client[kUrl], [client], new InformationalError('upgrade'))
468
468
 
469
469
  try {
470
- request.onUpgrade(statusCode, headers, socket)
471
- } catch (err) {
472
- util.destroy(socket, err)
470
+ request.onUpgrade(statusCode, headers, socket, statusText)
471
+ } catch (error) {
472
+ util.errorRequest(client, request, error)
473
+ util.destroy(socket, error)
473
474
  }
474
475
 
475
476
  client[kResume]()
@@ -876,7 +877,7 @@ async function connectH1 (client, socket) {
876
877
 
877
878
  function clearIdleSocketValidation (socket) {
878
879
  if (socket[kIdleSocketValidationTimeout]) {
879
- clearTimeout(socket[kIdleSocketValidationTimeout])
880
+ clearImmediate(socket[kIdleSocketValidationTimeout])
880
881
  socket[kIdleSocketValidationTimeout] = null
881
882
  }
882
883
 
@@ -885,15 +886,23 @@ function clearIdleSocketValidation (socket) {
885
886
 
886
887
  function scheduleIdleSocketValidation (client, socket) {
887
888
  socket[kIdleSocketValidation] = 1
888
- socket[kIdleSocketValidationTimeout] = setTimeout(() => {
889
+ // Yield to the check phase (after poll) so unsolicited bytes / FIN / RST
890
+ // already pending on this idle keep-alive socket are processed before the
891
+ // next request is written (GHSA-35p6-xmwp-9g52).
892
+ //
893
+ // setTimeout(0) pays Node's ~1ms timer floor on every sequential reuse
894
+ // (#5493). setImmediate avoids that, but an *unref'd* Immediate lets poll
895
+ // block for ~500ms when the event loop is otherwise idle (#5600 / #5606).
896
+ // A ref'd Immediate both keeps the pending request alive and makes poll
897
+ // return immediately — the hybrid those issues asked for.
898
+ socket[kIdleSocketValidationTimeout] = setImmediate(() => {
889
899
  socket[kIdleSocketValidationTimeout] = null
890
900
  socket[kIdleSocketValidation] = 2
891
901
 
892
902
  if (client[kSocket] === socket && !socket.destroyed) {
893
903
  client[kResume]()
894
904
  }
895
- }, 0)
896
- socket[kIdleSocketValidationTimeout].unref?.()
905
+ })
897
906
  }
898
907
 
899
908
  /**
@@ -1042,12 +1051,22 @@ function writeH1 (client, request) {
1042
1051
  const socket = client[kSocket]
1043
1052
  clearIdleSocketValidation(socket)
1044
1053
 
1045
- const abort = (err) => {
1046
- if (request.aborted || request.completed) {
1054
+ /**
1055
+ * @param {Error} [error]
1056
+ */
1057
+ const abort = (error) => {
1058
+ if (request.aborted) {
1059
+ return
1060
+ }
1061
+
1062
+ if (request.completed) {
1063
+ if (request.upgrade || request.method === 'CONNECT') {
1064
+ util.destroy(socket, new InformationalError('aborted'))
1065
+ }
1047
1066
  return
1048
1067
  }
1049
1068
 
1050
- util.errorRequest(client, request, err || new RequestAbortedError())
1069
+ util.errorRequest(client, request, error || new RequestAbortedError())
1051
1070
 
1052
1071
  util.destroy(body)
1053
1072
  util.destroy(socket, new InformationalError('aborted'))
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const assert = require('node:assert')
4
+ const { errorMonitor } = require('node:events')
4
5
  const { pipeline } = require('node:stream')
5
6
  const util = require('../core/util.js')
6
7
  const {
@@ -77,6 +78,15 @@ function parseH2Headers (headers) {
77
78
  return result
78
79
  }
79
80
 
81
+ /**
82
+ * @param {import('node:http2').IncomingHttpHeaders} headers
83
+ * @returns {Buffer[]}
84
+ */
85
+ function parseH2ResponseHeaders (headers) {
86
+ const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers
87
+ return parseH2Headers(realHeaders)
88
+ }
89
+
80
90
  async function connectH2 (client, socket) {
81
91
  client[kSocket] = socket
82
92
 
@@ -297,22 +307,32 @@ function writeH2 (client, request) {
297
307
  headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ''}`
298
308
  headers[HTTP2_HEADER_METHOD] = method
299
309
 
300
- const abort = (err) => {
301
- if (request.aborted || request.completed) {
310
+ /**
311
+ * @param {Error} [error]
312
+ */
313
+ const abort = (error) => {
314
+ if (request.aborted) {
315
+ return
316
+ }
317
+
318
+ if (request.completed) {
319
+ if (method === 'CONNECT' && stream != null) {
320
+ util.destroy(stream, error || new RequestAbortedError())
321
+ }
302
322
  return
303
323
  }
304
324
 
305
- err = err || new RequestAbortedError()
325
+ error = error || new RequestAbortedError()
306
326
 
307
- util.errorRequest(client, request, err)
327
+ util.errorRequest(client, request, error)
308
328
 
309
329
  if (stream != null) {
310
- util.destroy(stream, err)
330
+ util.destroy(stream, error)
311
331
  }
312
332
 
313
333
  // We do not destroy the socket as we can continue using the session
314
334
  // the stream get's destroyed and the session remains to create new streams
315
- util.destroy(body, err)
335
+ util.destroy(body, error)
316
336
  client[kQueue][client[kRunningIdx]++] = null
317
337
  client[kResume]()
318
338
  }
@@ -331,25 +351,57 @@ function writeH2 (client, request) {
331
351
 
332
352
  if (method === 'CONNECT') {
333
353
  session.ref()
334
- // We are already connected, streams are pending, first request
335
- // will create a new stream. We trigger a request to create the stream and wait until
336
- // `ready` event is triggered
337
354
  // We disabled endStream to allow the user to write to the stream
338
355
  stream = session.request(headers, { endStream: false, signal })
356
+ let upgradeResponseFinished = false
357
+
358
+ /**
359
+ * @param {import('node:http2').IncomingHttpHeaders} headers
360
+ */
361
+ const onResponse = (headers) => {
362
+ upgradeResponseFinished = true
363
+ stream.off(errorMonitor, onUpgradeError)
364
+ request.onUpgradeResponse(Number(headers[HTTP2_HEADER_STATUS]), headers, parseH2ResponseHeaders)
365
+ }
339
366
 
340
- if (stream.id && !stream.pending) {
341
- request.onUpgrade(null, null, stream)
342
- ++session[kOpenStreams]
343
- client[kQueue][client[kRunningIdx]++] = null
344
- } else {
345
- stream.once('ready', () => {
367
+ /**
368
+ * @param {Error} error
369
+ */
370
+ const onUpgradeError = (error) => {
371
+ upgradeResponseFinished = true
372
+ stream.off('response', onResponse)
373
+ request.onUpgradeError(error)
374
+ }
375
+
376
+ const onReady = () => {
377
+ try {
346
378
  request.onUpgrade(null, null, stream)
347
- ++session[kOpenStreams]
348
- client[kQueue][client[kRunningIdx]++] = null
349
- })
379
+ } catch (error) {
380
+ stream.off('response', onResponse)
381
+ abort(error)
382
+ return
383
+ }
384
+
385
+ if (request.aborted) {
386
+ return
387
+ }
388
+
389
+ stream.off('error', abort)
390
+ stream.once(errorMonitor, onUpgradeError)
391
+ client[kQueue][client[kRunningIdx]++] = null
350
392
  }
351
393
 
394
+ stream.once('response', onResponse)
395
+ stream.once('error', abort)
396
+ ++session[kOpenStreams]
397
+ onReady()
398
+
352
399
  stream.once('close', () => {
400
+ if (!upgradeResponseFinished && request.completed) {
401
+ stream.off('response', onResponse)
402
+ stream.off(errorMonitor, onUpgradeError)
403
+ request.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`))
404
+ }
353
405
  session[kOpenStreams] -= 1
354
406
  if (session[kOpenStreams] === 0) session.unref()
355
407
  })
@@ -90,6 +90,7 @@ class RetryHandler {
90
90
  this.end = null
91
91
  this.etag = null
92
92
  this.resume = null
93
+ this.headersSent = false
93
94
 
94
95
  // Handle possible onConnect duplication
95
96
  this.handler.onConnect(reason => {
@@ -102,6 +103,20 @@ class RetryHandler {
102
103
  })
103
104
  }
104
105
 
106
+ checkpointResponseEnd (headers, resume) {
107
+ if (this.end == null && this.opts.method !== 'HEAD') {
108
+ const contentLength = headers['content-length']
109
+ this.end = contentLength != null ? Number(contentLength) - 1 : null
110
+
111
+ assert(
112
+ this.end == null || Number.isFinite(this.end),
113
+ 'invalid content-length'
114
+ )
115
+ }
116
+
117
+ this.resume = this.end != null ? resume : null
118
+ }
119
+
105
120
  onRequestSent () {
106
121
  if (this.handler.onRequestSent) {
107
122
  this.handler.onRequestSent()
@@ -190,7 +205,12 @@ class RetryHandler {
190
205
  this.retryCount += 1
191
206
 
192
207
  if (statusCode >= 300) {
193
- if (this.retryOpts.statusCodes.includes(statusCode) === false) {
208
+ // Only expose a response if no earlier attempt has reached the caller.
209
+ // Otherwise abort this attempt so the error settles the existing body
210
+ // instead of replacing it with a new response.
211
+ if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) {
212
+ this.headersSent = true
213
+ this.checkpointResponseEnd(headers, resume)
194
214
  return this.handler.onHeaders(
195
215
  statusCode,
196
216
  rawHeaders,
@@ -259,8 +279,15 @@ class RetryHandler {
259
279
 
260
280
  const { start, size, end = size - 1 } = contentRange
261
281
 
262
- assert(this.start === start, 'content-range mismatch')
263
- assert(this.end == null || this.end === end, 'content-range mismatch')
282
+ if (this.start !== start || (this.end != null && this.end !== end)) {
283
+ this.abort(
284
+ new RequestRetryError('Content-Range mismatch', statusCode, {
285
+ headers,
286
+ data: { count: this.retryCount }
287
+ })
288
+ )
289
+ return false
290
+ }
264
291
 
265
292
  this.resume = resume
266
293
  return true
@@ -272,6 +299,7 @@ class RetryHandler {
272
299
  const range = parseRangeHeader(headers['content-range'])
273
300
 
274
301
  if (range == null) {
302
+ this.headersSent = true
275
303
  return this.handler.onHeaders(
276
304
  statusCode,
277
305
  rawHeaders,
@@ -310,6 +338,7 @@ class RetryHandler {
310
338
  )
311
339
 
312
340
  this.resume = resume
341
+ this.headersSent = true
313
342
  this.etag = headers.etag != null ? headers.etag : null
314
343
 
315
344
  // Weak etags are not useful for comparison nor cache
@@ -349,7 +378,7 @@ class RetryHandler {
349
378
  }
350
379
 
351
380
  onError (err) {
352
- if (this.aborted || isDisturbed(this.opts.body)) {
381
+ if (this.aborted || isDisturbed(this.opts.body) || (this.headersSent && this.resume == null)) {
353
382
  return this.handler.onError(err)
354
383
  }
355
384
 
@@ -23,6 +23,49 @@ const COLON = 0x3A
23
23
  */
24
24
  const SPACE = 0x20
25
25
 
26
+ const DATA = Buffer.from('data')
27
+ const EVENT = Buffer.from('event')
28
+ const ID = Buffer.from('id')
29
+ const RETRY = Buffer.from('retry')
30
+
31
+ function isASCIINumberBytes (buffer, start) {
32
+ if (start >= buffer.length) {
33
+ return false
34
+ }
35
+
36
+ for (let i = start; i < buffer.length; i++) {
37
+ if (buffer[i] < 0x30 || buffer[i] > 0x39) {
38
+ return false
39
+ }
40
+ }
41
+
42
+ return true
43
+ }
44
+
45
+ function isValidLastEventIdBytes (buffer, start) {
46
+ for (let i = start; i < buffer.length; i++) {
47
+ if (buffer[i] === 0x00) {
48
+ return false
49
+ }
50
+ }
51
+
52
+ return true
53
+ }
54
+
55
+ function isFieldName (line, length, field) {
56
+ if (length !== field.length) {
57
+ return false
58
+ }
59
+
60
+ for (let i = 0; i < length; i++) {
61
+ if (line[i] !== field[i]) {
62
+ return false
63
+ }
64
+ }
65
+
66
+ return true
67
+ }
68
+
26
69
  /**
27
70
  * @typedef {object} EventSourceStreamEvent
28
71
  * @type {object}
@@ -63,11 +106,14 @@ class EventSourceStream extends Transform {
63
106
  eventEndCheck = false
64
107
 
65
108
  /**
66
- * @type {Buffer}
109
+ * @type {Buffer[]}
67
110
  */
68
- buffer = null
111
+ chunks = []
69
112
 
113
+ chunkIndex = 0
70
114
  pos = 0
115
+ lineChunkIndex = 0
116
+ linePos = 0
71
117
 
72
118
  event = {
73
119
  data: undefined,
@@ -106,92 +152,20 @@ class EventSourceStream extends Transform {
106
152
  return
107
153
  }
108
154
 
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
- }
155
+ this.chunks.push(chunk)
119
156
 
120
157
  // Strip leading byte-order-mark if we opened the stream and started
121
158
  // the processing of the incoming data
122
159
  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
160
+ if (this.handleBOM()) {
161
+ callback()
162
+ return
191
163
  }
192
164
  }
193
165
 
194
- while (this.pos < this.buffer.length) {
166
+ while (this.hasCurrentByte()) {
167
+ const byte = this.currentByte()
168
+
195
169
  // If the previous line ended with an end-of-line, we need to check
196
170
  // if the next character is also an end-of-line.
197
171
  if (this.eventEndCheck) {
@@ -204,10 +178,9 @@ class EventSourceStream extends Transform {
204
178
  if (this.crlfCheck) {
205
179
  // If the current character is a line feed, we can remove it
206
180
  // 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
181
+ if (byte === LF) {
210
182
  this.crlfCheck = false
183
+ this.consumeCurrentByte()
211
184
 
212
185
  // It is possible that the line feed is not the end of the
213
186
  // event. We need to check if the next character is an
@@ -223,19 +196,17 @@ class EventSourceStream extends Transform {
223
196
  this.crlfCheck = false
224
197
  }
225
198
 
226
- if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) {
199
+ if (byte === LF || byte === CR) {
227
200
  // If the current character is a carriage return, we need to
228
201
  // set the crlfCheck flag to true, as we need to check if the
229
202
  // next character is a line feed so we can remove it from the
230
203
  // buffer
231
- if (this.buffer[this.pos] === CR) {
204
+ if (byte === CR) {
232
205
  this.crlfCheck = true
233
206
  }
234
207
 
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) {
208
+ this.consumeCurrentByte()
209
+ if (this.hasPendingEvent()) {
239
210
  this.processEvent(this.event)
240
211
  }
241
212
  this.clearEvent()
@@ -249,22 +220,18 @@ class EventSourceStream extends Transform {
249
220
 
250
221
  // If the current character is an end-of-line, we can process the
251
222
  // line
252
- if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) {
223
+ if (byte === LF || byte === CR) {
253
224
  // If the current character is a carriage return, we need to
254
225
  // set the crlfCheck flag to true, as we need to check if the
255
226
  // next character is a line feed
256
- if (this.buffer[this.pos] === CR) {
227
+ if (byte === CR) {
257
228
  this.crlfCheck = true
258
229
  }
259
230
 
260
231
  // In any case, we can process the line as we reached an
261
232
  // 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
233
+ this.parseLine(this.readLine(), this.event)
234
+ this.consumeCurrentByte()
268
235
  // A line was processed and this could be the end of the event. We need
269
236
  // to check if the next line is empty to determine if the event is
270
237
  // finished.
@@ -272,7 +239,7 @@ class EventSourceStream extends Transform {
272
239
  continue
273
240
  }
274
241
 
275
- this.pos++
242
+ this.advanceCursor()
276
243
  }
277
244
 
278
245
  callback()
@@ -297,64 +264,53 @@ class EventSourceStream extends Transform {
297
264
  return
298
265
  }
299
266
 
300
- let field = ''
301
- let value = ''
267
+ let fieldLength = line.length
268
+ let valueStart = line.length
302
269
 
303
270
  // If the line contains a U+003A COLON character (:)
304
271
  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')
272
+ fieldLength = colonPosition
311
273
 
312
274
  // Collect the characters on the line after the first U+003A COLON
313
275
  // character (:), and let value be that string.
314
276
  // If value starts with a U+0020 SPACE character, remove it from value.
315
- let valueStart = colonPosition + 1
277
+ valueStart = colonPosition + 1
316
278
  if (line[valueStart] === SPACE) {
317
279
  ++valueStart
318
280
  }
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
281
  }
332
282
 
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
283
+ if (isFieldName(line, fieldLength, DATA)) {
284
+ const value = line.toString('utf8', valueStart)
285
+
286
+ if (event.data === undefined) {
287
+ event.data = value
288
+ } else {
289
+ event.data += `\n${value}`
290
+ }
291
+ return
292
+ }
293
+
294
+ if (isFieldName(line, fieldLength, RETRY)) {
295
+ if (isASCIINumberBytes(line, valueStart)) {
296
+ event.retry = line.toString('utf8', valueStart)
297
+ }
298
+ return
299
+ }
300
+
301
+ if (isFieldName(line, fieldLength, ID)) {
302
+ if (isValidLastEventIdBytes(line, valueStart)) {
303
+ event.id = line.toString('utf8', valueStart)
304
+ }
305
+ return
306
+ }
307
+
308
+ if (isFieldName(line, fieldLength, EVENT)) {
309
+ const value = line.toString('utf8', valueStart)
310
+
311
+ if (value.length > 0) {
312
+ event.event = value
313
+ }
358
314
  }
359
315
  }
360
316
 
@@ -384,12 +340,151 @@ class EventSourceStream extends Transform {
384
340
  }
385
341
 
386
342
  clearEvent () {
387
- this.event = {
388
- data: undefined,
389
- event: undefined,
390
- id: undefined,
391
- retry: undefined
343
+ this.event.data = undefined
344
+ this.event.event = undefined
345
+ this.event.id = undefined
346
+ this.event.retry = undefined
347
+ }
348
+
349
+ hasPendingEvent () {
350
+ return this.event.data !== undefined ||
351
+ this.event.event !== undefined ||
352
+ this.event.id !== undefined ||
353
+ this.event.retry !== undefined
354
+ }
355
+
356
+ hasCurrentByte () {
357
+ return this.chunkIndex < this.chunks.length &&
358
+ this.pos < this.chunks[this.chunkIndex].length
359
+ }
360
+
361
+ currentByte () {
362
+ return this.chunks[this.chunkIndex][this.pos]
363
+ }
364
+
365
+ consumeCurrentByte () {
366
+ this.advanceCursor()
367
+ this.syncLineStartToCursor()
368
+ }
369
+
370
+ advanceCursor () {
371
+ this.pos++
372
+
373
+ while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) {
374
+ this.chunkIndex++
375
+ this.pos = 0
376
+ }
377
+ }
378
+
379
+ syncLineStartToCursor () {
380
+ this.lineChunkIndex = this.chunkIndex
381
+ this.linePos = this.pos
382
+ this.dropConsumedChunks()
383
+ }
384
+
385
+ dropConsumedChunks () {
386
+ while (this.lineChunkIndex > 0) {
387
+ this.chunks.shift()
388
+ this.lineChunkIndex--
389
+ this.chunkIndex--
390
+ }
391
+
392
+ if (this.chunkIndex === this.chunks.length) {
393
+ this.chunks.length = 0
394
+ this.chunkIndex = 0
395
+ this.pos = 0
396
+ this.lineChunkIndex = 0
397
+ this.linePos = 0
398
+ }
399
+ }
400
+
401
+ readLine () {
402
+ if (this.lineChunkIndex === this.chunkIndex) {
403
+ return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos)
404
+ }
405
+
406
+ const chunks = []
407
+ let length = 0
408
+
409
+ for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) {
410
+ const chunk = this.chunks[i]
411
+ const start = i === this.lineChunkIndex ? this.linePos : 0
412
+ const end = i === this.chunkIndex ? this.pos : chunk.length
413
+ const slice = chunk.subarray(start, end)
414
+ length += slice.length
415
+ chunks.push(slice)
416
+ }
417
+
418
+ return Buffer.concat(chunks, length)
419
+ }
420
+
421
+ peekBufferedByte (offset) {
422
+ let chunkIndex = this.lineChunkIndex
423
+ let pos = this.linePos
424
+
425
+ while (chunkIndex < this.chunks.length) {
426
+ const chunk = this.chunks[chunkIndex]
427
+ const remaining = chunk.length - pos
428
+
429
+ if (offset < remaining) {
430
+ return chunk[pos + offset]
431
+ }
432
+
433
+ offset -= remaining
434
+ chunkIndex++
435
+ pos = 0
436
+ }
437
+ }
438
+
439
+ discardLeadingBytes (count) {
440
+ while (count > 0 && this.lineChunkIndex < this.chunks.length) {
441
+ const chunk = this.chunks[this.lineChunkIndex]
442
+ const remaining = chunk.length - this.linePos
443
+
444
+ if (count < remaining) {
445
+ this.linePos += count
446
+ count = 0
447
+ } else {
448
+ count -= remaining
449
+ this.lineChunkIndex++
450
+ this.linePos = 0
451
+ }
452
+ }
453
+
454
+ this.chunkIndex = this.lineChunkIndex
455
+ this.pos = this.linePos
456
+ this.dropConsumedChunks()
457
+ }
458
+
459
+ handleBOM () {
460
+ const first = this.peekBufferedByte(0)
461
+ const second = this.peekBufferedByte(1)
462
+ const third = this.peekBufferedByte(2)
463
+
464
+ if (second === undefined) {
465
+ if (first === BOM[0]) {
466
+ return true
467
+ }
468
+
469
+ this.checkBOM = false
470
+ return true
471
+ }
472
+
473
+ if (third === undefined) {
474
+ if (first === BOM[0] && second === BOM[1]) {
475
+ return true
476
+ }
477
+
478
+ this.checkBOM = false
479
+ return false
392
480
  }
481
+
482
+ if (first === BOM[0] && second === BOM[1] && third === BOM[2]) {
483
+ this.discardLeadingBytes(3)
484
+ }
485
+
486
+ this.checkBOM = false
487
+ return !this.hasCurrentByte()
393
488
  }
394
489
  }
395
490
 
@@ -192,7 +192,7 @@ function establishWebSocketConnection (url, protocols, client, ws, onEstablish,
192
192
  // is specified, the server needs to include the same field and one of
193
193
  // the selected subprotocol values in its response for the connection to
194
194
  // be established.
195
- if (!requestProtocols.includes(secProtocol)) {
195
+ if (requestProtocols === null || !requestProtocols.includes(secProtocol)) {
196
196
  failWebsocketConnection(ws, 'Protocol was not set in the opening handshake.')
197
197
  return
198
198
  }
@@ -63,7 +63,12 @@ class PerMessageDeflate {
63
63
 
64
64
  if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) {
65
65
  callback(new MessageSizeExceededError())
66
+ // The inflater may still hold buffered input that can emit a late
67
+ // zlib error. Remove the data listener, then deterministically stop
68
+ // the stream so a subsequent 'error' cannot fire without a listener
69
+ // (which would terminate the process as an unhandled error event).
66
70
  this.#inflate.removeAllListeners()
71
+ this.#inflate.destroy()
67
72
  this.#inflate = null
68
73
  return
69
74
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "6.28.0",
3
+ "version": "6.29.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": {