@nxtedition/nxt-undici 7.4.2 → 7.4.3
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 +16 -1
- package/lib/interceptor/cache.js +431 -99
- package/lib/interceptor/proxy.js +4 -1
- package/lib/sqlite-cache-store.js +141 -15
- package/lib/utils.js +288 -8
- package/package.json +1 -2
package/lib/index.d.ts
CHANGED
|
@@ -134,8 +134,15 @@ export interface ProxyOptions {
|
|
|
134
134
|
export interface CacheOptions {
|
|
135
135
|
store?: CacheStore
|
|
136
136
|
maxEntrySize?: number
|
|
137
|
-
/** Upper bound on an entry's freshness lifetime, in seconds
|
|
137
|
+
/** Upper bound on an entry's freshness lifetime AND retention, in seconds
|
|
138
|
+
* (default 30 days). */
|
|
138
139
|
maxEntryTTL?: number
|
|
140
|
+
/** Opt-in RFC 9111 §4.2.2 heuristic freshness (10% of time since
|
|
141
|
+
* Last-Modified) for 200 responses without explicit expiration. */
|
|
142
|
+
heuristic?: boolean
|
|
143
|
+
/** Opt-in fallback freshness lifetime in seconds for 200 responses without
|
|
144
|
+
* any expiration information. */
|
|
145
|
+
defaultTTL?: number
|
|
139
146
|
}
|
|
140
147
|
|
|
141
148
|
export interface VerifyOptions {
|
|
@@ -245,6 +252,8 @@ export interface CacheValue {
|
|
|
245
252
|
etag?: string
|
|
246
253
|
vary?: Record<string, string | string[]>
|
|
247
254
|
cachedAt: number
|
|
255
|
+
/** Optional on set(): omitting it defaults to deleteAt (staleAt === deleteAt). */
|
|
256
|
+
staleAt?: number
|
|
248
257
|
deleteAt?: number
|
|
249
258
|
}
|
|
250
259
|
|
|
@@ -257,6 +266,7 @@ export interface CacheGetResult {
|
|
|
257
266
|
cacheControlDirectives?: Record<string, unknown>
|
|
258
267
|
vary?: Record<string, string | string[]>
|
|
259
268
|
cachedAt: number
|
|
269
|
+
staleAt: number
|
|
260
270
|
deleteAt: number
|
|
261
271
|
}
|
|
262
272
|
|
|
@@ -266,6 +276,9 @@ export interface CacheStore {
|
|
|
266
276
|
key: CacheKey,
|
|
267
277
|
value: CacheValue & { body: null | Buffer | Buffer[]; start: number; end: number },
|
|
268
278
|
): void
|
|
279
|
+
/** RFC 9111 §4.4 invalidation — optional; the cache interceptor
|
|
280
|
+
* feature-detects it and skips invalidation when absent. */
|
|
281
|
+
delete?(key: CacheKey): void
|
|
269
282
|
gc(): void
|
|
270
283
|
clear(): void
|
|
271
284
|
close(): void
|
|
@@ -338,6 +351,8 @@ export class SqliteCacheStore implements CacheStore {
|
|
|
338
351
|
key: CacheKey,
|
|
339
352
|
value: CacheValue & { body: null | Buffer | Buffer[]; start: number; end: number },
|
|
340
353
|
): void
|
|
354
|
+
/** RFC 9111 §4.4: invalidates every stored response for the key's URI. */
|
|
355
|
+
delete(key: CacheKey): void
|
|
341
356
|
gc(): void
|
|
342
357
|
clear(): void
|
|
343
358
|
close(): void
|
package/lib/interceptor/cache.js
CHANGED
|
@@ -1,18 +1,142 @@
|
|
|
1
1
|
import undici from '@nxtedition/undici'
|
|
2
|
+
import { stringify } from 'fast-querystring'
|
|
2
3
|
import {
|
|
3
4
|
DecoratorHandler,
|
|
4
|
-
getFastNow,
|
|
5
5
|
isStream,
|
|
6
6
|
parseCacheControl,
|
|
7
7
|
parseContentRange,
|
|
8
|
+
parseHeaders,
|
|
9
|
+
parseHttpDate,
|
|
8
10
|
} from '../utils.js'
|
|
11
|
+
import { isHopByHop } from './proxy.js'
|
|
9
12
|
import { SqliteCacheStore } from '../sqlite-cache-store.js'
|
|
10
13
|
|
|
11
14
|
let DEFAULT_STORE = null
|
|
12
15
|
const DEFAULT_MAX_ENTRY_SIZE = 128 * 1024
|
|
13
|
-
const DEFAULT_MAX_ENTRY_TTL = 30 * 24 * 3600
|
|
16
|
+
const DEFAULT_MAX_ENTRY_TTL = 30 * 24 * 3600 // seconds
|
|
17
|
+
// RFC 8246 'immutable' has no lifetime of its own; this is the customary
|
|
18
|
+
// 1-year default, capped by maxEntryTTL below.
|
|
19
|
+
const IMMUTABLE_LIFETIME = 31556952 // seconds
|
|
14
20
|
const NOOP = () => {}
|
|
15
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Explicit (or opt-in heuristic) freshness lifetime in seconds, or null when
|
|
24
|
+
* the response carries no usable expiration information. RFC 9111 §4.2.1
|
|
25
|
+
* priority for a shared cache: s-maxage > max-age > Expires. immutable
|
|
26
|
+
* (RFC 8246) and the opt-in heuristics only apply when no explicit lifetime
|
|
27
|
+
* is present. `explicit` marks origin-provided expiration — required for the
|
|
28
|
+
* stale-on-arrival store-and-revalidate path (never keep heuristically-stale
|
|
29
|
+
* content around for revalidation).
|
|
30
|
+
*
|
|
31
|
+
* @returns {{ lifetime: number, explicit: boolean } | null}
|
|
32
|
+
*/
|
|
33
|
+
function determineLifetime(
|
|
34
|
+
statusCode,
|
|
35
|
+
headers,
|
|
36
|
+
cacheControlDirectives,
|
|
37
|
+
{ heuristic, defaultTTL },
|
|
38
|
+
now,
|
|
39
|
+
) {
|
|
40
|
+
const explicit = cacheControlDirectives['s-maxage'] ?? cacheControlDirectives['max-age']
|
|
41
|
+
if (explicit != null) {
|
|
42
|
+
return { lifetime: explicit, explicit: true }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (headers.expires != null) {
|
|
46
|
+
// RFC 9111 §5.3: an invalid Expires (notably `Expires: 0`) means already
|
|
47
|
+
// expired — the parse failure must surface as lifetime 0, not fall through
|
|
48
|
+
// to heuristics. Arrays (duplicated, potentially conflicting Expires field
|
|
49
|
+
// lines) are treated the same way.
|
|
50
|
+
const expires = typeof headers.expires === 'string' ? parseHttpDate(headers.expires) : undefined
|
|
51
|
+
if (!expires) {
|
|
52
|
+
return { lifetime: 0, explicit: true }
|
|
53
|
+
}
|
|
54
|
+
const date = typeof headers.date === 'string' ? parseHttpDate(headers.date) : undefined
|
|
55
|
+
return {
|
|
56
|
+
lifetime: Math.floor((expires.getTime() - (date ? date.getTime() : now)) / 1000),
|
|
57
|
+
explicit: true,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (cacheControlDirectives.immutable) {
|
|
62
|
+
return { lifetime: IMMUTABLE_LIFETIME, explicit: false }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Heuristic freshness and defaultTTL are per-request client opt-ins and
|
|
66
|
+
// deliberately restricted to plain 200s — heuristically extending 206/307
|
|
67
|
+
// would cache partials and temporary redirects without origin consent.
|
|
68
|
+
if (statusCode === 200) {
|
|
69
|
+
if (heuristic && typeof headers['last-modified'] === 'string') {
|
|
70
|
+
// RFC 9111 §4.2.2 suggested heuristic: 10% of time since Last-Modified.
|
|
71
|
+
// §4.2.2 forbids heuristics when an explicit expiration exists; Expires
|
|
72
|
+
// was handled (including the invalid form) above, so this is reached
|
|
73
|
+
// only when none does.
|
|
74
|
+
const lastModified = parseHttpDate(headers['last-modified'])
|
|
75
|
+
if (lastModified && lastModified.getTime() < now) {
|
|
76
|
+
return { lifetime: Math.floor((now - lastModified.getTime()) / 10 / 1000), explicit: false }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (typeof defaultTTL === 'number' && defaultTTL > 0) {
|
|
80
|
+
return { lifetime: defaultTTL, explicit: false }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Corrected initial age in whole seconds per RFC 9111 §4.2.3 (simplified):
|
|
89
|
+
* the larger of the Age header and the apparent age (receipt time minus the
|
|
90
|
+
* origin Date). A response relayed through intermediaries that don't add Age
|
|
91
|
+
* would otherwise get an over-extended TTL and be served stale.
|
|
92
|
+
*/
|
|
93
|
+
function determineAge(headers, now) {
|
|
94
|
+
const rawAge = headers.age
|
|
95
|
+
// A duplicated Age header arrives as an array; take the first value.
|
|
96
|
+
const rawAgeValue = Array.isArray(rawAge) ? rawAge[0] : rawAge
|
|
97
|
+
// RFC 9111 §5.1 Age is delta-seconds (1*DIGIT): require a pure integer so a
|
|
98
|
+
// malformed value like "5junk" isn't parseInt-coerced to 5 and used to
|
|
99
|
+
// backdate cachedAt / extend staleness.
|
|
100
|
+
const age =
|
|
101
|
+
typeof rawAgeValue === 'string' && /^\d+$/.test(rawAgeValue.trim())
|
|
102
|
+
? parseInt(rawAgeValue, 10)
|
|
103
|
+
: 0
|
|
104
|
+
const date = typeof headers.date === 'string' ? parseHttpDate(headers.date) : undefined
|
|
105
|
+
const apparentAge = date ? Math.max(0, Math.floor((now - date.getTime()) / 1000)) : 0
|
|
106
|
+
return Math.max(age, apparentAge)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Computes the entry's absolute times, or null when it shouldn't be stored.
|
|
111
|
+
*
|
|
112
|
+
* cachedAt is backdated by the corrected initial age so all downstream age
|
|
113
|
+
* math (served Age header, freshness checks) reduces to `now - cachedAt`; the
|
|
114
|
+
* origin Age header is stripped before storing to match.
|
|
115
|
+
*
|
|
116
|
+
* This cache does not retain entries past freshness for revalidation, so
|
|
117
|
+
* deleteAt == staleAt: an entry is dropped by the store as soon as it goes
|
|
118
|
+
* stale, and the read path never serves a stale entry. (The staleAt column
|
|
119
|
+
* exists for forward compatibility with revalidation.) Everything is capped
|
|
120
|
+
* by maxEntryTTL, measured from the (backdated) cachedAt.
|
|
121
|
+
*/
|
|
122
|
+
function computeEntryTimes(lifetime, age, maxEntryTTL, now) {
|
|
123
|
+
if (!Number.isFinite(lifetime)) {
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const freshness = Math.min(lifetime, maxEntryTTL) // seconds
|
|
128
|
+
if (freshness - age <= 0) {
|
|
129
|
+
// Stale on arrival — not worth storing.
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const cachedAt = now - age * 1000
|
|
134
|
+
const staleAt = cachedAt + freshness * 1000
|
|
135
|
+
const deleteAt = staleAt
|
|
136
|
+
|
|
137
|
+
return { cachedAt, staleAt, deleteAt }
|
|
138
|
+
}
|
|
139
|
+
|
|
16
140
|
class CacheHandler extends DecoratorHandler {
|
|
17
141
|
#key
|
|
18
142
|
#value
|
|
@@ -20,8 +144,10 @@ class CacheHandler extends DecoratorHandler {
|
|
|
20
144
|
#logger
|
|
21
145
|
#maxEntrySize
|
|
22
146
|
#maxEntryTTL
|
|
147
|
+
#heuristic
|
|
148
|
+
#defaultTTL
|
|
23
149
|
|
|
24
|
-
constructor(key, { store, logger, handler, maxEntrySize, maxEntryTTL }) {
|
|
150
|
+
constructor(key, { store, logger, handler, maxEntrySize, maxEntryTTL, heuristic, defaultTTL }) {
|
|
25
151
|
super(handler)
|
|
26
152
|
|
|
27
153
|
this.#key = key
|
|
@@ -30,6 +156,8 @@ class CacheHandler extends DecoratorHandler {
|
|
|
30
156
|
this.#store = store
|
|
31
157
|
this.#maxEntrySize = maxEntrySize ?? store.maxEntrySize ?? DEFAULT_MAX_ENTRY_SIZE
|
|
32
158
|
this.#maxEntryTTL = maxEntryTTL ?? store.maxEntryTTL ?? DEFAULT_MAX_ENTRY_TTL
|
|
159
|
+
this.#heuristic = heuristic ?? false
|
|
160
|
+
this.#defaultTTL = defaultTTL ?? null
|
|
33
161
|
}
|
|
34
162
|
|
|
35
163
|
onConnect(abort) {
|
|
@@ -94,11 +222,30 @@ class CacheHandler extends DecoratorHandler {
|
|
|
94
222
|
|
|
95
223
|
const cacheControlDirectives = parseCacheControl(headers['cache-control']) ?? {}
|
|
96
224
|
|
|
97
|
-
|
|
98
|
-
|
|
225
|
+
// RFC 9111 §3.5: a shared cache may store a response to a request with
|
|
226
|
+
// Authorization only when the response explicitly allows it: public,
|
|
227
|
+
// s-maxage, or must-revalidate (safe because such entries are never
|
|
228
|
+
// served stale without successful revalidation). Must stay in lockstep
|
|
229
|
+
// with the serve-side gate in the interceptor below. A duplicated
|
|
230
|
+
// (array) authorization header is refused outright.
|
|
231
|
+
const authorization = this.#key.headers.authorization
|
|
232
|
+
if (authorization != null) {
|
|
233
|
+
if (
|
|
234
|
+
typeof authorization !== 'string' ||
|
|
235
|
+
!(
|
|
236
|
+
cacheControlDirectives.public === true ||
|
|
237
|
+
cacheControlDirectives['s-maxage'] != null ||
|
|
238
|
+
cacheControlDirectives['must-revalidate'] === true
|
|
239
|
+
)
|
|
240
|
+
) {
|
|
241
|
+
return super.onHeaders(statusCode, headers, resume)
|
|
242
|
+
}
|
|
99
243
|
}
|
|
100
244
|
|
|
101
|
-
|
|
245
|
+
// Unqualified private forbids shared-cache storage entirely; the
|
|
246
|
+
// qualified form (private="field") only forbids storing the listed
|
|
247
|
+
// fields, which are stripped below (RFC 9111 §5.2.2.7).
|
|
248
|
+
if (cacheControlDirectives['no-store'] || cacheControlDirectives.private === true) {
|
|
102
249
|
return super.onHeaders(statusCode, headers, resume)
|
|
103
250
|
}
|
|
104
251
|
|
|
@@ -113,11 +260,14 @@ class CacheHandler extends DecoratorHandler {
|
|
|
113
260
|
if (
|
|
114
261
|
cacheControlDirectives['must-revalidate'] ||
|
|
115
262
|
cacheControlDirectives['proxy-revalidate'] ||
|
|
116
|
-
cacheControlDirectives['stale-while-revalidate'] ||
|
|
117
|
-
cacheControlDirectives['stale-if-error'] ||
|
|
118
|
-
cacheControlDirectives['no-cache']
|
|
263
|
+
cacheControlDirectives['stale-while-revalidate'] != null ||
|
|
264
|
+
cacheControlDirectives['stale-if-error'] != null ||
|
|
265
|
+
cacheControlDirectives['no-cache'] === true
|
|
119
266
|
) {
|
|
120
|
-
//
|
|
267
|
+
// These directives require origin revalidation, which this cache does
|
|
268
|
+
// not yet perform — so the responses are not stored (a follow-up adds
|
|
269
|
+
// conditional revalidation and turns these into stored-and-validated
|
|
270
|
+
// entries).
|
|
121
271
|
return super.onHeaders(statusCode, headers, resume)
|
|
122
272
|
}
|
|
123
273
|
|
|
@@ -144,26 +294,23 @@ class CacheHandler extends DecoratorHandler {
|
|
|
144
294
|
}
|
|
145
295
|
}
|
|
146
296
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
cacheControlDirectives
|
|
153
|
-
|
|
154
|
-
|
|
297
|
+
const now = Date.now()
|
|
298
|
+
|
|
299
|
+
const lifetimeInfo = determineLifetime(
|
|
300
|
+
statusCode,
|
|
301
|
+
headers,
|
|
302
|
+
cacheControlDirectives,
|
|
303
|
+
{ heuristic: this.#heuristic, defaultTTL: this.#defaultTTL },
|
|
304
|
+
now,
|
|
155
305
|
)
|
|
156
|
-
if (
|
|
306
|
+
if (lifetimeInfo == null) {
|
|
157
307
|
return super.onHeaders(statusCode, headers, resume)
|
|
158
308
|
}
|
|
159
309
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const lifetime = Math.min(ttl, this.#maxEntryTTL) - (Number.isFinite(age) && age > 0 ? age : 0)
|
|
165
|
-
if (lifetime <= 0) {
|
|
166
|
-
// Already stale on arrival — not worth caching.
|
|
310
|
+
const etag = typeof headers.etag === 'string' && isEtagUsable(headers.etag) ? headers.etag : ''
|
|
311
|
+
const age = determineAge(headers, now)
|
|
312
|
+
const times = computeEntryTimes(lifetimeInfo.lifetime, age, this.#maxEntryTTL, now)
|
|
313
|
+
if (times == null) {
|
|
167
314
|
return super.onHeaders(statusCode, headers, resume)
|
|
168
315
|
}
|
|
169
316
|
|
|
@@ -175,7 +322,6 @@ class CacheHandler extends DecoratorHandler {
|
|
|
175
322
|
const end = contentRange ? contentRange.end : this.#key.method === 'HEAD' ? 0 : contentLength
|
|
176
323
|
|
|
177
324
|
if (end == null || end - start <= this.#maxEntrySize) {
|
|
178
|
-
const cachedAt = Date.now()
|
|
179
325
|
// Snapshot the headers: the same object is delivered downstream to the
|
|
180
326
|
// caller (request() resolves with it before the body finishes), and the
|
|
181
327
|
// entry isn't serialized to the store until onComplete. Without a copy,
|
|
@@ -188,23 +334,58 @@ class CacheHandler extends DecoratorHandler {
|
|
|
188
334
|
// plain `{}` a header literally named `__proto__` would hit the
|
|
189
335
|
// Object.prototype setter instead of becoming a data property (silent
|
|
190
336
|
// drop / prototype-pollution vector).
|
|
337
|
+
//
|
|
338
|
+
// Stripped while copying (RFC 9111 §3.1): hop-by-hop fields, fields
|
|
339
|
+
// listed in the Connection header, fields named by qualified
|
|
340
|
+
// no-cache=/private= directives (§5.2.2.4/§5.2.2.7), and Age — cachedAt
|
|
341
|
+
// is backdated by the corrected initial age, so the served Age is fully
|
|
342
|
+
// recomputed and a stored Age would double-count.
|
|
343
|
+
const excludedHeaders = new Set(['age'])
|
|
344
|
+
const connection = headers.connection
|
|
345
|
+
if (typeof connection === 'string') {
|
|
346
|
+
for (const name of connection.split(',')) {
|
|
347
|
+
excludedHeaders.add(name.trim().toLowerCase())
|
|
348
|
+
}
|
|
349
|
+
} else if (Array.isArray(connection)) {
|
|
350
|
+
for (const line of connection) {
|
|
351
|
+
for (const name of `${line}`.split(',')) {
|
|
352
|
+
excludedHeaders.add(name.trim().toLowerCase())
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (Array.isArray(cacheControlDirectives['no-cache'])) {
|
|
357
|
+
for (const name of cacheControlDirectives['no-cache']) {
|
|
358
|
+
excludedHeaders.add(name)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (Array.isArray(cacheControlDirectives.private)) {
|
|
362
|
+
for (const name of cacheControlDirectives.private) {
|
|
363
|
+
excludedHeaders.add(name)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
191
367
|
const storedHeaders = Object.create(null)
|
|
192
368
|
for (const name of Object.keys(headers)) {
|
|
369
|
+
if (isHopByHop(name) || excludedHeaders.has(name.toLowerCase())) {
|
|
370
|
+
continue
|
|
371
|
+
}
|
|
193
372
|
const val = headers[name]
|
|
194
373
|
storedHeaders[name] = Array.isArray(val) ? val.slice() : val
|
|
195
374
|
}
|
|
375
|
+
|
|
196
376
|
this.#value = {
|
|
197
377
|
body: [],
|
|
198
378
|
start,
|
|
199
379
|
end,
|
|
200
|
-
|
|
380
|
+
cachedAt: times.cachedAt,
|
|
381
|
+
staleAt: times.staleAt,
|
|
382
|
+
deleteAt: times.deleteAt,
|
|
201
383
|
statusCode,
|
|
202
384
|
statusMessage: '',
|
|
203
385
|
headers: storedHeaders,
|
|
204
386
|
cacheControlDirectives,
|
|
205
|
-
etag
|
|
387
|
+
etag,
|
|
206
388
|
vary,
|
|
207
|
-
cachedAt,
|
|
208
389
|
// Handler state.
|
|
209
390
|
size: 0,
|
|
210
391
|
}
|
|
@@ -246,24 +427,114 @@ class CacheHandler extends DecoratorHandler {
|
|
|
246
427
|
}
|
|
247
428
|
}
|
|
248
429
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
430
|
+
/**
|
|
431
|
+
* RFC 9111 §4.4: a non-error response to an unsafe method invalidates the
|
|
432
|
+
* stored entries for the target URI and any same-origin Location /
|
|
433
|
+
* Content-Location URIs (undici PR #5514). Cross-origin targets are skipped —
|
|
434
|
+
* honoring an attacker-influenced Location against another origin's entries
|
|
435
|
+
* would be a cache-poisoning vector.
|
|
436
|
+
*/
|
|
437
|
+
class InvalidationHandler extends DecoratorHandler {
|
|
438
|
+
#key
|
|
439
|
+
#store
|
|
440
|
+
#logger
|
|
441
|
+
|
|
442
|
+
constructor(key, { store, logger, handler }) {
|
|
443
|
+
super(handler)
|
|
444
|
+
this.#key = key
|
|
445
|
+
this.#store = store
|
|
446
|
+
this.#logger = logger
|
|
252
447
|
}
|
|
253
448
|
|
|
254
|
-
|
|
255
|
-
|
|
449
|
+
onHeaders(statusCode, headers, resume) {
|
|
450
|
+
if (statusCode >= 200 && statusCode <= 399) {
|
|
451
|
+
// Invalidation failures must never break the actual response. Deletes
|
|
452
|
+
// are idempotent, so a retry re-driving onHeaders is harmless.
|
|
453
|
+
try {
|
|
454
|
+
this.#invalidate(headers)
|
|
455
|
+
} catch (err) {
|
|
456
|
+
if (err.message === 'database is locked') {
|
|
457
|
+
this.#logger?.debug({ err }, 'failed to invalidate cache entry')
|
|
458
|
+
} else {
|
|
459
|
+
this.#logger?.error({ err }, 'failed to invalidate cache entry')
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return super.onHeaders(statusCode, headers, resume)
|
|
256
464
|
}
|
|
257
465
|
|
|
258
|
-
|
|
466
|
+
#invalidate(headers) {
|
|
467
|
+
this.#store.delete(this.#key)
|
|
259
468
|
|
|
469
|
+
const invalidated = new Set([this.#key.path])
|
|
470
|
+
let base
|
|
471
|
+
for (const name of ['location', 'content-location']) {
|
|
472
|
+
let value = headers[name]
|
|
473
|
+
if (Array.isArray(value)) {
|
|
474
|
+
value = value[0]
|
|
475
|
+
}
|
|
476
|
+
if (typeof value !== 'string' || value === '') {
|
|
477
|
+
continue
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
base ??= new URL(this.#key.path, this.#key.origin)
|
|
481
|
+
let target
|
|
482
|
+
try {
|
|
483
|
+
target = new URL(value, base)
|
|
484
|
+
} catch {
|
|
485
|
+
continue
|
|
486
|
+
}
|
|
487
|
+
if (target.origin !== base.origin) {
|
|
488
|
+
continue
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const path = target.pathname + target.search
|
|
492
|
+
if (!invalidated.has(path)) {
|
|
493
|
+
invalidated.add(path)
|
|
494
|
+
this.#store.delete({ ...this.#key, path })
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function getStore(opts) {
|
|
501
|
+
return opts.cache.store ?? (DEFAULT_STORE ??= new SqliteCacheStore({ location: ':memory:' }))
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function tryGetEntry(store, key, logger) {
|
|
505
|
+
try {
|
|
506
|
+
return store.get(key)
|
|
507
|
+
} catch (err) {
|
|
508
|
+
if (err.message === 'database is locked') {
|
|
509
|
+
// Database is busy. We don't bother trying again...
|
|
510
|
+
logger?.debug({ err }, 'failed to get cache entry')
|
|
511
|
+
} else {
|
|
512
|
+
logger?.error({ err }, 'failed to get cache entry')
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Builds the cache key shared by the get and set paths.
|
|
519
|
+
*/
|
|
520
|
+
function makeKey(opts) {
|
|
260
521
|
// Build the key the same way for lookups and stores: makeCacheKey
|
|
261
522
|
// stringifies the origin (e.g. URL objects), so using raw opts on the get
|
|
262
523
|
// path while the set path normalizes would make the cache permanently miss.
|
|
263
|
-
|
|
524
|
+
// The flat name/value array form of opts.headers (legal at the undici
|
|
525
|
+
// client level) makes makeCacheKey throw — normalize it through
|
|
526
|
+
// parseHeaders first (which also lowercases the names). Header names are
|
|
527
|
+
// caller-controlled, so parse into a null-prototype target: a `__proto__`
|
|
528
|
+
// name on a plain `{}` would hit the Object.prototype setter instead of
|
|
529
|
+
// becoming a data property.
|
|
530
|
+
const key = undici.util.cache.makeCacheKey(
|
|
531
|
+
Array.isArray(opts.headers)
|
|
532
|
+
? { ...opts, headers: parseHeaders(opts.headers, Object.create(null)) }
|
|
533
|
+
: opts,
|
|
534
|
+
)
|
|
264
535
|
|
|
265
536
|
// makeCacheKey preserves request header names verbatim. Vary selector names
|
|
266
|
-
// are lowercased (in
|
|
537
|
+
// are lowercased (in CacheHandler and matchesValue), so lowercase the key's
|
|
267
538
|
// header names once here — the same key feeds both the get and set paths, so
|
|
268
539
|
// this keeps Vary matching symmetric even when a caller supplies non-lowercase
|
|
269
540
|
// header names (the standalone interceptors.cache() composition; the wrapped
|
|
@@ -279,6 +550,64 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
279
550
|
key.headers = lower
|
|
280
551
|
}
|
|
281
552
|
|
|
553
|
+
// The vendored makeCacheKey ignores opts.query. The wrapped pipeline is
|
|
554
|
+
// immune (the query interceptor rewrites path before the cache sees it),
|
|
555
|
+
// but a standalone interceptors.cache() composition would silently collide
|
|
556
|
+
// distinct query strings onto one entry and serve the wrong response
|
|
557
|
+
// (undici issue #4209 / PR #5081) — fold the query into the key path.
|
|
558
|
+
if (
|
|
559
|
+
opts.query &&
|
|
560
|
+
typeof key.path === 'string' &&
|
|
561
|
+
!key.path.includes('?') &&
|
|
562
|
+
!key.path.includes('#')
|
|
563
|
+
) {
|
|
564
|
+
const qs = stringify(opts.query)
|
|
565
|
+
if (qs) {
|
|
566
|
+
key.path = `${key.path || '/'}?${qs}`
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return key
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function cacheOptsOf(opts) {
|
|
574
|
+
return {
|
|
575
|
+
maxEntrySize: opts.cache.maxEntrySize,
|
|
576
|
+
maxEntryTTL: opts.cache.maxEntryTTL,
|
|
577
|
+
heuristic: opts.cache.heuristic,
|
|
578
|
+
defaultTTL: opts.cache.defaultTTL,
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export default () => (dispatch) => (opts, handler) => {
|
|
583
|
+
if (!opts.cache || opts.upgrade) {
|
|
584
|
+
return dispatch(opts, handler)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (opts.method !== 'GET' && opts.method !== 'HEAD') {
|
|
588
|
+
// RFC 9110 §9.2.1: OPTIONS and TRACE are safe — never cached, but they
|
|
589
|
+
// must not invalidate either. Every other method (POST/PUT/DELETE/...)
|
|
590
|
+
// invalidates the target URI on a non-error response (RFC 9111 §4.4).
|
|
591
|
+
if (opts.method === 'OPTIONS' || opts.method === 'TRACE') {
|
|
592
|
+
return dispatch(opts, handler)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const store = getStore(opts)
|
|
596
|
+
if (typeof store.delete !== 'function') {
|
|
597
|
+
// User-supplied store without invalidation support.
|
|
598
|
+
return dispatch(opts, handler)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return dispatch(
|
|
602
|
+
opts,
|
|
603
|
+
new InvalidationHandler(makeKey(opts), { store, logger: opts.logger, handler }),
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// TODO (fix): enable range requests
|
|
608
|
+
|
|
609
|
+
const key = makeKey(opts)
|
|
610
|
+
|
|
282
611
|
// All request-directive and conditional-header guards below MUST read from
|
|
283
612
|
// the lowercased key.headers, not raw opts.headers — otherwise a caller
|
|
284
613
|
// supplying capitalized names (e.g. `Authorization`, `Cache-Control` via the
|
|
@@ -287,64 +616,74 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
287
616
|
// cached response to an authorized request (RFC 9111 §3.5).
|
|
288
617
|
const headers = key.headers ?? {}
|
|
289
618
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
// keeping the raw-string directive checks below working.
|
|
293
|
-
let rawCacheControl = headers['cache-control']
|
|
294
|
-
if (Array.isArray(rawCacheControl)) {
|
|
295
|
-
rawCacheControl = rawCacheControl.join(', ')
|
|
296
|
-
}
|
|
297
|
-
const cacheControlDirectives = parseCacheControl(rawCacheControl) ?? {}
|
|
298
|
-
// cache-control-parser does not recognise 'only-if-cached', so check the raw string.
|
|
299
|
-
const onlyIfCached =
|
|
300
|
-
typeof rawCacheControl === 'string' && rawCacheControl.includes('only-if-cached')
|
|
619
|
+
const rawCacheControl = headers['cache-control']
|
|
620
|
+
const requestCacheControl = parseCacheControl(rawCacheControl) ?? {}
|
|
301
621
|
|
|
302
622
|
// RFC 9111 Section 5.4: Pragma: no-cache should be treated as
|
|
303
623
|
// Cache-Control: no-cache when Cache-Control is absent.
|
|
304
624
|
if (rawCacheControl == null && headers.pragma === 'no-cache') {
|
|
305
|
-
|
|
625
|
+
requestCacheControl['no-cache'] = true
|
|
306
626
|
}
|
|
307
627
|
|
|
308
|
-
if (
|
|
628
|
+
if (requestCacheControl['no-transform']) {
|
|
309
629
|
// Do nothing. We don't transform requests...
|
|
310
630
|
}
|
|
311
631
|
|
|
312
|
-
if
|
|
313
|
-
|
|
314
|
-
cacheControlDirectives['max-age'] != null ||
|
|
315
|
-
cacheControlDirectives['no-cache'] ||
|
|
316
|
-
cacheControlDirectives['stale-if-error'] != null ||
|
|
317
|
-
// cache-control-parser does not recognise 'max-stale'/'min-fresh', so
|
|
318
|
-
// check the raw string like we do for 'only-if-cached'.
|
|
319
|
-
(typeof rawCacheControl === 'string' &&
|
|
320
|
-
(rawCacheControl.includes('max-stale') || rawCacheControl.includes('min-fresh')))
|
|
321
|
-
) {
|
|
322
|
-
// TODO (fix): Support all cache control directives...
|
|
323
|
-
return dispatch(opts, handler)
|
|
324
|
-
}
|
|
632
|
+
const onlyIfCached = requestCacheControl['only-if-cached'] === true
|
|
633
|
+
const store = getStore(opts)
|
|
325
634
|
|
|
326
|
-
|
|
327
|
-
opts.cache.store ?? (DEFAULT_STORE ??= new SqliteCacheStore({ location: ':memory:' }))
|
|
635
|
+
let entry = tryGetEntry(store, key, opts.logger)
|
|
328
636
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
637
|
+
// RFC 9111 §3.5 serve-side authorization gate: a shared cache must not
|
|
638
|
+
// reuse a stored response for a request with Authorization unless the
|
|
639
|
+
// response allowed it (public, s-maxage or must-revalidate — the mirror of
|
|
640
|
+
// the store-side gate in CacheHandler; both sites must stay in lockstep).
|
|
641
|
+
if (entry && headers.authorization != null) {
|
|
642
|
+
const directives = entry.cacheControlDirectives
|
|
643
|
+
if (
|
|
644
|
+
typeof headers.authorization !== 'string' ||
|
|
645
|
+
!(
|
|
646
|
+
directives?.public === true ||
|
|
647
|
+
directives?.['s-maxage'] != null ||
|
|
648
|
+
directives?.['must-revalidate'] === true
|
|
649
|
+
)
|
|
650
|
+
) {
|
|
651
|
+
entry = undefined
|
|
338
652
|
}
|
|
339
653
|
}
|
|
340
654
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
655
|
+
const cacheHandler = () =>
|
|
656
|
+
new CacheHandler(key, {
|
|
657
|
+
...cacheOptsOf(opts),
|
|
658
|
+
store,
|
|
659
|
+
logger: opts.logger,
|
|
660
|
+
handler,
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
// Request Cache-Control directives that this cache does not evaluate locally
|
|
664
|
+
// (a follow-up adds conditional revalidation and local evaluation) cause a
|
|
665
|
+
// bypass to the origin. These constrain REUSE of a stored response, not the
|
|
666
|
+
// storage of a fresh one — so the bypass still writes the origin response
|
|
667
|
+
// back through CacheHandler for later callers (undici PR #5510), unless the
|
|
668
|
+
// request's own no-store forbids storing. only-if-cached is the exception:
|
|
669
|
+
// it forbids contacting the origin, so it is handled from the cache below
|
|
670
|
+
// instead of bypassing.
|
|
671
|
+
const bypass =
|
|
672
|
+
!onlyIfCached &&
|
|
673
|
+
// != null: 'max-age=0' parses to 0 (falsy) but still demands revalidation.
|
|
674
|
+
(requestCacheControl['max-age'] != null ||
|
|
675
|
+
requestCacheControl['no-cache'] === true ||
|
|
676
|
+
requestCacheControl['stale-if-error'] != null ||
|
|
677
|
+
requestCacheControl['max-stale'] != null ||
|
|
678
|
+
requestCacheControl['min-fresh'] != null)
|
|
679
|
+
|
|
680
|
+
if (bypass) {
|
|
681
|
+
return dispatch(opts, requestCacheControl['no-store'] ? handler : cacheHandler())
|
|
345
682
|
}
|
|
346
683
|
|
|
347
|
-
// RFC 9110 Section 13:
|
|
684
|
+
// RFC 9110 Section 13: evaluate conditional request headers against the
|
|
685
|
+
// cached entry. The store only returns entries that are still fresh
|
|
686
|
+
// (deleteAt === staleAt), so a returned entry is always servable.
|
|
348
687
|
// typeof guards: duplicated conditional headers arrive as arrays — treat
|
|
349
688
|
// them as non-matching and bypass to origin rather than crashing.
|
|
350
689
|
if (entry && headers['if-none-match']) {
|
|
@@ -384,20 +723,13 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
384
723
|
}
|
|
385
724
|
|
|
386
725
|
if (!entry && !onlyIfCached) {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
? handler
|
|
391
|
-
: new CacheHandler(key, {
|
|
392
|
-
maxEntrySize: opts.cache.maxEntrySize,
|
|
393
|
-
maxEntryTTL: opts.cache.maxEntryTTL,
|
|
394
|
-
store,
|
|
395
|
-
logger: opts.logger,
|
|
396
|
-
handler,
|
|
397
|
-
}),
|
|
398
|
-
)
|
|
726
|
+
// A miss keeps the CacheHandler write-back unless the request's no-store
|
|
727
|
+
// forbids storing.
|
|
728
|
+
return dispatch(opts, requestCacheControl['no-store'] ? handler : cacheHandler())
|
|
399
729
|
}
|
|
400
730
|
|
|
731
|
+
// A hit (fresh, per the store) is served; only-if-cached with no usable
|
|
732
|
+
// entry yields 504 (RFC 9111 §5.2.1.7).
|
|
401
733
|
return serveFromCache(entry ?? { statusCode: 504 }, opts, handler)
|
|
402
734
|
}
|
|
403
735
|
|
|
@@ -406,13 +738,13 @@ function serveFromCache(entry, opts, handler) {
|
|
|
406
738
|
|
|
407
739
|
let headers = entry.headers
|
|
408
740
|
if (entry.cachedAt != null) {
|
|
409
|
-
// RFC 9111 §5.1: every response served from cache must carry an Age
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const age =
|
|
741
|
+
// RFC 9111 §5.1: every response served from cache must carry an Age
|
|
742
|
+
// header. cachedAt is backdated by the corrected initial age at store
|
|
743
|
+
// time (§4.2.3) and the origin's Age header is stripped, so resident time
|
|
744
|
+
// IS the response's age — no origin-Age addition. Date.now(), not
|
|
745
|
+
// getFastNow(): the lagging clock would understate a relayed response's
|
|
746
|
+
// initial age by up to a second.
|
|
747
|
+
const age = Math.max(0, Math.floor((Date.now() - entry.cachedAt) / 1000))
|
|
416
748
|
headers = { ...headers, age: `${age}` }
|
|
417
749
|
}
|
|
418
750
|
|
package/lib/interceptor/proxy.js
CHANGED
|
@@ -108,10 +108,13 @@ function eqiLower(a, b) {
|
|
|
108
108
|
// allocation-free and letting the common (non-hop) header bail out in a single
|
|
109
109
|
// comparison.
|
|
110
110
|
/**
|
|
111
|
+
* Exported for reuse by the cache interceptor: RFC 9111 §3.1 forbids storing
|
|
112
|
+
* hop-by-hop fields, which is the same set a proxy must not retransmit.
|
|
113
|
+
*
|
|
111
114
|
* @param {string} key
|
|
112
115
|
* @returns {boolean}
|
|
113
116
|
*/
|
|
114
|
-
function isHopByHop(key) {
|
|
117
|
+
export function isHopByHop(key) {
|
|
115
118
|
switch (key.length) {
|
|
116
119
|
case 2:
|
|
117
120
|
return eqiLower(key, 'te')
|
|
@@ -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 =
|
|
5
|
+
const VERSION = 11
|
|
6
6
|
|
|
7
7
|
// Registry of live stores so process-level broadcasts (nxt:offPeak,
|
|
8
8
|
// nxt:clearCache) can reach them. Stores are held via WeakRef so that a store
|
|
@@ -51,8 +51,9 @@ function forEachStore(fn) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
54
|
+
* Synchronous get/set/delete cache store backed by node:sqlite. Note: this is
|
|
55
|
+
* NOT undici's stream-based CacheStore interface (createWriteStream); the
|
|
56
|
+
* cache interceptor in this package drives it directly.
|
|
56
57
|
*
|
|
57
58
|
* @typedef {{
|
|
58
59
|
* id: Readonly<number>,
|
|
@@ -66,6 +67,7 @@ function forEachStore(fn) {
|
|
|
66
67
|
* etag?: string
|
|
67
68
|
* cacheControlDirectives?: string
|
|
68
69
|
* cachedAt: number
|
|
70
|
+
* staleAt: number
|
|
69
71
|
* deleteAt: number
|
|
70
72
|
* }} SqliteStoreValue
|
|
71
73
|
*/
|
|
@@ -100,6 +102,21 @@ export class SqliteCacheStore {
|
|
|
100
102
|
*/
|
|
101
103
|
#evictQuery
|
|
102
104
|
|
|
105
|
+
/**
|
|
106
|
+
* @type {import('node:sqlite').StatementSync}
|
|
107
|
+
*/
|
|
108
|
+
#deleteByUrlQuery
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @type {import('node:sqlite').StatementSync}
|
|
112
|
+
*/
|
|
113
|
+
#supersedeFullQuery
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @type {import('node:sqlite').StatementSync}
|
|
117
|
+
*/
|
|
118
|
+
#supersede206Query
|
|
119
|
+
|
|
103
120
|
#insertBatch = []
|
|
104
121
|
#insertSeq = 0
|
|
105
122
|
#closed = false
|
|
@@ -136,6 +153,7 @@ export class SqliteCacheStore {
|
|
|
136
153
|
body BLOB NULL,
|
|
137
154
|
start INTEGER NOT NULL,
|
|
138
155
|
end INTEGER NOT NULL,
|
|
156
|
+
staleAt INTEGER NOT NULL,
|
|
139
157
|
deleteAt INTEGER NOT NULL,
|
|
140
158
|
statusCode INTEGER NOT NULL,
|
|
141
159
|
statusMessage TEXT NOT NULL,
|
|
@@ -148,6 +166,12 @@ export class SqliteCacheStore {
|
|
|
148
166
|
|
|
149
167
|
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_getValuesQuery ON cacheInterceptorV${VERSION}(url, method, start, deleteAt);
|
|
150
168
|
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_deleteExpiredValuesQuery ON cacheInterceptorV${VERSION}(deleteAt);
|
|
169
|
+
-- Covers the supersede-on-flush DELETEs (#supersedeFullQuery /
|
|
170
|
+
-- #supersede206Query), whose predicate is url+method+vary (+statusCode,
|
|
171
|
+
-- and start/end for 206) — so a re-cache supersedes via an index seek
|
|
172
|
+
-- instead of scanning every row for a hot url+method with many Vary
|
|
173
|
+
-- variants or partial windows.
|
|
174
|
+
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_supersedeQuery ON cacheInterceptorV${VERSION}(url, method, vary, statusCode, start, end);
|
|
151
175
|
`)
|
|
152
176
|
|
|
153
177
|
// Drop tables left behind by previous schema versions. gc(), clear() and
|
|
@@ -200,6 +224,7 @@ export class SqliteCacheStore {
|
|
|
200
224
|
body,
|
|
201
225
|
start,
|
|
202
226
|
end,
|
|
227
|
+
staleAt,
|
|
203
228
|
deleteAt,
|
|
204
229
|
statusCode,
|
|
205
230
|
statusMessage,
|
|
@@ -215,7 +240,7 @@ export class SqliteCacheStore {
|
|
|
215
240
|
AND start <= ?
|
|
216
241
|
AND deleteAt > ?
|
|
217
242
|
ORDER BY
|
|
218
|
-
|
|
243
|
+
id DESC
|
|
219
244
|
`)
|
|
220
245
|
|
|
221
246
|
this.#insertValueQuery = this.#db.prepare(`
|
|
@@ -225,6 +250,7 @@ export class SqliteCacheStore {
|
|
|
225
250
|
body,
|
|
226
251
|
start,
|
|
227
252
|
end,
|
|
253
|
+
staleAt,
|
|
228
254
|
deleteAt,
|
|
229
255
|
statusCode,
|
|
230
256
|
statusMessage,
|
|
@@ -233,7 +259,7 @@ export class SqliteCacheStore {
|
|
|
233
259
|
cacheControlDirectives,
|
|
234
260
|
vary,
|
|
235
261
|
cachedAt
|
|
236
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
262
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
237
263
|
`)
|
|
238
264
|
|
|
239
265
|
this.#deleteExpiredValuesQuery = this.#db.prepare(
|
|
@@ -246,6 +272,35 @@ export class SqliteCacheStore {
|
|
|
246
272
|
`DELETE FROM cacheInterceptorV${VERSION} WHERE id IN (SELECT id FROM cacheInterceptorV${VERSION} ORDER BY deleteAt ASC LIMIT ?)`,
|
|
247
273
|
)
|
|
248
274
|
|
|
275
|
+
// RFC 9111 §4.4 invalidation: a successful unsafe request invalidates every
|
|
276
|
+
// stored response for the URI, across methods and vary variants.
|
|
277
|
+
this.#deleteByUrlQuery = this.#db.prepare(
|
|
278
|
+
`DELETE FROM cacheInterceptorV${VERSION} WHERE url = ?`,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
// Supersede queries: a newly flushed row replaces prior rows for the same
|
|
282
|
+
// representation so hot keys don't accumulate dead duplicates until gc.
|
|
283
|
+
// Receipt order (insertion order), NOT cachedAt, decides who wins — the
|
|
284
|
+
// same semantics as upstream undici's update-in-place set(). cachedAt is
|
|
285
|
+
// backdated by the corrected initial age (RFC 9111 §4.2.3), so a
|
|
286
|
+
// replacement fetched through a relay advertising a large Age (or a 304
|
|
287
|
+
// freshening with a skewed origin Date) can carry an OLDER cachedAt than
|
|
288
|
+
// the stale row it replaces; keying supersede or the read sort on
|
|
289
|
+
// cachedAt would let the stale row win every read, forever. `vary IS ?`
|
|
290
|
+
// is NULL-safe and compares the serialized-JSON text, deterministic
|
|
291
|
+
// because both sides are produced by the same CacheHandler code path
|
|
292
|
+
// (key order = Vary header order). A new full (non-206) representation
|
|
293
|
+
// supersedes all prior full ones regardless of byte window; a 206 only
|
|
294
|
+
// supersedes the exact same window so distinct partials coexist, and
|
|
295
|
+
// never a 200 row (matchesValue routes range and non-range requests to
|
|
296
|
+
// different rows).
|
|
297
|
+
this.#supersedeFullQuery = this.#db.prepare(
|
|
298
|
+
`DELETE FROM cacheInterceptorV${VERSION} WHERE url = ? AND method = ? AND vary IS ? AND statusCode != 206`,
|
|
299
|
+
)
|
|
300
|
+
this.#supersede206Query = this.#db.prepare(
|
|
301
|
+
`DELETE FROM cacheInterceptorV${VERSION} WHERE url = ? AND method = ? AND vary IS ? AND statusCode = 206 AND start = ? AND end = ?`,
|
|
302
|
+
)
|
|
303
|
+
|
|
249
304
|
this.#ref = new WeakRef(this)
|
|
250
305
|
stores.add(this.#ref)
|
|
251
306
|
// The store itself doubles as the unregister token so close() can drop
|
|
@@ -371,7 +426,7 @@ export class SqliteCacheStore {
|
|
|
371
426
|
setImmediate(this.#flush)
|
|
372
427
|
}
|
|
373
428
|
|
|
374
|
-
|
|
429
|
+
const entry = {
|
|
375
430
|
// Monotonic per-store sequence used only to break cachedAt ties in
|
|
376
431
|
// #findValue (newest write wins). Not persisted — #flush ignores it.
|
|
377
432
|
seq: this.#insertSeq++,
|
|
@@ -380,6 +435,9 @@ export class SqliteCacheStore {
|
|
|
380
435
|
body,
|
|
381
436
|
start: value.start,
|
|
382
437
|
end: value.end,
|
|
438
|
+
// staleAt column is NOT NULL; default to deleteAt when the caller omits
|
|
439
|
+
// it (pre-v11 value shape) — matches this package's staleAt === deleteAt.
|
|
440
|
+
staleAt: value.staleAt ?? value.deleteAt,
|
|
383
441
|
deleteAt: value.deleteAt,
|
|
384
442
|
statusCode: value.statusCode,
|
|
385
443
|
statusMessage: value.statusMessage,
|
|
@@ -390,7 +448,56 @@ export class SqliteCacheStore {
|
|
|
390
448
|
: null,
|
|
391
449
|
vary: value.vary ? JSON.stringify(value.vary) : null,
|
|
392
450
|
cachedAt: value.cachedAt,
|
|
393
|
-
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Coalesce within the pending batch: a stampede of identical misses would
|
|
454
|
+
// otherwise commit N identical rows. Receipt order wins (see the
|
|
455
|
+
// #supersede* query comments): the entry being added supersedes every
|
|
456
|
+
// pending entry for the same representation.
|
|
457
|
+
for (let i = this.#insertBatch.length - 1; i >= 0; i--) {
|
|
458
|
+
const other = this.#insertBatch[i]
|
|
459
|
+
if (
|
|
460
|
+
other.url === entry.url &&
|
|
461
|
+
other.method === entry.method &&
|
|
462
|
+
other.vary === entry.vary &&
|
|
463
|
+
(entry.statusCode !== 206
|
|
464
|
+
? other.statusCode !== 206
|
|
465
|
+
: other.statusCode === 206 && other.start === entry.start && other.end === entry.end)
|
|
466
|
+
) {
|
|
467
|
+
this.#insertBatch.splice(i, 1)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
this.#insertBatch.push(entry)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Invalidates every stored response for the key's URI (RFC 9111 §4.4) —
|
|
476
|
+
* across methods and vary variants. Pending batched inserts for the URI are
|
|
477
|
+
* dropped as well: a batch entry flushed after the DELETE would otherwise
|
|
478
|
+
* resurrect the invalidated response. Note this only covers THIS process's
|
|
479
|
+
* batch — with a shared on-disk store, another process's in-flight batch
|
|
480
|
+
* can still transiently resurrect an entry (bounded by its ~10ms flush
|
|
481
|
+
* slice); cross-process invalidation is inherently best-effort.
|
|
482
|
+
*
|
|
483
|
+
* @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
|
|
484
|
+
*/
|
|
485
|
+
delete(key) {
|
|
486
|
+
assertCacheKey(key)
|
|
487
|
+
|
|
488
|
+
if (this.#closed) {
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const url = makeValueUrl(key)
|
|
493
|
+
|
|
494
|
+
for (let i = this.#insertBatch.length - 1; i >= 0; i--) {
|
|
495
|
+
if (this.#insertBatch[i].url === url) {
|
|
496
|
+
this.#insertBatch.splice(i, 1)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
this.#deleteByUrlQuery.run(url)
|
|
394
501
|
}
|
|
395
502
|
|
|
396
503
|
#flush = (final = false) => {
|
|
@@ -412,6 +519,7 @@ export class SqliteCacheStore {
|
|
|
412
519
|
body,
|
|
413
520
|
start,
|
|
414
521
|
end,
|
|
522
|
+
staleAt,
|
|
415
523
|
deleteAt,
|
|
416
524
|
statusCode,
|
|
417
525
|
statusMessage,
|
|
@@ -421,12 +529,21 @@ export class SqliteCacheStore {
|
|
|
421
529
|
vary,
|
|
422
530
|
cachedAt,
|
|
423
531
|
} = this.#insertBatch[n++]
|
|
532
|
+
// Supersede prior rows for the same representation (see the
|
|
533
|
+
// #supersede* query comments) inside the same transaction, so a
|
|
534
|
+
// re-cache replaces instead of accumulating.
|
|
535
|
+
if (statusCode === 206) {
|
|
536
|
+
this.#supersede206Query.run(url, method, vary, start, end)
|
|
537
|
+
} else {
|
|
538
|
+
this.#supersedeFullQuery.run(url, method, vary)
|
|
539
|
+
}
|
|
424
540
|
this.#insertValueQuery.run(
|
|
425
541
|
url,
|
|
426
542
|
method,
|
|
427
543
|
body,
|
|
428
544
|
start,
|
|
429
545
|
end,
|
|
546
|
+
staleAt,
|
|
430
547
|
deleteAt,
|
|
431
548
|
statusCode,
|
|
432
549
|
statusMessage,
|
|
@@ -534,16 +651,14 @@ export class SqliteCacheStore {
|
|
|
534
651
|
return undefined
|
|
535
652
|
}
|
|
536
653
|
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
// (tagged with a monotonic seq) are
|
|
541
|
-
// and within each source a higher
|
|
654
|
+
// Most recently WRITTEN representation wins — receipt order, not
|
|
655
|
+
// cachedAt (see the #supersede* query comments: cachedAt is backdated by
|
|
656
|
+
// the corrected initial age, so a fresher write can carry an older
|
|
657
|
+
// cachedAt). Pending batch entries (tagged with a monotonic seq) are
|
|
658
|
+
// always newer than any flushed DB row, and within each source a higher
|
|
659
|
+
// seq/id wins.
|
|
542
660
|
if (values.length > 1) {
|
|
543
661
|
values.sort((a, b) => {
|
|
544
|
-
if (a.cachedAt !== b.cachedAt) {
|
|
545
|
-
return b.cachedAt - a.cachedAt
|
|
546
|
-
}
|
|
547
662
|
const aBatch = a.seq != null
|
|
548
663
|
const bBatch = b.seq != null
|
|
549
664
|
if (aBatch !== bBatch) {
|
|
@@ -657,6 +772,7 @@ function makeResult(value) {
|
|
|
657
772
|
? JSON.parse(value.cacheControlDirectives)
|
|
658
773
|
: undefined,
|
|
659
774
|
cachedAt: value.cachedAt,
|
|
775
|
+
staleAt: value.staleAt,
|
|
660
776
|
deleteAt: value.deleteAt,
|
|
661
777
|
}
|
|
662
778
|
}
|
|
@@ -704,6 +820,16 @@ function assertCacheValue(value) {
|
|
|
704
820
|
}
|
|
705
821
|
}
|
|
706
822
|
|
|
823
|
+
// staleAt is optional for backward compatibility: pre-v11 callers of the
|
|
824
|
+
// public SqliteCacheStore.set() omit it, and set() defaults it to deleteAt
|
|
825
|
+
// (this package's staleAt === deleteAt semantics). Validate the type only
|
|
826
|
+
// when supplied.
|
|
827
|
+
if (value.staleAt !== undefined && typeof value.staleAt !== 'number') {
|
|
828
|
+
throw new TypeError(
|
|
829
|
+
`expected value.staleAt to be number, got ${printType(value.staleAt)} [${value.staleAt}]`,
|
|
830
|
+
)
|
|
831
|
+
}
|
|
832
|
+
|
|
707
833
|
if (typeof value.statusMessage !== 'string') {
|
|
708
834
|
throw new TypeError(
|
|
709
835
|
`expected value.statusMessage to be string, got ${printType(value.statusMessage)} [${value.statusMessage}]`,
|
package/lib/utils.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import cacheControlParser from 'cache-control-parser'
|
|
2
1
|
import stream from 'node:stream'
|
|
3
2
|
import assert from 'node:assert'
|
|
4
3
|
import { util } from '@nxtedition/undici'
|
|
@@ -14,14 +13,295 @@ export function getFastNow() {
|
|
|
14
13
|
return fastNow
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Vendored from undici's parseCacheControlHeader (lib/util/cache.js) with two
|
|
18
|
+
* deliberate deviations, both toward the conservative reading of RFC 9111:
|
|
19
|
+
* - duplicated max-age keeps the SMALLER value (§4.2.1 says a cache is free to
|
|
20
|
+
* pick, so pick the one that revalidates sooner), upstream keeps the larger;
|
|
21
|
+
* - a bare (valueless) max-stale is represented as Infinity per §5.2.1.2
|
|
22
|
+
* ("willing to accept a stale response of any age"), upstream drops it.
|
|
23
|
+
*
|
|
24
|
+
* Qualified no-cache/private (e.g. `no-cache="set-cookie"`) parse to an array
|
|
25
|
+
* of the listed field names; the unqualified forms parse to `true`. Callers
|
|
26
|
+
* that mean "unqualified" must check `=== true`, not truthiness.
|
|
27
|
+
*
|
|
28
|
+
* Keeps the historical wrapper contract: '', [], and non-strings return null
|
|
29
|
+
* (call sites rely on `?? {}`); an array of field lines is accepted directly
|
|
30
|
+
* (Cache-Control is list-typed, duplicated lines are legal per RFC 9110 §5.2).
|
|
31
|
+
*
|
|
32
|
+
* @param {string | string[] | null | undefined} header
|
|
33
|
+
* @returns {Record<string, boolean | number | string[]> | null}
|
|
34
|
+
*/
|
|
35
|
+
export function parseCacheControl(header) {
|
|
36
|
+
let directives
|
|
37
|
+
if (Array.isArray(header)) {
|
|
38
|
+
directives = []
|
|
39
|
+
for (const line of header) {
|
|
40
|
+
if (typeof line !== 'string') {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
directives.push(...line.split(','))
|
|
44
|
+
}
|
|
45
|
+
if (directives.length === 0) {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
} else if (header && typeof header === 'string') {
|
|
49
|
+
directives = header.split(',')
|
|
50
|
+
} else {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const output = {}
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < directives.length; i++) {
|
|
57
|
+
const directive = directives[i].toLowerCase()
|
|
58
|
+
const keyValueDelimiter = directive.indexOf('=')
|
|
59
|
+
|
|
60
|
+
let key
|
|
61
|
+
let value
|
|
62
|
+
if (keyValueDelimiter !== -1) {
|
|
63
|
+
key = directive.substring(0, keyValueDelimiter).trim()
|
|
64
|
+
value = directive.substring(keyValueDelimiter + 1)
|
|
65
|
+
} else {
|
|
66
|
+
key = directive.trim()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
switch (key) {
|
|
70
|
+
case 'min-fresh':
|
|
71
|
+
case 'max-stale':
|
|
72
|
+
case 'max-age':
|
|
73
|
+
case 's-maxage':
|
|
74
|
+
case 'stale-while-revalidate':
|
|
75
|
+
case 'stale-if-error': {
|
|
76
|
+
if (value === undefined) {
|
|
77
|
+
if (key === 'max-stale') {
|
|
78
|
+
// Bare max-stale: any staleness is acceptable (RFC 9111
|
|
79
|
+
// §5.2.1.2). Number semantics keep call-site math
|
|
80
|
+
// (`staleAt + max-stale * 1000`) working unchanged.
|
|
81
|
+
output[key] = Infinity
|
|
82
|
+
}
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// RFC 9110 §5.6.3: a recipient may remove BWS/OWS around the value, so
|
|
87
|
+
// tolerate whitespace (`max-age= 60`, `max-age=60 `) rather than
|
|
88
|
+
// dropping the directive.
|
|
89
|
+
value = value.trim()
|
|
90
|
+
|
|
91
|
+
if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') {
|
|
92
|
+
value = value.substring(1, value.length - 1)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// RFC 9111 delta-seconds are 1*DIGIT: require the whole value to be
|
|
96
|
+
// digits so malformed inputs (`max-age=60junk`, `-1`, `1.5`) are
|
|
97
|
+
// dropped rather than parseInt-coerced into freshness math.
|
|
98
|
+
if (!/^\d+$/.test(value)) {
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
const parsedValue = parseInt(value, 10)
|
|
102
|
+
|
|
103
|
+
if (key === 'max-age' && key in output && output[key] <= parsedValue) {
|
|
104
|
+
// Duplicate max-age: keep the smaller (sooner-stale) value.
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
output[key] = parsedValue
|
|
109
|
+
|
|
110
|
+
break
|
|
111
|
+
}
|
|
112
|
+
case 'private':
|
|
113
|
+
case 'no-cache': {
|
|
114
|
+
if (value) {
|
|
115
|
+
// Qualified form: a quoted, possibly comma-separated list of field
|
|
116
|
+
// names (`no-cache="set-cookie, warning"`). The split-by-comma above
|
|
117
|
+
// may have cut the quoted list apart, so scan forward until the part
|
|
118
|
+
// that carries the closing quote. https://www.rfc-editor.org/rfc/rfc9111.html#name-no-cache-2
|
|
119
|
+
//
|
|
120
|
+
// The unqualified form is strictly more restrictive (forbids
|
|
121
|
+
// storing / demands validation for the WHOLE response), so when
|
|
122
|
+
// both forms appear (`private, private="x"` — invalid but seen in
|
|
123
|
+
// the wild), the qualified form must never clobber the `true`.
|
|
124
|
+
value = value.trim()
|
|
125
|
+
if (value[0] === '"') {
|
|
126
|
+
const headerNames = [value.substring(1)]
|
|
127
|
+
|
|
128
|
+
// length > 1: a lone `"` is an opening quote, not a closed list.
|
|
129
|
+
let foundEndingQuote = value.length > 1 && value[value.length - 1] === '"'
|
|
130
|
+
if (!foundEndingQuote) {
|
|
131
|
+
for (let j = i + 1; j < directives.length; j++) {
|
|
132
|
+
// Trim before the closing-quote check: optional whitespace
|
|
133
|
+
// between the quote and the next comma (`no-cache="a, b" ,x`)
|
|
134
|
+
// must not defeat the scan.
|
|
135
|
+
const nextPart = directives[j].trim()
|
|
136
|
+
headerNames.push(nextPart)
|
|
137
|
+
if (nextPart.length !== 0 && nextPart[nextPart.length - 1] === '"') {
|
|
138
|
+
foundEndingQuote = true
|
|
139
|
+
// Consume the scanned parts so a quoted fragment (e.g. a
|
|
140
|
+
// literal `max-age=1` inside the field list) is not
|
|
141
|
+
// re-parsed as a real directive.
|
|
142
|
+
i = j
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (foundEndingQuote) {
|
|
149
|
+
const lastHeader = headerNames[headerNames.length - 1]
|
|
150
|
+
if (lastHeader[lastHeader.length - 1] === '"') {
|
|
151
|
+
headerNames[headerNames.length - 1] = lastHeader.substring(0, lastHeader.length - 1)
|
|
152
|
+
}
|
|
153
|
+
if (output[key] !== true) {
|
|
154
|
+
const fields = headerNames.map((name) => name.trim().toLowerCase())
|
|
155
|
+
output[key] = Array.isArray(output[key]) ? output[key].concat(fields) : fields
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
// Unterminated quoted list (invalid header). Fail restrictive:
|
|
159
|
+
// treat as the unqualified form, and consume the remaining
|
|
160
|
+
// parts — they are fragments of the broken quoted string, not
|
|
161
|
+
// real directives.
|
|
162
|
+
output[key] = true
|
|
163
|
+
i = directives.length
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
// Unquoted value (e.g. `private=set-cookie`): the qualified form
|
|
167
|
+
// MUST be a quoted field-list (RFC 9111 §5.2.2.7). A bare token is
|
|
168
|
+
// malformed — fail restrictive and treat it as the unqualified
|
|
169
|
+
// directive (forbid storing / demand revalidation for the whole
|
|
170
|
+
// response) rather than a more permissive field list.
|
|
171
|
+
output[key] = true
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
break
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// eslint-disable-next-line no-fallthrough
|
|
178
|
+
case 'public':
|
|
179
|
+
case 'no-store':
|
|
180
|
+
case 'must-revalidate':
|
|
181
|
+
case 'proxy-revalidate':
|
|
182
|
+
case 'immutable':
|
|
183
|
+
case 'no-transform':
|
|
184
|
+
case 'must-understand':
|
|
185
|
+
case 'only-if-cached':
|
|
186
|
+
// These directives take no value. An explicit `=` (even empty, e.g.
|
|
187
|
+
// `public=`) is an invalid qualified form and is ignored — not
|
|
188
|
+
// treated as the bare directive. `value === undefined` is the genuine
|
|
189
|
+
// valueless form (including the private/no-cache fallthrough above).
|
|
190
|
+
if (value !== undefined) {
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
output[key] = true
|
|
195
|
+
break
|
|
196
|
+
default:
|
|
197
|
+
// Unknown directives are ignored per RFC 9111 §5.2.3.
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return output
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const HTTP_DATE_MONTHS = {
|
|
206
|
+
Jan: 0,
|
|
207
|
+
Feb: 1,
|
|
208
|
+
Mar: 2,
|
|
209
|
+
Apr: 3,
|
|
210
|
+
May: 4,
|
|
211
|
+
Jun: 5,
|
|
212
|
+
Jul: 6,
|
|
213
|
+
Aug: 7,
|
|
214
|
+
Sep: 8,
|
|
215
|
+
Oct: 9,
|
|
216
|
+
Nov: 10,
|
|
217
|
+
Dec: 11,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const HTTP_DATE_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
221
|
+
const HTTP_DATE_FULL_DAYS = [
|
|
222
|
+
'Sunday',
|
|
223
|
+
'Monday',
|
|
224
|
+
'Tuesday',
|
|
225
|
+
'Wednesday',
|
|
226
|
+
'Thursday',
|
|
227
|
+
'Friday',
|
|
228
|
+
'Saturday',
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
// IMF-fixdate: Sun, 06 Nov 1994 08:49:37 GMT
|
|
232
|
+
const IMF_DATE_RE =
|
|
233
|
+
/^(Sun|Mon|Tue|Wed|Thu|Fri|Sat), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/
|
|
234
|
+
// obsolete RFC 850: Sunday, 06-Nov-94 08:49:37 GMT
|
|
235
|
+
const RFC850_DATE_RE =
|
|
236
|
+
/^(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/
|
|
237
|
+
// ANSI C asctime(): Sun Nov 6 08:49:37 1994 (day space-padded)
|
|
238
|
+
const ASCTIME_DATE_RE =
|
|
239
|
+
/^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Strict HTTP-date parser per RFC 9110 §5.6.7 (the three accepted formats
|
|
243
|
+
* only). Deliberately NOT `new Date(str)`: V8 accepts many non-HTTP formats,
|
|
244
|
+
* and RFC 9111 §5.3 requires an invalid Expires (notably `Expires: 0`) to be
|
|
245
|
+
* treated as already expired rather than silently ignored — which needs the
|
|
246
|
+
* parse failure to be observable.
|
|
247
|
+
*
|
|
248
|
+
* @param {string} date
|
|
249
|
+
* @returns {Date | undefined} undefined when not a valid HTTP-date
|
|
250
|
+
*/
|
|
251
|
+
export function parseHttpDate(date) {
|
|
252
|
+
if (typeof date !== 'string') {
|
|
253
|
+
return undefined
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let weekday
|
|
257
|
+
let day
|
|
258
|
+
let month
|
|
259
|
+
let year
|
|
260
|
+
let hour
|
|
261
|
+
let minute
|
|
262
|
+
let second
|
|
263
|
+
|
|
264
|
+
let m = IMF_DATE_RE.exec(date)
|
|
265
|
+
if (m) {
|
|
266
|
+
weekday = HTTP_DATE_DAYS.indexOf(m[1])
|
|
267
|
+
day = Number(m[2])
|
|
268
|
+
month = HTTP_DATE_MONTHS[m[3]]
|
|
269
|
+
year = Number(m[4])
|
|
270
|
+
hour = Number(m[5])
|
|
271
|
+
minute = Number(m[6])
|
|
272
|
+
second = Number(m[7])
|
|
273
|
+
} else if ((m = RFC850_DATE_RE.exec(date))) {
|
|
274
|
+
weekday = HTTP_DATE_FULL_DAYS.indexOf(m[1])
|
|
275
|
+
day = Number(m[2])
|
|
276
|
+
month = HTTP_DATE_MONTHS[m[3]]
|
|
277
|
+
// RFC 6265 §5.1.1 two-digit year windowing: 70-99 → 19xx, 00-69 → 20xx.
|
|
278
|
+
year = Number(m[4])
|
|
279
|
+
year += year < 70 ? 2000 : 1900
|
|
280
|
+
hour = Number(m[5])
|
|
281
|
+
minute = Number(m[6])
|
|
282
|
+
second = Number(m[7])
|
|
283
|
+
} else if ((m = ASCTIME_DATE_RE.exec(date))) {
|
|
284
|
+
weekday = HTTP_DATE_DAYS.indexOf(m[1])
|
|
285
|
+
month = HTTP_DATE_MONTHS[m[2]]
|
|
286
|
+
day = Number(m[3].trim())
|
|
287
|
+
hour = Number(m[4])
|
|
288
|
+
minute = Number(m[5])
|
|
289
|
+
second = Number(m[6])
|
|
290
|
+
year = Number(m[7])
|
|
291
|
+
} else {
|
|
292
|
+
return undefined
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) {
|
|
296
|
+
return undefined
|
|
23
297
|
}
|
|
24
|
-
|
|
298
|
+
|
|
299
|
+
const result = new Date(Date.UTC(year, month, day, hour, minute, second))
|
|
300
|
+
// Date.UTC normalizes out-of-range components (Feb 30 → Mar 2) and the named
|
|
301
|
+
// weekday must match the actual date — both are rejected by the same check
|
|
302
|
+
// upstream uses (getUTCDay comparison catches normalization only by luck;
|
|
303
|
+
// check the day-of-month explicitly as well).
|
|
304
|
+
return result.getUTCDate() === day && result.getUTCDay() === weekday ? result : undefined
|
|
25
305
|
}
|
|
26
306
|
|
|
27
307
|
export function isDisturbed(body) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxtedition/nxt-undici",
|
|
3
|
-
"version": "7.4.
|
|
3
|
+
"version": "7.4.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Robert Nagy <robert.nagy@boffins.se>",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@nxtedition/scheduler": "^4.1.1",
|
|
13
13
|
"@nxtedition/undici": "^11.1.4",
|
|
14
|
-
"cache-control-parser": "^2.2.0",
|
|
15
14
|
"fast-querystring": "^1.1.2",
|
|
16
15
|
"http-errors": "^2.0.1",
|
|
17
16
|
"xxhash-wasm": "^1.1.0"
|