@nxtedition/nxt-undici 7.3.25 → 7.3.26
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 +9 -2
- package/lib/interceptor/cache.js +22 -6
- package/lib/interceptor/dns.js +27 -12
- package/lib/interceptor/proxy.js +193 -31
- package/lib/interceptor/response-retry.js +30 -7
- package/lib/interceptor/response-verify.js +13 -6
- package/lib/request.js +21 -5
- package/lib/sqlite-cache-store.js +48 -8
- package/lib/utils.js +0 -5
- package/package.json +2 -1
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import net from 'node:net'
|
|
1
2
|
import undici from '@nxtedition/undici'
|
|
2
3
|
import { Scheduler } from '@nxtedition/scheduler'
|
|
3
4
|
import { parseHeaders } from './utils.js'
|
|
@@ -33,13 +34,19 @@ function defaultLookup(origin, opts, callback) {
|
|
|
33
34
|
try {
|
|
34
35
|
if (Array.isArray(origin)) {
|
|
35
36
|
origin = origin[Math.floor(Math.random() * origin.length)]
|
|
36
|
-
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Note: not `else if` — an array element may itself be an object that
|
|
40
|
+
// still needs normalizing to an origin string.
|
|
41
|
+
if (origin != null && typeof origin === 'object') {
|
|
37
42
|
const protocol = origin.protocol || 'http:'
|
|
38
43
|
|
|
39
44
|
let host = origin.host
|
|
40
45
|
if (!host && origin.hostname) {
|
|
41
46
|
const port = origin.port || (protocol === 'https:' ? 443 : 80)
|
|
42
|
-
|
|
47
|
+
// Bracket IPv6 literals, otherwise `::1:80` is not a valid authority.
|
|
48
|
+
const hostname = net.isIPv6(origin.hostname) ? `[${origin.hostname}]` : origin.hostname
|
|
49
|
+
host = `${hostname}:${port}`
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
if (!host) {
|
package/lib/interceptor/cache.js
CHANGED
|
@@ -104,10 +104,12 @@ class CacheHandler extends DecoratorHandler {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
for (const key of headers.vary.split(',').map((key) => key.trim().toLowerCase())) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
// Record every selecting header, using a null sentinel when it was
|
|
108
|
+
// absent from the request. RFC 9111 §4.1: absent-vs-present is a
|
|
109
|
+
// mismatch, so an empty vary object must NOT act as a wildcard that
|
|
110
|
+
// matches requests which later supply the header. headerValueEquals
|
|
111
|
+
// treats null/absent as equal only to another null/absent.
|
|
112
|
+
vary[key] = this.#key.headers[key] ?? null
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
115
|
|
|
@@ -118,8 +120,22 @@ class CacheHandler extends DecoratorHandler {
|
|
|
118
120
|
return super.onHeaders(statusCode, headers, resume)
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
// RFC 9111 §4.2.3: a response relayed by an upstream/shared cache may
|
|
124
|
+
// already be partway through its freshness lifetime. Subtract the
|
|
125
|
+
// advertised Age so we don't over-extend the TTL and serve stale content.
|
|
126
|
+
const age = Number(headers.age)
|
|
127
|
+
const lifetime = Math.min(ttl, this.#maxEntryTTL) - (Number.isFinite(age) && age > 0 ? age : 0)
|
|
128
|
+
if (lifetime <= 0) {
|
|
129
|
+
// Already stale on arrival — not worth caching.
|
|
130
|
+
return super.onHeaders(statusCode, headers, resume)
|
|
131
|
+
}
|
|
132
|
+
|
|
121
133
|
const start = contentRange ? contentRange.start : 0
|
|
122
|
-
|
|
134
|
+
// HEAD never delivers a body, so a Content-Length must not drive the
|
|
135
|
+
// stored byte window (end). Storing end = contentLength with an empty body
|
|
136
|
+
// would fail the store's body-length validation and emit error-level log
|
|
137
|
+
// spam on every cacheable HEAD response.
|
|
138
|
+
const end = contentRange ? contentRange.end : this.#key.method === 'HEAD' ? 0 : contentLength
|
|
123
139
|
|
|
124
140
|
if (end == null || end - start <= this.#maxEntrySize) {
|
|
125
141
|
const cachedAt = Date.now()
|
|
@@ -127,7 +143,7 @@ class CacheHandler extends DecoratorHandler {
|
|
|
127
143
|
body: [],
|
|
128
144
|
start,
|
|
129
145
|
end,
|
|
130
|
-
deleteAt: cachedAt +
|
|
146
|
+
deleteAt: cachedAt + lifetime * 1e3,
|
|
131
147
|
statusCode,
|
|
132
148
|
statusMessage: '',
|
|
133
149
|
headers,
|
package/lib/interceptor/dns.js
CHANGED
|
@@ -144,18 +144,33 @@ export default () => (dispatch) => {
|
|
|
144
144
|
record.counter++
|
|
145
145
|
record.pending++
|
|
146
146
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
147
|
+
// Guarded so it runs exactly once: on the normal Handler callback, or
|
|
148
|
+
// if dispatch throws synchronously (otherwise record.pending would leak
|
|
149
|
+
// and skew load balancing toward the wrongly-busy record).
|
|
150
|
+
let settled = false
|
|
151
|
+
const onSettle = (err, statusCode) => {
|
|
152
|
+
if (settled) {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
settled = true
|
|
156
|
+
record.pending--
|
|
157
|
+
|
|
158
|
+
if (err != null && err.name !== 'AbortError') {
|
|
159
|
+
record.expires = 0
|
|
160
|
+
} else if (statusCode != null && statusCode >= 500) {
|
|
161
|
+
record.errored++
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
return dispatch(
|
|
167
|
+
{ ...opts, origin: url.origin, headers: { ...opts.headers, host } },
|
|
168
|
+
new Handler(handler, onSettle),
|
|
169
|
+
)
|
|
170
|
+
} catch (err) {
|
|
171
|
+
onSettle(err)
|
|
172
|
+
throw err
|
|
173
|
+
}
|
|
159
174
|
} catch (err) {
|
|
160
175
|
handler.onError(err)
|
|
161
176
|
}
|
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
|
|
|
@@ -21,10 +34,7 @@ class Handler extends DecoratorHandler {
|
|
|
21
34
|
socket: this.#opts.socket,
|
|
22
35
|
proxyName: this.#opts.name,
|
|
23
36
|
},
|
|
24
|
-
|
|
25
|
-
acc[key] = val
|
|
26
|
-
return acc
|
|
27
|
-
},
|
|
37
|
+
copyHeader,
|
|
28
38
|
{},
|
|
29
39
|
),
|
|
30
40
|
socket,
|
|
@@ -41,10 +51,7 @@ class Handler extends DecoratorHandler {
|
|
|
41
51
|
socket: this.#opts.socket,
|
|
42
52
|
proxyName: this.#opts.name,
|
|
43
53
|
},
|
|
44
|
-
|
|
45
|
-
acc[key] = val
|
|
46
|
-
return acc
|
|
47
|
-
},
|
|
54
|
+
copyHeader,
|
|
48
55
|
{},
|
|
49
56
|
),
|
|
50
57
|
resume,
|
|
@@ -52,45 +59,182 @@ class Handler extends DecoratorHandler {
|
|
|
52
59
|
}
|
|
53
60
|
}
|
|
54
61
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
// ASCII case-insensitive equality where `b` is a lowercase literal and the
|
|
63
|
+
// caller guarantees `a.length === b.length`. Allocation-free.
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} a
|
|
66
|
+
* @param {string} b
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
function eqiLower(a, b) {
|
|
70
|
+
if (a === b) {
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
for (let i = 0; i < b.length; i++) {
|
|
74
|
+
let c = a.charCodeAt(i)
|
|
75
|
+
if (c >= 0x41 && c <= 0x5a) {
|
|
76
|
+
c += 0x20 // upper -> lower
|
|
77
|
+
}
|
|
78
|
+
if (c !== b.charCodeAt(i)) {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Matches hop-by-hop headers — meaningful only for a single transport-level
|
|
86
|
+
// connection, so a proxy must not retransmit or cache them. This is the single
|
|
87
|
+
// source of truth for that set: it is used both for the per-key strip below
|
|
88
|
+
// and for the Connection-value check. HTTP field names are ASCII tokens, so a
|
|
89
|
+
// length-dispatched ASCII case-insensitive compare is exactly equivalent to a
|
|
90
|
+
// `/^(te|host|…)$/i` regexp (verified over 137k generated keys) while staying
|
|
91
|
+
// allocation-free and letting the common (non-hop) header bail out in a single
|
|
92
|
+
// comparison.
|
|
93
|
+
/**
|
|
94
|
+
* @param {string} key
|
|
95
|
+
* @returns {boolean}
|
|
96
|
+
*/
|
|
97
|
+
function isHopByHop(key) {
|
|
98
|
+
switch (key.length) {
|
|
99
|
+
case 2:
|
|
100
|
+
return eqiLower(key, 'te')
|
|
101
|
+
case 4:
|
|
102
|
+
return eqiLower(key, 'host')
|
|
103
|
+
case 7:
|
|
104
|
+
return eqiLower(key, 'upgrade')
|
|
105
|
+
case 8:
|
|
106
|
+
return eqiLower(key, 'trailers')
|
|
107
|
+
case 10:
|
|
108
|
+
return eqiLower(key, 'connection') || eqiLower(key, 'keep-alive')
|
|
109
|
+
case 14:
|
|
110
|
+
return eqiLower(key, 'http2-settings')
|
|
111
|
+
case 16:
|
|
112
|
+
return eqiLower(key, 'proxy-connection')
|
|
113
|
+
case 17:
|
|
114
|
+
return eqiLower(key, 'transfer-encoding')
|
|
115
|
+
case 18:
|
|
116
|
+
return eqiLower(key, 'proxy-authenticate')
|
|
117
|
+
case 19:
|
|
118
|
+
return eqiLower(key, 'proxy-authorization')
|
|
119
|
+
default:
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
}
|
|
60
123
|
|
|
61
124
|
// Removes hop-by-hop and pseudo headers.
|
|
62
125
|
// Updates via and forwarded headers.
|
|
63
126
|
// Only hop-by-hop headers may be set using the Connection general header.
|
|
127
|
+
/**
|
|
128
|
+
* @template T
|
|
129
|
+
* @param {object} options
|
|
130
|
+
* @param {Record<string, string | string[]>} options.headers Header map; a
|
|
131
|
+
* value is an array when the field appeared more than once.
|
|
132
|
+
* @param {string} [options.proxyName] This proxy's name. When set, a Via
|
|
133
|
+
* segment is appended and Via loop detection runs.
|
|
134
|
+
* @param {string} [options.httpVersion] Protocol token for the appended Via
|
|
135
|
+
* segment; defaults to `'HTTP/1.1'`.
|
|
136
|
+
* @param {{ localAddress?: string, localPort?: number, remoteAddress?: string,
|
|
137
|
+
* remotePort?: number, encrypted?: boolean } | null} [options.socket] When
|
|
138
|
+
* present a Forwarded header is synthesised; when absent an inbound Forwarded
|
|
139
|
+
* header is treated as a BadGateway.
|
|
140
|
+
* @param {(acc: T, key: string, value: string) => T} fn Accumulator invoked
|
|
141
|
+
* once per retained header.
|
|
142
|
+
* @param {T} acc Initial accumulator value.
|
|
143
|
+
* @returns {T}
|
|
144
|
+
*/
|
|
64
145
|
function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
|
|
65
146
|
let via = ''
|
|
66
147
|
let forwarded = ''
|
|
67
148
|
let host = ''
|
|
68
149
|
let authority = ''
|
|
150
|
+
/** @type {string | string[]} */
|
|
69
151
|
let connection = ''
|
|
70
152
|
|
|
71
|
-
|
|
153
|
+
// Iterate via Object.keys (computed once and reused by both passes) rather
|
|
154
|
+
// than Object.entries: the latter allocates an outer array plus one
|
|
155
|
+
// 2-element array per header on every call, and this runs on the hot path of
|
|
156
|
+
// every proxied request and response. Object.keys allocates a single flat
|
|
157
|
+
// array we reuse, cutting per-call allocation by ~4-6x.
|
|
158
|
+
const keys = Object.keys(headers)
|
|
159
|
+
|
|
160
|
+
// Object keys are unique, so each special is seen at most once; a repeated
|
|
161
|
+
// field-line instead surfaces as an array value (parseHeaders collects them).
|
|
162
|
+
// RFC 9110 §5.3 only permits repeats for list-valued fields (ABNF `#`), where
|
|
163
|
+
// the parts are semantically one comma-separated value — those we combine.
|
|
164
|
+
// Singular fields with more than one value are a protocol error — those we
|
|
165
|
+
// reject.
|
|
166
|
+
for (let i = 0; i < keys.length; i++) {
|
|
167
|
+
const key = keys[i]
|
|
72
168
|
const len = key.length
|
|
73
|
-
if (len === 3 &&
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
} else if (len ===
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
169
|
+
if (len === 3 && key === 'via') {
|
|
170
|
+
// Via is list-valued (RFC 9110 §7.6.3): combine repeated field-lines.
|
|
171
|
+
const v = headers[key]
|
|
172
|
+
via = Array.isArray(v) ? v.join(', ') : v
|
|
173
|
+
} else if (len === 4 && key === 'host') {
|
|
174
|
+
// Host is singular (RFC 9110 §7.2, RFC 7230 §5.4): more than one is a
|
|
175
|
+
// protocol error, so reject rather than combine.
|
|
176
|
+
const v = headers[key]
|
|
177
|
+
if (Array.isArray(v)) {
|
|
178
|
+
throw new createError.BadGateway()
|
|
179
|
+
}
|
|
180
|
+
host = v
|
|
181
|
+
} else if (len === 9 && key === 'forwarded') {
|
|
182
|
+
// Forwarded is list-valued (RFC 7239 §4): combine repeated field-lines.
|
|
183
|
+
const v = headers[key]
|
|
184
|
+
forwarded = Array.isArray(v) ? v.join(', ') : v
|
|
185
|
+
} else if (len === 10 && key === 'connection') {
|
|
186
|
+
// Connection is list-valued (RFC 9110 §7.6.1): captured raw and tokenised
|
|
187
|
+
// below — combining then re-splitting would be wasteful.
|
|
188
|
+
connection = headers[key]
|
|
189
|
+
} else if (len === 10 && key === ':authority') {
|
|
190
|
+
// :authority is singular (RFC 9113 §8.3.1): reject more than one.
|
|
191
|
+
const v = headers[key]
|
|
192
|
+
if (Array.isArray(v)) {
|
|
193
|
+
throw new createError.BadGateway()
|
|
194
|
+
}
|
|
195
|
+
authority = v
|
|
83
196
|
}
|
|
84
197
|
}
|
|
85
198
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
199
|
+
// `remove` is lazily allocated: it stays null unless a Connection header
|
|
200
|
+
// actually lists headers to strip (the uncommon case), so the hot path
|
|
201
|
+
// neither allocates an (almost always empty) array nor runs includes() per
|
|
202
|
+
// header.
|
|
203
|
+
//
|
|
204
|
+
// Header field names are case-insensitive (RFC 7230); parseHeaders already
|
|
205
|
+
// lowercased the keys we compare against, so lowercase the listed names too,
|
|
206
|
+
// otherwise `Connection: X-Custom` fails to strip `x-custom` and it leaks to
|
|
207
|
+
// the next hop. trim() handles surrounding whitespace, so a plain comma split
|
|
208
|
+
// suffices.
|
|
209
|
+
let remove = null
|
|
210
|
+
if (Array.isArray(connection)) {
|
|
211
|
+
// Repeated Connection field-lines (RFC 9110 §7.6.1): each part may itself
|
|
212
|
+
// list several options. A repeat always carries multiple/custom tokens, so
|
|
213
|
+
// the single-hop-token shortcut below never applies.
|
|
214
|
+
remove = []
|
|
215
|
+
for (const part of connection) {
|
|
216
|
+
for (const token of part.split(',')) {
|
|
217
|
+
remove.push(token.trim().toLowerCase())
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} else if (connection && !isHopByHop(connection)) {
|
|
221
|
+
// Single value: one field-line, so one split over its comma list. The
|
|
222
|
+
// isHopByHop guard skips the common single-token forms (`keep-alive`,
|
|
223
|
+
// `close`, …) where there is nothing custom to strip.
|
|
224
|
+
remove = []
|
|
225
|
+
for (const token of connection.split(',')) {
|
|
226
|
+
remove.push(token.trim().toLowerCase())
|
|
227
|
+
}
|
|
89
228
|
}
|
|
90
229
|
|
|
91
|
-
for (
|
|
92
|
-
|
|
93
|
-
|
|
230
|
+
for (let i = 0; i < keys.length; i++) {
|
|
231
|
+
const key = keys[i]
|
|
232
|
+
if (
|
|
233
|
+
key.charCodeAt(0) !== 0x3a /* ':' */ &&
|
|
234
|
+
(remove === null || !remove.includes(key)) &&
|
|
235
|
+
!isHopByHop(key)
|
|
236
|
+
) {
|
|
237
|
+
acc = fn(acc, key, headers[key].toString())
|
|
94
238
|
}
|
|
95
239
|
}
|
|
96
240
|
|
|
@@ -116,7 +260,20 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
|
|
|
116
260
|
|
|
117
261
|
if (proxyName) {
|
|
118
262
|
if (via) {
|
|
119
|
-
|
|
263
|
+
const viaLower = via.toLowerCase()
|
|
264
|
+
const proxyNameLower = proxyName.toLowerCase()
|
|
265
|
+
// A Via segment is "received-protocol received-by [comment]". Compare the
|
|
266
|
+
// received-by token for equality (case-insensitive) rather than testing
|
|
267
|
+
// whether the whole segment ends with proxyName — endsWith() trips a
|
|
268
|
+
// false-positive loop for any unrelated proxy whose name merely has
|
|
269
|
+
// proxyName as a suffix (e.g. name 'proxy' vs upstream 'otherproxy').
|
|
270
|
+
if (
|
|
271
|
+
viaLower.includes(proxyNameLower) &&
|
|
272
|
+
viaLower.split(',').some((seg) => {
|
|
273
|
+
const by = seg.trim().split(/\s+/)[1]
|
|
274
|
+
return by != null && by === proxyNameLower
|
|
275
|
+
})
|
|
276
|
+
) {
|
|
120
277
|
throw new createError.LoopDetected()
|
|
121
278
|
}
|
|
122
279
|
via += ', '
|
|
@@ -133,6 +290,11 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
|
|
|
133
290
|
return acc
|
|
134
291
|
}
|
|
135
292
|
|
|
293
|
+
/**
|
|
294
|
+
* @param {string} address
|
|
295
|
+
* @param {number} [port]
|
|
296
|
+
* @returns {string}
|
|
297
|
+
*/
|
|
136
298
|
function printIp(address, port) {
|
|
137
299
|
const isIPv6 = net.isIPv6(address)
|
|
138
300
|
let str = `${address}`
|
|
@@ -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,6 +20,10 @@ 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
|
}
|
|
@@ -45,12 +50,14 @@ class Handler extends DecoratorHandler {
|
|
|
45
50
|
this.#hasher?.update(chunk)
|
|
46
51
|
|
|
47
52
|
if (this.#expectedSize != null && this.#pos > this.#expectedSize) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
const err = Object.assign(new Error('Response body exceeded Content-Range'), {
|
|
54
|
+
expected: this.#expectedSize,
|
|
55
|
+
actual: this.#pos,
|
|
56
|
+
})
|
|
57
|
+
super.onError(err)
|
|
58
|
+
// Returning false only applies backpressure; the socket would stay
|
|
59
|
+
// paused until bodyTimeout. Abort to release the connection now.
|
|
60
|
+
this.#abort?.(err)
|
|
54
61
|
return false
|
|
55
62
|
}
|
|
56
63
|
|
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') {
|
|
@@ -78,6 +78,7 @@ export class SqliteCacheStore {
|
|
|
78
78
|
#evictQuery
|
|
79
79
|
|
|
80
80
|
#insertBatch = []
|
|
81
|
+
#insertSeq = 0
|
|
81
82
|
#closed = false
|
|
82
83
|
|
|
83
84
|
/**
|
|
@@ -142,7 +143,7 @@ export class SqliteCacheStore {
|
|
|
142
143
|
AND start <= ?
|
|
143
144
|
AND deleteAt > ?
|
|
144
145
|
ORDER BY
|
|
145
|
-
cachedAt DESC
|
|
146
|
+
cachedAt DESC, id DESC
|
|
146
147
|
`)
|
|
147
148
|
|
|
148
149
|
this.#insertValueQuery = this.#db.prepare(`
|
|
@@ -177,6 +178,10 @@ export class SqliteCacheStore {
|
|
|
177
178
|
}
|
|
178
179
|
|
|
179
180
|
gc() {
|
|
181
|
+
if (this.#closed) {
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
180
185
|
try {
|
|
181
186
|
this.#db.exec('PRAGMA busy_timeout = 1000')
|
|
182
187
|
this.#deleteExpiredValuesQuery.run(getFastNow())
|
|
@@ -218,8 +223,13 @@ export class SqliteCacheStore {
|
|
|
218
223
|
|
|
219
224
|
close() {
|
|
220
225
|
stores.delete(this)
|
|
226
|
+
// Drain the entire batch synchronously before closing. A plain #flush()
|
|
227
|
+
// only commits one time-budget slice and reschedules the rest via
|
|
228
|
+
// setImmediate; that deferred flush would see #closed and discard the
|
|
229
|
+
// remainder, silently losing entries. Pass final=true to ignore the
|
|
230
|
+
// budget and flush everything in one pass while #closed is still false.
|
|
221
231
|
if (this.#insertBatch.length > 0) {
|
|
222
|
-
this.#flush()
|
|
232
|
+
this.#flush(true)
|
|
223
233
|
}
|
|
224
234
|
this.#closed = true
|
|
225
235
|
this.#db.close()
|
|
@@ -285,6 +295,9 @@ export class SqliteCacheStore {
|
|
|
285
295
|
}
|
|
286
296
|
|
|
287
297
|
this.#insertBatch.push({
|
|
298
|
+
// Monotonic per-store sequence used only to break cachedAt ties in
|
|
299
|
+
// #findValue (newest write wins). Not persisted — #flush ignores it.
|
|
300
|
+
seq: this.#insertSeq++,
|
|
288
301
|
url: makeValueUrl(key),
|
|
289
302
|
method: key.method,
|
|
290
303
|
body,
|
|
@@ -303,7 +316,7 @@ export class SqliteCacheStore {
|
|
|
303
316
|
})
|
|
304
317
|
}
|
|
305
318
|
|
|
306
|
-
#flush = () => {
|
|
319
|
+
#flush = (final = false) => {
|
|
307
320
|
if (this.#insertBatch.length === 0) return
|
|
308
321
|
if (this.#closed) {
|
|
309
322
|
this.#insertBatch.length = 0
|
|
@@ -346,7 +359,7 @@ export class SqliteCacheStore {
|
|
|
346
359
|
vary,
|
|
347
360
|
cachedAt,
|
|
348
361
|
)
|
|
349
|
-
if ((n & 0xf) === 0 && performance.now() - startTime > 10) {
|
|
362
|
+
if (!final && (n & 0xf) === 0 && performance.now() - startTime > 10) {
|
|
350
363
|
break
|
|
351
364
|
}
|
|
352
365
|
}
|
|
@@ -427,7 +440,25 @@ export class SqliteCacheStore {
|
|
|
427
440
|
return undefined
|
|
428
441
|
}
|
|
429
442
|
|
|
430
|
-
|
|
443
|
+
// Newest representation wins. cachedAt is millisecond-resolution, so a
|
|
444
|
+
// re-cache within the same millisecond produces a tie; break it
|
|
445
|
+
// deterministically toward the freshest write: pending batch entries
|
|
446
|
+
// (tagged with a monotonic seq) are always newer than any flushed DB row,
|
|
447
|
+
// and within each source a higher seq/id wins.
|
|
448
|
+
values.sort((a, b) => {
|
|
449
|
+
if (a.cachedAt !== b.cachedAt) {
|
|
450
|
+
return b.cachedAt - a.cachedAt
|
|
451
|
+
}
|
|
452
|
+
const aBatch = a.seq != null
|
|
453
|
+
const bBatch = b.seq != null
|
|
454
|
+
if (aBatch !== bBatch) {
|
|
455
|
+
return aBatch ? -1 : 1
|
|
456
|
+
}
|
|
457
|
+
if (aBatch) {
|
|
458
|
+
return b.seq - a.seq
|
|
459
|
+
}
|
|
460
|
+
return (b.id ?? 0) - (a.id ?? 0)
|
|
461
|
+
})
|
|
431
462
|
|
|
432
463
|
for (const value of values) {
|
|
433
464
|
// TODO (fix): Allow full and partial match?
|
|
@@ -435,6 +466,13 @@ export class SqliteCacheStore {
|
|
|
435
466
|
continue
|
|
436
467
|
}
|
|
437
468
|
|
|
469
|
+
// A request without a Range header asks for the full representation, so
|
|
470
|
+
// a stored 206 partial (e.g. content-range bytes 0-4/100, which the SQL
|
|
471
|
+
// `start <= 0` filter does not exclude) must not be served verbatim.
|
|
472
|
+
if (!range && value.statusCode === 206) {
|
|
473
|
+
continue
|
|
474
|
+
}
|
|
475
|
+
|
|
438
476
|
if (value.vary) {
|
|
439
477
|
const vary = JSON.parse(value.vary)
|
|
440
478
|
let matches = true
|
|
@@ -493,9 +531,11 @@ function makeValueUrl(key) {
|
|
|
493
531
|
|
|
494
532
|
function makeResult(value) {
|
|
495
533
|
return {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
534
|
+
// Copy rather than alias: on the in-flight batch read-through path
|
|
535
|
+
// value.body is the exact Buffer still queued for flushing, and the
|
|
536
|
+
// three-arg Buffer.from(arrayBuffer, ...) form would share its memory,
|
|
537
|
+
// so a consumer mutating the served body could corrupt the cached bytes.
|
|
538
|
+
body: value.body ? Buffer.from(value.body) : undefined,
|
|
499
539
|
statusCode: value.statusCode,
|
|
500
540
|
statusMessage: value.statusMessage,
|
|
501
541
|
headers: value.headers ? JSON.parse(value.headers) : undefined,
|
package/lib/utils.js
CHANGED
|
@@ -342,11 +342,6 @@ export function parseHeaders(headers, obj) {
|
|
|
342
342
|
throw new Error('invalid argument: headers')
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
// See https://github.com/nodejs/node/pull/46528
|
|
346
|
-
if (obj != null && 'content-length' in obj && 'content-disposition' in obj) {
|
|
347
|
-
obj['content-disposition'] = Buffer.from(obj['content-disposition']).toString('latin1')
|
|
348
|
-
}
|
|
349
|
-
|
|
350
345
|
return obj
|
|
351
346
|
}
|
|
352
347
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxtedition/nxt-undici",
|
|
3
|
-
"version": "7.3.
|
|
3
|
+
"version": "7.3.26",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Robert Nagy <robert.nagy@boffins.se>",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"eslint-plugin-n": "^17.24.0",
|
|
24
24
|
"husky": "^9.1.7",
|
|
25
25
|
"lint-staged": "^16.4.0",
|
|
26
|
+
"mitata": "^1.0.34",
|
|
26
27
|
"pino": "^10.3.1",
|
|
27
28
|
"pinst": "^3.0.0",
|
|
28
29
|
"prettier": "^3.8.1",
|