undici 7.29.1 → 7.30.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.
@@ -27,6 +27,8 @@ Returns: `Client`
27
27
  * **webSocket** `WebSocketOptions` (optional) - WebSocket-specific configuration options.
28
28
  * **maxFragments** `number` (optional) - Default: `131072` - Maximum number of fragments in a message. Set to 0 to disable the limit.
29
29
  * **maxPayloadSize** `number` (optional) - Default: `134217728` (128 MB) - Maximum allowed payload size in bytes for WebSocket messages. Applied to uncompressed messages, compressed frame payloads, and decompressed (permessage-deflate) messages. Set to 0 to disable the limit.
30
+ * **eventSource** `EventSourceOptions` (optional) - EventSource-specific configuration options.
31
+ * **maxEventSize** `number` (optional) - The maximum total bytes of data and metadata field values retained for a pending EventSource event. Each unfinished line is separately limited to this value plus seven syntax bytes. The number of `data:` lines per event and input chunks buffered for an unfinished line are each limited to `min(131072, max(1024, floor(maxEventSize / 128)))` to bound object overhead. Set to `0` to disable these limits. Default: `buffer.kStringMaxLength`.
30
32
  * **pipelining** `number | null` (optional) - Default: `1` - The amount of concurrent requests to be sent over the single TCP/TLS connection according to [RFC7230](https://tools.ietf.org/html/rfc7230#section-6.3.2). Carefully consider your workload and environment before enabling concurrent requests as pipelining may reduce performance if used incorrectly. Pipelining is sensitive to network stack settings as well as head of line blocking caused by e.g. long running requests. Set to `0` to disable keep-alive connections.
31
33
  * **connect** `ConnectOptions | Function | null` (optional) - Default: `null`.
32
34
  * **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body. **Security Warning:** Disabling this option can expose your application to HTTP Request Smuggling attacks, where mismatched content-length headers cause servers and proxies to interpret request boundaries differently. This can lead to cache poisoning, credential hijacking, and bypassing security controls. Only disable this in controlled environments where you fully trust the request source.
@@ -53,7 +53,8 @@ diagnosticsChannel.channel('undici:request:bodySent').subscribe(({ request }) =>
53
53
 
54
54
  ## `undici:request:headers`
55
55
 
56
- This message is published after the response headers have been received.
56
+ This message is published after the response headers have been received. This includes a successful CONNECT or
57
+ protocol upgrade response.
57
58
 
58
59
  ```js
59
60
  import diagnosticsChannel from 'diagnostics_channel'
@@ -82,6 +83,7 @@ diagnosticsChannel.channel('undici:request:bodyChunkReceived').subscribe(({ requ
82
83
  ## `undici:request:trailers`
83
84
 
84
85
  This message is published after the response body and trailers have been received, i.e. the response has been completed.
86
+ After an upgraded socket is passed to the request handler, this message is published with an empty `trailers` array.
85
87
 
86
88
  ```js
87
89
  import diagnosticsChannel from 'diagnostics_channel'
@@ -1153,6 +1153,7 @@ The `decompress` interceptor automatically decompresses response bodies that are
1153
1153
 
1154
1154
  - `skipErrorResponses` - Whether to skip decompression for error responses (status codes >= 400). Default: `true`.
1155
1155
  - `skipStatusCodes` - Array of status codes to skip decompression for. Default: `[204, 304]`.
1156
+ - `maxSize` - Maximum decompressed response size in bytes for each decompression stage. Set to `0` to disable the limit. Default: `0`.
1156
1157
 
1157
1158
  **Example - Basic Decompress Interceptor**
1158
1159
 
@@ -41,16 +41,22 @@ const SessionCache = class WeakSessionCache {
41
41
  if (this._sessionCache.has(sessionKey)) {
42
42
  this._sessionCache.delete(sessionKey)
43
43
  } else if (this._sessionCache.size >= this._maxCachedSessions) {
44
+ // Make room for the new session: prefer an entry whose session has
45
+ // already been collected, otherwise evict the oldest one.
46
+ let evicted = false
44
47
  for (const [key, ref] of this._sessionCache) {
45
48
  if (ref.deref() === undefined) {
46
49
  this._sessionCache.delete(key)
47
- return
50
+ evicted = true
51
+ break
48
52
  }
49
53
  }
50
54
 
51
- const oldest = this._sessionCache.keys().next()
52
- if (!oldest.done) {
53
- this._sessionCache.delete(oldest.value)
55
+ if (!evicted) {
56
+ const oldest = this._sessionCache.keys().next()
57
+ if (!oldest.done) {
58
+ this._sessionCache.delete(oldest.value)
59
+ }
54
60
  }
55
61
  }
56
62
 
@@ -302,11 +302,32 @@ class Request {
302
302
  }
303
303
  }
304
304
 
305
- onUpgrade (statusCode, headers, socket) {
305
+ /**
306
+ * @param {number} statusCode
307
+ * @param {Buffer[]|string[]} headers
308
+ * @param {import('node:stream').Duplex} socket
309
+ * @param {string} [statusText]
310
+ */
311
+ onUpgrade (statusCode, headers, socket, statusText = '') {
312
+ this.onFinally()
313
+
306
314
  assert(!this.aborted)
307
315
  assert(!this.completed)
308
316
 
309
- return this[kHandler].onUpgrade(statusCode, headers, socket)
317
+ if (channels.headers.hasSubscribers) {
318
+ channels.headers.publish({ request: this, response: { statusCode, headers, statusText } })
319
+ }
320
+
321
+ const result = this[kHandler].onUpgrade(statusCode, headers, socket)
322
+
323
+ if (!this.aborted) {
324
+ this.completed = true
325
+ if (channels.trailers.hasSubscribers) {
326
+ channels.trailers.publish({ request: this, trailers: [] })
327
+ }
328
+ }
329
+
330
+ return result
310
331
  }
311
332
 
312
333
  onComplete (trailers) {
@@ -5,6 +5,7 @@ module.exports = {
5
5
  kDestroy: Symbol('destroy'),
6
6
  kDispatch: Symbol('dispatch'),
7
7
  kUrl: Symbol('url'),
8
+ kRedirectCacheControl: Symbol('redirect cache control'),
8
9
  kWriting: Symbol('writing'),
9
10
  kResuming: Symbol('resuming'),
10
11
  kQueue: Symbol('queue'),
@@ -22,7 +22,7 @@ function defaultFactory (origin, opts) {
22
22
  }
23
23
 
24
24
  class Agent extends DispatcherBase {
25
- constructor ({ factory = defaultFactory, maxOrigins = Infinity, connect, ...options } = {}) {
25
+ constructor ({ factory = defaultFactory, maxOrigins = Infinity, connect, tls, ...options } = {}) {
26
26
  if (typeof factory !== 'function') {
27
27
  throw new InvalidArgumentError('factory must be a function.')
28
28
  }
@@ -41,7 +41,11 @@ class Agent extends DispatcherBase {
41
41
  connect = { ...connect }
42
42
  }
43
43
 
44
- this[kOptions] = { ...util.deepClone(options), maxOrigins, connect }
44
+ if (tls && typeof tls !== 'function') {
45
+ tls = { ...tls }
46
+ }
47
+
48
+ this[kOptions] = { ...util.deepClone(options), maxOrigins, connect, tls }
45
49
  this[kFactory] = factory
46
50
  this[kClients] = new Map()
47
51
  this[kOrigins] = new Set()
@@ -69,8 +69,14 @@ function lazyllhttp () {
69
69
 
70
70
  let mod
71
71
 
72
- // We disable wasm SIMD on ppc64 as it seems to be broken on Power 9 architectures.
73
- let useWasmSIMD = process.arch !== 'ppc64'
72
+ // We disable wasm SIMD on older versions of Node.js on ppc64 that are broken on Power >=9 architectures.
73
+ let useWasmSIMD = true
74
+ if (process.arch === 'ppc64') {
75
+ const [major, minor] = process.versions.node.split('.').map(n => parseInt(n, 10))
76
+ if (major < 24 || (major === 24 && minor < 12)) {
77
+ useWasmSIMD = false
78
+ }
79
+ }
74
80
  // The Env Variable UNDICI_NO_WASM_SIMD allows explicitly overriding the default behavior
75
81
  if (process.env.UNDICI_NO_WASM_SIMD === '1') {
76
82
  useWasmSIMD = false
@@ -521,7 +527,7 @@ class Parser {
521
527
  * @param {Buffer} head
522
528
  */
523
529
  onUpgrade (head) {
524
- const { upgrade, client, socket, headers, statusCode } = this
530
+ const { upgrade, client, socket, headers, statusCode, statusText } = this
525
531
 
526
532
  assert(upgrade)
527
533
  assert(client[kSocket] === socket)
@@ -556,8 +562,9 @@ class Parser {
556
562
  client.emit('disconnect', client[kUrl], [client], new InformationalError('upgrade'))
557
563
 
558
564
  try {
559
- request.onUpgrade(statusCode, headers, socket)
565
+ request.onUpgrade(statusCode, headers, socket, statusText)
560
566
  } catch (err) {
567
+ util.errorRequest(client, request, err)
561
568
  util.destroy(socket, err)
562
569
  }
563
570
 
@@ -921,8 +928,6 @@ function connectH1 (client, socket) {
921
928
  }
922
929
 
923
930
  function onHttpSocketError (err) {
924
- assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
925
-
926
931
  const parser = this[kParser]
927
932
 
928
933
  // On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded
@@ -293,8 +293,6 @@ function onHttp2SendPing (session) {
293
293
  }
294
294
 
295
295
  function onHttp2SessionError (err) {
296
- assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
297
-
298
296
  this[kSocket][kError] = err
299
297
  this[kClient][kOnError](err)
300
298
  }
@@ -436,8 +434,6 @@ function onHttp2SocketClose () {
436
434
  }
437
435
 
438
436
  function onHttp2SocketError (err) {
439
- assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
440
-
441
437
  this[kError] = err
442
438
 
443
439
  this[kClient][kOnError](err)
@@ -575,13 +571,17 @@ function writeH2 (client, request) {
575
571
 
576
572
  stream = session.request(headers, { endStream: false, signal })
577
573
  stream[kHTTP2Stream] = true
574
+ ++session[kOpenStreams]
578
575
 
579
576
  stream.once('response', (headers, _flags) => {
580
577
  const { [HTTP2_HEADER_STATUS]: statusCode, ...realHeaders } = headers
581
578
 
582
- request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream)
583
-
584
- ++session[kOpenStreams]
579
+ try {
580
+ request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream)
581
+ } catch (err) {
582
+ abort(err)
583
+ return
584
+ }
585
585
  completeRequest(client, request)
586
586
  })
587
587
 
@@ -610,11 +610,16 @@ function writeH2 (client, request) {
610
610
  // We disabled endStream to allow the user to write to the stream
611
611
  stream = session.request(headers, { endStream: false, signal })
612
612
  stream[kHTTP2Stream] = true
613
+ ++session[kOpenStreams]
613
614
  stream.on('response', headers => {
614
615
  const { [HTTP2_HEADER_STATUS]: statusCode, ...realHeaders } = headers
615
616
 
616
- request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream)
617
- ++session[kOpenStreams]
617
+ try {
618
+ request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream)
619
+ } catch (err) {
620
+ abort(err)
621
+ return
622
+ }
618
623
  completeRequest(client, request)
619
624
  })
620
625
  stream.on('error', abort)
@@ -115,7 +115,8 @@ class Client extends DispatcherBase {
115
115
  initialWindowSize,
116
116
  connectionWindowSize,
117
117
  pingInterval,
118
- webSocket
118
+ webSocket,
119
+ eventSource
119
120
  } = {}) {
120
121
  if (keepAlive !== undefined) {
121
122
  throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
@@ -223,7 +224,7 @@ class Client extends DispatcherBase {
223
224
  throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0')
224
225
  }
225
226
 
226
- super({ webSocket })
227
+ super({ webSocket, eventSource })
227
228
 
228
229
  if (typeof connect !== 'function') {
229
230
  connect = buildConnector({
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const buffer = require('node:buffer')
3
4
  const Dispatcher = require('./dispatcher')
4
5
  const UnwrapHandler = require('../handler/unwrap-handler')
5
6
  const {
@@ -12,6 +13,7 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch } = require('../core/sy
12
13
  const kOnDestroyed = Symbol('onDestroyed')
13
14
  const kOnClosed = Symbol('onClosed')
14
15
  const kWebSocketOptions = Symbol('webSocketOptions')
16
+ const kEventSourceOptions = Symbol('eventSourceOptions')
15
17
 
16
18
  class DispatcherBase extends Dispatcher {
17
19
  /** @type {boolean} */
@@ -32,6 +34,7 @@ class DispatcherBase extends Dispatcher {
32
34
  constructor (opts) {
33
35
  super()
34
36
  this[kWebSocketOptions] = opts?.webSocket ?? {}
37
+ this[kEventSourceOptions] = opts?.eventSource ?? {}
35
38
  }
36
39
 
37
40
  /**
@@ -44,6 +47,15 @@ class DispatcherBase extends Dispatcher {
44
47
  }
45
48
  }
46
49
 
50
+ /**
51
+ * @returns {import('../../types/client').Client.EventSourceOptions}
52
+ */
53
+ get eventSourceOptions () {
54
+ return {
55
+ maxEventSize: this[kEventSourceOptions].maxEventSize ?? buffer.kStringMaxLength
56
+ }
57
+ }
58
+
47
59
  /** @returns {boolean} */
48
60
  get destroyed () {
49
61
  return this[kDestroyed]
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const util = require('../core/util')
4
+ const { kRedirectCacheControl } = require('../core/symbols')
4
5
  const {
5
6
  parseCacheControlHeader,
6
7
  hasInvalidCacheControlDirective,
@@ -13,6 +14,19 @@ const { parseHttpDate } = require('../util/date.js')
13
14
 
14
15
  function noop () {}
15
16
 
17
+ /**
18
+ * @param {string | URL} a
19
+ * @param {string | URL} b
20
+ * @returns {boolean}
21
+ */
22
+ function sameOrigin (a, b) {
23
+ try {
24
+ return new URL(String(a)).origin === new URL(String(b)).origin
25
+ } catch {
26
+ return String(a) === String(b)
27
+ }
28
+ }
29
+
16
30
  // Status codes that we can use some heuristics on to cache
17
31
  const HEURISTICALLY_CACHEABLE_STATUS_CODES = [
18
32
  200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501
@@ -151,6 +165,14 @@ class CacheHandler {
151
165
  */
152
166
  #writeStream
153
167
 
168
+ /**
169
+ * The URLs visited while following redirects, shared by reference from the
170
+ * redirect handler through the request context. Carries the suppressed hops'
171
+ * cache-control under {@link kRedirectCacheControl}.
172
+ * @type {URL[] | undefined}
173
+ */
174
+ #history
175
+
154
176
  /**
155
177
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} opts
156
178
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
@@ -167,6 +189,9 @@ class CacheHandler {
167
189
  onRequestStart (controller, context) {
168
190
  this.#writeStream?.destroy()
169
191
  this.#writeStream = undefined
192
+ // The redirect handler mutates this shared history as redirects are
193
+ // followed, allowing the cache to identify the response's actual origin.
194
+ this.#history = context?.history
170
195
  this.#handler.onRequestStart?.(controller, context)
171
196
  }
172
197
 
@@ -206,6 +231,29 @@ class CacheHandler {
206
231
  return downstreamOnHeaders()
207
232
  }
208
233
 
234
+ // Never store a response produced by a different origin under the original
235
+ // request's cache key.
236
+ if (this.#history && this.#history.length > 0) {
237
+ const finalUrl = this.#history[this.#history.length - 1]
238
+ if (!sameOrigin(finalUrl.origin, this.#cacheKey.origin)) {
239
+ return downstreamOnHeaders()
240
+ }
241
+ }
242
+
243
+ // Preserve restrictive cache policy from suppressed redirect responses.
244
+ const redirectCacheControl = this.#history?.[kRedirectCacheControl]
245
+ if (redirectCacheControl) {
246
+ for (const hopCacheControl of redirectCacheControl) {
247
+ const hopDirectives = hopCacheControl ? parseCacheControlHeader(hopCacheControl) : {}
248
+ if (
249
+ hopDirectives['no-store'] === true ||
250
+ (this.#cacheType === 'shared' && hopDirectives.private === true)
251
+ ) {
252
+ return downstreamOnHeaders()
253
+ }
254
+ }
255
+ }
256
+
209
257
  const cacheControlHeader = resHeaders['cache-control']
210
258
  const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {}
211
259
 
@@ -16,6 +16,7 @@ const DEFAULT_MAX_BUFFER_SIZE = 5 * 1024 * 1024
16
16
  * @property {number} bufferedBytes
17
17
  * @property {object | null} pendingTrailers
18
18
  * @property {boolean} done
19
+ * @property {boolean} [pendingStart] onRequestStart deferred until the primary request's context is known
19
20
  */
20
21
 
21
22
  /**
@@ -30,6 +31,20 @@ class DeduplicationHandler {
30
31
  */
31
32
  #primaryHandler
32
33
 
34
+ /**
35
+ * Whether the primary request has started (onRequestStart received). Until
36
+ * then the request context is unknown and cannot be given to waiting handlers.
37
+ * @type {boolean}
38
+ */
39
+ #requestStarted = false
40
+
41
+ /**
42
+ * The primary request context, replayed to waiting handlers so they observe
43
+ * the same redirect history as the primary.
44
+ * @type {any}
45
+ */
46
+ #context = null
47
+
33
48
  /**
34
49
  * @type {WaitingHandler[]}
35
50
  */
@@ -112,20 +127,30 @@ class DeduplicationHandler {
112
127
  const waitingController = waitingHandler.controller
113
128
 
114
129
  try {
115
- handler.onRequestStart?.(waitingController, null)
130
+ if (this.#requestStarted) {
131
+ // The primary request has started, so its context (including the
132
+ // redirect history) is known and can be delivered immediately.
133
+ handler.onRequestStart?.(waitingController, this.#context)
116
134
 
117
- if (waitingController.aborted) {
118
- waitingHandler.done = true
119
- return true
120
- }
135
+ if (waitingController.aborted) {
136
+ waitingHandler.done = true
137
+ return true
138
+ }
121
139
 
122
- if (this.#responseStarted) {
123
- handler.onResponseStart?.(
124
- waitingController,
125
- this.#statusCode,
126
- this.#headers,
127
- this.#statusMessage
128
- )
140
+ if (this.#responseStarted) {
141
+ handler.onResponseStart?.(
142
+ waitingController,
143
+ this.#statusCode,
144
+ this.#headers,
145
+ this.#statusMessage
146
+ )
147
+ }
148
+ } else {
149
+ // The primary request has not started yet. Defer onRequestStart until
150
+ // its context is known, so the waiting handler observes the same
151
+ // redirect history as the primary (a cache handler relies on it to
152
+ // reject cross-origin redirect responses).
153
+ waitingHandler.pendingStart = true
129
154
  }
130
155
  } catch {
131
156
  // Ignore errors from waiting handlers
@@ -146,6 +171,24 @@ class DeduplicationHandler {
146
171
  */
147
172
  onRequestStart (controller, context) {
148
173
  this.#controller = controller
174
+ if (!this.#requestStarted) {
175
+ this.#requestStarted = true
176
+ this.#context = context
177
+ // Deliver the now-known context to handlers that joined before the
178
+ // primary request started.
179
+ for (const waitingHandler of this.#waitingHandlers) {
180
+ if (waitingHandler.pendingStart && !waitingHandler.done && !waitingHandler.controller.aborted) {
181
+ waitingHandler.pendingStart = false
182
+ try {
183
+ waitingHandler.handler.onRequestStart?.(waitingHandler.controller, context)
184
+ } catch {
185
+ // Ignore errors from waiting handlers
186
+ waitingHandler.done = true
187
+ }
188
+ }
189
+ }
190
+ this.#pruneDoneWaitingHandlers()
191
+ }
149
192
  this.#primaryHandler.onRequestStart?.(controller, context)
150
193
  }
151
194
 
@@ -319,7 +362,8 @@ class DeduplicationHandler {
319
362
  bufferedChunks: [],
320
363
  bufferedBytes: 0,
321
364
  pendingTrailers: null,
322
- done: false
365
+ done: false,
366
+ pendingStart: false
323
367
  }
324
368
 
325
369
  const state = {
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const util = require('../core/util')
4
- const { kBodyUsed } = require('../core/symbols')
4
+ const { kBodyUsed, kRedirectCacheControl } = require('../core/symbols')
5
5
  const assert = require('node:assert')
6
6
  const { InvalidArgumentError } = require('../core/errors')
7
7
  const EE = require('node:events')
@@ -47,6 +47,11 @@ class RedirectHandler {
47
47
  this.maxRedirections = maxRedirections
48
48
  this.handler = handler
49
49
  this.history = []
50
+ // Retain cache-control from suppressed redirect hops for an outer cache.
51
+ Object.defineProperty(this.history, kRedirectCacheControl, {
52
+ value: [],
53
+ enumerable: false
54
+ })
50
55
 
51
56
  if (util.isStream(this.opts.body)) {
52
57
  // TODO (fix): Provide some way for the user to cache the file to e.g. /tmp
@@ -130,6 +135,8 @@ class RedirectHandler {
130
135
  return
131
136
  }
132
137
 
138
+ this.history[kRedirectCacheControl].push(headers['cache-control'])
139
+
133
140
  const { origin, pathname, search } = util.parseURL(new URL(this.location, this.opts.origin && new URL(this.opts.path, this.opts.origin)))
134
141
  const path = search ? `${pathname}${search}` : pathname
135
142