@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.
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import net from 'node:net'
1
2
  import undici from '@nxtedition/undici'
2
3
  import { Scheduler } from '@nxtedition/scheduler'
3
4
  import { parseHeaders } from './utils.js'
@@ -33,13 +34,19 @@ function defaultLookup(origin, opts, callback) {
33
34
  try {
34
35
  if (Array.isArray(origin)) {
35
36
  origin = origin[Math.floor(Math.random() * origin.length)]
36
- } else if (origin != null && typeof origin === 'object') {
37
+ }
38
+
39
+ // Note: not `else if` — an array element may itself be an object that
40
+ // still needs normalizing to an origin string.
41
+ if (origin != null && typeof origin === 'object') {
37
42
  const protocol = origin.protocol || 'http:'
38
43
 
39
44
  let host = origin.host
40
45
  if (!host && origin.hostname) {
41
46
  const port = origin.port || (protocol === 'https:' ? 443 : 80)
42
- host = `${origin.hostname}:${port}`
47
+ // Bracket IPv6 literals, otherwise `::1:80` is not a valid authority.
48
+ const hostname = net.isIPv6(origin.hostname) ? `[${origin.hostname}]` : origin.hostname
49
+ host = `${hostname}:${port}`
43
50
  }
44
51
 
45
52
  if (!host) {
@@ -132,7 +139,12 @@ function wrapDispatch(dispatcher) {
132
139
  }
133
140
 
134
141
  if (globalThis.__nxt_undici_global_headers) {
135
- 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))
136
148
  }
137
149
 
138
150
  return dispatch(
@@ -163,7 +175,14 @@ function wrapDispatch(dispatcher) {
163
175
  logger: opts.logger ?? null,
164
176
  dns: opts.dns ?? true,
165
177
  connect: opts.connect,
166
- 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']),
167
186
  lookup: opts.lookup ?? defaultLookup,
168
187
  },
169
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,10 +122,16 @@ class CacheHandler extends DecoratorHandler {
104
122
  }
105
123
 
106
124
  for (const key of headers.vary.split(',').map((key) => key.trim().toLowerCase())) {
107
- const val = this.#key.headers[key]
108
- if (val != null) {
109
- vary[key] = this.#key.headers[key]
125
+ if (key === '*') {
126
+ // RFC 9111 §4.1: a Vary field containing '*' never matches.
127
+ return super.onHeaders(statusCode, headers, resume)
110
128
  }
129
+ // Record every selecting header, using a null sentinel when it was
130
+ // absent from the request. RFC 9111 §4.1: absent-vs-present is a
131
+ // mismatch, so an empty vary object must NOT act as a wildcard that
132
+ // matches requests which later supply the header. headerValueEquals
133
+ // treats null/absent as equal only to another null/absent.
134
+ vary[key] = this.#key.headers[key] ?? null
111
135
  }
112
136
  }
113
137
 
@@ -118,8 +142,22 @@ class CacheHandler extends DecoratorHandler {
118
142
  return super.onHeaders(statusCode, headers, resume)
119
143
  }
120
144
 
145
+ // RFC 9111 §4.2.3: a response relayed by an upstream/shared cache may
146
+ // already be partway through its freshness lifetime. Subtract the
147
+ // advertised Age so we don't over-extend the TTL and serve stale content.
148
+ const age = Number(headers.age)
149
+ const lifetime = Math.min(ttl, this.#maxEntryTTL) - (Number.isFinite(age) && age > 0 ? age : 0)
150
+ if (lifetime <= 0) {
151
+ // Already stale on arrival — not worth caching.
152
+ return super.onHeaders(statusCode, headers, resume)
153
+ }
154
+
121
155
  const start = contentRange ? contentRange.start : 0
122
- const end = contentRange ? contentRange.end : contentLength
156
+ // HEAD never delivers a body, so a Content-Length must not drive the
157
+ // stored byte window (end). Storing end = contentLength with an empty body
158
+ // would fail the store's body-length validation and emit error-level log
159
+ // spam on every cacheable HEAD response.
160
+ const end = contentRange ? contentRange.end : this.#key.method === 'HEAD' ? 0 : contentLength
123
161
 
124
162
  if (end == null || end - start <= this.#maxEntrySize) {
125
163
  const cachedAt = Date.now()
@@ -127,7 +165,7 @@ class CacheHandler extends DecoratorHandler {
127
165
  body: [],
128
166
  start,
129
167
  end,
130
- deleteAt: cachedAt + Math.min(ttl, this.#maxEntryTTL) * 1e3,
168
+ deleteAt: cachedAt + lifetime * 1e3,
131
169
  statusCode,
132
170
  statusMessage: '',
133
171
  headers,
@@ -202,23 +240,46 @@ export default () => (dispatch) => (opts, handler) => {
202
240
  }
203
241
 
204
242
  if (
205
- cacheControlDirectives['max-age'] ||
206
- cacheControlDirectives['max-stale'] ||
207
- cacheControlDirectives['min-fresh'] ||
243
+ // != null: 'max-age=0' parses to 0 (falsy) but still demands revalidation.
244
+ cacheControlDirectives['max-age'] != null ||
208
245
  cacheControlDirectives['no-cache'] ||
209
- 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')))
210
251
  ) {
211
252
  // TODO (fix): Support all cache control directives...
212
253
  return dispatch(opts, handler)
213
254
  }
214
255
 
215
- const store = opts.cache.store ?? DEFAULT_STORE
256
+ const store =
257
+ opts.cache.store ?? (DEFAULT_STORE ??= new SqliteCacheStore({ location: ':memory:' }))
216
258
 
217
259
  // TODO (fix): enable range requests
218
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
+
219
280
  let entry
220
281
  try {
221
- entry = store.get(opts)
282
+ entry = store.get(key)
222
283
  } catch (err) {
223
284
  if (err.message === 'database is locked') {
224
285
  // Database is busy. We don't bother trying again...
@@ -235,16 +296,34 @@ export default () => (dispatch) => (opts, handler) => {
235
296
  }
236
297
 
237
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.
238
301
  if (entry && opts.headers?.['if-none-match']) {
239
- if (entry.etag && weakMatch(opts.headers['if-none-match'], entry.etag)) {
240
- 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
+ )
241
312
  }
242
313
  // Etag didn't match — bypass to origin.
243
314
  entry = undefined
244
315
  } else if (entry && opts.headers?.['if-modified-since']) {
245
316
  const lastModified = entry.headers?.['last-modified']
246
- if (lastModified && new Date(lastModified) <= new Date(opts.headers['if-modified-since'])) {
247
- 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
+ )
248
327
  }
249
328
  // No last-modified or modified since — bypass to origin.
250
329
  entry = undefined
@@ -264,7 +343,7 @@ export default () => (dispatch) => (opts, handler) => {
264
343
  opts,
265
344
  cacheControlDirectives['no-store']
266
345
  ? handler
267
- : new CacheHandler(undici.util.cache.makeCacheKey(opts), {
346
+ : new CacheHandler(key, {
268
347
  maxEntrySize: opts.cache.maxEntrySize,
269
348
  maxEntryTTL: opts.cache.maxEntryTTL,
270
349
  store,
@@ -278,11 +357,30 @@ export default () => (dispatch) => (opts, handler) => {
278
357
  }
279
358
 
280
359
  function serveFromCache(entry, opts, handler) {
281
- 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
+ }
282
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.
283
380
  let aborted = false
381
+ let completed = false
284
382
  const abort = (reason) => {
285
- if (!aborted) {
383
+ if (!aborted && !completed) {
286
384
  aborted = true
287
385
  handler.onError(reason)
288
386
  }
@@ -308,11 +406,13 @@ function serveFromCache(entry, opts, handler) {
308
406
  return
309
407
  }
310
408
  }
311
-
312
- handler.onComplete(trailers ?? {})
313
409
  } catch (err) {
314
410
  abort(err)
411
+ return
315
412
  }
413
+
414
+ completed = true
415
+ handler.onComplete(trailers ?? {})
316
416
  }
317
417
 
318
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)) {
@@ -144,18 +165,33 @@ export default () => (dispatch) => {
144
165
  record.counter++
145
166
  record.pending++
146
167
 
147
- return dispatch(
148
- { ...opts, origin: url.origin, headers: { ...opts.headers, host } },
149
- new Handler(handler, (err, statusCode) => {
150
- record.pending--
168
+ // Guarded so it runs exactly once: on the normal Handler callback, or
169
+ // if dispatch throws synchronously (otherwise record.pending would leak
170
+ // and skew load balancing toward the wrongly-busy record).
171
+ let settled = false
172
+ const onSettle = (err, statusCode) => {
173
+ if (settled) {
174
+ return
175
+ }
176
+ settled = true
177
+ record.pending--
151
178
 
152
- if (err != null && err.name !== 'AbortError') {
153
- record.expires = 0
154
- } else if (statusCode != null && statusCode >= 500) {
155
- record.errored++
156
- }
157
- }),
158
- )
179
+ if (err != null && err.name !== 'AbortError') {
180
+ record.expires = 0
181
+ } else if (statusCode != null && statusCode >= 500) {
182
+ record.errored++
183
+ }
184
+ }
185
+
186
+ try {
187
+ return dispatch(
188
+ { ...opts, origin: url.origin, headers: { ...opts.headers, host } },
189
+ new Handler(handler, onSettle),
190
+ )
191
+ } catch (err) {
192
+ onSettle(err)
193
+ throw err
194
+ }
159
195
  } catch (err) {
160
196
  handler.onError(err)
161
197
  }
@@ -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 {