undici 7.1.0 → 7.2.0

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/README.md CHANGED
@@ -281,17 +281,23 @@ stalls or deadlocks when running out of connections.
281
281
 
282
282
  ```js
283
283
  // Do
284
- const headers = await fetch(url)
285
- .then(async res => {
286
- for await (const chunk of res.body) {
287
- // force consumption of body
288
- }
289
- return res.headers
290
- })
284
+ const { body, headers } = await fetch(url);
285
+ for await (const chunk of body) {
286
+ // force consumption of body
287
+ }
291
288
 
292
289
  // Do not
293
- const headers = await fetch(url)
294
- .then(res => res.headers)
290
+ const { headers } = await fetch(url);
291
+ ```
292
+
293
+ The same applies for `request` too:
294
+ ```js
295
+ // Do
296
+ const { body, headers } = await request(url);
297
+ await res.body.dump(); // force consumption of body
298
+
299
+ // Do not
300
+ const { headers } = await request(url);
295
301
  ```
296
302
 
297
303
  However, if you want to get only headers, it might be better to use `HEAD` request method. Usage of this method will obviate the need for consumption or cancelling of the response body. See [MDN - HTTP - HTTP request methods - HEAD](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD) for more details.
@@ -445,6 +451,16 @@ and `undici.Agent`) which will enable the family autoselection algorithm when es
445
451
  * [__Robert Nagy__](https://github.com/ronag), <https://www.npmjs.com/~ronag>
446
452
  * [__Matthew Aitken__](https://github.com/KhafraDev), <https://www.npmjs.com/~khaf>
447
453
 
454
+ ## Long Term Support
455
+
456
+ Undici aligns with the Node.js LTS schedule. The following table shows the supported versions:
457
+
458
+ | Version | Node.js | End of Life |
459
+ |---------|-------------|-------------|
460
+ | 5.x | v18.x | 2024-04-30 |
461
+ | 6.x | v20.x v22.x | 2026-04-30 |
462
+ | 7.x | v24.x | 2027-04-30 |
463
+
448
464
  ## License
449
465
 
450
466
  MIT
@@ -15,6 +15,7 @@ Returns: `ProxyAgent`
15
15
  ### Parameter: `ProxyAgentOptions`
16
16
 
17
17
  Extends: [`AgentOptions`](/docs/docs/api/Agent.md#parameter-agentoptions)
18
+ > It ommits `AgentOptions#connect`.
18
19
 
19
20
  * **uri** `string | URL` (required) - The URI of the proxy server. This can be provided as a string, as an instance of the URL class, or as an object with a `uri` property of type string.
20
21
  If the `uri` is provided as a string or `uri` is an object with an `uri` property of type string, then it will be parsed into a `URL` object according to the [WHATWG URL Specification](https://url.spec.whatwg.org).
@@ -22,8 +23,8 @@ For detailed information on the parsing process and potential validation errors,
22
23
  * **token** `string` (optional) - It can be passed by a string of token for authentication.
23
24
  * **auth** `string` (**deprecated**) - Use token.
24
25
  * **clientFactory** `(origin: URL, opts: Object) => Dispatcher` (optional) - Default: `(origin, opts) => new Pool(origin, opts)`
25
- * **requestTls** `BuildOptions` (optional) - Options object passed when creating the underlying socket via the connector builder for the request. See [TLS](https://nodejs.org/api/tls.html#tlsconnectoptions-callback).
26
- * **proxyTls** `BuildOptions` (optional) - Options object passed when creating the underlying socket via the connector builder for the proxy server. See [TLS](https://nodejs.org/api/tls.html#tlsconnectoptions-callback).
26
+ * **requestTls** `BuildOptions` (optional) - Options object passed when creating the underlying socket via the connector builder for the request. It extends from [`Client#ConnectOptions`](/docs/docs/api/Client.md#parameter-connectoptions).
27
+ * **proxyTls** `BuildOptions` (optional) - Options object passed when creating the underlying socket via the connector builder for the proxy server. It extends from [`Client#ConnectOptions`](/docs/docs/api/Client.md#parameter-connectoptions).
27
28
 
28
29
  Examples:
29
30
 
@@ -35,6 +36,13 @@ const proxyAgent = new ProxyAgent('my.proxy.server')
35
36
  const proxyAgent = new ProxyAgent(new URL('my.proxy.server'))
36
37
  // or
37
38
  const proxyAgent = new ProxyAgent({ uri: 'my.proxy.server' })
39
+ // or
40
+ const proxyAgent = new ProxyAgent({
41
+ uri: new URL('my.proxy.server'),
42
+ proxyTls: {
43
+ signal: AbortSignal.timeout(1000)
44
+ }
45
+ })
38
46
  ```
39
47
 
40
48
  #### Example - Basic ProxyAgent instantiation
package/index.js CHANGED
@@ -49,15 +49,8 @@ module.exports.cacheStores = {
49
49
  MemoryCacheStore: require('./lib/cache/memory-cache-store')
50
50
  }
51
51
 
52
- try {
53
- const SqliteCacheStore = require('./lib/cache/sqlite-cache-store')
54
- module.exports.cacheStores.SqliteCacheStore = SqliteCacheStore
55
- } catch (err) {
56
- // Most likely node:sqlite was not present, since SqliteCacheStore is
57
- // optional, don't throw. Don't check specific error codes here because while
58
- // ERR_UNKNOWN_BUILTIN_MODULE is expected, users have seen other codes like
59
- // MODULE_NOT_FOUND
60
- }
52
+ const SqliteCacheStore = require('./lib/cache/sqlite-cache-store')
53
+ module.exports.cacheStores.SqliteCacheStore = SqliteCacheStore
61
54
 
62
55
  module.exports.buildConnector = buildConnector
63
56
  module.exports.errors = errors
@@ -1,9 +1,10 @@
1
1
  'use strict'
2
2
 
3
- const { DatabaseSync } = require('node:sqlite')
4
3
  const { Writable } = require('stream')
5
4
  const { assertCacheKey, assertCacheValue } = require('../util/cache.js')
6
5
 
6
+ let DatabaseSync
7
+
7
8
  const VERSION = 3
8
9
 
9
10
  // 2gb
@@ -101,6 +102,9 @@ module.exports = class SqliteCacheStore {
101
102
  }
102
103
  }
103
104
 
105
+ if (!DatabaseSync) {
106
+ DatabaseSync = require('node:sqlite').DatabaseSync
107
+ }
104
108
  this.#db = new DatabaseSync(opts?.location ?? ':memory:')
105
109
 
106
110
  this.#db.exec(`
@@ -30,6 +30,7 @@ const {
30
30
  kClosed,
31
31
  kBodyTimeout
32
32
  } = require('../core/symbols.js')
33
+ const { channels } = require('../core/diagnostics.js')
33
34
 
34
35
  const kOpenStreams = Symbol('open streams')
35
36
 
@@ -448,6 +449,14 @@ function writeH2 (client, request) {
448
449
 
449
450
  session.ref()
450
451
 
452
+ if (channels.sendHeaders.hasSubscribers) {
453
+ let header = ''
454
+ for (const key in headers) {
455
+ header += `${key}: ${headers[key]}\r\n`
456
+ }
457
+ channels.sendHeaders.publish({ request, headers: header, socket: session[kSocket] })
458
+ }
459
+
451
460
  // TODO(metcoder95): add support for sending trailers
452
461
  const shouldEndStream = method === 'GET' || method === 'HEAD' || body === null
453
462
  if (expectContinue) {
@@ -6,9 +6,17 @@ const {
6
6
  parseVaryHeader,
7
7
  isEtagUsable
8
8
  } = require('../util/cache')
9
+ const { parseHttpDate } = require('../util/date.js')
9
10
 
10
11
  function noop () {}
11
12
 
13
+ // Status codes that we can use some heuristics on to cache
14
+ const HEURISTICALLY_CACHEABLE_STATUS_CODES = [
15
+ 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501
16
+ ]
17
+
18
+ const MAX_RESPONSE_AGE = 2147483647000
19
+
12
20
  /**
13
21
  * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler
14
22
  *
@@ -68,17 +76,23 @@ class CacheHandler {
68
76
  this.#handler.onRequestUpgrade?.(controller, statusCode, headers, socket)
69
77
  }
70
78
 
79
+ /**
80
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller
81
+ * @param {number} statusCode
82
+ * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
83
+ * @param {string} statusMessage
84
+ */
71
85
  onResponseStart (
72
86
  controller,
73
87
  statusCode,
74
- headers,
88
+ resHeaders,
75
89
  statusMessage
76
90
  ) {
77
91
  const downstreamOnHeaders = () =>
78
92
  this.#handler.onResponseStart?.(
79
93
  controller,
80
94
  statusCode,
81
- headers,
95
+ resHeaders,
82
96
  statusMessage
83
97
  )
84
98
 
@@ -87,97 +101,113 @@ class CacheHandler {
87
101
  statusCode >= 200 &&
88
102
  statusCode <= 399
89
103
  ) {
90
- // https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-response
104
+ // Successful response to an unsafe method, delete it from cache
105
+ // https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-response
91
106
  try {
92
- this.#store.delete(this.#cacheKey).catch?.(noop)
107
+ this.#store.delete(this.#cacheKey)?.catch?.(noop)
93
108
  } catch {
94
109
  // Fail silently
95
110
  }
96
111
  return downstreamOnHeaders()
97
112
  }
98
113
 
99
- const cacheControlHeader = headers['cache-control']
100
- if (!cacheControlHeader && !headers['expires'] && !this.#cacheByDefault) {
101
- // Don't have the cache control header or the cache is full
114
+ const cacheControlHeader = resHeaders['cache-control']
115
+ const heuristicallyCacheable = resHeaders['last-modified'] && HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(statusCode)
116
+ if (
117
+ !cacheControlHeader &&
118
+ !resHeaders['expires'] &&
119
+ !heuristicallyCacheable &&
120
+ !this.#cacheByDefault
121
+ ) {
122
+ // Don't have anything to tell us this response is cachable and we're not
123
+ // caching by default
102
124
  return downstreamOnHeaders()
103
125
  }
104
126
 
105
127
  const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {}
106
- if (!canCacheResponse(this.#cacheType, statusCode, headers, cacheControlDirectives)) {
128
+ if (!canCacheResponse(this.#cacheType, statusCode, resHeaders, cacheControlDirectives)) {
107
129
  return downstreamOnHeaders()
108
130
  }
109
131
 
110
- const age = getAge(headers)
111
-
112
132
  const now = Date.now()
113
- const staleAt = determineStaleAt(this.#cacheType, now, headers, cacheControlDirectives) ?? this.#cacheByDefault
114
- if (staleAt) {
115
- let baseTime = now
116
- if (headers['date']) {
117
- const parsedDate = parseInt(headers['date'])
118
- const date = new Date(isNaN(parsedDate) ? headers['date'] : parsedDate)
119
- if (date instanceof Date && !isNaN(date)) {
120
- baseTime = date.getTime()
121
- }
122
- }
133
+ const resAge = resHeaders.age ? getAge(resHeaders.age) : undefined
134
+ if (resAge && resAge >= MAX_RESPONSE_AGE) {
135
+ // Response considered stale
136
+ return downstreamOnHeaders()
137
+ }
138
+
139
+ const resDate = typeof resHeaders.date === 'string'
140
+ ? parseHttpDate(resHeaders.date)
141
+ : undefined
142
+
143
+ const staleAt =
144
+ determineStaleAt(this.#cacheType, now, resAge, resHeaders, resDate, cacheControlDirectives) ??
145
+ this.#cacheByDefault
146
+ if (staleAt === undefined || (resAge && resAge > staleAt)) {
147
+ return downstreamOnHeaders()
148
+ }
123
149
 
124
- const absoluteStaleAt = staleAt + baseTime
150
+ const baseTime = resDate ? resDate.getTime() : now
151
+ const absoluteStaleAt = staleAt + baseTime
152
+ if (now >= absoluteStaleAt) {
153
+ // Response is already stale
154
+ return downstreamOnHeaders()
155
+ }
125
156
 
126
- if (now >= absoluteStaleAt || (age && age >= staleAt)) {
127
- // Response is already stale
157
+ let varyDirectives
158
+ if (this.#cacheKey.headers && resHeaders.vary) {
159
+ varyDirectives = parseVaryHeader(resHeaders.vary, this.#cacheKey.headers)
160
+ if (!varyDirectives) {
161
+ // Parse error
128
162
  return downstreamOnHeaders()
129
163
  }
164
+ }
130
165
 
131
- let varyDirectives
132
- if (this.#cacheKey.headers && headers.vary) {
133
- varyDirectives = parseVaryHeader(headers.vary, this.#cacheKey.headers)
134
- if (!varyDirectives) {
135
- // Parse error
136
- return downstreamOnHeaders()
137
- }
138
- }
166
+ const deleteAt = determineDeleteAt(baseTime, cacheControlDirectives, absoluteStaleAt)
167
+ const strippedHeaders = stripNecessaryHeaders(resHeaders, cacheControlDirectives)
168
+
169
+ /**
170
+ * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
171
+ */
172
+ const value = {
173
+ statusCode,
174
+ statusMessage,
175
+ headers: strippedHeaders,
176
+ vary: varyDirectives,
177
+ cacheControlDirectives,
178
+ cachedAt: resAge ? now - resAge : now,
179
+ staleAt: absoluteStaleAt,
180
+ deleteAt
181
+ }
139
182
 
140
- const deleteAt = determineDeleteAt(cacheControlDirectives, absoluteStaleAt)
141
- const strippedHeaders = stripNecessaryHeaders(headers, cacheControlDirectives)
183
+ if (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) {
184
+ value.etag = resHeaders.etag
185
+ }
142
186
 
143
- /**
144
- * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
145
- */
146
- const value = {
147
- statusCode,
148
- statusMessage,
149
- headers: strippedHeaders,
150
- vary: varyDirectives,
151
- cacheControlDirectives,
152
- cachedAt: age ? now - (age * 1000) : now,
153
- staleAt: absoluteStaleAt,
154
- deleteAt
155
- }
187
+ this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)
188
+ if (!this.#writeStream) {
189
+ return downstreamOnHeaders()
190
+ }
156
191
 
157
- if (typeof headers.etag === 'string' && isEtagUsable(headers.etag)) {
158
- value.etag = headers.etag
159
- }
192
+ const handler = this
193
+ this.#writeStream
194
+ .on('drain', () => controller.resume())
195
+ .on('error', function () {
196
+ // TODO (fix): Make error somehow observable?
197
+ handler.#writeStream = undefined
198
+
199
+ // Delete the value in case the cache store is holding onto state from
200
+ // the call to createWriteStream
201
+ handler.#store.delete(handler.#cacheKey)
202
+ })
203
+ .on('close', function () {
204
+ if (handler.#writeStream === this) {
205
+ handler.#writeStream = undefined
206
+ }
160
207
 
161
- this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)
162
-
163
- if (this.#writeStream) {
164
- const handler = this
165
- this.#writeStream
166
- .on('drain', () => controller.resume())
167
- .on('error', function () {
168
- // TODO (fix): Make error somehow observable?
169
- handler.#writeStream = undefined
170
- })
171
- .on('close', function () {
172
- if (handler.#writeStream === this) {
173
- handler.#writeStream = undefined
174
- }
175
-
176
- // TODO (fix): Should we resume even if was paused downstream?
177
- controller.resume()
178
- })
179
- }
180
- }
208
+ // TODO (fix): Should we resume even if was paused downstream?
209
+ controller.resume()
210
+ })
181
211
 
182
212
  return downstreamOnHeaders()
183
213
  }
@@ -207,18 +237,15 @@ class CacheHandler {
207
237
  *
208
238
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
209
239
  * @param {number} statusCode
210
- * @param {Record<string, string | string[]>} headers
240
+ * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
211
241
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
212
242
  */
213
- function canCacheResponse (cacheType, statusCode, headers, cacheControlDirectives) {
243
+ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives) {
214
244
  if (statusCode !== 200 && statusCode !== 307) {
215
245
  return false
216
246
  }
217
247
 
218
- if (
219
- cacheControlDirectives['no-cache'] === true ||
220
- cacheControlDirectives['no-store']
221
- ) {
248
+ if (cacheControlDirectives['no-store']) {
222
249
  return false
223
250
  }
224
251
 
@@ -227,13 +254,13 @@ function canCacheResponse (cacheType, statusCode, headers, cacheControlDirective
227
254
  }
228
255
 
229
256
  // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5
230
- if (headers.vary?.includes('*')) {
257
+ if (resHeaders.vary?.includes('*')) {
231
258
  return false
232
259
  }
233
260
 
234
261
  // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
235
- if (headers.authorization) {
236
- if (!cacheControlDirectives.public || typeof headers.authorization !== 'string') {
262
+ if (resHeaders.authorization) {
263
+ if (!cacheControlDirectives.public || typeof resHeaders.authorization !== 'string') {
237
264
  return false
238
265
  }
239
266
 
@@ -256,58 +283,77 @@ function canCacheResponse (cacheType, statusCode, headers, cacheControlDirective
256
283
  }
257
284
 
258
285
  /**
259
- * @param {Record<string, string | string[]>} headers
286
+ * @param {string | string[]} ageHeader
260
287
  * @returns {number | undefined}
261
288
  */
262
- function getAge (headers) {
263
- if (!headers.age) {
264
- return undefined
265
- }
289
+ function getAge (ageHeader) {
290
+ const age = parseInt(Array.isArray(ageHeader) ? ageHeader[0] : ageHeader)
266
291
 
267
- const age = parseInt(Array.isArray(headers.age) ? headers.age[0] : headers.age)
268
- if (isNaN(age) || age >= 2147483647) {
269
- return undefined
270
- }
271
-
272
- return age
292
+ return isNaN(age) ? undefined : age * 1000
273
293
  }
274
294
 
275
295
  /**
276
296
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
277
297
  * @param {number} now
278
- * @param {Record<string, string | string[]>} headers
298
+ * @param {number | undefined} age
299
+ * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
300
+ * @param {Date | undefined} responseDate
279
301
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
280
302
  *
281
- * @returns {number | undefined} time that the value is stale at or undefined if it shouldn't be cached
303
+ * @returns {number | undefined} time that the value is stale at in seconds or undefined if it shouldn't be cached
282
304
  */
283
- function determineStaleAt (cacheType, now, headers, cacheControlDirectives) {
305
+ function determineStaleAt (cacheType, now, age, resHeaders, responseDate, cacheControlDirectives) {
284
306
  if (cacheType === 'shared') {
285
307
  // Prioritize s-maxage since we're a shared cache
286
308
  // s-maxage > max-age > Expire
287
309
  // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3
288
310
  const sMaxAge = cacheControlDirectives['s-maxage']
289
- if (sMaxAge) {
290
- return sMaxAge * 1000
311
+ if (sMaxAge !== undefined) {
312
+ return sMaxAge > 0 ? sMaxAge * 1000 : undefined
291
313
  }
292
314
  }
293
315
 
294
316
  const maxAge = cacheControlDirectives['max-age']
295
- if (maxAge) {
296
- return maxAge * 1000
317
+ if (maxAge !== undefined) {
318
+ return maxAge > 0 ? maxAge * 1000 : undefined
297
319
  }
298
320
 
299
- if (headers.expires && typeof headers.expires === 'string') {
321
+ if (typeof resHeaders.expires === 'string') {
300
322
  // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3
301
- const expiresDate = new Date(headers.expires)
302
- if (expiresDate instanceof Date && Number.isFinite(expiresDate.valueOf())) {
323
+ const expiresDate = parseHttpDate(resHeaders.expires)
324
+ if (expiresDate) {
303
325
  if (now >= expiresDate.getTime()) {
304
326
  return undefined
305
327
  }
306
328
 
329
+ if (responseDate) {
330
+ if (responseDate >= expiresDate) {
331
+ return undefined
332
+ }
333
+
334
+ if (age !== undefined && age > (expiresDate - responseDate)) {
335
+ return undefined
336
+ }
337
+ }
338
+
307
339
  return expiresDate.getTime() - now
308
340
  }
309
341
  }
310
342
 
343
+ if (typeof resHeaders['last-modified'] === 'string') {
344
+ // https://www.rfc-editor.org/rfc/rfc9111.html#name-calculating-heuristic-fresh
345
+ const lastModified = new Date(resHeaders['last-modified'])
346
+ if (isValidDate(lastModified)) {
347
+ if (lastModified.getTime() >= now) {
348
+ return undefined
349
+ }
350
+
351
+ const responseAge = now - lastModified.getTime()
352
+
353
+ return responseAge * 0.1
354
+ }
355
+ }
356
+
311
357
  if (cacheControlDirectives.immutable) {
312
358
  // https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2
313
359
  return 31536000
@@ -317,10 +363,11 @@ function determineStaleAt (cacheType, now, headers, cacheControlDirectives) {
317
363
  }
318
364
 
319
365
  /**
366
+ * @param {number} now
320
367
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
321
368
  * @param {number} staleAt
322
369
  */
323
- function determineDeleteAt (cacheControlDirectives, staleAt) {
370
+ function determineDeleteAt (now, cacheControlDirectives, staleAt) {
324
371
  let staleWhileRevalidate = -Infinity
325
372
  let staleIfError = -Infinity
326
373
  let immutable = -Infinity
@@ -334,7 +381,7 @@ function determineDeleteAt (cacheControlDirectives, staleAt) {
334
381
  }
335
382
 
336
383
  if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity) {
337
- immutable = 31536000
384
+ immutable = now + 31536000000
338
385
  }
339
386
 
340
387
  return Math.max(staleAt, staleWhileRevalidate, staleIfError, immutable)
@@ -342,11 +389,11 @@ function determineDeleteAt (cacheControlDirectives, staleAt) {
342
389
 
343
390
  /**
344
391
  * Strips headers required to be removed in cached responses
345
- * @param {Record<string, string | string[]>} headers
392
+ * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
346
393
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
347
394
  * @returns {Record<string, string | string []>}
348
395
  */
349
- function stripNecessaryHeaders (headers, cacheControlDirectives) {
396
+ function stripNecessaryHeaders (resHeaders, cacheControlDirectives) {
350
397
  const headersToRemove = [
351
398
  'connection',
352
399
  'proxy-authenticate',
@@ -360,14 +407,14 @@ function stripNecessaryHeaders (headers, cacheControlDirectives) {
360
407
  'age'
361
408
  ]
362
409
 
363
- if (headers['connection']) {
364
- if (Array.isArray(headers['connection'])) {
410
+ if (resHeaders['connection']) {
411
+ if (Array.isArray(resHeaders['connection'])) {
365
412
  // connection: a
366
413
  // connection: b
367
- headersToRemove.push(...headers['connection'].map(header => header.trim()))
414
+ headersToRemove.push(...resHeaders['connection'].map(header => header.trim()))
368
415
  } else {
369
416
  // connection: a, b
370
- headersToRemove.push(...headers['connection'].split(',').map(header => header.trim()))
417
+ headersToRemove.push(...resHeaders['connection'].split(',').map(header => header.trim()))
371
418
  }
372
419
  }
373
420
 
@@ -381,13 +428,21 @@ function stripNecessaryHeaders (headers, cacheControlDirectives) {
381
428
 
382
429
  let strippedHeaders
383
430
  for (const headerName of headersToRemove) {
384
- if (headers[headerName]) {
385
- strippedHeaders ??= { ...headers }
431
+ if (resHeaders[headerName]) {
432
+ strippedHeaders ??= { ...resHeaders }
386
433
  delete strippedHeaders[headerName]
387
434
  }
388
435
  }
389
436
 
390
- return strippedHeaders ?? headers
437
+ return strippedHeaders ?? resHeaders
438
+ }
439
+
440
+ /**
441
+ * @param {Date} date
442
+ * @returns {boolean}
443
+ */
444
+ function isValidDate (date) {
445
+ return date instanceof Date && Number.isFinite(date.valueOf())
391
446
  }
392
447
 
393
448
  module.exports = CacheHandler
@@ -53,8 +53,7 @@ module.exports = class WrapHandler {
53
53
  onRequestUpgrade (controller, statusCode, headers, socket) {
54
54
  const rawHeaders = []
55
55
  for (const [key, val] of Object.entries(headers)) {
56
- // TODO (fix): What if val is Array
57
- rawHeaders.push(Buffer.from(key), Buffer.from(val))
56
+ rawHeaders.push(Buffer.from(key), Array.isArray(val) ? val.map(v => Buffer.from(v)) : Buffer.from(val))
58
57
  }
59
58
 
60
59
  this.#handler.onUpgrade?.(statusCode, rawHeaders, socket)
@@ -63,8 +62,7 @@ module.exports = class WrapHandler {
63
62
  onResponseStart (controller, statusCode, headers, statusMessage) {
64
63
  const rawHeaders = []
65
64
  for (const [key, val] of Object.entries(headers)) {
66
- // TODO (fix): What if val is Array
67
- rawHeaders.push(Buffer.from(key), Buffer.from(val))
65
+ rawHeaders.push(Buffer.from(key), Array.isArray(val) ? val.map(v => Buffer.from(v)) : Buffer.from(val))
68
66
  }
69
67
 
70
68
  if (this.#handler.onHeaders?.(statusCode, rawHeaders, () => controller.resume(), statusMessage) === false) {
@@ -81,8 +79,7 @@ module.exports = class WrapHandler {
81
79
  onResponseEnd (controller, trailers) {
82
80
  const rawTrailers = []
83
81
  for (const [key, val] of Object.entries(trailers)) {
84
- // TODO (fix): What if val is Array
85
- rawTrailers.push(Buffer.from(key), Buffer.from(val))
82
+ rawTrailers.push(Buffer.from(key), Array.isArray(val) ? val.map(v => Buffer.from(v)) : Buffer.from(val))
86
83
  }
87
84
 
88
85
  this.#handler.onComplete?.(rawTrailers)
@@ -103,10 +103,14 @@ function handleUncachedResponse (
103
103
  }
104
104
 
105
105
  /**
106
+ * @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler
107
+ * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts
106
108
  * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
107
109
  * @param {number} age
110
+ * @param {any} context
111
+ * @param {boolean} isStale
108
112
  */
109
- function sendCachedValue (handler, opts, result, age, context) {
113
+ function sendCachedValue (handler, opts, result, age, context, isStale) {
110
114
  // TODO (perf): Readable.from path can be optimized...
111
115
  const stream = util.isStream(result.body)
112
116
  ? result.body
@@ -160,8 +164,13 @@ function sendCachedValue (handler, opts, result, age, context) {
160
164
 
161
165
  // Add the age header
162
166
  // https://www.rfc-editor.org/rfc/rfc9111.html#name-age
163
- // TODO (fix): What if headers.age already exists?
164
- const headers = age != null ? { ...result.headers, age: String(age) } : result.headers
167
+ const headers = { ...result.headers, age: String(age) }
168
+
169
+ if (isStale) {
170
+ // Add warning header
171
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Warning
172
+ headers.warning = '110 - "response is stale"'
173
+ }
165
174
 
166
175
  handler.onResponseStart?.(controller, result.statusCode, headers, result.statusMessage)
167
176
 
@@ -225,8 +234,11 @@ function handleResult (
225
234
 
226
235
  let headers = {
227
236
  ...opts.headers,
228
- 'if-modified-since': new Date(result.cachedAt).toUTCString(),
229
- 'if-none-match': result.etag
237
+ 'if-modified-since': new Date(result.cachedAt).toUTCString()
238
+ }
239
+
240
+ if (result.etag) {
241
+ headers['if-none-match'] = result.etag
230
242
  }
231
243
 
232
244
  if (result.vary) {
@@ -245,7 +257,7 @@ function handleResult (
245
257
  new CacheRevalidationHandler(
246
258
  (success, context) => {
247
259
  if (success) {
248
- sendCachedValue(handler, opts, result, age, context)
260
+ sendCachedValue(handler, opts, result, age, context, true)
249
261
  } else if (util.isStream(result.body)) {
250
262
  result.body.on('error', () => {}).destroy()
251
263
  }
@@ -261,7 +273,7 @@ function handleResult (
261
273
  opts.body.on('error', () => {}).destroy()
262
274
  }
263
275
 
264
- sendCachedValue(handler, opts, result, age, null)
276
+ sendCachedValue(handler, opts, result, age, null, false)
265
277
  }
266
278
 
267
279
  /**
@@ -213,6 +213,10 @@ class DNSInstance {
213
213
  this.#records.set(origin.hostname, records)
214
214
  }
215
215
 
216
+ deleteRecords (origin) {
217
+ this.#records.delete(origin.hostname)
218
+ }
219
+
216
220
  getHandler (meta, opts) {
217
221
  return new DNSDispatchHandler(this, meta, opts)
218
222
  }
@@ -261,7 +265,7 @@ class DNSDispatchHandler extends DecoratorHandler {
261
265
  break
262
266
  }
263
267
  case 'ENOTFOUND':
264
- this.#state.deleteRecord(this.#origin)
268
+ this.#state.deleteRecords(this.#origin)
265
269
  // eslint-disable-next-line no-fallthrough
266
270
  default:
267
271
  super.onResponseError(controller, err)
@@ -358,7 +362,7 @@ module.exports = interceptorOpts => {
358
362
  servername: origin.hostname, // For SNI on TLS
359
363
  origin: newOrigin,
360
364
  headers: {
361
- host: origin.hostname,
365
+ host: origin.host,
362
366
  ...origDispatchOpts.headers
363
367
  }
364
368
  }
@@ -16,7 +16,7 @@ class ResponseErrorHandler extends DecoratorHandler {
16
16
  }
17
17
 
18
18
  #checkContentType (contentType) {
19
- return this.#contentType.indexOf(contentType) === 0
19
+ return (this.#contentType ?? '').indexOf(contentType) === 0
20
20
  }
21
21
 
22
22
  onRequestStart (controller, context) {
@@ -81,8 +81,8 @@ class ResponseErrorHandler extends DecoratorHandler {
81
81
  }
82
82
  }
83
83
 
84
- onResponseError (err) {
85
- super.onResponseError(err)
84
+ onResponseError (controller, err) {
85
+ super.onResponseError(controller, err)
86
86
  }
87
87
  }
88
88
 
package/lib/util/cache.js CHANGED
@@ -5,7 +5,6 @@ const {
5
5
  } = require('../core/util')
6
6
 
7
7
  /**
8
- *
9
8
  * @param {import('../../types/dispatcher.d.ts').default.DispatchOptions} opts
10
9
  */
11
10
  function makeCacheKey (opts) {
@@ -0,0 +1,259 @@
1
+ 'use strict'
2
+
3
+ const IMF_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
4
+ const IMF_SPACES = [4, 7, 11, 16, 25]
5
+ const IMF_MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
6
+ const IMF_COLONS = [19, 22]
7
+
8
+ const ASCTIME_SPACES = [3, 7, 10, 19]
9
+
10
+ const RFC850_DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
11
+
12
+ /**
13
+ * @see https://www.rfc-editor.org/rfc/rfc9110.html#name-date-time-formats
14
+ *
15
+ * @param {string} date
16
+ * @param {Date} [now]
17
+ * @returns {Date | undefined}
18
+ */
19
+ function parseHttpDate (date, now) {
20
+ // Sun, 06 Nov 1994 08:49:37 GMT ; IMF-fixdate
21
+ // Sun Nov 6 08:49:37 1994 ; ANSI C's asctime() format
22
+ // Sunday, 06-Nov-94 08:49:37 GMT ; obsolete RFC 850 format
23
+
24
+ date = date.toLowerCase()
25
+
26
+ switch (date[3]) {
27
+ case ',': return parseImfDate(date)
28
+ case ' ': return parseAscTimeDate(date)
29
+ default: return parseRfc850Date(date, now)
30
+ }
31
+ }
32
+
33
+ /**
34
+ * @see https://httpwg.org/specs/rfc9110.html#preferred.date.format
35
+ *
36
+ * @param {string} date
37
+ * @returns {Date | undefined}
38
+ */
39
+ function parseImfDate (date) {
40
+ if (date.length !== 29) {
41
+ return undefined
42
+ }
43
+
44
+ if (!date.endsWith('gmt')) {
45
+ // Unsupported timezone
46
+ return undefined
47
+ }
48
+
49
+ for (const spaceInx of IMF_SPACES) {
50
+ if (date[spaceInx] !== ' ') {
51
+ return undefined
52
+ }
53
+ }
54
+
55
+ for (const colonIdx of IMF_COLONS) {
56
+ if (date[colonIdx] !== ':') {
57
+ return undefined
58
+ }
59
+ }
60
+
61
+ const dayName = date.substring(0, 3)
62
+ if (!IMF_DAYS.includes(dayName)) {
63
+ return undefined
64
+ }
65
+
66
+ const dayString = date.substring(5, 7)
67
+ const day = Number.parseInt(dayString)
68
+ if (isNaN(day) || (day < 10 && dayString[0] !== '0')) {
69
+ // Not a number, 0, or it's less than 10 and didn't start with a 0
70
+ return undefined
71
+ }
72
+
73
+ const month = date.substring(8, 11)
74
+ const monthIdx = IMF_MONTHS.indexOf(month)
75
+ if (monthIdx === -1) {
76
+ return undefined
77
+ }
78
+
79
+ const year = Number.parseInt(date.substring(12, 16))
80
+ if (isNaN(year)) {
81
+ return undefined
82
+ }
83
+
84
+ const hourString = date.substring(17, 19)
85
+ const hour = Number.parseInt(hourString)
86
+ if (isNaN(hour) || (hour < 10 && hourString[0] !== '0')) {
87
+ return undefined
88
+ }
89
+
90
+ const minuteString = date.substring(20, 22)
91
+ const minute = Number.parseInt(minuteString)
92
+ if (isNaN(minute) || (minute < 10 && minuteString[0] !== '0')) {
93
+ return undefined
94
+ }
95
+
96
+ const secondString = date.substring(23, 25)
97
+ const second = Number.parseInt(secondString)
98
+ if (isNaN(second) || (second < 10 && secondString[0] !== '0')) {
99
+ return undefined
100
+ }
101
+
102
+ return new Date(Date.UTC(year, monthIdx, day, hour, minute, second))
103
+ }
104
+
105
+ /**
106
+ * @see https://httpwg.org/specs/rfc9110.html#obsolete.date.formats
107
+ *
108
+ * @param {string} date
109
+ * @returns {Date | undefined}
110
+ */
111
+ function parseAscTimeDate (date) {
112
+ // This is assumed to be in UTC
113
+
114
+ if (date.length !== 24) {
115
+ return undefined
116
+ }
117
+
118
+ for (const spaceIdx of ASCTIME_SPACES) {
119
+ if (date[spaceIdx] !== ' ') {
120
+ return undefined
121
+ }
122
+ }
123
+
124
+ const dayName = date.substring(0, 3)
125
+ if (!IMF_DAYS.includes(dayName)) {
126
+ return undefined
127
+ }
128
+
129
+ const month = date.substring(4, 7)
130
+ const monthIdx = IMF_MONTHS.indexOf(month)
131
+ if (monthIdx === -1) {
132
+ return undefined
133
+ }
134
+
135
+ const dayString = date.substring(8, 10)
136
+ const day = Number.parseInt(dayString)
137
+ if (isNaN(day) || (day < 10 && dayString[0] !== ' ')) {
138
+ return undefined
139
+ }
140
+
141
+ const hourString = date.substring(11, 13)
142
+ const hour = Number.parseInt(hourString)
143
+ if (isNaN(hour) || (hour < 10 && hourString[0] !== '0')) {
144
+ return undefined
145
+ }
146
+
147
+ const minuteString = date.substring(14, 16)
148
+ const minute = Number.parseInt(minuteString)
149
+ if (isNaN(minute) || (minute < 10 && minuteString[0] !== '0')) {
150
+ return undefined
151
+ }
152
+
153
+ const secondString = date.substring(17, 19)
154
+ const second = Number.parseInt(secondString)
155
+ if (isNaN(second) || (second < 10 && secondString[0] !== '0')) {
156
+ return undefined
157
+ }
158
+
159
+ const year = Number.parseInt(date.substring(20, 24))
160
+ if (isNaN(year)) {
161
+ return undefined
162
+ }
163
+
164
+ return new Date(Date.UTC(year, monthIdx, day, hour, minute, second))
165
+ }
166
+
167
+ /**
168
+ * @see https://httpwg.org/specs/rfc9110.html#obsolete.date.formats
169
+ *
170
+ * @param {string} date
171
+ * @param {Date} [now]
172
+ * @returns {Date | undefined}
173
+ */
174
+ function parseRfc850Date (date, now = new Date()) {
175
+ if (!date.endsWith('gmt')) {
176
+ // Unsupported timezone
177
+ return undefined
178
+ }
179
+
180
+ const commaIndex = date.indexOf(',')
181
+ if (commaIndex === -1) {
182
+ return undefined
183
+ }
184
+
185
+ if ((date.length - commaIndex - 1) !== 23) {
186
+ return undefined
187
+ }
188
+
189
+ const dayName = date.substring(0, commaIndex)
190
+ if (!RFC850_DAYS.includes(dayName)) {
191
+ return undefined
192
+ }
193
+
194
+ if (
195
+ date[commaIndex + 1] !== ' ' ||
196
+ date[commaIndex + 4] !== '-' ||
197
+ date[commaIndex + 8] !== '-' ||
198
+ date[commaIndex + 11] !== ' ' ||
199
+ date[commaIndex + 14] !== ':' ||
200
+ date[commaIndex + 17] !== ':' ||
201
+ date[commaIndex + 20] !== ' '
202
+ ) {
203
+ return undefined
204
+ }
205
+
206
+ const dayString = date.substring(commaIndex + 2, commaIndex + 4)
207
+ const day = Number.parseInt(dayString)
208
+ if (isNaN(day) || (day < 10 && dayString[0] !== '0')) {
209
+ // Not a number, or it's less than 10 and didn't start with a 0
210
+ return undefined
211
+ }
212
+
213
+ const month = date.substring(commaIndex + 5, commaIndex + 8)
214
+ const monthIdx = IMF_MONTHS.indexOf(month)
215
+ if (monthIdx === -1) {
216
+ return undefined
217
+ }
218
+
219
+ // As of this point year is just the decade (i.e. 94)
220
+ let year = Number.parseInt(date.substring(commaIndex + 9, commaIndex + 11))
221
+ if (isNaN(year)) {
222
+ return undefined
223
+ }
224
+
225
+ const currentYear = now.getUTCFullYear()
226
+ const currentDecade = currentYear % 100
227
+ const currentCentury = Math.floor(currentYear / 100)
228
+
229
+ if (year > currentDecade && year - currentDecade >= 50) {
230
+ // Over 50 years in future, go to previous century
231
+ year += (currentCentury - 1) * 100
232
+ } else {
233
+ year += currentCentury * 100
234
+ }
235
+
236
+ const hourString = date.substring(commaIndex + 12, commaIndex + 14)
237
+ const hour = Number.parseInt(hourString)
238
+ if (isNaN(hour) || (hour < 10 && hourString[0] !== '0')) {
239
+ return undefined
240
+ }
241
+
242
+ const minuteString = date.substring(commaIndex + 15, commaIndex + 17)
243
+ const minute = Number.parseInt(minuteString)
244
+ if (isNaN(minute) || (minute < 10 && minuteString[0] !== '0')) {
245
+ return undefined
246
+ }
247
+
248
+ const secondString = date.substring(commaIndex + 18, commaIndex + 20)
249
+ const second = Number.parseInt(secondString)
250
+ if (isNaN(second) || (second < 10 && secondString[0] !== '0')) {
251
+ return undefined
252
+ }
253
+
254
+ return new Date(Date.UTC(year, monthIdx, day, hour, minute, second))
255
+ }
256
+
257
+ module.exports = {
258
+ parseHttpDate
259
+ }
@@ -283,7 +283,7 @@ function parseMIMEType (input) {
283
283
 
284
284
  // 5. If position is past the end of input, then return
285
285
  // failure
286
- if (position.position > input.length) {
286
+ if (position.position >= input.length) {
287
287
  return 'failure'
288
288
  }
289
289
 
@@ -364,7 +364,7 @@ function parseMIMEType (input) {
364
364
  }
365
365
 
366
366
  // 6. If position is past the end of input, then break.
367
- if (position.position > input.length) {
367
+ if (position.position >= input.length) {
368
368
  break
369
369
  }
370
370
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "7.1.0",
3
+ "version": "7.2.0",
4
4
  "description": "An HTTP/1.1 client, written from scratch for Node.js",
5
5
  "homepage": "https://undici.nodejs.org",
6
6
  "bugs": {
@@ -69,7 +69,7 @@
69
69
  "lint:fix": "eslint --fix --cache",
70
70
  "test": "npm run test:javascript && cross-env NODE_V8_COVERAGE= npm run test:typescript",
71
71
  "test:javascript": "npm run test:javascript:no-jest && npm run test:jest",
72
- "test:javascript:no-jest": "npm run generate-pem && npm run test:unit && npm run test:node-fetch && npm run test:cache && npm run test:cache-interceptor && npm run test:interceptors && npm run test:fetch && npm run test:cookies && npm run test:eventsource && npm run test:wpt && npm run test:websocket && npm run test:node-test",
72
+ "test:javascript:no-jest": "npm run generate-pem && npm run test:unit && npm run test:node-fetch && npm run test:cache && npm run test:cache-interceptor && npm run test:interceptors && npm run test:fetch && npm run test:cookies && npm run test:eventsource && npm run test:wpt && npm run test:websocket && npm run test:node-test && npm run test:cache-tests",
73
73
  "test:javascript:without-intl": "npm run test:javascript:no-jest",
74
74
  "test:busboy": "borp -p \"test/busboy/*.js\"",
75
75
  "test:cache": "borp -p \"test/cache/*.js\"",
@@ -96,6 +96,7 @@
96
96
  "test:websocket:autobahn:report": "node test/autobahn/report.js",
97
97
  "test:wpt": "node test/wpt/start-fetch.mjs && node test/wpt/start-mimesniff.mjs && node test/wpt/start-xhr.mjs && node test/wpt/start-websockets.mjs && node test/wpt/start-cacheStorage.mjs && node test/wpt/start-eventsource.mjs",
98
98
  "test:wpt:withoutintl": "node test/wpt/start-fetch.mjs && node test/wpt/start-mimesniff.mjs && node test/wpt/start-xhr.mjs && node test/wpt/start-cacheStorage.mjs && node test/wpt/start-eventsource.mjs",
99
+ "test:cache-tests": "node test/cache-interceptor/cache-tests.mjs --ci",
99
100
  "coverage": "npm run coverage:clean && cross-env NODE_V8_COVERAGE=./coverage/tmp npm run test:javascript && npm run coverage:report",
100
101
  "coverage:ci": "npm run coverage:clean && cross-env NODE_V8_COVERAGE=./coverage/tmp npm run test:javascript && npm run coverage:report:ci",
101
102
  "coverage:clean": "node ./scripts/clean-coverage.js",
@@ -106,7 +107,7 @@
106
107
  "prepare": "husky && node ./scripts/platform-shell.js"
107
108
  },
108
109
  "devDependencies": {
109
- "@fastify/busboy": "3.0.0",
110
+ "@fastify/busboy": "3.1.0",
110
111
  "@matteo.collina/tspl": "^0.1.1",
111
112
  "@sinonjs/fake-timers": "^12.0.0",
112
113
  "@types/node": "^18.19.50",
@@ -121,7 +122,7 @@
121
122
  "https-pem": "^3.0.0",
122
123
  "husky": "^9.0.7",
123
124
  "jest": "^29.0.2",
124
- "neostandard": "^0.11.2",
125
+ "neostandard": "^0.12.0",
125
126
  "node-forge": "^1.3.1",
126
127
  "proxy": "^2.1.1",
127
128
  "tsd": "^0.31.2",
package/types/index.d.ts CHANGED
@@ -64,6 +64,7 @@ declare namespace Undici {
64
64
  const caches: typeof import('./cache').caches
65
65
  const interceptors: typeof import('./interceptors').default
66
66
  const cacheStores: {
67
- MemoryCacheStore: typeof import('./cache-interceptor').default.MemoryCacheStore
67
+ MemoryCacheStore: typeof import('./cache-interceptor').default.MemoryCacheStore,
68
+ SqliteCacheStore: typeof import('./cache-interceptor').default.SqliteCacheStore
68
69
  }
69
70
  }