@nxtedition/nxt-undici 7.4.1 → 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 +51 -8
- package/lib/index.js +1 -0
- package/lib/interceptor/cache.js +479 -108
- package/lib/interceptor/dns.js +73 -6
- package/lib/interceptor/log.js +184 -9
- package/lib/interceptor/pressure.js +11 -0
- package/lib/interceptor/proxy.js +46 -15
- package/lib/interceptor/redirect.js +35 -5
- package/lib/interceptor/request-body-factory.js +59 -8
- package/lib/interceptor/response-error.js +19 -2
- package/lib/interceptor/response-retry.js +273 -21
- package/lib/request.js +5 -1
- package/lib/sqlite-cache-store.js +228 -25
- package/lib/utils.js +320 -17
- package/package.json +1 -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,8 +13,295 @@ export function getFastNow() {
|
|
|
14
13
|
return fastNow
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
297
|
+
}
|
|
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
|
|
19
305
|
}
|
|
20
306
|
|
|
21
307
|
export function isDisturbed(body) {
|
|
@@ -133,26 +419,43 @@ export function parseURL(url) {
|
|
|
133
419
|
|
|
134
420
|
if (!(url instanceof URL)) {
|
|
135
421
|
const port = url.port != null ? url.port : url.protocol === 'https:' ? 443 : 80
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (origin.endsWith('/')) {
|
|
140
|
-
origin = origin.substring(0, origin.length - 1)
|
|
141
|
-
}
|
|
422
|
+
const origin = url.origin != null ? url.origin : `${url.protocol}//${url.hostname}:${port}`
|
|
423
|
+
const path = url.path != null ? url.path : `${url.pathname || ''}${url.search || ''}`
|
|
142
424
|
|
|
143
|
-
|
|
144
|
-
path = `/${path}`
|
|
145
|
-
}
|
|
146
|
-
// new URL(path, origin) is unsafe when `path` contains an absolute URL
|
|
147
|
-
// From https://developer.mozilla.org/en-US/docs/Web/API/URL/URL:
|
|
148
|
-
// If first parameter is a relative URL, second param is required, and will be used as the base URL.
|
|
149
|
-
// If first parameter is an absolute URL, a given second param will be ignored.
|
|
150
|
-
url = new URL(origin + path)
|
|
425
|
+
url = buildURL(origin, path)
|
|
151
426
|
}
|
|
152
427
|
|
|
153
428
|
return url
|
|
154
429
|
}
|
|
155
430
|
|
|
431
|
+
// Build an absolute URL from an origin and a path by concatenation.
|
|
432
|
+
//
|
|
433
|
+
// `new URL(path, origin)` is unsafe when `path` is itself absolute or
|
|
434
|
+
// protocol-relative (e.g. starts with `//host`): per the WHATWG URL spec, a
|
|
435
|
+
// protocol-relative first argument inherits only the scheme from the base and
|
|
436
|
+
// resolves its authority from `path`, so the origin's host is silently
|
|
437
|
+
// discarded. Concatenating `origin + path` and parsing the whole thing as a
|
|
438
|
+
// single absolute URL keeps the origin authoritative — which matters for
|
|
439
|
+
// redirect resolution, where a leaked host is an SSRF/misrouting vector.
|
|
440
|
+
//
|
|
441
|
+
// From https://developer.mozilla.org/en-US/docs/Web/API/URL/URL:
|
|
442
|
+
// If first parameter is a relative URL, second param is required, and will be used as the base URL.
|
|
443
|
+
// If first parameter is an absolute URL, a given second param will be ignored.
|
|
444
|
+
export function buildURL(origin, path) {
|
|
445
|
+
origin = String(origin)
|
|
446
|
+
path = path == null ? '' : String(path)
|
|
447
|
+
|
|
448
|
+
if (origin.endsWith('/')) {
|
|
449
|
+
origin = origin.substring(0, origin.length - 1)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (path && !path.startsWith('/')) {
|
|
453
|
+
path = `/${path}`
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return new URL(origin + path)
|
|
457
|
+
}
|
|
458
|
+
|
|
156
459
|
export function parseOrigin(url) {
|
|
157
460
|
url = parseURL(url)
|
|
158
461
|
|
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"
|