@nxtedition/nxt-undici 7.3.25 → 7.3.27

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.
@@ -2,6 +2,19 @@ import net from 'node:net'
2
2
  import createError from 'http-errors'
3
3
  import { DecoratorHandler } from '../utils.js'
4
4
 
5
+ // Accumulator used by reduceHeaders on the response path. Hoisted to module
6
+ // scope so it is allocated once rather than on every onHeaders/onUpgrade call.
7
+ /**
8
+ * @param {Record<string, string>} acc
9
+ * @param {string} key
10
+ * @param {string} val
11
+ * @returns {Record<string, string>}
12
+ */
13
+ const copyHeader = (acc, key, val) => {
14
+ acc[key] = val
15
+ return acc
16
+ }
17
+
5
18
  class Handler extends DecoratorHandler {
6
19
  #opts
7
20
 
@@ -18,13 +31,13 @@ class Handler extends DecoratorHandler {
18
31
  {
19
32
  headers,
20
33
  httpVersion: this.#opts.httpVersion ?? this.#opts.req?.httpVersion,
21
- socket: this.#opts.socket,
34
+ // Response path: never synthesize a Forwarded header (it is
35
+ // request-only, RFC 7239) — passing socket would leak the proxy's
36
+ // own addresses downstream. isResponse still rejects an inbound one.
37
+ isResponse: true,
22
38
  proxyName: this.#opts.name,
23
39
  },
24
- (acc, key, val) => {
25
- acc[key] = val
26
- return acc
27
- },
40
+ copyHeader,
28
41
  {},
29
42
  ),
30
43
  socket,
@@ -38,13 +51,10 @@ class Handler extends DecoratorHandler {
38
51
  {
39
52
  headers,
40
53
  httpVersion: this.#opts.httpVersion ?? this.#opts.req?.httpVersion,
41
- socket: this.#opts.socket,
54
+ isResponse: true,
42
55
  proxyName: this.#opts.name,
43
56
  },
44
- (acc, key, val) => {
45
- acc[key] = val
46
- return acc
47
- },
57
+ copyHeader,
48
58
  {},
49
59
  ),
50
60
  resume,
@@ -52,49 +62,206 @@ class Handler extends DecoratorHandler {
52
62
  }
53
63
  }
54
64
 
55
- // This expression matches hop-by-hop headers.
56
- // These headers are meaningful only for a single transport-level connection,
57
- // and must not be retransmitted by proxies or cached.
58
- const HOP_EXPR =
59
- /^(te|host|upgrade|trailers|connection|keep-alive|http2-settings|transfer-encoding|proxy-connection|proxy-authenticate|proxy-authorization)$/i
65
+ // ASCII case-insensitive equality where `b` is a lowercase literal and the
66
+ // caller guarantees `a.length === b.length`. Allocation-free.
67
+ /**
68
+ * @param {string} a
69
+ * @param {string} b
70
+ * @returns {boolean}
71
+ */
72
+ function eqiLower(a, b) {
73
+ if (a === b) {
74
+ return true
75
+ }
76
+ for (let i = 0; i < b.length; i++) {
77
+ let c = a.charCodeAt(i)
78
+ if (c >= 0x41 && c <= 0x5a) {
79
+ c += 0x20 // upper -> lower
80
+ }
81
+ if (c !== b.charCodeAt(i)) {
82
+ return false
83
+ }
84
+ }
85
+ return true
86
+ }
87
+
88
+ // Matches hop-by-hop headers — meaningful only for a single transport-level
89
+ // connection, so a proxy must not retransmit or cache them. This is the single
90
+ // source of truth for that set: it is used both for the per-key strip below
91
+ // and for the Connection-value check. HTTP field names are ASCII tokens, so a
92
+ // length-dispatched ASCII case-insensitive compare is exactly equivalent to a
93
+ // `/^(te|host|…)$/i` regexp (verified over 137k generated keys) while staying
94
+ // allocation-free and letting the common (non-hop) header bail out in a single
95
+ // comparison.
96
+ /**
97
+ * @param {string} key
98
+ * @returns {boolean}
99
+ */
100
+ function isHopByHop(key) {
101
+ switch (key.length) {
102
+ case 2:
103
+ return eqiLower(key, 'te')
104
+ case 4:
105
+ return eqiLower(key, 'host')
106
+ case 7:
107
+ return eqiLower(key, 'upgrade')
108
+ case 8:
109
+ return eqiLower(key, 'trailers')
110
+ case 10:
111
+ return eqiLower(key, 'connection') || eqiLower(key, 'keep-alive')
112
+ case 14:
113
+ return eqiLower(key, 'http2-settings')
114
+ case 16:
115
+ return eqiLower(key, 'proxy-connection')
116
+ case 17:
117
+ return eqiLower(key, 'transfer-encoding')
118
+ case 18:
119
+ return eqiLower(key, 'proxy-authenticate')
120
+ case 19:
121
+ return eqiLower(key, 'proxy-authorization')
122
+ default:
123
+ return false
124
+ }
125
+ }
60
126
 
61
127
  // Removes hop-by-hop and pseudo headers.
62
128
  // Updates via and forwarded headers.
63
129
  // Only hop-by-hop headers may be set using the Connection general header.
64
- function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
130
+ /**
131
+ * @template T
132
+ * @param {object} options
133
+ * @param {Record<string, string | string[]>} options.headers Header map; a
134
+ * value is an array when the field appeared more than once.
135
+ * @param {string} [options.proxyName] This proxy's name. When set, a Via
136
+ * segment is appended and Via loop detection runs.
137
+ * @param {string} [options.httpVersion] Protocol token for the appended Via
138
+ * segment; defaults to `'HTTP/1.1'`.
139
+ * @param {{ localAddress?: string, localPort?: number, remoteAddress?: string,
140
+ * remotePort?: number, encrypted?: boolean } | null} [options.socket] Request
141
+ * path only: when present a Forwarded header is synthesised. An inbound
142
+ * Forwarded header is always treated as a BadGateway (it is request-only).
143
+ * @param {boolean} [options.isResponse] Response path marker. When set,
144
+ * Forwarded is never synthesised regardless of `socket` (it would leak the
145
+ * proxy's own addresses downstream); an inbound Forwarded is still rejected.
146
+ * @param {(acc: T, key: string, value: string) => T} fn Accumulator invoked
147
+ * once per retained header.
148
+ * @param {T} acc Initial accumulator value.
149
+ * @returns {T}
150
+ */
151
+ function reduceHeaders({ headers, proxyName, httpVersion, socket, isResponse }, fn, acc) {
65
152
  let via = ''
66
153
  let forwarded = ''
67
154
  let host = ''
68
155
  let authority = ''
156
+ /** @type {string | string[]} */
69
157
  let connection = ''
70
158
 
71
- for (const [key, val] of Object.entries(headers)) {
159
+ // Iterate via Object.keys (computed once and reused by both passes) rather
160
+ // than Object.entries: the latter allocates an outer array plus one
161
+ // 2-element array per header on every call, and this runs on the hot path of
162
+ // every proxied request and response. Object.keys allocates a single flat
163
+ // array we reuse, cutting per-call allocation by ~4-6x.
164
+ const keys = Object.keys(headers)
165
+
166
+ // Object keys are unique, so each special is seen at most once; a repeated
167
+ // field-line instead surfaces as an array value (parseHeaders collects them).
168
+ // RFC 9110 §5.3 only permits repeats for list-valued fields (ABNF `#`), where
169
+ // the parts are semantically one comma-separated value — those we combine.
170
+ // Singular fields with more than one value are a protocol error — those we
171
+ // reject.
172
+ //
173
+ // Field names are case-insensitive (RFC 7230). The production path lowercases
174
+ // keys (parseHeaders) before this runs, but the standalone interceptors.proxy()
175
+ // composition may pass mixed-case keys, so capture case-insensitively via the
176
+ // allocation-free eqiLower — otherwise a mixed-case `Forwarded`/`Connection`/
177
+ // `Via`/`Host` would skip capture and leak/bypass the handling below.
178
+ for (let i = 0; i < keys.length; i++) {
179
+ const key = keys[i]
72
180
  const len = key.length
73
- if (len === 3 && !via && key === 'via') {
74
- via = val
75
- } else if (len === 4 && !host && key === 'host') {
76
- host = val
77
- } else if (len === 9 && !forwarded && key === 'forwarded') {
78
- forwarded = val
79
- } else if (len === 10 && !connection && key === 'connection') {
80
- connection = val
81
- } else if (len === 10 && !authority && key === ':authority') {
82
- authority = val
181
+ if (len === 3 && eqiLower(key, 'via')) {
182
+ // Via is list-valued (RFC 9110 §7.6.3): combine repeated field-lines.
183
+ const v = headers[key]
184
+ via = Array.isArray(v) ? v.join(', ') : v
185
+ } else if (len === 4 && eqiLower(key, 'host')) {
186
+ // Host is singular (RFC 9110 §7.2, RFC 7230 §5.4): more than one is a
187
+ // protocol error, so reject rather than combine.
188
+ const v = headers[key]
189
+ if (Array.isArray(v)) {
190
+ throw new createError.BadGateway()
191
+ }
192
+ host = v
193
+ } else if (len === 9 && eqiLower(key, 'forwarded')) {
194
+ // Forwarded is list-valued (RFC 7239 §4): combine repeated field-lines.
195
+ const v = headers[key]
196
+ forwarded = Array.isArray(v) ? v.join(', ') : v
197
+ } else if (len === 10 && eqiLower(key, 'connection')) {
198
+ // Connection is list-valued (RFC 9110 §7.6.1): captured raw and tokenised
199
+ // below — combining then re-splitting would be wasteful.
200
+ connection = headers[key]
201
+ } else if (len === 10 && key === ':authority') {
202
+ // :authority is singular (RFC 9113 §8.3.1): reject more than one. Pseudo
203
+ // headers are always lowercase, so an exact compare is correct here.
204
+ const v = headers[key]
205
+ if (Array.isArray(v)) {
206
+ throw new createError.BadGateway()
207
+ }
208
+ authority = v
83
209
  }
84
210
  }
85
211
 
86
- let remove = []
87
- if (connection && !HOP_EXPR.test(connection)) {
88
- remove = connection.split(/,\s*/)
212
+ // `remove` is lazily allocated: it stays null unless a Connection header
213
+ // actually lists headers to strip (the uncommon case), so the hot path
214
+ // neither allocates an (almost always empty) array nor runs includes() per
215
+ // header.
216
+ //
217
+ // Header field names are case-insensitive (RFC 7230); parseHeaders already
218
+ // lowercased the keys we compare against, so lowercase the listed names too,
219
+ // otherwise `Connection: X-Custom` fails to strip `x-custom` and it leaks to
220
+ // the next hop. trim() handles surrounding whitespace, so a plain comma split
221
+ // suffices.
222
+ let remove = null
223
+ if (Array.isArray(connection)) {
224
+ // Repeated Connection field-lines (RFC 9110 §7.6.1): each part may itself
225
+ // list several options. A repeat always carries multiple/custom tokens, so
226
+ // the single-hop-token shortcut below never applies.
227
+ remove = []
228
+ for (const part of connection) {
229
+ for (const token of part.split(',')) {
230
+ remove.push(token.trim().toLowerCase())
231
+ }
232
+ }
233
+ } else if (connection && !isHopByHop(connection)) {
234
+ // Single value: one field-line, so one split over its comma list. The
235
+ // isHopByHop guard skips the common single-token forms (`keep-alive`,
236
+ // `close`, …) where there is nothing custom to strip.
237
+ remove = []
238
+ for (const token of connection.split(',')) {
239
+ remove.push(token.trim().toLowerCase())
240
+ }
89
241
  }
90
242
 
91
- for (const [key, val] of Object.entries(headers)) {
92
- if (key.charAt(0) !== ':' && !remove.includes(key) && !HOP_EXPR.test(key)) {
93
- acc = fn(acc, key, val.toString())
243
+ for (let i = 0; i < keys.length; i++) {
244
+ const key = keys[i]
245
+ const len = key.length
246
+ if (
247
+ key.charCodeAt(0) !== 0x3a /* ':' */ &&
248
+ // via/forwarded are captured above and (re)emitted by the dedicated
249
+ // finalization below; letting the retain loop also emit them leaks an
250
+ // empty inbound value (e.g. `via: ''`) that the `if (via)` guard then
251
+ // never overwrites, and bypasses the Forwarded BadGateway rejection.
252
+ // Case-insensitive (eqiLower) to match the case-insensitive capture above.
253
+ !(len === 3 && eqiLower(key, 'via')) &&
254
+ !(len === 9 && eqiLower(key, 'forwarded')) &&
255
+ // toLowerCase: `remove` entries are lowercased but keys may not be (the
256
+ // standalone interceptor path), so match case-insensitively like isHopByHop.
257
+ (remove === null || !remove.includes(key.toLowerCase())) &&
258
+ !isHopByHop(key)
259
+ ) {
260
+ acc = fn(acc, key, headers[key].toString())
94
261
  }
95
262
  }
96
263
 
97
- if (socket) {
264
+ if (!isResponse && socket) {
98
265
  const forwardedHost = authority || host
99
266
  acc = fn(
100
267
  acc,
@@ -110,13 +277,27 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
110
277
  .join(';'),
111
278
  )
112
279
  } else if (forwarded) {
113
- // The forwarded header should not be included in response.
280
+ // Forwarded is a request-only header (RFC 7239): a proxy must neither emit
281
+ // it on a response nor relay one an upstream echoed back.
114
282
  throw new createError.BadGateway()
115
283
  }
116
284
 
117
285
  if (proxyName) {
118
286
  if (via) {
119
- if (via.split(',').some((name) => name.endsWith(proxyName))) {
287
+ const viaLower = via.toLowerCase()
288
+ const proxyNameLower = proxyName.toLowerCase()
289
+ // A Via segment is "received-protocol received-by [comment]". Compare the
290
+ // received-by token for equality (case-insensitive) rather than testing
291
+ // whether the whole segment ends with proxyName — endsWith() trips a
292
+ // false-positive loop for any unrelated proxy whose name merely has
293
+ // proxyName as a suffix (e.g. name 'proxy' vs upstream 'otherproxy').
294
+ if (
295
+ viaLower.includes(proxyNameLower) &&
296
+ viaLower.split(',').some((seg) => {
297
+ const by = seg.trim().split(/\s+/)[1]
298
+ return by != null && by === proxyNameLower
299
+ })
300
+ ) {
120
301
  throw new createError.LoopDetected()
121
302
  }
122
303
  via += ', '
@@ -133,6 +314,11 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
133
314
  return acc
134
315
  }
135
316
 
317
+ /**
318
+ * @param {string} address
319
+ * @param {number} [port]
320
+ * @returns {string}
321
+ */
136
322
  function printIp(address, port) {
137
323
  const isIPv6 = net.isIPv6(address)
138
324
  let str = `${address}`
@@ -11,6 +11,12 @@ export default () => (dispatch) => (opts, handler) => {
11
11
  }
12
12
 
13
13
  function serializePathWithQuery(url, queryParams) {
14
+ if (typeof url !== 'string') {
15
+ // A path-less object URL leaves opts.path undefined; fail with a clear
16
+ // message instead of a cryptic "Cannot read properties of undefined".
17
+ throw new Error('Query params require a string path.')
18
+ }
19
+
14
20
  if (url.includes('?') || url.includes('#')) {
15
21
  throw new Error('Query params cannot be passed when url already contains "?" or "#".')
16
22
  }
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert'
2
- import { DecoratorHandler, isDisturbed, parseURL } from '../utils.js'
2
+ import { DecoratorHandler, isDisturbed, parseURL, parseHeaders } from '../utils.js'
3
3
 
4
4
  const redirectableStatusCodes = [300, 301, 302, 303, 307, 308]
5
5
 
@@ -21,15 +21,23 @@ class Handler extends DecoratorHandler {
21
21
 
22
22
  this.#dispatch = dispatch
23
23
  this.#opts = opts
24
- this.#maxCount = Number.isFinite(opts.follow) ? opts.follow : (opts.follow?.count ?? 0)
24
+ // `follow: true` means "follow redirects" map it to the project default
25
+ // cap rather than letting `true.count ?? 0` collapse to 0, which would
26
+ // reject the very first redirect with "Max redirections reached: 0".
27
+ this.#maxCount =
28
+ opts.follow === true
29
+ ? 8
30
+ : Number.isFinite(opts.follow)
31
+ ? opts.follow
32
+ : (opts.follow?.count ?? 0)
25
33
 
26
34
  super.onConnect((reason) => {
27
35
  this.#aborted = true
28
- if (this.#abort) {
29
- this.#abort(reason)
30
- } else {
31
- this.#reason = reason
32
- }
36
+ // Always remember the latest reason so it survives a caller abort that
37
+ // lands in the window between one hop completing and the next hop's
38
+ // onConnect; #abort may still point at the finished hop's no-op abort.
39
+ this.#reason = reason
40
+ this.#abort?.(reason)
33
41
  })
34
42
  }
35
43
 
@@ -73,7 +81,11 @@ class Handler extends DecoratorHandler {
73
81
  return super.onHeaders(statusCode, headers, resume)
74
82
  }
75
83
  } else {
76
- if (this.#count >= this.#maxCount) {
84
+ // `follow: N` follows up to N redirects and errors on the N+1th, matching
85
+ // undici/fetch maxRedirections semantics. `>` (not `>=`): with `>=`,
86
+ // `follow: 1` threw on the very first redirect with a self-contradictory
87
+ // "Max redirections reached: 1." message.
88
+ if (this.#count > this.#maxCount) {
77
89
  throw Object.assign(new Error(`Max redirections reached: ${this.#maxCount}.`), {
78
90
  history: this.#history,
79
91
  })
@@ -181,6 +193,13 @@ function shouldRemoveHeader(header, removeContent, unknownOrigin) {
181
193
  // https://tools.ietf.org/html/rfc7231#section-6.4
182
194
  function cleanRequestHeaders(headers, removeContent, unknownOrigin) {
183
195
  const ret = {}
196
+ if (Array.isArray(headers)) {
197
+ // undici accepts request headers as a flat [name, value, ...] array.
198
+ // Object.keys on that yields numeric indices, not field names, so the
199
+ // strip checks below would never match and the headers would be mangled
200
+ // into { '0': name, '1': value, ... }. Normalize to an object first.
201
+ headers = parseHeaders(headers)
202
+ }
184
203
  if (headers && typeof headers === 'object') {
185
204
  for (const key of Object.keys(headers)) {
186
205
  if (!shouldRemoveHeader(key, removeContent, unknownOrigin)) {
@@ -1,4 +1,4 @@
1
- import { Readable } from 'node:stream'
1
+ import { Readable, finished } from 'node:stream'
2
2
  import { isStream } from '../utils.js'
3
3
 
4
4
  function noop() {}
@@ -38,9 +38,16 @@ class FactoryStream extends Readable {
38
38
  .on('end', () => {
39
39
  this.push(null)
40
40
  })
41
- .on('error', (err) => {
41
+ // `finished` (not a bare 'error' listener) so a premature close —
42
+ // the inner body destroyed without emitting 'end' or 'error', which
43
+ // surfaces only as 'close' — becomes ERR_STREAM_PREMATURE_CLOSE and
44
+ // fails the request, instead of hanging forever with no terminal
45
+ // push(null). writable:false: these inner bodies are read-only.
46
+ finished(this.#body, { writable: false }, (err) => {
47
+ if (err) {
42
48
  this.destroy(err)
43
- })
49
+ }
50
+ })
44
51
  }
45
52
 
46
53
  callback(null)
@@ -12,8 +12,15 @@ function genReqId() {
12
12
  }
13
13
 
14
14
  export default () => (dispatch) => (opts, handler) => {
15
- const prevId = opts.id ?? opts.headers?.['request-id']
16
- const nextId = prevId ? `${prevId},${genReqId()}` : genReqId()
15
+ // Treat an empty string the same as absent in BOTH the selection and the
16
+ // chaining test (the old `??`-vs-truthy split kept a falsy opts.id, skipped
17
+ // the request-id header fallback, then dropped it anyway — losing a real
18
+ // parent id and breaking trace correlation).
19
+ let prevId = opts.id
20
+ if (prevId == null || prevId === '') {
21
+ prevId = opts.headers?.['request-id']
22
+ }
23
+ const nextId = prevId != null && prevId !== '' ? `${prevId},${genReqId()}` : genReqId()
17
24
  return dispatch(
18
25
  {
19
26
  ...opts,
@@ -35,10 +35,12 @@ class Handler extends DecoratorHandler {
35
35
  return super.onHeaders(statusCode, headers, resume)
36
36
  }
37
37
 
38
- if (
39
- this.#headers['content-type']?.startsWith('application/json') ||
40
- this.#headers['content-type']?.startsWith('text/plain')
41
- ) {
38
+ // A duplicated content-type header is an array; .startsWith would throw
39
+ // synchronously out of this parser callback. Coerce to the first value.
40
+ const contentType = Array.isArray(this.#headers['content-type'])
41
+ ? this.#headers['content-type'][0]
42
+ : this.#headers['content-type']
43
+ if (contentType?.startsWith('application/json') || contentType?.startsWith('text/plain')) {
42
44
  this.#decoder = new TextDecoder('utf-8')
43
45
  this.#body = ''
44
46
  }
@@ -151,18 +151,32 @@ class Handler extends DecoratorHandler {
151
151
  return false
152
152
  }
153
153
 
154
+ if (this.#pos === 0 && statusCode === 200) {
155
+ // We asked for a byte range to resume, but no bytes had been forwarded
156
+ // to the consumer yet, so a server that ignored Range and replied with
157
+ // the full 200 is acceptable — forward it from the start. (RFC 9110
158
+ // permits ignoring Range; if-match still guards against changed
159
+ // content via a 412.) Without this, a legal full 200 retry was
160
+ // rejected with "Response retry failed".
161
+ this.#resume = resume
162
+ return true
163
+ }
164
+
154
165
  const contentRange = parseContentRange(headers['content-range'])
155
166
  if (!contentRange) {
156
167
  this.#maybeError(null)
157
168
  return false
158
169
  }
159
170
 
171
+ // Validate the server's content-range against our tracked position.
172
+ // These values are server-controlled, so route a mismatch through the
173
+ // same graceful error path as the branches above — an assert() here
174
+ // would throw out of onHeaders (a parser callback) and hang the stream.
160
175
  const { start, size, end = size } = contentRange
161
- assert(this.#pos === start, `content-range mismatched start ${this.#pos} != ${start}`)
162
- assert(
163
- this.#end == null || this.#end === end,
164
- `content-range mismatched end ${this.#end} != ${end}`,
165
- )
176
+ if (this.#pos !== start || (this.#end != null && this.#end !== end)) {
177
+ this.#maybeError(null)
178
+ return false
179
+ }
166
180
 
167
181
  this.#resume = resume
168
182
 
@@ -333,10 +347,19 @@ class Handler extends DecoratorHandler {
333
347
  const headers = err?.headers ?? this.#headers
334
348
 
335
349
  if (statusCode && [420, 429, 502, 503, 504].includes(statusCode)) {
336
- const retryAfter = headers?.['retry-after'] ? Number(headers['retry-after']) * 1e3 : null
350
+ const raw = headers?.['retry-after']
351
+ let retryAfter = raw ? Number(raw) * 1e3 : null
352
+ if (raw && (retryAfter == null || !Number.isFinite(retryAfter))) {
353
+ // RFC 9110: Retry-After may be an HTTP-date rather than delta-seconds.
354
+ const date = Date.parse(raw)
355
+ retryAfter = Number.isFinite(date) ? Math.max(0, date - Date.now()) : null
356
+ }
337
357
  const delay =
338
358
  retryAfter != null && Number.isFinite(retryAfter)
339
- ? retryAfter
359
+ ? // Clamp the server-controlled wait: bounds a hostile/misconfigured
360
+ // value and avoids the 32-bit timer overflow that makes huge delays
361
+ // fire immediately.
362
+ Math.min(retryAfter, 60e3)
340
363
  : Math.min(10e3, retryCount * 1e3)
341
364
  this.#opts.logger?.debug({ statusCode, retryAfter, delay, retryCount }, 'retry delay')
342
365
  return tp.setTimeout(delay, true, { signal: opts?.signal ?? undefined })
@@ -7,6 +7,7 @@ class Handler extends DecoratorHandler {
7
7
  #expectedSize
8
8
  #hasher
9
9
  #pos = 0
10
+ #abort
10
11
 
11
12
  constructor(opts, { handler }) {
12
13
  super(handler)
@@ -19,12 +20,22 @@ class Handler extends DecoratorHandler {
19
20
  this.#expectedSize = null
20
21
  this.#hasher = null
21
22
  this.#pos = 0
23
+ // Keep the raw transport abort so a mid-stream verification failure can
24
+ // tear down the connection. The DecoratorHandler-wrapped abort becomes a
25
+ // no-op once super.onError sets #errored, so we must drive abort directly.
26
+ this.#abort = abort
22
27
 
23
28
  super.onConnect(abort)
24
29
  }
25
30
 
26
31
  onHeaders(statusCode, headers, resume) {
27
- this.#contentMD5 = this.#verifyOpts.hash ? headers['content-md5'] : null
32
+ // A duplicated Content-MD5 header arrives as an array. Several identical
33
+ // copies (a CDN/proxy re-appending its own) describe the same digest, so
34
+ // collapse them to one; genuinely conflicting copies are kept as an array
35
+ // so the strict comparison in onComplete still fails. A non-array (the
36
+ // common single-header case) is used verbatim.
37
+ const md5 = this.#verifyOpts.hash ? headers['content-md5'] : null
38
+ this.#contentMD5 = Array.isArray(md5) && md5.every((v) => v === md5[0]) ? md5[0] : md5
28
39
 
29
40
  if (this.#verifyOpts.size) {
30
41
  const contentRange = parseContentRange(headers['content-range'])
@@ -45,12 +56,14 @@ class Handler extends DecoratorHandler {
45
56
  this.#hasher?.update(chunk)
46
57
 
47
58
  if (this.#expectedSize != null && this.#pos > this.#expectedSize) {
48
- super.onError(
49
- Object.assign(new Error('Response body exceeded Content-Range'), {
50
- expected: this.#expectedSize,
51
- actual: this.#pos,
52
- }),
53
- )
59
+ const err = Object.assign(new Error('Response body exceeded Content-Range'), {
60
+ expected: this.#expectedSize,
61
+ actual: this.#pos,
62
+ })
63
+ super.onError(err)
64
+ // Returning false only applies backpressure; the socket would stay
65
+ // paused until bodyTimeout. Abort to release the connection now.
66
+ this.#abort?.(err)
54
67
  return false
55
68
  }
56
69
 
package/lib/request.js CHANGED
@@ -58,8 +58,16 @@ export class RequestHandler {
58
58
  this.abort(this.reason)
59
59
  }
60
60
  }
61
- signal.addEventListener('abort', onAbort)
62
- this.removeAbortListener = () => signal.removeEventListener('abort', onAbort)
61
+ // The validation above accepts either an EventTarget (addEventListener)
62
+ // or an EventEmitter (.on), matching undici's contract — so honour both
63
+ // here instead of assuming addEventListener and crashing on an emitter.
64
+ if (typeof signal.addEventListener === 'function') {
65
+ signal.addEventListener('abort', onAbort)
66
+ this.removeAbortListener = () => signal.removeEventListener('abort', onAbort)
67
+ } else {
68
+ signal.on('abort', onAbort)
69
+ this.removeAbortListener = () => signal.removeListener('abort', onAbort)
70
+ }
63
71
  }
64
72
  }
65
73
 
@@ -150,9 +158,17 @@ export function request(dispatch, urlOrOpts, optsOrNully) {
150
158
  let url = urlOrOpts
151
159
  let opts = optsOrNully
152
160
 
153
- if (typeof url === 'object' && url != null && opts == null) {
154
- opts = url
155
- url = opts.url ?? opts
161
+ if (typeof url === 'object' && url != null) {
162
+ if (opts == null) {
163
+ // Single-arg form: the object is both the url source and the opts.
164
+ opts = url
165
+ url = url.url ?? url
166
+ } else if (url.url != null) {
167
+ // Two-arg object-first form, e.g. request({ url }, { dispatcher }):
168
+ // unwrap the url field but keep the separately-provided opts. A genuine
169
+ // WHATWG URL has no `.url` property, so real URL objects are unaffected.
170
+ url = url.url
171
+ }
156
172
  }
157
173
 
158
174
  if (typeof url === 'string') {
@@ -172,7 +188,9 @@ export function request(dispatch, urlOrOpts, optsOrNully) {
172
188
  const protocol = url.protocol ?? 'http:'
173
189
  const host =
174
190
  url.host ??
175
- (url.hostname ? `${url.hostname}:${url.port ?? (protocol === 'https:' ? 443 : 80)}` : null)
191
+ // `||` not `??`: an empty-string port must fall back to the default,
192
+ // matching defaultLookup; `??` would yield a trailing-colon origin.
193
+ (url.hostname ? `${url.hostname}:${url.port || (protocol === 'https:' ? 443 : 80)}` : null)
176
194
 
177
195
  if (!host || !protocol) {
178
196
  throw new InvalidArgumentError('invalid url')
@@ -193,5 +211,16 @@ export function request(dispatch, urlOrOpts, optsOrNully) {
193
211
  body: isStream(opts?.body) && opts.body.closed ? null : opts?.body,
194
212
  }
195
213
 
196
- return new Promise((resolve) => dispatch(opts, new RequestHandler(opts, resolve)))
214
+ return new Promise((resolve) => {
215
+ const handler = new RequestHandler(opts, resolve)
216
+ try {
217
+ dispatch(opts, handler)
218
+ } catch (err) {
219
+ // A synchronous throw from dispatch otherwise skips onConnect/onError, so
220
+ // the request body stream is never destroyed and the signal's abort
221
+ // listener is never removed — both leak. Route it through onError, whose
222
+ // idempotent guards make this harmless if dispatch already reported.
223
+ handler.onError(err)
224
+ }
225
+ })
197
226
  }