@nxtedition/nxt-undici 7.3.25 → 7.3.27
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.js +23 -4
- package/lib/interceptor/cache.js +124 -24
- package/lib/interceptor/dns.js +47 -11
- package/lib/interceptor/lookup.js +10 -2
- package/lib/interceptor/priority.js +23 -4
- package/lib/interceptor/proxy.js +222 -36
- package/lib/interceptor/query.js +6 -0
- package/lib/interceptor/redirect.js +27 -8
- package/lib/interceptor/request-body-factory.js +10 -3
- package/lib/interceptor/request-id.js +9 -2
- package/lib/interceptor/response-error.js +6 -4
- package/lib/interceptor/response-retry.js +30 -7
- package/lib/interceptor/response-verify.js +20 -7
- package/lib/request.js +36 -7
- package/lib/sqlite-cache-store.js +104 -29
- package/lib/utils.js +16 -12
- package/package.json +2 -1
package/lib/interceptor/proxy.js
CHANGED
|
@@ -2,6 +2,19 @@ import net from 'node:net'
|
|
|
2
2
|
import createError from 'http-errors'
|
|
3
3
|
import { DecoratorHandler } from '../utils.js'
|
|
4
4
|
|
|
5
|
+
// Accumulator used by reduceHeaders on the response path. Hoisted to module
|
|
6
|
+
// scope so it is allocated once rather than on every onHeaders/onUpgrade call.
|
|
7
|
+
/**
|
|
8
|
+
* @param {Record<string, string>} acc
|
|
9
|
+
* @param {string} key
|
|
10
|
+
* @param {string} val
|
|
11
|
+
* @returns {Record<string, string>}
|
|
12
|
+
*/
|
|
13
|
+
const copyHeader = (acc, key, val) => {
|
|
14
|
+
acc[key] = val
|
|
15
|
+
return acc
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
class Handler extends DecoratorHandler {
|
|
6
19
|
#opts
|
|
7
20
|
|
|
@@ -18,13 +31,13 @@ class Handler extends DecoratorHandler {
|
|
|
18
31
|
{
|
|
19
32
|
headers,
|
|
20
33
|
httpVersion: this.#opts.httpVersion ?? this.#opts.req?.httpVersion,
|
|
21
|
-
|
|
34
|
+
// Response path: never synthesize a Forwarded header (it is
|
|
35
|
+
// request-only, RFC 7239) — passing socket would leak the proxy's
|
|
36
|
+
// own addresses downstream. isResponse still rejects an inbound one.
|
|
37
|
+
isResponse: true,
|
|
22
38
|
proxyName: this.#opts.name,
|
|
23
39
|
},
|
|
24
|
-
|
|
25
|
-
acc[key] = val
|
|
26
|
-
return acc
|
|
27
|
-
},
|
|
40
|
+
copyHeader,
|
|
28
41
|
{},
|
|
29
42
|
),
|
|
30
43
|
socket,
|
|
@@ -38,13 +51,10 @@ class Handler extends DecoratorHandler {
|
|
|
38
51
|
{
|
|
39
52
|
headers,
|
|
40
53
|
httpVersion: this.#opts.httpVersion ?? this.#opts.req?.httpVersion,
|
|
41
|
-
|
|
54
|
+
isResponse: true,
|
|
42
55
|
proxyName: this.#opts.name,
|
|
43
56
|
},
|
|
44
|
-
|
|
45
|
-
acc[key] = val
|
|
46
|
-
return acc
|
|
47
|
-
},
|
|
57
|
+
copyHeader,
|
|
48
58
|
{},
|
|
49
59
|
),
|
|
50
60
|
resume,
|
|
@@ -52,49 +62,206 @@ class Handler extends DecoratorHandler {
|
|
|
52
62
|
}
|
|
53
63
|
}
|
|
54
64
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
// ASCII case-insensitive equality where `b` is a lowercase literal and the
|
|
66
|
+
// caller guarantees `a.length === b.length`. Allocation-free.
|
|
67
|
+
/**
|
|
68
|
+
* @param {string} a
|
|
69
|
+
* @param {string} b
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
function eqiLower(a, b) {
|
|
73
|
+
if (a === b) {
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
for (let i = 0; i < b.length; i++) {
|
|
77
|
+
let c = a.charCodeAt(i)
|
|
78
|
+
if (c >= 0x41 && c <= 0x5a) {
|
|
79
|
+
c += 0x20 // upper -> lower
|
|
80
|
+
}
|
|
81
|
+
if (c !== b.charCodeAt(i)) {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Matches hop-by-hop headers — meaningful only for a single transport-level
|
|
89
|
+
// connection, so a proxy must not retransmit or cache them. This is the single
|
|
90
|
+
// source of truth for that set: it is used both for the per-key strip below
|
|
91
|
+
// and for the Connection-value check. HTTP field names are ASCII tokens, so a
|
|
92
|
+
// length-dispatched ASCII case-insensitive compare is exactly equivalent to a
|
|
93
|
+
// `/^(te|host|…)$/i` regexp (verified over 137k generated keys) while staying
|
|
94
|
+
// allocation-free and letting the common (non-hop) header bail out in a single
|
|
95
|
+
// comparison.
|
|
96
|
+
/**
|
|
97
|
+
* @param {string} key
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
function isHopByHop(key) {
|
|
101
|
+
switch (key.length) {
|
|
102
|
+
case 2:
|
|
103
|
+
return eqiLower(key, 'te')
|
|
104
|
+
case 4:
|
|
105
|
+
return eqiLower(key, 'host')
|
|
106
|
+
case 7:
|
|
107
|
+
return eqiLower(key, 'upgrade')
|
|
108
|
+
case 8:
|
|
109
|
+
return eqiLower(key, 'trailers')
|
|
110
|
+
case 10:
|
|
111
|
+
return eqiLower(key, 'connection') || eqiLower(key, 'keep-alive')
|
|
112
|
+
case 14:
|
|
113
|
+
return eqiLower(key, 'http2-settings')
|
|
114
|
+
case 16:
|
|
115
|
+
return eqiLower(key, 'proxy-connection')
|
|
116
|
+
case 17:
|
|
117
|
+
return eqiLower(key, 'transfer-encoding')
|
|
118
|
+
case 18:
|
|
119
|
+
return eqiLower(key, 'proxy-authenticate')
|
|
120
|
+
case 19:
|
|
121
|
+
return eqiLower(key, 'proxy-authorization')
|
|
122
|
+
default:
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
}
|
|
60
126
|
|
|
61
127
|
// Removes hop-by-hop and pseudo headers.
|
|
62
128
|
// Updates via and forwarded headers.
|
|
63
129
|
// Only hop-by-hop headers may be set using the Connection general header.
|
|
64
|
-
|
|
130
|
+
/**
|
|
131
|
+
* @template T
|
|
132
|
+
* @param {object} options
|
|
133
|
+
* @param {Record<string, string | string[]>} options.headers Header map; a
|
|
134
|
+
* value is an array when the field appeared more than once.
|
|
135
|
+
* @param {string} [options.proxyName] This proxy's name. When set, a Via
|
|
136
|
+
* segment is appended and Via loop detection runs.
|
|
137
|
+
* @param {string} [options.httpVersion] Protocol token for the appended Via
|
|
138
|
+
* segment; defaults to `'HTTP/1.1'`.
|
|
139
|
+
* @param {{ localAddress?: string, localPort?: number, remoteAddress?: string,
|
|
140
|
+
* remotePort?: number, encrypted?: boolean } | null} [options.socket] Request
|
|
141
|
+
* path only: when present a Forwarded header is synthesised. An inbound
|
|
142
|
+
* Forwarded header is always treated as a BadGateway (it is request-only).
|
|
143
|
+
* @param {boolean} [options.isResponse] Response path marker. When set,
|
|
144
|
+
* Forwarded is never synthesised regardless of `socket` (it would leak the
|
|
145
|
+
* proxy's own addresses downstream); an inbound Forwarded is still rejected.
|
|
146
|
+
* @param {(acc: T, key: string, value: string) => T} fn Accumulator invoked
|
|
147
|
+
* once per retained header.
|
|
148
|
+
* @param {T} acc Initial accumulator value.
|
|
149
|
+
* @returns {T}
|
|
150
|
+
*/
|
|
151
|
+
function reduceHeaders({ headers, proxyName, httpVersion, socket, isResponse }, fn, acc) {
|
|
65
152
|
let via = ''
|
|
66
153
|
let forwarded = ''
|
|
67
154
|
let host = ''
|
|
68
155
|
let authority = ''
|
|
156
|
+
/** @type {string | string[]} */
|
|
69
157
|
let connection = ''
|
|
70
158
|
|
|
71
|
-
|
|
159
|
+
// Iterate via Object.keys (computed once and reused by both passes) rather
|
|
160
|
+
// than Object.entries: the latter allocates an outer array plus one
|
|
161
|
+
// 2-element array per header on every call, and this runs on the hot path of
|
|
162
|
+
// every proxied request and response. Object.keys allocates a single flat
|
|
163
|
+
// array we reuse, cutting per-call allocation by ~4-6x.
|
|
164
|
+
const keys = Object.keys(headers)
|
|
165
|
+
|
|
166
|
+
// Object keys are unique, so each special is seen at most once; a repeated
|
|
167
|
+
// field-line instead surfaces as an array value (parseHeaders collects them).
|
|
168
|
+
// RFC 9110 §5.3 only permits repeats for list-valued fields (ABNF `#`), where
|
|
169
|
+
// the parts are semantically one comma-separated value — those we combine.
|
|
170
|
+
// Singular fields with more than one value are a protocol error — those we
|
|
171
|
+
// reject.
|
|
172
|
+
//
|
|
173
|
+
// Field names are case-insensitive (RFC 7230). The production path lowercases
|
|
174
|
+
// keys (parseHeaders) before this runs, but the standalone interceptors.proxy()
|
|
175
|
+
// composition may pass mixed-case keys, so capture case-insensitively via the
|
|
176
|
+
// allocation-free eqiLower — otherwise a mixed-case `Forwarded`/`Connection`/
|
|
177
|
+
// `Via`/`Host` would skip capture and leak/bypass the handling below.
|
|
178
|
+
for (let i = 0; i < keys.length; i++) {
|
|
179
|
+
const key = keys[i]
|
|
72
180
|
const len = key.length
|
|
73
|
-
if (len === 3 &&
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
} else if (len ===
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
181
|
+
if (len === 3 && eqiLower(key, 'via')) {
|
|
182
|
+
// Via is list-valued (RFC 9110 §7.6.3): combine repeated field-lines.
|
|
183
|
+
const v = headers[key]
|
|
184
|
+
via = Array.isArray(v) ? v.join(', ') : v
|
|
185
|
+
} else if (len === 4 && eqiLower(key, 'host')) {
|
|
186
|
+
// Host is singular (RFC 9110 §7.2, RFC 7230 §5.4): more than one is a
|
|
187
|
+
// protocol error, so reject rather than combine.
|
|
188
|
+
const v = headers[key]
|
|
189
|
+
if (Array.isArray(v)) {
|
|
190
|
+
throw new createError.BadGateway()
|
|
191
|
+
}
|
|
192
|
+
host = v
|
|
193
|
+
} else if (len === 9 && eqiLower(key, 'forwarded')) {
|
|
194
|
+
// Forwarded is list-valued (RFC 7239 §4): combine repeated field-lines.
|
|
195
|
+
const v = headers[key]
|
|
196
|
+
forwarded = Array.isArray(v) ? v.join(', ') : v
|
|
197
|
+
} else if (len === 10 && eqiLower(key, 'connection')) {
|
|
198
|
+
// Connection is list-valued (RFC 9110 §7.6.1): captured raw and tokenised
|
|
199
|
+
// below — combining then re-splitting would be wasteful.
|
|
200
|
+
connection = headers[key]
|
|
201
|
+
} else if (len === 10 && key === ':authority') {
|
|
202
|
+
// :authority is singular (RFC 9113 §8.3.1): reject more than one. Pseudo
|
|
203
|
+
// headers are always lowercase, so an exact compare is correct here.
|
|
204
|
+
const v = headers[key]
|
|
205
|
+
if (Array.isArray(v)) {
|
|
206
|
+
throw new createError.BadGateway()
|
|
207
|
+
}
|
|
208
|
+
authority = v
|
|
83
209
|
}
|
|
84
210
|
}
|
|
85
211
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
212
|
+
// `remove` is lazily allocated: it stays null unless a Connection header
|
|
213
|
+
// actually lists headers to strip (the uncommon case), so the hot path
|
|
214
|
+
// neither allocates an (almost always empty) array nor runs includes() per
|
|
215
|
+
// header.
|
|
216
|
+
//
|
|
217
|
+
// Header field names are case-insensitive (RFC 7230); parseHeaders already
|
|
218
|
+
// lowercased the keys we compare against, so lowercase the listed names too,
|
|
219
|
+
// otherwise `Connection: X-Custom` fails to strip `x-custom` and it leaks to
|
|
220
|
+
// the next hop. trim() handles surrounding whitespace, so a plain comma split
|
|
221
|
+
// suffices.
|
|
222
|
+
let remove = null
|
|
223
|
+
if (Array.isArray(connection)) {
|
|
224
|
+
// Repeated Connection field-lines (RFC 9110 §7.6.1): each part may itself
|
|
225
|
+
// list several options. A repeat always carries multiple/custom tokens, so
|
|
226
|
+
// the single-hop-token shortcut below never applies.
|
|
227
|
+
remove = []
|
|
228
|
+
for (const part of connection) {
|
|
229
|
+
for (const token of part.split(',')) {
|
|
230
|
+
remove.push(token.trim().toLowerCase())
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} else if (connection && !isHopByHop(connection)) {
|
|
234
|
+
// Single value: one field-line, so one split over its comma list. The
|
|
235
|
+
// isHopByHop guard skips the common single-token forms (`keep-alive`,
|
|
236
|
+
// `close`, …) where there is nothing custom to strip.
|
|
237
|
+
remove = []
|
|
238
|
+
for (const token of connection.split(',')) {
|
|
239
|
+
remove.push(token.trim().toLowerCase())
|
|
240
|
+
}
|
|
89
241
|
}
|
|
90
242
|
|
|
91
|
-
for (
|
|
92
|
-
|
|
93
|
-
|
|
243
|
+
for (let i = 0; i < keys.length; i++) {
|
|
244
|
+
const key = keys[i]
|
|
245
|
+
const len = key.length
|
|
246
|
+
if (
|
|
247
|
+
key.charCodeAt(0) !== 0x3a /* ':' */ &&
|
|
248
|
+
// via/forwarded are captured above and (re)emitted by the dedicated
|
|
249
|
+
// finalization below; letting the retain loop also emit them leaks an
|
|
250
|
+
// empty inbound value (e.g. `via: ''`) that the `if (via)` guard then
|
|
251
|
+
// never overwrites, and bypasses the Forwarded BadGateway rejection.
|
|
252
|
+
// Case-insensitive (eqiLower) to match the case-insensitive capture above.
|
|
253
|
+
!(len === 3 && eqiLower(key, 'via')) &&
|
|
254
|
+
!(len === 9 && eqiLower(key, 'forwarded')) &&
|
|
255
|
+
// toLowerCase: `remove` entries are lowercased but keys may not be (the
|
|
256
|
+
// standalone interceptor path), so match case-insensitively like isHopByHop.
|
|
257
|
+
(remove === null || !remove.includes(key.toLowerCase())) &&
|
|
258
|
+
!isHopByHop(key)
|
|
259
|
+
) {
|
|
260
|
+
acc = fn(acc, key, headers[key].toString())
|
|
94
261
|
}
|
|
95
262
|
}
|
|
96
263
|
|
|
97
|
-
if (socket) {
|
|
264
|
+
if (!isResponse && socket) {
|
|
98
265
|
const forwardedHost = authority || host
|
|
99
266
|
acc = fn(
|
|
100
267
|
acc,
|
|
@@ -110,13 +277,27 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
|
|
|
110
277
|
.join(';'),
|
|
111
278
|
)
|
|
112
279
|
} else if (forwarded) {
|
|
113
|
-
//
|
|
280
|
+
// Forwarded is a request-only header (RFC 7239): a proxy must neither emit
|
|
281
|
+
// it on a response nor relay one an upstream echoed back.
|
|
114
282
|
throw new createError.BadGateway()
|
|
115
283
|
}
|
|
116
284
|
|
|
117
285
|
if (proxyName) {
|
|
118
286
|
if (via) {
|
|
119
|
-
|
|
287
|
+
const viaLower = via.toLowerCase()
|
|
288
|
+
const proxyNameLower = proxyName.toLowerCase()
|
|
289
|
+
// A Via segment is "received-protocol received-by [comment]". Compare the
|
|
290
|
+
// received-by token for equality (case-insensitive) rather than testing
|
|
291
|
+
// whether the whole segment ends with proxyName — endsWith() trips a
|
|
292
|
+
// false-positive loop for any unrelated proxy whose name merely has
|
|
293
|
+
// proxyName as a suffix (e.g. name 'proxy' vs upstream 'otherproxy').
|
|
294
|
+
if (
|
|
295
|
+
viaLower.includes(proxyNameLower) &&
|
|
296
|
+
viaLower.split(',').some((seg) => {
|
|
297
|
+
const by = seg.trim().split(/\s+/)[1]
|
|
298
|
+
return by != null && by === proxyNameLower
|
|
299
|
+
})
|
|
300
|
+
) {
|
|
120
301
|
throw new createError.LoopDetected()
|
|
121
302
|
}
|
|
122
303
|
via += ', '
|
|
@@ -133,6 +314,11 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
|
|
|
133
314
|
return acc
|
|
134
315
|
}
|
|
135
316
|
|
|
317
|
+
/**
|
|
318
|
+
* @param {string} address
|
|
319
|
+
* @param {number} [port]
|
|
320
|
+
* @returns {string}
|
|
321
|
+
*/
|
|
136
322
|
function printIp(address, port) {
|
|
137
323
|
const isIPv6 = net.isIPv6(address)
|
|
138
324
|
let str = `${address}`
|
package/lib/interceptor/query.js
CHANGED
|
@@ -11,6 +11,12 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
function serializePathWithQuery(url, queryParams) {
|
|
14
|
+
if (typeof url !== 'string') {
|
|
15
|
+
// A path-less object URL leaves opts.path undefined; fail with a clear
|
|
16
|
+
// message instead of a cryptic "Cannot read properties of undefined".
|
|
17
|
+
throw new Error('Query params require a string path.')
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
if (url.includes('?') || url.includes('#')) {
|
|
15
21
|
throw new Error('Query params cannot be passed when url already contains "?" or "#".')
|
|
16
22
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert'
|
|
2
|
-
import { DecoratorHandler, isDisturbed, parseURL } from '../utils.js'
|
|
2
|
+
import { DecoratorHandler, isDisturbed, parseURL, parseHeaders } from '../utils.js'
|
|
3
3
|
|
|
4
4
|
const redirectableStatusCodes = [300, 301, 302, 303, 307, 308]
|
|
5
5
|
|
|
@@ -21,15 +21,23 @@ class Handler extends DecoratorHandler {
|
|
|
21
21
|
|
|
22
22
|
this.#dispatch = dispatch
|
|
23
23
|
this.#opts = opts
|
|
24
|
-
|
|
24
|
+
// `follow: true` means "follow redirects" — map it to the project default
|
|
25
|
+
// cap rather than letting `true.count ?? 0` collapse to 0, which would
|
|
26
|
+
// reject the very first redirect with "Max redirections reached: 0".
|
|
27
|
+
this.#maxCount =
|
|
28
|
+
opts.follow === true
|
|
29
|
+
? 8
|
|
30
|
+
: Number.isFinite(opts.follow)
|
|
31
|
+
? opts.follow
|
|
32
|
+
: (opts.follow?.count ?? 0)
|
|
25
33
|
|
|
26
34
|
super.onConnect((reason) => {
|
|
27
35
|
this.#aborted = true
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
// Always remember the latest reason so it survives a caller abort that
|
|
37
|
+
// lands in the window between one hop completing and the next hop's
|
|
38
|
+
// onConnect; #abort may still point at the finished hop's no-op abort.
|
|
39
|
+
this.#reason = reason
|
|
40
|
+
this.#abort?.(reason)
|
|
33
41
|
})
|
|
34
42
|
}
|
|
35
43
|
|
|
@@ -73,7 +81,11 @@ class Handler extends DecoratorHandler {
|
|
|
73
81
|
return super.onHeaders(statusCode, headers, resume)
|
|
74
82
|
}
|
|
75
83
|
} else {
|
|
76
|
-
|
|
84
|
+
// `follow: N` follows up to N redirects and errors on the N+1th, matching
|
|
85
|
+
// undici/fetch maxRedirections semantics. `>` (not `>=`): with `>=`,
|
|
86
|
+
// `follow: 1` threw on the very first redirect with a self-contradictory
|
|
87
|
+
// "Max redirections reached: 1." message.
|
|
88
|
+
if (this.#count > this.#maxCount) {
|
|
77
89
|
throw Object.assign(new Error(`Max redirections reached: ${this.#maxCount}.`), {
|
|
78
90
|
history: this.#history,
|
|
79
91
|
})
|
|
@@ -181,6 +193,13 @@ function shouldRemoveHeader(header, removeContent, unknownOrigin) {
|
|
|
181
193
|
// https://tools.ietf.org/html/rfc7231#section-6.4
|
|
182
194
|
function cleanRequestHeaders(headers, removeContent, unknownOrigin) {
|
|
183
195
|
const ret = {}
|
|
196
|
+
if (Array.isArray(headers)) {
|
|
197
|
+
// undici accepts request headers as a flat [name, value, ...] array.
|
|
198
|
+
// Object.keys on that yields numeric indices, not field names, so the
|
|
199
|
+
// strip checks below would never match and the headers would be mangled
|
|
200
|
+
// into { '0': name, '1': value, ... }. Normalize to an object first.
|
|
201
|
+
headers = parseHeaders(headers)
|
|
202
|
+
}
|
|
184
203
|
if (headers && typeof headers === 'object') {
|
|
185
204
|
for (const key of Object.keys(headers)) {
|
|
186
205
|
if (!shouldRemoveHeader(key, removeContent, unknownOrigin)) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Readable } from 'node:stream'
|
|
1
|
+
import { Readable, finished } from 'node:stream'
|
|
2
2
|
import { isStream } from '../utils.js'
|
|
3
3
|
|
|
4
4
|
function noop() {}
|
|
@@ -38,9 +38,16 @@ class FactoryStream extends Readable {
|
|
|
38
38
|
.on('end', () => {
|
|
39
39
|
this.push(null)
|
|
40
40
|
})
|
|
41
|
-
|
|
41
|
+
// `finished` (not a bare 'error' listener) so a premature close —
|
|
42
|
+
// the inner body destroyed without emitting 'end' or 'error', which
|
|
43
|
+
// surfaces only as 'close' — becomes ERR_STREAM_PREMATURE_CLOSE and
|
|
44
|
+
// fails the request, instead of hanging forever with no terminal
|
|
45
|
+
// push(null). writable:false: these inner bodies are read-only.
|
|
46
|
+
finished(this.#body, { writable: false }, (err) => {
|
|
47
|
+
if (err) {
|
|
42
48
|
this.destroy(err)
|
|
43
|
-
}
|
|
49
|
+
}
|
|
50
|
+
})
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
callback(null)
|
|
@@ -12,8 +12,15 @@ function genReqId() {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export default () => (dispatch) => (opts, handler) => {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// Treat an empty string the same as absent in BOTH the selection and the
|
|
16
|
+
// chaining test (the old `??`-vs-truthy split kept a falsy opts.id, skipped
|
|
17
|
+
// the request-id header fallback, then dropped it anyway — losing a real
|
|
18
|
+
// parent id and breaking trace correlation).
|
|
19
|
+
let prevId = opts.id
|
|
20
|
+
if (prevId == null || prevId === '') {
|
|
21
|
+
prevId = opts.headers?.['request-id']
|
|
22
|
+
}
|
|
23
|
+
const nextId = prevId != null && prevId !== '' ? `${prevId},${genReqId()}` : genReqId()
|
|
17
24
|
return dispatch(
|
|
18
25
|
{
|
|
19
26
|
...opts,
|
|
@@ -35,10 +35,12 @@ class Handler extends DecoratorHandler {
|
|
|
35
35
|
return super.onHeaders(statusCode, headers, resume)
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
// A duplicated content-type header is an array; .startsWith would throw
|
|
39
|
+
// synchronously out of this parser callback. Coerce to the first value.
|
|
40
|
+
const contentType = Array.isArray(this.#headers['content-type'])
|
|
41
|
+
? this.#headers['content-type'][0]
|
|
42
|
+
: this.#headers['content-type']
|
|
43
|
+
if (contentType?.startsWith('application/json') || contentType?.startsWith('text/plain')) {
|
|
42
44
|
this.#decoder = new TextDecoder('utf-8')
|
|
43
45
|
this.#body = ''
|
|
44
46
|
}
|
|
@@ -151,18 +151,32 @@ class Handler extends DecoratorHandler {
|
|
|
151
151
|
return false
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
if (this.#pos === 0 && statusCode === 200) {
|
|
155
|
+
// We asked for a byte range to resume, but no bytes had been forwarded
|
|
156
|
+
// to the consumer yet, so a server that ignored Range and replied with
|
|
157
|
+
// the full 200 is acceptable — forward it from the start. (RFC 9110
|
|
158
|
+
// permits ignoring Range; if-match still guards against changed
|
|
159
|
+
// content via a 412.) Without this, a legal full 200 retry was
|
|
160
|
+
// rejected with "Response retry failed".
|
|
161
|
+
this.#resume = resume
|
|
162
|
+
return true
|
|
163
|
+
}
|
|
164
|
+
|
|
154
165
|
const contentRange = parseContentRange(headers['content-range'])
|
|
155
166
|
if (!contentRange) {
|
|
156
167
|
this.#maybeError(null)
|
|
157
168
|
return false
|
|
158
169
|
}
|
|
159
170
|
|
|
171
|
+
// Validate the server's content-range against our tracked position.
|
|
172
|
+
// These values are server-controlled, so route a mismatch through the
|
|
173
|
+
// same graceful error path as the branches above — an assert() here
|
|
174
|
+
// would throw out of onHeaders (a parser callback) and hang the stream.
|
|
160
175
|
const { start, size, end = size } = contentRange
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
)
|
|
176
|
+
if (this.#pos !== start || (this.#end != null && this.#end !== end)) {
|
|
177
|
+
this.#maybeError(null)
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
166
180
|
|
|
167
181
|
this.#resume = resume
|
|
168
182
|
|
|
@@ -333,10 +347,19 @@ class Handler extends DecoratorHandler {
|
|
|
333
347
|
const headers = err?.headers ?? this.#headers
|
|
334
348
|
|
|
335
349
|
if (statusCode && [420, 429, 502, 503, 504].includes(statusCode)) {
|
|
336
|
-
const
|
|
350
|
+
const raw = headers?.['retry-after']
|
|
351
|
+
let retryAfter = raw ? Number(raw) * 1e3 : null
|
|
352
|
+
if (raw && (retryAfter == null || !Number.isFinite(retryAfter))) {
|
|
353
|
+
// RFC 9110: Retry-After may be an HTTP-date rather than delta-seconds.
|
|
354
|
+
const date = Date.parse(raw)
|
|
355
|
+
retryAfter = Number.isFinite(date) ? Math.max(0, date - Date.now()) : null
|
|
356
|
+
}
|
|
337
357
|
const delay =
|
|
338
358
|
retryAfter != null && Number.isFinite(retryAfter)
|
|
339
|
-
?
|
|
359
|
+
? // Clamp the server-controlled wait: bounds a hostile/misconfigured
|
|
360
|
+
// value and avoids the 32-bit timer overflow that makes huge delays
|
|
361
|
+
// fire immediately.
|
|
362
|
+
Math.min(retryAfter, 60e3)
|
|
340
363
|
: Math.min(10e3, retryCount * 1e3)
|
|
341
364
|
this.#opts.logger?.debug({ statusCode, retryAfter, delay, retryCount }, 'retry delay')
|
|
342
365
|
return tp.setTimeout(delay, true, { signal: opts?.signal ?? undefined })
|
|
@@ -7,6 +7,7 @@ class Handler extends DecoratorHandler {
|
|
|
7
7
|
#expectedSize
|
|
8
8
|
#hasher
|
|
9
9
|
#pos = 0
|
|
10
|
+
#abort
|
|
10
11
|
|
|
11
12
|
constructor(opts, { handler }) {
|
|
12
13
|
super(handler)
|
|
@@ -19,12 +20,22 @@ class Handler extends DecoratorHandler {
|
|
|
19
20
|
this.#expectedSize = null
|
|
20
21
|
this.#hasher = null
|
|
21
22
|
this.#pos = 0
|
|
23
|
+
// Keep the raw transport abort so a mid-stream verification failure can
|
|
24
|
+
// tear down the connection. The DecoratorHandler-wrapped abort becomes a
|
|
25
|
+
// no-op once super.onError sets #errored, so we must drive abort directly.
|
|
26
|
+
this.#abort = abort
|
|
22
27
|
|
|
23
28
|
super.onConnect(abort)
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
onHeaders(statusCode, headers, resume) {
|
|
27
|
-
|
|
32
|
+
// A duplicated Content-MD5 header arrives as an array. Several identical
|
|
33
|
+
// copies (a CDN/proxy re-appending its own) describe the same digest, so
|
|
34
|
+
// collapse them to one; genuinely conflicting copies are kept as an array
|
|
35
|
+
// so the strict comparison in onComplete still fails. A non-array (the
|
|
36
|
+
// common single-header case) is used verbatim.
|
|
37
|
+
const md5 = this.#verifyOpts.hash ? headers['content-md5'] : null
|
|
38
|
+
this.#contentMD5 = Array.isArray(md5) && md5.every((v) => v === md5[0]) ? md5[0] : md5
|
|
28
39
|
|
|
29
40
|
if (this.#verifyOpts.size) {
|
|
30
41
|
const contentRange = parseContentRange(headers['content-range'])
|
|
@@ -45,12 +56,14 @@ class Handler extends DecoratorHandler {
|
|
|
45
56
|
this.#hasher?.update(chunk)
|
|
46
57
|
|
|
47
58
|
if (this.#expectedSize != null && this.#pos > this.#expectedSize) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
59
|
+
const err = Object.assign(new Error('Response body exceeded Content-Range'), {
|
|
60
|
+
expected: this.#expectedSize,
|
|
61
|
+
actual: this.#pos,
|
|
62
|
+
})
|
|
63
|
+
super.onError(err)
|
|
64
|
+
// Returning false only applies backpressure; the socket would stay
|
|
65
|
+
// paused until bodyTimeout. Abort to release the connection now.
|
|
66
|
+
this.#abort?.(err)
|
|
54
67
|
return false
|
|
55
68
|
}
|
|
56
69
|
|
package/lib/request.js
CHANGED
|
@@ -58,8 +58,16 @@ export class RequestHandler {
|
|
|
58
58
|
this.abort(this.reason)
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// The validation above accepts either an EventTarget (addEventListener)
|
|
62
|
+
// or an EventEmitter (.on), matching undici's contract — so honour both
|
|
63
|
+
// here instead of assuming addEventListener and crashing on an emitter.
|
|
64
|
+
if (typeof signal.addEventListener === 'function') {
|
|
65
|
+
signal.addEventListener('abort', onAbort)
|
|
66
|
+
this.removeAbortListener = () => signal.removeEventListener('abort', onAbort)
|
|
67
|
+
} else {
|
|
68
|
+
signal.on('abort', onAbort)
|
|
69
|
+
this.removeAbortListener = () => signal.removeListener('abort', onAbort)
|
|
70
|
+
}
|
|
63
71
|
}
|
|
64
72
|
}
|
|
65
73
|
|
|
@@ -150,9 +158,17 @@ export function request(dispatch, urlOrOpts, optsOrNully) {
|
|
|
150
158
|
let url = urlOrOpts
|
|
151
159
|
let opts = optsOrNully
|
|
152
160
|
|
|
153
|
-
if (typeof url === 'object' && url != null
|
|
154
|
-
opts
|
|
155
|
-
|
|
161
|
+
if (typeof url === 'object' && url != null) {
|
|
162
|
+
if (opts == null) {
|
|
163
|
+
// Single-arg form: the object is both the url source and the opts.
|
|
164
|
+
opts = url
|
|
165
|
+
url = url.url ?? url
|
|
166
|
+
} else if (url.url != null) {
|
|
167
|
+
// Two-arg object-first form, e.g. request({ url }, { dispatcher }):
|
|
168
|
+
// unwrap the url field but keep the separately-provided opts. A genuine
|
|
169
|
+
// WHATWG URL has no `.url` property, so real URL objects are unaffected.
|
|
170
|
+
url = url.url
|
|
171
|
+
}
|
|
156
172
|
}
|
|
157
173
|
|
|
158
174
|
if (typeof url === 'string') {
|
|
@@ -172,7 +188,9 @@ export function request(dispatch, urlOrOpts, optsOrNully) {
|
|
|
172
188
|
const protocol = url.protocol ?? 'http:'
|
|
173
189
|
const host =
|
|
174
190
|
url.host ??
|
|
175
|
-
|
|
191
|
+
// `||` not `??`: an empty-string port must fall back to the default,
|
|
192
|
+
// matching defaultLookup; `??` would yield a trailing-colon origin.
|
|
193
|
+
(url.hostname ? `${url.hostname}:${url.port || (protocol === 'https:' ? 443 : 80)}` : null)
|
|
176
194
|
|
|
177
195
|
if (!host || !protocol) {
|
|
178
196
|
throw new InvalidArgumentError('invalid url')
|
|
@@ -193,5 +211,16 @@ export function request(dispatch, urlOrOpts, optsOrNully) {
|
|
|
193
211
|
body: isStream(opts?.body) && opts.body.closed ? null : opts?.body,
|
|
194
212
|
}
|
|
195
213
|
|
|
196
|
-
return new Promise((resolve) =>
|
|
214
|
+
return new Promise((resolve) => {
|
|
215
|
+
const handler = new RequestHandler(opts, resolve)
|
|
216
|
+
try {
|
|
217
|
+
dispatch(opts, handler)
|
|
218
|
+
} catch (err) {
|
|
219
|
+
// A synchronous throw from dispatch otherwise skips onConnect/onError, so
|
|
220
|
+
// the request body stream is never destroyed and the signal's abort
|
|
221
|
+
// listener is never removed — both leak. Route it through onError, whose
|
|
222
|
+
// idempotent guards make this harmless if dispatch already reported.
|
|
223
|
+
handler.onError(err)
|
|
224
|
+
}
|
|
225
|
+
})
|
|
197
226
|
}
|