@nxtedition/nxt-undici 7.3.18 → 7.3.20

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.d.ts CHANGED
@@ -155,7 +155,6 @@ export interface CacheValue {
155
155
  etag?: string
156
156
  vary?: Record<string, string | string[]>
157
157
  cachedAt: number
158
- staleAt: number
159
158
  deleteAt?: number
160
159
  }
161
160
 
@@ -168,7 +167,6 @@ export interface CacheGetResult {
168
167
  cacheControlDirectives?: Record<string, unknown>
169
168
  vary?: Record<string, string | string[]>
170
169
  cachedAt: number
171
- staleAt: number
172
170
  deleteAt: number
173
171
  }
174
172
 
package/lib/index.js CHANGED
@@ -159,7 +159,7 @@ function wrapDispatch(dispatcher) {
159
159
  upgrade: opts.upgrade ?? false,
160
160
  follow: opts.follow ?? opts.redirect ?? 8,
161
161
  error: opts.error ?? opts.throwOnError ?? true,
162
- verify: opts.verify ?? false,
162
+ verify: opts.verify ?? { size: true, hash: false },
163
163
  logger: opts.logger ?? null,
164
164
  dns: opts.dns ?? true,
165
165
  connect: opts.connect,
@@ -135,7 +135,6 @@ class CacheHandler extends DecoratorHandler {
135
135
  etag: isEtagUsable(headers.etag) ? headers.etag : '',
136
136
  vary,
137
137
  cachedAt,
138
- staleAt: 0,
139
138
  // Handler state.
140
139
  size: 0,
141
140
  }
@@ -192,6 +191,12 @@ export default () => (dispatch) => (opts, handler) => {
192
191
  const onlyIfCached =
193
192
  typeof rawCacheControl === 'string' && rawCacheControl.includes('only-if-cached')
194
193
 
194
+ // RFC 9111 Section 5.4: Pragma: no-cache should be treated as
195
+ // Cache-Control: no-cache when Cache-Control is absent.
196
+ if (rawCacheControl == null && opts?.headers?.pragma === 'no-cache') {
197
+ cacheControlDirectives['no-cache'] = true
198
+ }
199
+
195
200
  if (cacheControlDirectives['no-transform']) {
196
201
  // Do nothing. We don't transform requests...
197
202
  }
@@ -223,6 +228,37 @@ export default () => (dispatch) => (opts, handler) => {
223
228
  }
224
229
  }
225
230
 
231
+ // RFC 9111 Section 3.5: A shared cache must not use a cached response to a
232
+ // request with Authorization unless the response includes a public directive.
233
+ if (entry && opts.headers?.authorization && !entry.cacheControlDirectives?.public) {
234
+ entry = undefined
235
+ }
236
+
237
+ // RFC 9110 Section 13: Evaluate conditional request headers against cached entry.
238
+ 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)
241
+ }
242
+ // Etag didn't match — bypass to origin.
243
+ entry = undefined
244
+ } else if (entry && opts.headers?.['if-modified-since']) {
245
+ 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)
248
+ }
249
+ // No last-modified or modified since — bypass to origin.
250
+ entry = undefined
251
+ }
252
+
253
+ if (
254
+ opts.headers?.['if-match'] ||
255
+ opts.headers?.['if-unmodified-since'] ||
256
+ opts.headers?.['if-range']
257
+ ) {
258
+ // TODO (fix): evaluate these conditional headers against cached entry.
259
+ return dispatch(opts, handler)
260
+ }
261
+
226
262
  if (!entry && !onlyIfCached) {
227
263
  return dispatch(
228
264
  opts,
@@ -238,7 +274,11 @@ export default () => (dispatch) => (opts, handler) => {
238
274
  )
239
275
  }
240
276
 
241
- const { statusCode, headers, trailers, body } = entry ?? { statusCode: 504 }
277
+ return serveFromCache(entry ?? { statusCode: 504 }, opts, handler)
278
+ }
279
+
280
+ function serveFromCache(entry, opts, handler) {
281
+ const { statusCode, headers, trailers, body } = entry
242
282
 
243
283
  let aborted = false
244
284
  const abort = (reason) => {
@@ -275,6 +315,31 @@ export default () => (dispatch) => (opts, handler) => {
275
315
  }
276
316
  }
277
317
 
318
+ /**
319
+ * RFC 9110 Section 8.8.3.2: Weak comparison — two etags match if their
320
+ * opaque-tags match, ignoring the W/ prefix.
321
+ *
322
+ * @param {string} ifNoneMatch - The If-None-Match header value (may contain multiple etags)
323
+ * @param {string} etag - The cached etag
324
+ * @returns {boolean}
325
+ */
326
+ function weakMatch(ifNoneMatch, etag) {
327
+ if (ifNoneMatch === '*') {
328
+ return true
329
+ }
330
+
331
+ const normalize = (tag) => (tag.startsWith('W/') ? tag.slice(2) : tag)
332
+ const cached = normalize(etag)
333
+
334
+ for (const raw of ifNoneMatch.split(',')) {
335
+ if (normalize(raw.trim()) === cached) {
336
+ return true
337
+ }
338
+ }
339
+
340
+ return false
341
+ }
342
+
278
343
  /**
279
344
  * Note: this deviates from the spec a little. Empty etags ("", W/"") are valid,
280
345
  * however, including them in cached resposnes serves little to no purpose.
@@ -52,7 +52,6 @@ export default () => (dispatch) => {
52
52
  pending: 0,
53
53
  errored: 0,
54
54
  counter: 0,
55
- timeout: 0,
56
55
  }
57
56
  })
58
57
 
@@ -68,35 +67,31 @@ export default () => (dispatch) => {
68
67
  }
69
68
 
70
69
  return async (opts, handler) => {
71
- if (!opts.dns || !opts.origin) {
72
- return dispatch(opts, handler)
73
- }
70
+ try {
71
+ if (!opts.dns || !opts.origin) {
72
+ return dispatch(opts, handler)
73
+ }
74
74
 
75
- const ttl = opts.dns.ttl ?? 2e3
76
- const url = new URL(opts.path ?? '', opts.origin)
77
- const balance = opts.dns.balance
75
+ const ttl = opts.dns.ttl ?? 2e3
76
+ const url = new URL(opts.path ?? '', opts.origin)
77
+ const balance = opts.dns.balance
78
78
 
79
- const { host, hostname, pathname } = url
79
+ const { host, hostname, pathname } = url
80
80
 
81
- if (net.isIP(hostname)) {
82
- return dispatch(opts, handler)
83
- }
81
+ if (net.isIP(hostname)) {
82
+ return dispatch(opts, handler)
83
+ }
84
84
 
85
- try {
86
85
  const now = getFastNow()
87
86
 
88
87
  let records = cache.get(hostname)
89
88
 
90
89
  if (records == null || records.every((x) => x.expires < now)) {
91
90
  const [err, val] = await resolve(hostname, { ttl })
92
-
93
91
  if (err) {
94
92
  throw err
95
93
  }
96
-
97
94
  records = val
98
- } else if (records.some((x) => x.expires < now + 1e3)) {
99
- resolve(hostname, { ttl })
100
95
  }
101
96
 
102
97
  let record
@@ -108,7 +103,7 @@ export default () => (dispatch) => {
108
103
 
109
104
  for (let i = 0; i < records.length; i++) {
110
105
  const idx = (hash + i) % records.length
111
- if (records[idx].expires >= now && records[idx].timeout < now) {
106
+ if (records[idx].expires >= now) {
112
107
  record = records[idx]
113
108
  break
114
109
  }
@@ -116,13 +111,14 @@ export default () => (dispatch) => {
116
111
  }
117
112
 
118
113
  if (record == null) {
119
- records.sort(
114
+ // toSorted — balance:'hash' relies on the cached array's index order.
115
+ const sorted = records.toSorted(
120
116
  (a, b) => a.errored - b.errored || a.pending - b.pending || a.counter - b.counter,
121
117
  )
122
118
 
123
- for (let i = 0; i < records.length; i++) {
124
- if (records[i].expires >= now && records[i].timeout < now) {
125
- record = records[i]
119
+ for (let i = 0; i < sorted.length; i++) {
120
+ if (sorted[i].expires >= now) {
121
+ record = sorted[i]
126
122
  break
127
123
  }
128
124
  }
@@ -135,7 +131,15 @@ export default () => (dispatch) => {
135
131
  })
136
132
  }
137
133
 
138
- url.hostname = record.address
134
+ // Pre-emptive refresh when any record is past half its TTL — the
135
+ // in-flight request still uses the already-selected `record`; the
136
+ // refreshed records land in cache for the next request, smoothing
137
+ // out DNS lookup latency. `resolve()` dedupes via `promises`.
138
+ if (records.some((x) => x.expires < now + ttl / 2)) {
139
+ resolve(hostname, { ttl })
140
+ }
141
+
142
+ url.hostname = net.isIPv6(record.address) ? `[${record.address}]` : record.address
139
143
 
140
144
  record.counter++
141
145
  record.pending++
@@ -150,10 +154,6 @@ export default () => (dispatch) => {
150
154
  } else if (statusCode != null && statusCode >= 500) {
151
155
  record.errored++
152
156
  }
153
-
154
- if (err != null || statusCode >= 500) {
155
- record.timeout = getFastNow() + 10e3
156
- }
157
157
  }),
158
158
  )
159
159
  } catch (err) {
@@ -1,7 +1,7 @@
1
1
  import { DecoratorHandler } from '../utils.js'
2
2
 
3
- const kGlobalIndex = Symbol('globalIndex')
4
- const kGlobalArray = Symbol('globalArray')
3
+ const kGlobalIndex = Symbol.for('@nxtedition/nxt-undici#globalIndex')
4
+ const kGlobalArray = Symbol.for('@nxtedition/nxt-undici#globalArray')
5
5
 
6
6
  class Handler extends DecoratorHandler {
7
7
  #opts
@@ -35,7 +35,7 @@ class Handler extends DecoratorHandler {
35
35
  this.#logger.debug('upstream request started')
36
36
  this.#timing.created = this.#created + performance.timeOrigin
37
37
 
38
- this[kGlobalArray] = globalThis.__undici_requests ??= []
38
+ this[kGlobalArray] = globalThis[kGlobalArray] ??= []
39
39
  this[kGlobalIndex] = this[kGlobalArray].push(this) - 1
40
40
  }
41
41
 
@@ -42,6 +42,8 @@ class Handler extends DecoratorHandler {
42
42
  this.#decoder = new TextDecoder('utf-8')
43
43
  this.#body = ''
44
44
  }
45
+
46
+ return true
45
47
  }
46
48
 
47
49
  onData(chunk) {
@@ -177,11 +177,6 @@ class Handler extends DecoratorHandler {
177
177
  onData(chunk) {
178
178
  if (this.#pos != null) {
179
179
  this.#pos += chunk.byteLength
180
-
181
- if (this.#end != null && this.#pos > this.#end) {
182
- this.#maybeError(new Error('Response body exceeded Content-Range'))
183
- return false
184
- }
185
180
  }
186
181
 
187
182
  if (this.#statusCode < 400) {
@@ -202,10 +197,6 @@ class Handler extends DecoratorHandler {
202
197
  this.#trailers = trailers
203
198
 
204
199
  if (this.#statusCode < 400) {
205
- if (this.#end != null && this.#pos !== this.#end && this.#opts.method !== 'HEAD') {
206
- this.#maybeError(new Error('Response body length mismatch with Content-Range'))
207
- return
208
- }
209
200
  return super.onComplete(trailers)
210
201
  }
211
202
 
@@ -1,10 +1,10 @@
1
1
  import crypto from 'node:crypto'
2
- import { DecoratorHandler } from '../utils.js'
2
+ import { DecoratorHandler, parseContentRange } from '../utils.js'
3
3
 
4
4
  class Handler extends DecoratorHandler {
5
5
  #verifyOpts
6
6
  #contentMD5
7
- #contentLength
7
+ #expectedSize
8
8
  #hasher
9
9
  #pos = 0
10
10
 
@@ -16,7 +16,7 @@ class Handler extends DecoratorHandler {
16
16
 
17
17
  onConnect(abort) {
18
18
  this.#contentMD5 = null
19
- this.#contentLength = null
19
+ this.#expectedSize = null
20
20
  this.#hasher = null
21
21
  this.#pos = 0
22
22
 
@@ -25,7 +25,16 @@ class Handler extends DecoratorHandler {
25
25
 
26
26
  onHeaders(statusCode, headers, resume) {
27
27
  this.#contentMD5 = this.#verifyOpts.hash ? headers['content-md5'] : null
28
- this.#contentLength = this.#verifyOpts.size ? headers['content-length'] : null
28
+
29
+ if (this.#verifyOpts.size) {
30
+ const contentRange = parseContentRange(headers['content-range'])
31
+ if (contentRange?.start != null && contentRange?.end != null) {
32
+ this.#expectedSize = contentRange.end - contentRange.start
33
+ } else if (headers['content-length'] != null) {
34
+ this.#expectedSize = Number(headers['content-length'])
35
+ }
36
+ }
37
+
29
38
  this.#hasher = this.#contentMD5 != null ? crypto.createHash('md5') : null
30
39
 
31
40
  return super.onHeaders(statusCode, headers, resume)
@@ -35,16 +44,26 @@ class Handler extends DecoratorHandler {
35
44
  this.#pos += chunk.length
36
45
  this.#hasher?.update(chunk)
37
46
 
47
+ 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
+ )
54
+ return false
55
+ }
56
+
38
57
  return super.onData(chunk)
39
58
  }
40
59
 
41
60
  onComplete(trailers) {
42
61
  const contentMD5 = this.#hasher?.digest('base64')
43
62
 
44
- if (this.#contentLength != null && this.#pos !== Number(this.#contentLength)) {
63
+ if (this.#expectedSize != null && this.#pos !== this.#expectedSize) {
45
64
  super.onError(
46
- Object.assign(new Error('Response Content-Length mismatch'), {
47
- expected: Number(this.#contentLength),
65
+ Object.assign(new Error('Response body size mismatch'), {
66
+ expected: this.#expectedSize,
48
67
  actual: this.#pos,
49
68
  }),
50
69
  )
@@ -2,7 +2,7 @@ import { DatabaseSync } from 'node:sqlite'
2
2
  import { parseRangeHeader, getFastNow } from './utils.js'
3
3
 
4
4
  // Bump version when the URL key format or schema changes to invalidate old caches.
5
- const VERSION = 9
5
+ const VERSION = 10
6
6
 
7
7
  /** @typedef {{ purgeStale: () => void } } */
8
8
  const stores = new Set()
@@ -33,7 +33,6 @@ const stores = new Set()
33
33
  * etag?: string
34
34
  * cacheControlDirectives?: string
35
35
  * cachedAt: number
36
- * staleAt: number
37
36
  * deleteAt: number
38
37
  * }} SqliteStoreValue
39
38
  */
@@ -96,8 +95,7 @@ export class SqliteCacheStore {
96
95
  cacheControlDirectives TEXT NULL,
97
96
  etag TEXT NULL,
98
97
  vary TEXT NULL,
99
- cachedAt INTEGER NOT NULL,
100
- staleAt INTEGER NOT NULL
98
+ cachedAt INTEGER NOT NULL
101
99
  );
102
100
 
103
101
  CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_getValuesQuery ON cacheInterceptorV${VERSION}(url, method, start, deleteAt);
@@ -117,8 +115,7 @@ export class SqliteCacheStore {
117
115
  etag,
118
116
  cacheControlDirectives,
119
117
  vary,
120
- cachedAt,
121
- staleAt
118
+ cachedAt
122
119
  FROM cacheInterceptorV${VERSION}
123
120
  WHERE
124
121
  url = ?
@@ -143,9 +140,8 @@ export class SqliteCacheStore {
143
140
  etag,
144
141
  cacheControlDirectives,
145
142
  vary,
146
- cachedAt,
147
- staleAt
148
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
143
+ cachedAt
144
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
149
145
  `)
150
146
 
151
147
  this.#deleteExpiredValuesQuery = this.#db.prepare(
@@ -255,7 +251,6 @@ export class SqliteCacheStore {
255
251
  : null,
256
252
  vary: value.vary ? JSON.stringify(value.vary) : null,
257
253
  cachedAt: value.cachedAt,
258
- staleAt: value.staleAt,
259
254
  })
260
255
  }
261
256
 
@@ -286,7 +281,6 @@ export class SqliteCacheStore {
286
281
  cacheControlDirectives,
287
282
  vary,
288
283
  cachedAt,
289
- staleAt,
290
284
  } = this.#insertBatch[n++]
291
285
  this.#insertValueQuery.run(
292
286
  url,
@@ -302,7 +296,6 @@ export class SqliteCacheStore {
302
296
  cacheControlDirectives,
303
297
  vary,
304
298
  cachedAt,
305
- staleAt,
306
299
  )
307
300
  if ((n & 0xf) === 0 && performance.now() - startTime > 10) {
308
301
  break
@@ -463,7 +456,6 @@ function makeResult(value) {
463
456
  ? JSON.parse(value.cacheControlDirectives)
464
457
  : undefined,
465
458
  cachedAt: value.cachedAt,
466
- staleAt: value.staleAt,
467
459
  deleteAt: value.deleteAt,
468
460
  }
469
461
  }
@@ -503,7 +495,7 @@ function assertCacheValue(value) {
503
495
  throw new TypeError(`expected value to be object, got ${printType(value)}`)
504
496
  }
505
497
 
506
- for (const property of ['statusCode', 'cachedAt', 'staleAt', 'deleteAt']) {
498
+ for (const property of ['statusCode', 'cachedAt', 'deleteAt']) {
507
499
  if (typeof value[property] !== 'number') {
508
500
  throw new TypeError(
509
501
  `expected value.${property} to be number, got ${printType(value[property])} [${value[property]}]`,
package/lib/utils.js CHANGED
@@ -163,14 +163,6 @@ export function parseOrigin(url) {
163
163
  return url
164
164
  }
165
165
 
166
- export class AbortError extends Error {
167
- constructor(message) {
168
- super(message ?? 'The operation was aborted')
169
- this.code = 'ABORT_ERR'
170
- this.name = 'AbortError'
171
- }
172
- }
173
-
174
166
  export function isStream(obj) {
175
167
  return (
176
168
  obj && typeof obj === 'object' && typeof obj.pipe === 'function' && typeof obj.on === 'function'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/nxt-undici",
3
- "version": "7.3.18",
3
+ "version": "7.3.20",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",