@nxtedition/nxt-undici 7.4.2 → 7.5.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/lib/index.d.ts +27 -1
- package/lib/index.js +15 -0
- package/lib/interceptor/cache.js +667 -115
- package/lib/interceptor/dns.js +101 -2
- package/lib/interceptor/log.js +126 -59
- package/lib/interceptor/lookup.js +53 -1
- package/lib/interceptor/pressure.js +86 -3
- package/lib/interceptor/priority.js +73 -3
- package/lib/interceptor/proxy.js +4 -1
- package/lib/interceptor/redirect.js +26 -0
- package/lib/interceptor/response-retry.js +32 -2
- package/lib/interceptor/response-verify.js +36 -0
- package/lib/sqlite-cache-store.js +141 -15
- package/lib/trace.js +87 -0
- package/lib/utils.js +288 -8
- package/package.json +2 -2
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.
|
|
3
|
+
"version": "7.5.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Robert Nagy <robert.nagy@boffins.se>",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
],
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@nxtedition/scheduler": "^4.1.1",
|
|
13
|
+
"@nxtedition/trace": "^1.0.0",
|
|
13
14
|
"@nxtedition/undici": "^11.1.4",
|
|
14
|
-
"cache-control-parser": "^2.2.0",
|
|
15
15
|
"fast-querystring": "^1.1.2",
|
|
16
16
|
"http-errors": "^2.0.1",
|
|
17
17
|
"xxhash-wasm": "^1.1.0"
|