@nxtedition/nxt-undici 7.3.26 → 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.
package/lib/index.js CHANGED
@@ -139,7 +139,12 @@ function wrapDispatch(dispatcher) {
139
139
  }
140
140
 
141
141
  if (globalThis.__nxt_undici_global_headers) {
142
- Object.assign(headers, globalThis.__nxt_undici_global_headers)
142
+ // Run global headers through parseHeaders too, so they share the
143
+ // pipeline invariant (lowercased names, stringified values) instead
144
+ // of landing verbatim with mixed-case keys or non-string values.
145
+ // parseHeaders into a fresh object keeps Object.assign's overwrite
146
+ // semantics (its two-arg form would append instead).
147
+ Object.assign(headers, parseHeaders(globalThis.__nxt_undici_global_headers))
143
148
  }
144
149
 
145
150
  return dispatch(
@@ -170,7 +175,14 @@ function wrapDispatch(dispatcher) {
170
175
  logger: opts.logger ?? null,
171
176
  dns: opts.dns ?? true,
172
177
  connect: opts.connect,
173
- priority: opts.priority ?? headers['nxt-priority'],
178
+ // A duplicated nxt-priority request header parses to an array; the
179
+ // scheduler and PRIORITY_TOS_MAP expect a scalar, so take the last
180
+ // (last-wins). opts.priority, when set, is already scalar.
181
+ priority:
182
+ opts.priority ??
183
+ (Array.isArray(headers['nxt-priority'])
184
+ ? headers['nxt-priority'][headers['nxt-priority'].length - 1]
185
+ : headers['nxt-priority']),
174
186
  lookup: opts.lookup ?? defaultLookup,
175
187
  },
176
188
  handler,
@@ -1,8 +1,8 @@
1
1
  import undici from '@nxtedition/undici'
2
- import { DecoratorHandler, parseCacheControl, parseContentRange } from '../utils.js'
2
+ import { DecoratorHandler, getFastNow, parseCacheControl, parseContentRange } from '../utils.js'
3
3
  import { SqliteCacheStore } from '../sqlite-cache-store.js'
4
4
 
5
- const DEFAULT_STORE = new SqliteCacheStore({ location: ':memory:' })
5
+ let DEFAULT_STORE = null
6
6
  const DEFAULT_MAX_ENTRY_SIZE = 128 * 1024
7
7
  const DEFAULT_MAX_ENTRY_TTL = 30 * 24 * 3600
8
8
  const NOOP = () => {}
@@ -40,18 +40,36 @@ class CacheHandler extends DecoratorHandler {
40
40
  return super.onHeaders(statusCode, headers, resume)
41
41
  }
42
42
 
43
- if (headers.vary === '*' || headers.trailers) {
43
+ // 'trailer' is the RFC 9110 field name; 'trailers' is kept for backwards
44
+ // compatibility with servers that misspell it.
45
+ if (headers.vary === '*' || headers.trailer || headers.trailers) {
44
46
  // Not cacheble...
45
47
  return super.onHeaders(statusCode, headers, resume)
46
48
  }
47
49
 
50
+ if (headers['set-cookie']) {
51
+ // Shared cache: replaying Set-Cookie to other clients leaks sessions.
52
+ return super.onHeaders(statusCode, headers, resume)
53
+ }
54
+
48
55
  let contentRange
49
56
  if (headers['content-range']) {
50
57
  contentRange = parseContentRange(headers['content-range'])
51
- if (contentRange === null) {
58
+ if (
59
+ contentRange == null ||
60
+ (contentRange.end != null &&
61
+ (contentRange.end <= contentRange.start ||
62
+ (contentRange.size != null && contentRange.end > contentRange.size)))
63
+ ) {
52
64
  // We don't support caching responses with invalid content-range...
53
65
  return super.onHeaders(statusCode, headers, resume)
54
66
  }
67
+ if (this.#key.method === 'HEAD') {
68
+ // A HEAD response delivers no body, so we never receive the byte
69
+ // window Content-Range describes — storing it would fail the store's
70
+ // body-length validation.
71
+ return super.onHeaders(statusCode, headers, resume)
72
+ }
55
73
  }
56
74
 
57
75
  let contentLength
@@ -104,6 +122,10 @@ class CacheHandler extends DecoratorHandler {
104
122
  }
105
123
 
106
124
  for (const key of headers.vary.split(',').map((key) => key.trim().toLowerCase())) {
125
+ if (key === '*') {
126
+ // RFC 9111 §4.1: a Vary field containing '*' never matches.
127
+ return super.onHeaders(statusCode, headers, resume)
128
+ }
107
129
  // Record every selecting header, using a null sentinel when it was
108
130
  // absent from the request. RFC 9111 §4.1: absent-vs-present is a
109
131
  // mismatch, so an empty vary object must NOT act as a wildcard that
@@ -218,23 +240,46 @@ export default () => (dispatch) => (opts, handler) => {
218
240
  }
219
241
 
220
242
  if (
221
- cacheControlDirectives['max-age'] ||
222
- cacheControlDirectives['max-stale'] ||
223
- cacheControlDirectives['min-fresh'] ||
243
+ // != null: 'max-age=0' parses to 0 (falsy) but still demands revalidation.
244
+ cacheControlDirectives['max-age'] != null ||
224
245
  cacheControlDirectives['no-cache'] ||
225
- cacheControlDirectives['stale-if-error']
246
+ cacheControlDirectives['stale-if-error'] != null ||
247
+ // cache-control-parser does not recognise 'max-stale'/'min-fresh', so
248
+ // check the raw string like we do for 'only-if-cached'.
249
+ (typeof rawCacheControl === 'string' &&
250
+ (rawCacheControl.includes('max-stale') || rawCacheControl.includes('min-fresh')))
226
251
  ) {
227
252
  // TODO (fix): Support all cache control directives...
228
253
  return dispatch(opts, handler)
229
254
  }
230
255
 
231
- const store = opts.cache.store ?? DEFAULT_STORE
256
+ const store =
257
+ opts.cache.store ?? (DEFAULT_STORE ??= new SqliteCacheStore({ location: ':memory:' }))
232
258
 
233
259
  // TODO (fix): enable range requests
234
260
 
261
+ // Build the key the same way for lookups and stores: makeCacheKey
262
+ // stringifies the origin (e.g. URL objects), so using raw opts on the get
263
+ // path while the set path normalizes would make the cache permanently miss.
264
+ const key = undici.util.cache.makeCacheKey(opts)
265
+
266
+ // makeCacheKey preserves request header names verbatim. Vary selector names
267
+ // are lowercased (in onHeaders and matchesValue), so lowercase the key's
268
+ // header names once here — the same key feeds both the get and set paths, so
269
+ // this keeps Vary matching symmetric even when a caller supplies non-lowercase
270
+ // header names (the standalone interceptors.cache() composition; the wrapped
271
+ // pipeline already normalizes). A fresh object avoids mutating opts.headers.
272
+ if (key.headers && typeof key.headers === 'object') {
273
+ const lower = {}
274
+ for (const name in key.headers) {
275
+ lower[name.toLowerCase()] = key.headers[name]
276
+ }
277
+ key.headers = lower
278
+ }
279
+
235
280
  let entry
236
281
  try {
237
- entry = store.get(opts)
282
+ entry = store.get(key)
238
283
  } catch (err) {
239
284
  if (err.message === 'database is locked') {
240
285
  // Database is busy. We don't bother trying again...
@@ -251,16 +296,34 @@ export default () => (dispatch) => (opts, handler) => {
251
296
  }
252
297
 
253
298
  // RFC 9110 Section 13: Evaluate conditional request headers against cached entry.
299
+ // typeof guards: duplicated conditional headers arrive as arrays — treat
300
+ // them as non-matching and bypass to origin rather than crashing.
254
301
  if (entry && opts.headers?.['if-none-match']) {
255
- if (entry.etag && weakMatch(opts.headers['if-none-match'], entry.etag)) {
256
- return serveFromCache({ statusCode: 304, headers: entry.headers }, opts, handler)
302
+ if (
303
+ typeof opts.headers['if-none-match'] === 'string' &&
304
+ entry.etag &&
305
+ weakMatch(opts.headers['if-none-match'], entry.etag)
306
+ ) {
307
+ return serveFromCache(
308
+ { statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
309
+ opts,
310
+ handler,
311
+ )
257
312
  }
258
313
  // Etag didn't match — bypass to origin.
259
314
  entry = undefined
260
315
  } else if (entry && opts.headers?.['if-modified-since']) {
261
316
  const lastModified = entry.headers?.['last-modified']
262
- if (lastModified && new Date(lastModified) <= new Date(opts.headers['if-modified-since'])) {
263
- return serveFromCache({ statusCode: 304, headers: entry.headers }, opts, handler)
317
+ if (
318
+ typeof opts.headers['if-modified-since'] === 'string' &&
319
+ lastModified &&
320
+ new Date(lastModified) <= new Date(opts.headers['if-modified-since'])
321
+ ) {
322
+ return serveFromCache(
323
+ { statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
324
+ opts,
325
+ handler,
326
+ )
264
327
  }
265
328
  // No last-modified or modified since — bypass to origin.
266
329
  entry = undefined
@@ -280,7 +343,7 @@ export default () => (dispatch) => (opts, handler) => {
280
343
  opts,
281
344
  cacheControlDirectives['no-store']
282
345
  ? handler
283
- : new CacheHandler(undici.util.cache.makeCacheKey(opts), {
346
+ : new CacheHandler(key, {
284
347
  maxEntrySize: opts.cache.maxEntrySize,
285
348
  maxEntryTTL: opts.cache.maxEntryTTL,
286
349
  store,
@@ -294,11 +357,30 @@ export default () => (dispatch) => (opts, handler) => {
294
357
  }
295
358
 
296
359
  function serveFromCache(entry, opts, handler) {
297
- const { statusCode, headers, trailers, body } = entry
360
+ const { statusCode, trailers, body } = entry
361
+
362
+ let headers = entry.headers
363
+ if (entry.cachedAt != null) {
364
+ // RFC 9111 §5.1: every response served from cache must carry an Age header
365
+ // reflecting time spent in this cache plus any age it arrived with —
366
+ // otherwise downstream caches treat it as fresh-from-origin.
367
+ // getFastNow has 1s resolution — Age is whole seconds, so that's enough.
368
+ const residentAge = Math.max(0, Math.floor((getFastNow() - entry.cachedAt) / 1000))
369
+ const originAge = Number(headers?.age)
370
+ const age = Number.isFinite(originAge) && originAge > 0 ? originAge + residentAge : residentAge
371
+ headers = { ...headers, age: `${age}` }
372
+ }
298
373
 
374
+ // serveFromCache drives the raw user handler directly (no DecoratorHandler),
375
+ // so it must enforce the contract itself: onError is terminal and mutually
376
+ // exclusive with onComplete. The `completed` guard makes a late abort() a
377
+ // no-op, and onComplete runs outside the try so a throw from the user's
378
+ // terminal callback propagates instead of being converted into a second
379
+ // (post-complete) onError.
299
380
  let aborted = false
381
+ let completed = false
300
382
  const abort = (reason) => {
301
- if (!aborted) {
383
+ if (!aborted && !completed) {
302
384
  aborted = true
303
385
  handler.onError(reason)
304
386
  }
@@ -324,11 +406,13 @@ function serveFromCache(entry, opts, handler) {
324
406
  return
325
407
  }
326
408
  }
327
-
328
- handler.onComplete(trailers ?? {})
329
409
  } catch (err) {
330
410
  abort(err)
411
+ return
331
412
  }
413
+
414
+ completed = true
415
+ handler.onComplete(trailers ?? {})
332
416
  }
333
417
 
334
418
  /**
@@ -30,10 +30,29 @@ class Handler extends DecoratorHandler {
30
30
  }
31
31
  }
32
32
 
33
+ const SWEEP_INTERVAL = 30e3
34
+
33
35
  export default () => (dispatch) => {
34
36
  const cache = new Map()
35
37
  const promises = new Map()
36
38
 
39
+ // The `cache` Map is otherwise only ever written, never trimmed, so a process
40
+ // touching many distinct hostnames over its lifetime would leak entries that
41
+ // can never be selected again. Sweep dead entries (all records expired and
42
+ // none in flight) at most once per SWEEP_INTERVAL to bound the O(n) cost.
43
+ let lastSweep = 0
44
+ function sweep(now) {
45
+ if (now - lastSweep < SWEEP_INTERVAL) {
46
+ return
47
+ }
48
+ lastSweep = now
49
+ for (const [hostname, records] of cache) {
50
+ if (records.every((x) => x.expires < now && x.pending === 0)) {
51
+ cache.delete(hostname)
52
+ }
53
+ }
54
+ }
55
+
37
56
  function resolve(hostname, { ttl }) {
38
57
  let promise = promises.get(hostname)
39
58
  if (!promise) {
@@ -84,6 +103,8 @@ export default () => (dispatch) => {
84
103
 
85
104
  const now = getFastNow()
86
105
 
106
+ sweep(now)
107
+
87
108
  let records = cache.get(hostname)
88
109
 
89
110
  if (records == null || records.every((x) => x.expires < now)) {
@@ -1,3 +1,5 @@
1
+ import { DecoratorHandler } from '../utils.js'
2
+
1
3
  export default () => (dispatch) => async (opts, handler) => {
2
4
  const lookup = opts.lookup
3
5
 
@@ -5,6 +7,12 @@ export default () => (dispatch) => async (opts, handler) => {
5
7
  return dispatch(opts, handler)
6
8
  }
7
9
 
10
+ // Wrap so the catch below can't deliver a second onError: if a downstream
11
+ // layer already reported a terminal callback and then let an error escape
12
+ // dispatch synchronously, DecoratorHandler's #errored/#completed guards
13
+ // absorb the duplicate instead of violating the once-only onError contract.
14
+ const wrapped = new DecoratorHandler(handler)
15
+
8
16
  try {
9
17
  const origin = await new Promise((resolve, reject) => {
10
18
  const thenable = lookup(opts.origin, { signal: opts.signal ?? undefined }, (err, val) => {
@@ -24,8 +32,8 @@ export default () => (dispatch) => async (opts, handler) => {
24
32
  throw new Error('invalid origin: ' + origin)
25
33
  }
26
34
 
27
- return dispatch({ ...opts, origin }, handler)
35
+ return dispatch({ ...opts, origin }, wrapped)
28
36
  } catch (err) {
29
- handler.onError(err)
37
+ wrapped.onError(err)
30
38
  }
31
39
  }
@@ -3,10 +3,12 @@ import { DecoratorHandler } from '../utils.js'
3
3
 
4
4
  class Handler extends DecoratorHandler {
5
5
  #scheduler
6
+ #onIdle
6
7
 
7
- constructor(handler, scheduler) {
8
+ constructor(handler, scheduler, onIdle) {
8
9
  super(handler)
9
10
  this.#scheduler = scheduler
11
+ this.#onIdle = onIdle
10
12
  }
11
13
 
12
14
  onConnect(abort) {
@@ -29,6 +31,7 @@ class Handler extends DecoratorHandler {
29
31
  const scheduler = this.#scheduler
30
32
  this.#scheduler = null
31
33
  scheduler.release()
34
+ this.#onIdle?.()
32
35
  }
33
36
  }
34
37
  }
@@ -41,13 +44,29 @@ export default () => (dispatch) => {
41
44
  return dispatch(opts, handler)
42
45
  }
43
46
 
44
- let scheduler = schedulers.get(opts.origin)
47
+ // Key on the logical origin, not opts.origin: an outer dns interceptor
48
+ // rewrites opts.origin to a rotating resolved IP, which would scatter one
49
+ // logical host across many schedulers and silently defeat the per-origin
50
+ // concurrency limit. dns preserves the logical host in the `host` header.
51
+ const key = (typeof opts.headers?.host === 'string' && opts.headers.host) || opts.origin
52
+
53
+ let scheduler = schedulers.get(key)
45
54
  if (!scheduler) {
46
55
  scheduler = new Scheduler({ concurrency: 1 })
47
- schedulers.set(opts.origin, scheduler)
56
+ schedulers.set(key, scheduler)
57
+ }
58
+
59
+ // Evict a scheduler once it has fully drained, so a client touching many
60
+ // distinct origins doesn't accumulate them forever. The `=== scheduler`
61
+ // guard avoids deleting a freshly-created replacement; release() drains
62
+ // pending synchronously, so running===0 && pending===0 here means idle.
63
+ const onIdle = () => {
64
+ if (schedulers.get(key) === scheduler && scheduler.running === 0 && scheduler.pending === 0) {
65
+ schedulers.delete(key)
66
+ }
48
67
  }
49
68
 
50
- const priorityHandler = new Handler(handler, scheduler)
69
+ const priorityHandler = new Handler(handler, scheduler, onIdle)
51
70
  scheduler.acquire(
52
71
  (priorityHandler) => {
53
72
  try {
@@ -31,7 +31,10 @@ class Handler extends DecoratorHandler {
31
31
  {
32
32
  headers,
33
33
  httpVersion: this.#opts.httpVersion ?? this.#opts.req?.httpVersion,
34
- 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,
35
38
  proxyName: this.#opts.name,
36
39
  },
37
40
  copyHeader,
@@ -48,7 +51,7 @@ class Handler extends DecoratorHandler {
48
51
  {
49
52
  headers,
50
53
  httpVersion: this.#opts.httpVersion ?? this.#opts.req?.httpVersion,
51
- socket: this.#opts.socket,
54
+ isResponse: true,
52
55
  proxyName: this.#opts.name,
53
56
  },
54
57
  copyHeader,
@@ -134,15 +137,18 @@ function isHopByHop(key) {
134
137
  * @param {string} [options.httpVersion] Protocol token for the appended Via
135
138
  * segment; defaults to `'HTTP/1.1'`.
136
139
  * @param {{ localAddress?: string, localPort?: number, remoteAddress?: string,
137
- * remotePort?: number, encrypted?: boolean } | null} [options.socket] When
138
- * present a Forwarded header is synthesised; when absent an inbound Forwarded
139
- * header is treated as a BadGateway.
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.
140
146
  * @param {(acc: T, key: string, value: string) => T} fn Accumulator invoked
141
147
  * once per retained header.
142
148
  * @param {T} acc Initial accumulator value.
143
149
  * @returns {T}
144
150
  */
145
- function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
151
+ function reduceHeaders({ headers, proxyName, httpVersion, socket, isResponse }, fn, acc) {
146
152
  let via = ''
147
153
  let forwarded = ''
148
154
  let host = ''
@@ -163,14 +169,20 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
163
169
  // the parts are semantically one comma-separated value — those we combine.
164
170
  // Singular fields with more than one value are a protocol error — those we
165
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.
166
178
  for (let i = 0; i < keys.length; i++) {
167
179
  const key = keys[i]
168
180
  const len = key.length
169
- if (len === 3 && key === 'via') {
181
+ if (len === 3 && eqiLower(key, 'via')) {
170
182
  // Via is list-valued (RFC 9110 §7.6.3): combine repeated field-lines.
171
183
  const v = headers[key]
172
184
  via = Array.isArray(v) ? v.join(', ') : v
173
- } else if (len === 4 && key === 'host') {
185
+ } else if (len === 4 && eqiLower(key, 'host')) {
174
186
  // Host is singular (RFC 9110 §7.2, RFC 7230 §5.4): more than one is a
175
187
  // protocol error, so reject rather than combine.
176
188
  const v = headers[key]
@@ -178,16 +190,17 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
178
190
  throw new createError.BadGateway()
179
191
  }
180
192
  host = v
181
- } else if (len === 9 && key === 'forwarded') {
193
+ } else if (len === 9 && eqiLower(key, 'forwarded')) {
182
194
  // Forwarded is list-valued (RFC 7239 §4): combine repeated field-lines.
183
195
  const v = headers[key]
184
196
  forwarded = Array.isArray(v) ? v.join(', ') : v
185
- } else if (len === 10 && key === 'connection') {
197
+ } else if (len === 10 && eqiLower(key, 'connection')) {
186
198
  // Connection is list-valued (RFC 9110 §7.6.1): captured raw and tokenised
187
199
  // below — combining then re-splitting would be wasteful.
188
200
  connection = headers[key]
189
201
  } else if (len === 10 && key === ':authority') {
190
- // :authority is singular (RFC 9113 §8.3.1): reject more than one.
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.
191
204
  const v = headers[key]
192
205
  if (Array.isArray(v)) {
193
206
  throw new createError.BadGateway()
@@ -229,16 +242,26 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
229
242
 
230
243
  for (let i = 0; i < keys.length; i++) {
231
244
  const key = keys[i]
245
+ const len = key.length
232
246
  if (
233
247
  key.charCodeAt(0) !== 0x3a /* ':' */ &&
234
- (remove === null || !remove.includes(key)) &&
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())) &&
235
258
  !isHopByHop(key)
236
259
  ) {
237
260
  acc = fn(acc, key, headers[key].toString())
238
261
  }
239
262
  }
240
263
 
241
- if (socket) {
264
+ if (!isResponse && socket) {
242
265
  const forwardedHost = authority || host
243
266
  acc = fn(
244
267
  acc,
@@ -254,7 +277,8 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
254
277
  .join(';'),
255
278
  )
256
279
  } else if (forwarded) {
257
- // 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.
258
282
  throw new createError.BadGateway()
259
283
  }
260
284
 
@@ -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
  }
@@ -29,7 +29,13 @@ class Handler extends DecoratorHandler {
29
29
  }
30
30
 
31
31
  onHeaders(statusCode, headers, resume) {
32
- 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
33
39
 
34
40
  if (this.#verifyOpts.size) {
35
41
  const contentRange = parseContentRange(headers['content-range'])
package/lib/request.js CHANGED
@@ -188,7 +188,9 @@ export function request(dispatch, urlOrOpts, optsOrNully) {
188
188
  const protocol = url.protocol ?? 'http:'
189
189
  const host =
190
190
  url.host ??
191
- (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)
192
194
 
193
195
  if (!host || !protocol) {
194
196
  throw new InvalidArgumentError('invalid url')
@@ -209,5 +211,16 @@ export function request(dispatch, urlOrOpts, optsOrNully) {
209
211
  body: isStream(opts?.body) && opts.body.closed ? null : opts?.body,
210
212
  }
211
213
 
212
- 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
+ })
213
226
  }
@@ -420,6 +420,23 @@ export class SqliteCacheStore {
420
420
  const now = getFastNow()
421
421
  const requestedStart = range?.start ?? 0
422
422
 
423
+ if (this.#insertBatch.length === 0) {
424
+ // Fast path: rows arrive sorted (cachedAt DESC, id DESC) and there are
425
+ // no pending batch entries to merge, so fetch only the newest candidate.
426
+ // This covers misses and first-row hits (the overwhelmingly common
427
+ // cases) with a single row materialized — re-cached duplicates of a hot
428
+ // key would otherwise all be read including their blobs. Only when the
429
+ // newest row doesn't match (vary variant, range/206 mismatch) do we
430
+ // fall through to scan the full candidate set.
431
+ const value = this.#getValuesQuery.get(url, method, requestedStart, now)
432
+ if (value === undefined) {
433
+ return undefined
434
+ }
435
+ if (matchesValue(value, range, headers)) {
436
+ return value
437
+ }
438
+ }
439
+
423
440
  /**
424
441
  * @type {SqliteStoreValue[]}
425
442
  */
@@ -445,55 +462,63 @@ export class SqliteCacheStore {
445
462
  // deterministically toward the freshest write: pending batch entries
446
463
  // (tagged with a monotonic seq) are always newer than any flushed DB row,
447
464
  // and within each source a higher seq/id wins.
448
- values.sort((a, b) => {
449
- if (a.cachedAt !== b.cachedAt) {
450
- return b.cachedAt - a.cachedAt
451
- }
452
- const aBatch = a.seq != null
453
- const bBatch = b.seq != null
454
- if (aBatch !== bBatch) {
455
- return aBatch ? -1 : 1
456
- }
457
- if (aBatch) {
458
- return b.seq - a.seq
459
- }
460
- return (b.id ?? 0) - (a.id ?? 0)
461
- })
465
+ if (values.length > 1) {
466
+ values.sort((a, b) => {
467
+ if (a.cachedAt !== b.cachedAt) {
468
+ return b.cachedAt - a.cachedAt
469
+ }
470
+ const aBatch = a.seq != null
471
+ const bBatch = b.seq != null
472
+ if (aBatch !== bBatch) {
473
+ return aBatch ? -1 : 1
474
+ }
475
+ if (aBatch) {
476
+ return b.seq - a.seq
477
+ }
478
+ return (b.id ?? 0) - (a.id ?? 0)
479
+ })
480
+ }
462
481
 
463
482
  for (const value of values) {
464
- // TODO (fix): Allow full and partial match?
465
- if (range && (range.start !== value.start || range.end !== value.end)) {
466
- continue
483
+ if (matchesValue(value, range, headers)) {
484
+ return value
467
485
  }
486
+ }
468
487
 
469
- // A request without a Range header asks for the full representation, so
470
- // a stored 206 partial (e.g. content-range bytes 0-4/100, which the SQL
471
- // `start <= 0` filter does not exclude) must not be served verbatim.
472
- if (!range && value.statusCode === 206) {
473
- continue
474
- }
488
+ return undefined
489
+ }
490
+ }
475
491
 
476
- if (value.vary) {
477
- const vary = JSON.parse(value.vary)
478
- let matches = true
492
+ /**
493
+ * @param {SqliteStoreValue} value
494
+ * @param {import('./utils.js').RangeHeader | undefined} range
495
+ * @param {Record<string, string | string[]> | undefined} headers
496
+ * @returns {boolean}
497
+ */
498
+ function matchesValue(value, range, headers) {
499
+ // TODO (fix): Allow full and partial match?
500
+ if (range && (range.start !== value.start || range.end !== value.end)) {
501
+ return false
502
+ }
479
503
 
480
- for (const header in vary) {
481
- if (!headerValueEquals(headers?.[header], vary[header])) {
482
- matches = false
483
- break
484
- }
485
- }
504
+ // A request without a Range header asks for the full representation, so
505
+ // a stored 206 partial (e.g. content-range bytes 0-4/100, which the SQL
506
+ // `start <= 0` filter does not exclude) must not be served verbatim.
507
+ if (!range && value.statusCode === 206) {
508
+ return false
509
+ }
486
510
 
487
- if (!matches) {
488
- continue
489
- }
490
- }
511
+ if (value.vary) {
512
+ const vary = JSON.parse(value.vary)
491
513
 
492
- return value
514
+ for (const header in vary) {
515
+ if (!headerValueEquals(headers?.[header], vary[header])) {
516
+ return false
517
+ }
493
518
  }
494
-
495
- return undefined
496
519
  }
520
+
521
+ return true
497
522
  }
498
523
 
499
524
  /**
@@ -510,15 +535,21 @@ function headerValueEquals(lhs, rhs) {
510
535
  return false
511
536
  }
512
537
 
513
- if (Array.isArray(lhs) && Array.isArray(rhs)) {
514
- if (lhs.length !== rhs.length) {
538
+ // A single-element array and the bare scalar denote the same logical header
539
+ // value (e.g. 'gzip' vs ['gzip']); normalize so an inconsistently-shaped
540
+ // selecting header doesn't cause an avoidable cache miss.
541
+ const a = Array.isArray(lhs) && lhs.length === 1 ? lhs[0] : lhs
542
+ const b = Array.isArray(rhs) && rhs.length === 1 ? rhs[0] : rhs
543
+
544
+ if (Array.isArray(a) && Array.isArray(b)) {
545
+ if (a.length !== b.length) {
515
546
  return false
516
547
  }
517
548
 
518
- return lhs.every((x, i) => x === rhs[i])
549
+ return a.every((x, i) => x === b[i])
519
550
  }
520
551
 
521
- return lhs === rhs
552
+ return a === b
522
553
  }
523
554
 
524
555
  /**
@@ -531,11 +562,15 @@ function makeValueUrl(key) {
531
562
 
532
563
  function makeResult(value) {
533
564
  return {
534
- // Copy rather than alias: on the in-flight batch read-through path
535
- // value.body is the exact Buffer still queued for flushing, and the
536
- // three-arg Buffer.from(arrayBuffer, ...) form would share its memory,
537
- // so a consumer mutating the served body could corrupt the cached bytes.
538
- body: value.body ? Buffer.from(value.body) : undefined,
565
+ // Batch entries (tagged with seq) must be copied: value.body is the exact
566
+ // Buffer still queued for flushing, so a consumer mutating the served body
567
+ // could corrupt the bytes about to be written. DB rows are safe to alias —
568
+ // node:sqlite allocates a fresh Uint8Array per read, so wrap it zero-copy.
569
+ body: value.body
570
+ ? value.seq != null
571
+ ? Buffer.from(value.body)
572
+ : Buffer.from(value.body.buffer, value.body.byteOffset, value.body.byteLength)
573
+ : undefined,
539
574
  statusCode: value.statusCode,
540
575
  statusMessage: value.statusMessage,
541
576
  headers: value.headers ? JSON.parse(value.headers) : undefined,
package/lib/utils.js CHANGED
@@ -291,7 +291,10 @@ export function parseHeaders(headers, obj) {
291
291
  const key = util.headerNameToString(key2)
292
292
  let val = obj[key]
293
293
 
294
- if (val) {
294
+ // `key in obj`, not `if (val)`: an empty-string value ('') is a valid,
295
+ // present header. A truthy check would treat it as absent and overwrite
296
+ // it on a duplicate occurrence, silently dropping the first value.
297
+ if (key in obj) {
295
298
  if (!Array.isArray(val)) {
296
299
  val = [val]
297
300
  obj[key] = val
@@ -322,7 +325,9 @@ export function parseHeaders(headers, obj) {
322
325
  const key = util.headerNameToString(key2)
323
326
  let val = obj[key]
324
327
 
325
- if (val) {
328
+ // See the array branch above: presence check, not truthiness, so a
329
+ // stored empty string is not clobbered by a later duplicate.
330
+ if (key in obj) {
326
331
  if (!Array.isArray(val)) {
327
332
  val = [val]
328
333
  obj[key] = val
@@ -332,7 +337,7 @@ export function parseHeaders(headers, obj) {
332
337
  } else {
333
338
  val.push(`${val2}`)
334
339
  }
335
- } else if (val2 != null) {
340
+ } else {
336
341
  obj[key] = Array.isArray(val2)
337
342
  ? val2.filter((x) => x != null).map((x) => `${x}`)
338
343
  : `${val2}`
@@ -374,10 +379,14 @@ export function decorateError(err, opts, { statusCode, headers, trailers, body }
374
379
  body = null
375
380
  }
376
381
 
377
- if (
378
- typeof body === 'string' &&
379
- (!headers?.['content-type'] || headers['content-type'].startsWith('application/json'))
380
- ) {
382
+ // A duplicated content-type response header arrives as an array (undici's
383
+ // parseHeaders collapses repeats); coerce to the first value before
384
+ // calling string methods, otherwise this throws and the catch below
385
+ // discards all decoration into an opaque AggregateError.
386
+ const contentType = Array.isArray(headers?.['content-type'])
387
+ ? headers['content-type'][0]
388
+ : headers?.['content-type']
389
+ if (typeof body === 'string' && (!contentType || contentType.startsWith('application/json'))) {
381
390
  try {
382
391
  body = JSON.parse(body)
383
392
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/nxt-undici",
3
- "version": "7.3.26",
3
+ "version": "7.3.27",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",