@nxtedition/nxt-undici 7.3.26 → 7.4.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 +56 -0
- package/lib/index.js +15 -2
- package/lib/interceptor/cache.js +103 -19
- package/lib/interceptor/dns.js +21 -0
- package/lib/interceptor/lookup.js +10 -2
- package/lib/interceptor/pressure.js +317 -0
- package/lib/interceptor/priority.js +23 -4
- package/lib/interceptor/proxy.js +38 -14
- 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-verify.js +7 -1
- package/lib/request.js +15 -2
- package/lib/sqlite-cache-store.js +83 -48
- package/lib/utils.js +16 -7
- package/package.json +1 -1
package/lib/index.d.ts
CHANGED
|
@@ -137,6 +137,61 @@ export interface LogInterceptorOptions {
|
|
|
137
137
|
bindings?: Record<string, unknown>
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
export interface PressureInterceptorOptions {
|
|
141
|
+
/** Sampling interval for the internal EWMA loop, in ms (default 200).
|
|
142
|
+
* Set to 0 to disable the internal timer and drive sampling yourself via
|
|
143
|
+
* `sample()` from a loop you already run. */
|
|
144
|
+
sampleInterval?: number
|
|
145
|
+
/** EWMA time-constant in ms — the smoothing/desensitizing window (default 10000). */
|
|
146
|
+
tau?: number
|
|
147
|
+
/** Schmitt-trigger dead-band for `some` (shed discretionary work). */
|
|
148
|
+
someHi?: number
|
|
149
|
+
someLo?: number
|
|
150
|
+
/** Schmitt-trigger dead-band for `full` (pause the producer). */
|
|
151
|
+
fullHi?: number
|
|
152
|
+
fullLo?: number
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface PressureStats {
|
|
156
|
+
/** Gauge: requests dispatched but not yet connected (waiting for a slot). */
|
|
157
|
+
pending: number
|
|
158
|
+
/** Gauge: requests connected and in-flight. */
|
|
159
|
+
running: number
|
|
160
|
+
/** Counter: cumulative settled requests (onComplete + onError). */
|
|
161
|
+
completed: number
|
|
162
|
+
/** EWMA in [0,1]: fraction of recent time the origin had a connection backlog. */
|
|
163
|
+
some: number
|
|
164
|
+
/** EWMA in [0,1]: fraction of recent time the origin made zero progress under backlog. */
|
|
165
|
+
full: number
|
|
166
|
+
/** Latched: shed discretionary work (engaged when `some` crosses `someHi`). */
|
|
167
|
+
shed: boolean
|
|
168
|
+
/** Latched: pause the producer (engaged when `full` crosses `fullHi`). */
|
|
169
|
+
paused: boolean
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface PressureReading {
|
|
173
|
+
some: number
|
|
174
|
+
full: number
|
|
175
|
+
shed: boolean
|
|
176
|
+
paused: boolean
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface PressureInterceptor {
|
|
180
|
+
(dispatch: DispatchFn): DispatchFn
|
|
181
|
+
/** Per-origin snapshot, or — with no argument — an array over every tracked origin. */
|
|
182
|
+
stats(origin: string): PressureStats | undefined
|
|
183
|
+
stats(): Array<PressureStats & { origin: string }>
|
|
184
|
+
/** Smoothed pressure for an origin (zeroed/false for an untracked origin). */
|
|
185
|
+
pressure(origin: string): PressureReading
|
|
186
|
+
/** `full` pauses everything; `some` sheds only discretionary (low-priority) work. */
|
|
187
|
+
shouldBackoff(origin: string, priority?: Priority): boolean
|
|
188
|
+
/** Manually tick the EWMA loop (for `sampleInterval: 0`). */
|
|
189
|
+
sample(): void
|
|
190
|
+
/** Stop the internal timer and drop all tracked origins. */
|
|
191
|
+
close(): void
|
|
192
|
+
[Symbol.dispose](): void
|
|
193
|
+
}
|
|
194
|
+
|
|
140
195
|
export interface CacheKey {
|
|
141
196
|
origin: string
|
|
142
197
|
method: string
|
|
@@ -230,6 +285,7 @@ export const interceptors: {
|
|
|
230
285
|
dns: () => Interceptor
|
|
231
286
|
lookup: () => Interceptor
|
|
232
287
|
priority: () => Interceptor
|
|
288
|
+
pressure: (opts?: PressureInterceptorOptions) => PressureInterceptor
|
|
233
289
|
}
|
|
234
290
|
|
|
235
291
|
export const cache: {
|
package/lib/index.js
CHANGED
|
@@ -21,6 +21,7 @@ export const interceptors = {
|
|
|
21
21
|
dns: (await import('./interceptor/dns.js')).default,
|
|
22
22
|
lookup: (await import('./interceptor/lookup.js')).default,
|
|
23
23
|
priority: (await import('./interceptor/priority.js')).default,
|
|
24
|
+
pressure: (await import('./interceptor/pressure.js')).default,
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export const cache = {
|
|
@@ -139,7 +140,12 @@ function wrapDispatch(dispatcher) {
|
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
if (globalThis.__nxt_undici_global_headers) {
|
|
142
|
-
|
|
143
|
+
// Run global headers through parseHeaders too, so they share the
|
|
144
|
+
// pipeline invariant (lowercased names, stringified values) instead
|
|
145
|
+
// of landing verbatim with mixed-case keys or non-string values.
|
|
146
|
+
// parseHeaders into a fresh object keeps Object.assign's overwrite
|
|
147
|
+
// semantics (its two-arg form would append instead).
|
|
148
|
+
Object.assign(headers, parseHeaders(globalThis.__nxt_undici_global_headers))
|
|
143
149
|
}
|
|
144
150
|
|
|
145
151
|
return dispatch(
|
|
@@ -170,7 +176,14 @@ function wrapDispatch(dispatcher) {
|
|
|
170
176
|
logger: opts.logger ?? null,
|
|
171
177
|
dns: opts.dns ?? true,
|
|
172
178
|
connect: opts.connect,
|
|
173
|
-
|
|
179
|
+
// A duplicated nxt-priority request header parses to an array; the
|
|
180
|
+
// scheduler and PRIORITY_TOS_MAP expect a scalar, so take the last
|
|
181
|
+
// (last-wins). opts.priority, when set, is already scalar.
|
|
182
|
+
priority:
|
|
183
|
+
opts.priority ??
|
|
184
|
+
(Array.isArray(headers['nxt-priority'])
|
|
185
|
+
? headers['nxt-priority'][headers['nxt-priority'].length - 1]
|
|
186
|
+
: headers['nxt-priority']),
|
|
174
187
|
lookup: opts.lookup ?? defaultLookup,
|
|
175
188
|
},
|
|
176
189
|
handler,
|
package/lib/interceptor/cache.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import undici from '@nxtedition/undici'
|
|
2
|
-
import { DecoratorHandler, parseCacheControl, parseContentRange } from '../utils.js'
|
|
2
|
+
import { DecoratorHandler, getFastNow, parseCacheControl, parseContentRange } from '../utils.js'
|
|
3
3
|
import { SqliteCacheStore } from '../sqlite-cache-store.js'
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
let DEFAULT_STORE = null
|
|
6
6
|
const DEFAULT_MAX_ENTRY_SIZE = 128 * 1024
|
|
7
7
|
const DEFAULT_MAX_ENTRY_TTL = 30 * 24 * 3600
|
|
8
8
|
const NOOP = () => {}
|
|
@@ -40,18 +40,36 @@ class CacheHandler extends DecoratorHandler {
|
|
|
40
40
|
return super.onHeaders(statusCode, headers, resume)
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
// 'trailer' is the RFC 9110 field name; 'trailers' is kept for backwards
|
|
44
|
+
// compatibility with servers that misspell it.
|
|
45
|
+
if (headers.vary === '*' || headers.trailer || headers.trailers) {
|
|
44
46
|
// Not cacheble...
|
|
45
47
|
return super.onHeaders(statusCode, headers, resume)
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
if (headers['set-cookie']) {
|
|
51
|
+
// Shared cache: replaying Set-Cookie to other clients leaks sessions.
|
|
52
|
+
return super.onHeaders(statusCode, headers, resume)
|
|
53
|
+
}
|
|
54
|
+
|
|
48
55
|
let contentRange
|
|
49
56
|
if (headers['content-range']) {
|
|
50
57
|
contentRange = parseContentRange(headers['content-range'])
|
|
51
|
-
if (
|
|
58
|
+
if (
|
|
59
|
+
contentRange == null ||
|
|
60
|
+
(contentRange.end != null &&
|
|
61
|
+
(contentRange.end <= contentRange.start ||
|
|
62
|
+
(contentRange.size != null && contentRange.end > contentRange.size)))
|
|
63
|
+
) {
|
|
52
64
|
// We don't support caching responses with invalid content-range...
|
|
53
65
|
return super.onHeaders(statusCode, headers, resume)
|
|
54
66
|
}
|
|
67
|
+
if (this.#key.method === 'HEAD') {
|
|
68
|
+
// A HEAD response delivers no body, so we never receive the byte
|
|
69
|
+
// window Content-Range describes — storing it would fail the store's
|
|
70
|
+
// body-length validation.
|
|
71
|
+
return super.onHeaders(statusCode, headers, resume)
|
|
72
|
+
}
|
|
55
73
|
}
|
|
56
74
|
|
|
57
75
|
let contentLength
|
|
@@ -104,6 +122,10 @@ class CacheHandler extends DecoratorHandler {
|
|
|
104
122
|
}
|
|
105
123
|
|
|
106
124
|
for (const key of headers.vary.split(',').map((key) => key.trim().toLowerCase())) {
|
|
125
|
+
if (key === '*') {
|
|
126
|
+
// RFC 9111 §4.1: a Vary field containing '*' never matches.
|
|
127
|
+
return super.onHeaders(statusCode, headers, resume)
|
|
128
|
+
}
|
|
107
129
|
// Record every selecting header, using a null sentinel when it was
|
|
108
130
|
// absent from the request. RFC 9111 §4.1: absent-vs-present is a
|
|
109
131
|
// mismatch, so an empty vary object must NOT act as a wildcard that
|
|
@@ -218,23 +240,46 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
218
240
|
}
|
|
219
241
|
|
|
220
242
|
if (
|
|
221
|
-
|
|
222
|
-
cacheControlDirectives['max-
|
|
223
|
-
cacheControlDirectives['min-fresh'] ||
|
|
243
|
+
// != null: 'max-age=0' parses to 0 (falsy) but still demands revalidation.
|
|
244
|
+
cacheControlDirectives['max-age'] != null ||
|
|
224
245
|
cacheControlDirectives['no-cache'] ||
|
|
225
|
-
cacheControlDirectives['stale-if-error']
|
|
246
|
+
cacheControlDirectives['stale-if-error'] != null ||
|
|
247
|
+
// cache-control-parser does not recognise 'max-stale'/'min-fresh', so
|
|
248
|
+
// check the raw string like we do for 'only-if-cached'.
|
|
249
|
+
(typeof rawCacheControl === 'string' &&
|
|
250
|
+
(rawCacheControl.includes('max-stale') || rawCacheControl.includes('min-fresh')))
|
|
226
251
|
) {
|
|
227
252
|
// TODO (fix): Support all cache control directives...
|
|
228
253
|
return dispatch(opts, handler)
|
|
229
254
|
}
|
|
230
255
|
|
|
231
|
-
const store =
|
|
256
|
+
const store =
|
|
257
|
+
opts.cache.store ?? (DEFAULT_STORE ??= new SqliteCacheStore({ location: ':memory:' }))
|
|
232
258
|
|
|
233
259
|
// TODO (fix): enable range requests
|
|
234
260
|
|
|
261
|
+
// Build the key the same way for lookups and stores: makeCacheKey
|
|
262
|
+
// stringifies the origin (e.g. URL objects), so using raw opts on the get
|
|
263
|
+
// path while the set path normalizes would make the cache permanently miss.
|
|
264
|
+
const key = undici.util.cache.makeCacheKey(opts)
|
|
265
|
+
|
|
266
|
+
// makeCacheKey preserves request header names verbatim. Vary selector names
|
|
267
|
+
// are lowercased (in onHeaders and matchesValue), so lowercase the key's
|
|
268
|
+
// header names once here — the same key feeds both the get and set paths, so
|
|
269
|
+
// this keeps Vary matching symmetric even when a caller supplies non-lowercase
|
|
270
|
+
// header names (the standalone interceptors.cache() composition; the wrapped
|
|
271
|
+
// pipeline already normalizes). A fresh object avoids mutating opts.headers.
|
|
272
|
+
if (key.headers && typeof key.headers === 'object') {
|
|
273
|
+
const lower = {}
|
|
274
|
+
for (const name in key.headers) {
|
|
275
|
+
lower[name.toLowerCase()] = key.headers[name]
|
|
276
|
+
}
|
|
277
|
+
key.headers = lower
|
|
278
|
+
}
|
|
279
|
+
|
|
235
280
|
let entry
|
|
236
281
|
try {
|
|
237
|
-
entry = store.get(
|
|
282
|
+
entry = store.get(key)
|
|
238
283
|
} catch (err) {
|
|
239
284
|
if (err.message === 'database is locked') {
|
|
240
285
|
// Database is busy. We don't bother trying again...
|
|
@@ -251,16 +296,34 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
251
296
|
}
|
|
252
297
|
|
|
253
298
|
// RFC 9110 Section 13: Evaluate conditional request headers against cached entry.
|
|
299
|
+
// typeof guards: duplicated conditional headers arrive as arrays — treat
|
|
300
|
+
// them as non-matching and bypass to origin rather than crashing.
|
|
254
301
|
if (entry && opts.headers?.['if-none-match']) {
|
|
255
|
-
if (
|
|
256
|
-
|
|
302
|
+
if (
|
|
303
|
+
typeof opts.headers['if-none-match'] === 'string' &&
|
|
304
|
+
entry.etag &&
|
|
305
|
+
weakMatch(opts.headers['if-none-match'], entry.etag)
|
|
306
|
+
) {
|
|
307
|
+
return serveFromCache(
|
|
308
|
+
{ statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
|
|
309
|
+
opts,
|
|
310
|
+
handler,
|
|
311
|
+
)
|
|
257
312
|
}
|
|
258
313
|
// Etag didn't match — bypass to origin.
|
|
259
314
|
entry = undefined
|
|
260
315
|
} else if (entry && opts.headers?.['if-modified-since']) {
|
|
261
316
|
const lastModified = entry.headers?.['last-modified']
|
|
262
|
-
if (
|
|
263
|
-
|
|
317
|
+
if (
|
|
318
|
+
typeof opts.headers['if-modified-since'] === 'string' &&
|
|
319
|
+
lastModified &&
|
|
320
|
+
new Date(lastModified) <= new Date(opts.headers['if-modified-since'])
|
|
321
|
+
) {
|
|
322
|
+
return serveFromCache(
|
|
323
|
+
{ statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
|
|
324
|
+
opts,
|
|
325
|
+
handler,
|
|
326
|
+
)
|
|
264
327
|
}
|
|
265
328
|
// No last-modified or modified since — bypass to origin.
|
|
266
329
|
entry = undefined
|
|
@@ -280,7 +343,7 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
280
343
|
opts,
|
|
281
344
|
cacheControlDirectives['no-store']
|
|
282
345
|
? handler
|
|
283
|
-
: new CacheHandler(
|
|
346
|
+
: new CacheHandler(key, {
|
|
284
347
|
maxEntrySize: opts.cache.maxEntrySize,
|
|
285
348
|
maxEntryTTL: opts.cache.maxEntryTTL,
|
|
286
349
|
store,
|
|
@@ -294,11 +357,30 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
294
357
|
}
|
|
295
358
|
|
|
296
359
|
function serveFromCache(entry, opts, handler) {
|
|
297
|
-
const { statusCode,
|
|
360
|
+
const { statusCode, trailers, body } = entry
|
|
361
|
+
|
|
362
|
+
let headers = entry.headers
|
|
363
|
+
if (entry.cachedAt != null) {
|
|
364
|
+
// RFC 9111 §5.1: every response served from cache must carry an Age header
|
|
365
|
+
// reflecting time spent in this cache plus any age it arrived with —
|
|
366
|
+
// otherwise downstream caches treat it as fresh-from-origin.
|
|
367
|
+
// getFastNow has 1s resolution — Age is whole seconds, so that's enough.
|
|
368
|
+
const residentAge = Math.max(0, Math.floor((getFastNow() - entry.cachedAt) / 1000))
|
|
369
|
+
const originAge = Number(headers?.age)
|
|
370
|
+
const age = Number.isFinite(originAge) && originAge > 0 ? originAge + residentAge : residentAge
|
|
371
|
+
headers = { ...headers, age: `${age}` }
|
|
372
|
+
}
|
|
298
373
|
|
|
374
|
+
// serveFromCache drives the raw user handler directly (no DecoratorHandler),
|
|
375
|
+
// so it must enforce the contract itself: onError is terminal and mutually
|
|
376
|
+
// exclusive with onComplete. The `completed` guard makes a late abort() a
|
|
377
|
+
// no-op, and onComplete runs outside the try so a throw from the user's
|
|
378
|
+
// terminal callback propagates instead of being converted into a second
|
|
379
|
+
// (post-complete) onError.
|
|
299
380
|
let aborted = false
|
|
381
|
+
let completed = false
|
|
300
382
|
const abort = (reason) => {
|
|
301
|
-
if (!aborted) {
|
|
383
|
+
if (!aborted && !completed) {
|
|
302
384
|
aborted = true
|
|
303
385
|
handler.onError(reason)
|
|
304
386
|
}
|
|
@@ -324,11 +406,13 @@ function serveFromCache(entry, opts, handler) {
|
|
|
324
406
|
return
|
|
325
407
|
}
|
|
326
408
|
}
|
|
327
|
-
|
|
328
|
-
handler.onComplete(trailers ?? {})
|
|
329
409
|
} catch (err) {
|
|
330
410
|
abort(err)
|
|
411
|
+
return
|
|
331
412
|
}
|
|
413
|
+
|
|
414
|
+
completed = true
|
|
415
|
+
handler.onComplete(trailers ?? {})
|
|
332
416
|
}
|
|
333
417
|
|
|
334
418
|
/**
|
package/lib/interceptor/dns.js
CHANGED
|
@@ -30,10 +30,29 @@ class Handler extends DecoratorHandler {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
const SWEEP_INTERVAL = 30e3
|
|
34
|
+
|
|
33
35
|
export default () => (dispatch) => {
|
|
34
36
|
const cache = new Map()
|
|
35
37
|
const promises = new Map()
|
|
36
38
|
|
|
39
|
+
// The `cache` Map is otherwise only ever written, never trimmed, so a process
|
|
40
|
+
// touching many distinct hostnames over its lifetime would leak entries that
|
|
41
|
+
// can never be selected again. Sweep dead entries (all records expired and
|
|
42
|
+
// none in flight) at most once per SWEEP_INTERVAL to bound the O(n) cost.
|
|
43
|
+
let lastSweep = 0
|
|
44
|
+
function sweep(now) {
|
|
45
|
+
if (now - lastSweep < SWEEP_INTERVAL) {
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
lastSweep = now
|
|
49
|
+
for (const [hostname, records] of cache) {
|
|
50
|
+
if (records.every((x) => x.expires < now && x.pending === 0)) {
|
|
51
|
+
cache.delete(hostname)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
37
56
|
function resolve(hostname, { ttl }) {
|
|
38
57
|
let promise = promises.get(hostname)
|
|
39
58
|
if (!promise) {
|
|
@@ -84,6 +103,8 @@ export default () => (dispatch) => {
|
|
|
84
103
|
|
|
85
104
|
const now = getFastNow()
|
|
86
105
|
|
|
106
|
+
sweep(now)
|
|
107
|
+
|
|
87
108
|
let records = cache.get(hostname)
|
|
88
109
|
|
|
89
110
|
if (records == null || records.every((x) => x.expires < now)) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { DecoratorHandler } from '../utils.js'
|
|
2
|
+
|
|
1
3
|
export default () => (dispatch) => async (opts, handler) => {
|
|
2
4
|
const lookup = opts.lookup
|
|
3
5
|
|
|
@@ -5,6 +7,12 @@ export default () => (dispatch) => async (opts, handler) => {
|
|
|
5
7
|
return dispatch(opts, handler)
|
|
6
8
|
}
|
|
7
9
|
|
|
10
|
+
// Wrap so the catch below can't deliver a second onError: if a downstream
|
|
11
|
+
// layer already reported a terminal callback and then let an error escape
|
|
12
|
+
// dispatch synchronously, DecoratorHandler's #errored/#completed guards
|
|
13
|
+
// absorb the duplicate instead of violating the once-only onError contract.
|
|
14
|
+
const wrapped = new DecoratorHandler(handler)
|
|
15
|
+
|
|
8
16
|
try {
|
|
9
17
|
const origin = await new Promise((resolve, reject) => {
|
|
10
18
|
const thenable = lookup(opts.origin, { signal: opts.signal ?? undefined }, (err, val) => {
|
|
@@ -24,8 +32,8 @@ export default () => (dispatch) => async (opts, handler) => {
|
|
|
24
32
|
throw new Error('invalid origin: ' + origin)
|
|
25
33
|
}
|
|
26
34
|
|
|
27
|
-
return dispatch({ ...opts, origin },
|
|
35
|
+
return dispatch({ ...opts, origin }, wrapped)
|
|
28
36
|
} catch (err) {
|
|
29
|
-
|
|
37
|
+
wrapped.onError(err)
|
|
30
38
|
}
|
|
31
39
|
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { parsePriority, Scheduler } from '@nxtedition/scheduler'
|
|
2
|
+
import { DecoratorHandler } from '../utils.js'
|
|
3
|
+
|
|
4
|
+
// Reconstruct a PSI-style pressure signal per origin from the request lifecycle
|
|
5
|
+
// this interceptor observes, then expose latched flags so producers can back
|
|
6
|
+
// off. See @nxtedition/scheduler's README ("Pressure & backoff: utilization is
|
|
7
|
+
// the wrong trigger"): a resource can be 100% utilized with ~0 pressure — what
|
|
8
|
+
// you must throttle on is the fraction of time runnable work is *stalled*, not
|
|
9
|
+
// how busy you are.
|
|
10
|
+
//
|
|
11
|
+
// The HTTP analogue of the scheduler's `pending > 0 && saturated` is simply
|
|
12
|
+
// `pending > 0`: undici leaves a request un-connected (`pending`, in our
|
|
13
|
+
// accounting: dispatched but no `onConnect` yet) *only* when it has no free
|
|
14
|
+
// connection slot to place it on. The instant a slot frees, the queued request
|
|
15
|
+
// connects. So a standing `pending > 0` already means "backlog AND no free
|
|
16
|
+
// capacity" — a busy-but-keeping-up origin drains `pending` back to 0 between
|
|
17
|
+
// samples and scores 0, exactly the healthy-saturation false positive the naive
|
|
18
|
+
// `running/concurrency` recipe fires on.
|
|
19
|
+
//
|
|
20
|
+
// - some (PSI "some"): at least one request waiting for a connection -> shed
|
|
21
|
+
// discretionary work.
|
|
22
|
+
// - full (PSI "full"): a backlog AND zero completions this window -> nothing is
|
|
23
|
+
// making progress (origin hung / all sockets stuck) -> pause the producer.
|
|
24
|
+
//
|
|
25
|
+
// Two independent defenses give "sensitive to real overload, not so twitchy it
|
|
26
|
+
// flaps": the predicate filters healthy saturation before it enters the signal,
|
|
27
|
+
// and the EWMA window + Schmitt-trigger dead-band (engage high, release low) is
|
|
28
|
+
// the oomd-style sustained-duration requirement.
|
|
29
|
+
|
|
30
|
+
const EPS = 1e-3
|
|
31
|
+
|
|
32
|
+
// Smallest priority that is still "discretionary" — sheddable under `some`
|
|
33
|
+
// pressure. low/lower/lowest (<= -1); normal and above are never shed.
|
|
34
|
+
function isDiscretionary(priority) {
|
|
35
|
+
if (priority == null) {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return parsePriority(priority) <= Scheduler.LOW
|
|
40
|
+
} catch {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class PressureMonitor {
|
|
46
|
+
#origins = new Map()
|
|
47
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
48
|
+
#timer = null
|
|
49
|
+
|
|
50
|
+
#sampleInterval
|
|
51
|
+
#tau
|
|
52
|
+
#someHi
|
|
53
|
+
#someLo
|
|
54
|
+
#fullHi
|
|
55
|
+
#fullLo
|
|
56
|
+
|
|
57
|
+
constructor({
|
|
58
|
+
sampleInterval = 200,
|
|
59
|
+
tau = 10_000,
|
|
60
|
+
someHi = 0.5,
|
|
61
|
+
someLo = 0.2,
|
|
62
|
+
fullHi = 0.3,
|
|
63
|
+
fullLo = 0.1,
|
|
64
|
+
} = {}) {
|
|
65
|
+
this.#sampleInterval = sampleInterval
|
|
66
|
+
this.#tau = tau
|
|
67
|
+
this.#someHi = someHi
|
|
68
|
+
this.#someLo = someLo
|
|
69
|
+
this.#fullHi = fullHi
|
|
70
|
+
this.#fullLo = fullLo
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Called from the interceptor on each dispatch to get (or lazily create) the
|
|
74
|
+
// per-origin record the handler mutates as the request progresses. The
|
|
75
|
+
// `pending` increment is done by the Handler constructor, not here, so it is
|
|
76
|
+
// tied to a *successfully* constructed handler: a handler that fails
|
|
77
|
+
// validation (DecoratorHandler throws on a non-object handler) never leaves a
|
|
78
|
+
// phantom pending count wedging the origin under pressure. A record created
|
|
79
|
+
// here but never incremented is harmless — it reports no pressure and is
|
|
80
|
+
// evicted on the next idle tick.
|
|
81
|
+
track(key) {
|
|
82
|
+
let rec = this.#origins.get(key)
|
|
83
|
+
if (rec == null) {
|
|
84
|
+
rec = {
|
|
85
|
+
pending: 0, // gauge: dispatched, awaiting onConnect (waiting for a slot)
|
|
86
|
+
running: 0, // gauge: connected and in-flight
|
|
87
|
+
completed: 0, // counter: cumulative settled (onComplete + onError)
|
|
88
|
+
prevCompleted: 0, // snapshot of `completed` at the previous sample
|
|
89
|
+
some: 0, // EWMA: fraction of recent time `someNow` held
|
|
90
|
+
full: 0, // EWMA: fraction of recent time `fullNow` held
|
|
91
|
+
shed: false, // latched: shed discretionary work
|
|
92
|
+
paused: false, // latched: pause the producer
|
|
93
|
+
lastSample: performance.now(),
|
|
94
|
+
}
|
|
95
|
+
this.#origins.set(key, rec)
|
|
96
|
+
}
|
|
97
|
+
this.#ensureTimer()
|
|
98
|
+
return rec
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// One sample -> instantaneous "stalled right now?" per level, smoothed into
|
|
102
|
+
// loadavg-shaped EWMAs with a dt-aware gain (keeps the time-constant honest
|
|
103
|
+
// under a jittery loop). Counters (completed) carry the `full` decision so a
|
|
104
|
+
// burst that fills and drains between two samples can't alias it away.
|
|
105
|
+
#sample(rec, now) {
|
|
106
|
+
const dt = now - rec.lastSample
|
|
107
|
+
if (dt <= 0) {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
rec.lastSample = now
|
|
111
|
+
|
|
112
|
+
const someNow = rec.pending > 0
|
|
113
|
+
const progressed = rec.completed - rec.prevCompleted > 0
|
|
114
|
+
const fullNow = someNow && !progressed
|
|
115
|
+
rec.prevCompleted = rec.completed
|
|
116
|
+
|
|
117
|
+
const a = 1 - Math.exp(-dt / this.#tau)
|
|
118
|
+
rec.some += a * ((someNow ? 1 : 0) - rec.some)
|
|
119
|
+
rec.full += a * ((fullNow ? 1 : 0) - rec.full)
|
|
120
|
+
|
|
121
|
+
// Hysteresis: engage high, release low.
|
|
122
|
+
if (!rec.shed && rec.some > this.#someHi) {
|
|
123
|
+
rec.shed = true
|
|
124
|
+
} else if (rec.shed && rec.some < this.#someLo) {
|
|
125
|
+
rec.shed = false
|
|
126
|
+
}
|
|
127
|
+
if (!rec.paused && rec.full > this.#fullHi) {
|
|
128
|
+
rec.paused = true
|
|
129
|
+
} else if (rec.paused && rec.full < this.#fullLo) {
|
|
130
|
+
rec.paused = false
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Tick every tracked origin, then evict any that is fully idle and has decayed
|
|
135
|
+
// back to ~0 on both levels, so a client touching many origins doesn't
|
|
136
|
+
// accumulate records forever. Stop the timer once nothing is tracked.
|
|
137
|
+
#tick() {
|
|
138
|
+
const now = performance.now()
|
|
139
|
+
for (const [key, rec] of this.#origins) {
|
|
140
|
+
this.#sample(rec, now)
|
|
141
|
+
if (rec.pending === 0 && rec.running === 0 && rec.some < EPS && rec.full < EPS) {
|
|
142
|
+
this.#origins.delete(key)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (this.#origins.size === 0 && this.#timer != null) {
|
|
146
|
+
clearInterval(this.#timer)
|
|
147
|
+
this.#timer = null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#ensureTimer() {
|
|
152
|
+
if (this.#timer == null && this.#sampleInterval > 0 && this.#origins.size > 0) {
|
|
153
|
+
this.#timer = setInterval(() => this.#tick(), this.#sampleInterval)
|
|
154
|
+
// Never keep the event loop alive just to sample — the producer's own
|
|
155
|
+
// activity is what matters.
|
|
156
|
+
this.#timer.unref?.()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Manual tick, for callers that disable the internal timer (sampleInterval: 0)
|
|
161
|
+
// and drive sampling from a loop they already run (health check, metrics
|
|
162
|
+
// scrape) — the scheduler README's preferred "the loop is YOURS" pattern.
|
|
163
|
+
sample() {
|
|
164
|
+
this.#tick()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#snapshot(rec) {
|
|
168
|
+
return {
|
|
169
|
+
pending: rec.pending,
|
|
170
|
+
running: rec.running,
|
|
171
|
+
completed: rec.completed,
|
|
172
|
+
some: rec.some,
|
|
173
|
+
full: rec.full,
|
|
174
|
+
shed: rec.shed,
|
|
175
|
+
paused: rec.paused,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// No arg -> array of { origin, ...snapshot } for every tracked origin (a
|
|
180
|
+
// metrics scrape). With an origin -> that origin's snapshot, or undefined if
|
|
181
|
+
// it isn't being tracked (never seen, or evicted after going idle).
|
|
182
|
+
stats(origin) {
|
|
183
|
+
if (origin == null) {
|
|
184
|
+
const out = []
|
|
185
|
+
for (const [key, rec] of this.#origins) {
|
|
186
|
+
out.push({ origin: key, ...this.#snapshot(rec) })
|
|
187
|
+
}
|
|
188
|
+
return out
|
|
189
|
+
}
|
|
190
|
+
const rec = this.#origins.get(origin)
|
|
191
|
+
return rec ? this.#snapshot(rec) : undefined
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The smoothed pressure for an origin. An untracked origin is, by definition,
|
|
195
|
+
// not under pressure.
|
|
196
|
+
pressure(origin) {
|
|
197
|
+
const rec = this.#origins.get(origin)
|
|
198
|
+
return rec
|
|
199
|
+
? { some: rec.some, full: rec.full, shed: rec.shed, paused: rec.paused }
|
|
200
|
+
: { some: 0, full: 0, shed: false, paused: false }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Producer-side gate mirroring the README's `submit` recipe: `full` pauses
|
|
204
|
+
// everything; `some` sheds only discretionary (low-priority) work.
|
|
205
|
+
shouldBackoff(origin, priority) {
|
|
206
|
+
const rec = this.#origins.get(origin)
|
|
207
|
+
if (rec == null) {
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
if (rec.paused) {
|
|
211
|
+
return true
|
|
212
|
+
}
|
|
213
|
+
if (rec.shed) {
|
|
214
|
+
return isDiscretionary(priority)
|
|
215
|
+
}
|
|
216
|
+
return false
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
close() {
|
|
220
|
+
if (this.#timer != null) {
|
|
221
|
+
clearInterval(this.#timer)
|
|
222
|
+
this.#timer = null
|
|
223
|
+
}
|
|
224
|
+
this.#origins.clear()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
[Symbol.dispose]() {
|
|
228
|
+
this.close()
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
class Handler extends DecoratorHandler {
|
|
233
|
+
#rec
|
|
234
|
+
// 'pending' (awaiting onConnect) -> 'running' (connected) -> 'done' (settled).
|
|
235
|
+
// Tracked here so each transition fires exactly once regardless of how many
|
|
236
|
+
// times onConnect is invoked (e.g. a retry handler upstream) or which
|
|
237
|
+
// terminal callback fires.
|
|
238
|
+
#state = 'pending'
|
|
239
|
+
|
|
240
|
+
constructor(handler, rec) {
|
|
241
|
+
// super() validates the handler and throws on a non-object before we touch
|
|
242
|
+
// the gauge, so a rejected handler can't leak a pending count.
|
|
243
|
+
super(handler)
|
|
244
|
+
this.#rec = rec
|
|
245
|
+
rec.pending += 1
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
onConnect(abort) {
|
|
249
|
+
if (this.#state === 'pending') {
|
|
250
|
+
this.#state = 'running'
|
|
251
|
+
this.#rec.pending -= 1
|
|
252
|
+
this.#rec.running += 1
|
|
253
|
+
}
|
|
254
|
+
super.onConnect(abort)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
onComplete(trailers) {
|
|
258
|
+
this.#settle()
|
|
259
|
+
super.onComplete(trailers)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
onError(err) {
|
|
263
|
+
this.#settle()
|
|
264
|
+
super.onError(err)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#settle() {
|
|
268
|
+
if (this.#state === 'done') {
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
if (this.#state === 'pending') {
|
|
272
|
+
this.#rec.pending -= 1
|
|
273
|
+
} else {
|
|
274
|
+
this.#rec.running -= 1
|
|
275
|
+
}
|
|
276
|
+
this.#rec.completed += 1
|
|
277
|
+
this.#state = 'done'
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export default (opts) => {
|
|
282
|
+
const monitor = new PressureMonitor(opts)
|
|
283
|
+
|
|
284
|
+
const interceptor = (dispatch) => (opts, handler) => {
|
|
285
|
+
if (!opts.origin) {
|
|
286
|
+
return dispatch(opts, handler)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Key on opts.origin — the logical origin the caller knows and queries by.
|
|
290
|
+
// Compose this interceptor *ahead of* (outer to) `dns()` so opts.origin is
|
|
291
|
+
// still the logical host rather than a rotating resolved IP.
|
|
292
|
+
const key = opts.origin
|
|
293
|
+
|
|
294
|
+
const rec = monitor.track(key)
|
|
295
|
+
// Construct before the try: a handler that fails validation throws straight
|
|
296
|
+
// to the caller (as with any DecoratorHandler-based interceptor) and, since
|
|
297
|
+
// the constructor increments `pending` only on success, leaks nothing.
|
|
298
|
+
const pressureHandler = new Handler(handler, rec)
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
return dispatch(opts, pressureHandler)
|
|
302
|
+
} catch (err) {
|
|
303
|
+
pressureHandler.onError(err)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// The monitor is owned by the interceptor instance; surface its read API on
|
|
308
|
+
// the composed function so callers hold a single handle.
|
|
309
|
+
interceptor.stats = (origin) => monitor.stats(origin)
|
|
310
|
+
interceptor.pressure = (origin) => monitor.pressure(origin)
|
|
311
|
+
interceptor.shouldBackoff = (origin, priority) => monitor.shouldBackoff(origin, priority)
|
|
312
|
+
interceptor.sample = () => monitor.sample()
|
|
313
|
+
interceptor.close = () => monitor.close()
|
|
314
|
+
interceptor[Symbol.dispose] = () => monitor.close()
|
|
315
|
+
|
|
316
|
+
return interceptor
|
|
317
|
+
}
|
|
@@ -3,10 +3,12 @@ import { DecoratorHandler } from '../utils.js'
|
|
|
3
3
|
|
|
4
4
|
class Handler extends DecoratorHandler {
|
|
5
5
|
#scheduler
|
|
6
|
+
#onIdle
|
|
6
7
|
|
|
7
|
-
constructor(handler, scheduler) {
|
|
8
|
+
constructor(handler, scheduler, onIdle) {
|
|
8
9
|
super(handler)
|
|
9
10
|
this.#scheduler = scheduler
|
|
11
|
+
this.#onIdle = onIdle
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
onConnect(abort) {
|
|
@@ -29,6 +31,7 @@ class Handler extends DecoratorHandler {
|
|
|
29
31
|
const scheduler = this.#scheduler
|
|
30
32
|
this.#scheduler = null
|
|
31
33
|
scheduler.release()
|
|
34
|
+
this.#onIdle?.()
|
|
32
35
|
}
|
|
33
36
|
}
|
|
34
37
|
}
|
|
@@ -41,13 +44,29 @@ export default () => (dispatch) => {
|
|
|
41
44
|
return dispatch(opts, handler)
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
|
|
47
|
+
// Key on the logical origin, not opts.origin: an outer dns interceptor
|
|
48
|
+
// rewrites opts.origin to a rotating resolved IP, which would scatter one
|
|
49
|
+
// logical host across many schedulers and silently defeat the per-origin
|
|
50
|
+
// concurrency limit. dns preserves the logical host in the `host` header.
|
|
51
|
+
const key = (typeof opts.headers?.host === 'string' && opts.headers.host) || opts.origin
|
|
52
|
+
|
|
53
|
+
let scheduler = schedulers.get(key)
|
|
45
54
|
if (!scheduler) {
|
|
46
55
|
scheduler = new Scheduler({ concurrency: 1 })
|
|
47
|
-
schedulers.set(
|
|
56
|
+
schedulers.set(key, scheduler)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Evict a scheduler once it has fully drained, so a client touching many
|
|
60
|
+
// distinct origins doesn't accumulate them forever. The `=== scheduler`
|
|
61
|
+
// guard avoids deleting a freshly-created replacement; release() drains
|
|
62
|
+
// pending synchronously, so running===0 && pending===0 here means idle.
|
|
63
|
+
const onIdle = () => {
|
|
64
|
+
if (schedulers.get(key) === scheduler && scheduler.running === 0 && scheduler.pending === 0) {
|
|
65
|
+
schedulers.delete(key)
|
|
66
|
+
}
|
|
48
67
|
}
|
|
49
68
|
|
|
50
|
-
const priorityHandler = new Handler(handler, scheduler)
|
|
69
|
+
const priorityHandler = new Handler(handler, scheduler, onIdle)
|
|
51
70
|
scheduler.acquire(
|
|
52
71
|
(priorityHandler) => {
|
|
53
72
|
try {
|
package/lib/interceptor/proxy.js
CHANGED
|
@@ -31,7 +31,10 @@ class Handler extends DecoratorHandler {
|
|
|
31
31
|
{
|
|
32
32
|
headers,
|
|
33
33
|
httpVersion: this.#opts.httpVersion ?? this.#opts.req?.httpVersion,
|
|
34
|
-
|
|
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,
|
|
35
38
|
proxyName: this.#opts.name,
|
|
36
39
|
},
|
|
37
40
|
copyHeader,
|
|
@@ -48,7 +51,7 @@ class Handler extends DecoratorHandler {
|
|
|
48
51
|
{
|
|
49
52
|
headers,
|
|
50
53
|
httpVersion: this.#opts.httpVersion ?? this.#opts.req?.httpVersion,
|
|
51
|
-
|
|
54
|
+
isResponse: true,
|
|
52
55
|
proxyName: this.#opts.name,
|
|
53
56
|
},
|
|
54
57
|
copyHeader,
|
|
@@ -134,15 +137,18 @@ function isHopByHop(key) {
|
|
|
134
137
|
* @param {string} [options.httpVersion] Protocol token for the appended Via
|
|
135
138
|
* segment; defaults to `'HTTP/1.1'`.
|
|
136
139
|
* @param {{ localAddress?: string, localPort?: number, remoteAddress?: string,
|
|
137
|
-
* remotePort?: number, encrypted?: boolean } | null} [options.socket]
|
|
138
|
-
* present a Forwarded header is synthesised
|
|
139
|
-
* header is treated as a BadGateway.
|
|
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.
|
|
140
146
|
* @param {(acc: T, key: string, value: string) => T} fn Accumulator invoked
|
|
141
147
|
* once per retained header.
|
|
142
148
|
* @param {T} acc Initial accumulator value.
|
|
143
149
|
* @returns {T}
|
|
144
150
|
*/
|
|
145
|
-
function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
|
|
151
|
+
function reduceHeaders({ headers, proxyName, httpVersion, socket, isResponse }, fn, acc) {
|
|
146
152
|
let via = ''
|
|
147
153
|
let forwarded = ''
|
|
148
154
|
let host = ''
|
|
@@ -163,14 +169,20 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
|
|
|
163
169
|
// the parts are semantically one comma-separated value — those we combine.
|
|
164
170
|
// Singular fields with more than one value are a protocol error — those we
|
|
165
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.
|
|
166
178
|
for (let i = 0; i < keys.length; i++) {
|
|
167
179
|
const key = keys[i]
|
|
168
180
|
const len = key.length
|
|
169
|
-
if (len === 3 && key
|
|
181
|
+
if (len === 3 && eqiLower(key, 'via')) {
|
|
170
182
|
// Via is list-valued (RFC 9110 §7.6.3): combine repeated field-lines.
|
|
171
183
|
const v = headers[key]
|
|
172
184
|
via = Array.isArray(v) ? v.join(', ') : v
|
|
173
|
-
} else if (len === 4 && key
|
|
185
|
+
} else if (len === 4 && eqiLower(key, 'host')) {
|
|
174
186
|
// Host is singular (RFC 9110 §7.2, RFC 7230 §5.4): more than one is a
|
|
175
187
|
// protocol error, so reject rather than combine.
|
|
176
188
|
const v = headers[key]
|
|
@@ -178,16 +190,17 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
|
|
|
178
190
|
throw new createError.BadGateway()
|
|
179
191
|
}
|
|
180
192
|
host = v
|
|
181
|
-
} else if (len === 9 && key
|
|
193
|
+
} else if (len === 9 && eqiLower(key, 'forwarded')) {
|
|
182
194
|
// Forwarded is list-valued (RFC 7239 §4): combine repeated field-lines.
|
|
183
195
|
const v = headers[key]
|
|
184
196
|
forwarded = Array.isArray(v) ? v.join(', ') : v
|
|
185
|
-
} else if (len === 10 && key
|
|
197
|
+
} else if (len === 10 && eqiLower(key, 'connection')) {
|
|
186
198
|
// Connection is list-valued (RFC 9110 §7.6.1): captured raw and tokenised
|
|
187
199
|
// below — combining then re-splitting would be wasteful.
|
|
188
200
|
connection = headers[key]
|
|
189
201
|
} else if (len === 10 && key === ':authority') {
|
|
190
|
-
// :authority is singular (RFC 9113 §8.3.1): reject more than one.
|
|
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.
|
|
191
204
|
const v = headers[key]
|
|
192
205
|
if (Array.isArray(v)) {
|
|
193
206
|
throw new createError.BadGateway()
|
|
@@ -229,16 +242,26 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
|
|
|
229
242
|
|
|
230
243
|
for (let i = 0; i < keys.length; i++) {
|
|
231
244
|
const key = keys[i]
|
|
245
|
+
const len = key.length
|
|
232
246
|
if (
|
|
233
247
|
key.charCodeAt(0) !== 0x3a /* ':' */ &&
|
|
234
|
-
|
|
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())) &&
|
|
235
258
|
!isHopByHop(key)
|
|
236
259
|
) {
|
|
237
260
|
acc = fn(acc, key, headers[key].toString())
|
|
238
261
|
}
|
|
239
262
|
}
|
|
240
263
|
|
|
241
|
-
if (socket) {
|
|
264
|
+
if (!isResponse && socket) {
|
|
242
265
|
const forwardedHost = authority || host
|
|
243
266
|
acc = fn(
|
|
244
267
|
acc,
|
|
@@ -254,7 +277,8 @@ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
|
|
|
254
277
|
.join(';'),
|
|
255
278
|
)
|
|
256
279
|
} else if (forwarded) {
|
|
257
|
-
//
|
|
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.
|
|
258
282
|
throw new createError.BadGateway()
|
|
259
283
|
}
|
|
260
284
|
|
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
|
}
|
|
@@ -29,7 +29,13 @@ class Handler extends DecoratorHandler {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
onHeaders(statusCode, headers, resume) {
|
|
32
|
-
|
|
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
|
|
33
39
|
|
|
34
40
|
if (this.#verifyOpts.size) {
|
|
35
41
|
const contentRange = parseContentRange(headers['content-range'])
|
package/lib/request.js
CHANGED
|
@@ -188,7 +188,9 @@ export function request(dispatch, urlOrOpts, optsOrNully) {
|
|
|
188
188
|
const protocol = url.protocol ?? 'http:'
|
|
189
189
|
const host =
|
|
190
190
|
url.host ??
|
|
191
|
-
|
|
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)
|
|
192
194
|
|
|
193
195
|
if (!host || !protocol) {
|
|
194
196
|
throw new InvalidArgumentError('invalid url')
|
|
@@ -209,5 +211,16 @@ export function request(dispatch, urlOrOpts, optsOrNully) {
|
|
|
209
211
|
body: isStream(opts?.body) && opts.body.closed ? null : opts?.body,
|
|
210
212
|
}
|
|
211
213
|
|
|
212
|
-
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
|
+
})
|
|
213
226
|
}
|
|
@@ -420,6 +420,23 @@ export class SqliteCacheStore {
|
|
|
420
420
|
const now = getFastNow()
|
|
421
421
|
const requestedStart = range?.start ?? 0
|
|
422
422
|
|
|
423
|
+
if (this.#insertBatch.length === 0) {
|
|
424
|
+
// Fast path: rows arrive sorted (cachedAt DESC, id DESC) and there are
|
|
425
|
+
// no pending batch entries to merge, so fetch only the newest candidate.
|
|
426
|
+
// This covers misses and first-row hits (the overwhelmingly common
|
|
427
|
+
// cases) with a single row materialized — re-cached duplicates of a hot
|
|
428
|
+
// key would otherwise all be read including their blobs. Only when the
|
|
429
|
+
// newest row doesn't match (vary variant, range/206 mismatch) do we
|
|
430
|
+
// fall through to scan the full candidate set.
|
|
431
|
+
const value = this.#getValuesQuery.get(url, method, requestedStart, now)
|
|
432
|
+
if (value === undefined) {
|
|
433
|
+
return undefined
|
|
434
|
+
}
|
|
435
|
+
if (matchesValue(value, range, headers)) {
|
|
436
|
+
return value
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
423
440
|
/**
|
|
424
441
|
* @type {SqliteStoreValue[]}
|
|
425
442
|
*/
|
|
@@ -445,55 +462,63 @@ export class SqliteCacheStore {
|
|
|
445
462
|
// deterministically toward the freshest write: pending batch entries
|
|
446
463
|
// (tagged with a monotonic seq) are always newer than any flushed DB row,
|
|
447
464
|
// and within each source a higher seq/id wins.
|
|
448
|
-
values.
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
465
|
+
if (values.length > 1) {
|
|
466
|
+
values.sort((a, b) => {
|
|
467
|
+
if (a.cachedAt !== b.cachedAt) {
|
|
468
|
+
return b.cachedAt - a.cachedAt
|
|
469
|
+
}
|
|
470
|
+
const aBatch = a.seq != null
|
|
471
|
+
const bBatch = b.seq != null
|
|
472
|
+
if (aBatch !== bBatch) {
|
|
473
|
+
return aBatch ? -1 : 1
|
|
474
|
+
}
|
|
475
|
+
if (aBatch) {
|
|
476
|
+
return b.seq - a.seq
|
|
477
|
+
}
|
|
478
|
+
return (b.id ?? 0) - (a.id ?? 0)
|
|
479
|
+
})
|
|
480
|
+
}
|
|
462
481
|
|
|
463
482
|
for (const value of values) {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
continue
|
|
483
|
+
if (matchesValue(value, range, headers)) {
|
|
484
|
+
return value
|
|
467
485
|
}
|
|
486
|
+
}
|
|
468
487
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
if (!range && value.statusCode === 206) {
|
|
473
|
-
continue
|
|
474
|
-
}
|
|
488
|
+
return undefined
|
|
489
|
+
}
|
|
490
|
+
}
|
|
475
491
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
492
|
+
/**
|
|
493
|
+
* @param {SqliteStoreValue} value
|
|
494
|
+
* @param {import('./utils.js').RangeHeader | undefined} range
|
|
495
|
+
* @param {Record<string, string | string[]> | undefined} headers
|
|
496
|
+
* @returns {boolean}
|
|
497
|
+
*/
|
|
498
|
+
function matchesValue(value, range, headers) {
|
|
499
|
+
// TODO (fix): Allow full and partial match?
|
|
500
|
+
if (range && (range.start !== value.start || range.end !== value.end)) {
|
|
501
|
+
return false
|
|
502
|
+
}
|
|
479
503
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
504
|
+
// A request without a Range header asks for the full representation, so
|
|
505
|
+
// a stored 206 partial (e.g. content-range bytes 0-4/100, which the SQL
|
|
506
|
+
// `start <= 0` filter does not exclude) must not be served verbatim.
|
|
507
|
+
if (!range && value.statusCode === 206) {
|
|
508
|
+
return false
|
|
509
|
+
}
|
|
486
510
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
}
|
|
511
|
+
if (value.vary) {
|
|
512
|
+
const vary = JSON.parse(value.vary)
|
|
491
513
|
|
|
492
|
-
|
|
514
|
+
for (const header in vary) {
|
|
515
|
+
if (!headerValueEquals(headers?.[header], vary[header])) {
|
|
516
|
+
return false
|
|
517
|
+
}
|
|
493
518
|
}
|
|
494
|
-
|
|
495
|
-
return undefined
|
|
496
519
|
}
|
|
520
|
+
|
|
521
|
+
return true
|
|
497
522
|
}
|
|
498
523
|
|
|
499
524
|
/**
|
|
@@ -510,15 +535,21 @@ function headerValueEquals(lhs, rhs) {
|
|
|
510
535
|
return false
|
|
511
536
|
}
|
|
512
537
|
|
|
513
|
-
|
|
514
|
-
|
|
538
|
+
// A single-element array and the bare scalar denote the same logical header
|
|
539
|
+
// value (e.g. 'gzip' vs ['gzip']); normalize so an inconsistently-shaped
|
|
540
|
+
// selecting header doesn't cause an avoidable cache miss.
|
|
541
|
+
const a = Array.isArray(lhs) && lhs.length === 1 ? lhs[0] : lhs
|
|
542
|
+
const b = Array.isArray(rhs) && rhs.length === 1 ? rhs[0] : rhs
|
|
543
|
+
|
|
544
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
545
|
+
if (a.length !== b.length) {
|
|
515
546
|
return false
|
|
516
547
|
}
|
|
517
548
|
|
|
518
|
-
return
|
|
549
|
+
return a.every((x, i) => x === b[i])
|
|
519
550
|
}
|
|
520
551
|
|
|
521
|
-
return
|
|
552
|
+
return a === b
|
|
522
553
|
}
|
|
523
554
|
|
|
524
555
|
/**
|
|
@@ -531,11 +562,15 @@ function makeValueUrl(key) {
|
|
|
531
562
|
|
|
532
563
|
function makeResult(value) {
|
|
533
564
|
return {
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
body: value.body
|
|
565
|
+
// Batch entries (tagged with seq) must be copied: value.body is the exact
|
|
566
|
+
// Buffer still queued for flushing, so a consumer mutating the served body
|
|
567
|
+
// could corrupt the bytes about to be written. DB rows are safe to alias —
|
|
568
|
+
// node:sqlite allocates a fresh Uint8Array per read, so wrap it zero-copy.
|
|
569
|
+
body: value.body
|
|
570
|
+
? value.seq != null
|
|
571
|
+
? Buffer.from(value.body)
|
|
572
|
+
: Buffer.from(value.body.buffer, value.body.byteOffset, value.body.byteLength)
|
|
573
|
+
: undefined,
|
|
539
574
|
statusCode: value.statusCode,
|
|
540
575
|
statusMessage: value.statusMessage,
|
|
541
576
|
headers: value.headers ? JSON.parse(value.headers) : undefined,
|
package/lib/utils.js
CHANGED
|
@@ -291,7 +291,10 @@ export function parseHeaders(headers, obj) {
|
|
|
291
291
|
const key = util.headerNameToString(key2)
|
|
292
292
|
let val = obj[key]
|
|
293
293
|
|
|
294
|
-
if (val)
|
|
294
|
+
// `key in obj`, not `if (val)`: an empty-string value ('') is a valid,
|
|
295
|
+
// present header. A truthy check would treat it as absent and overwrite
|
|
296
|
+
// it on a duplicate occurrence, silently dropping the first value.
|
|
297
|
+
if (key in obj) {
|
|
295
298
|
if (!Array.isArray(val)) {
|
|
296
299
|
val = [val]
|
|
297
300
|
obj[key] = val
|
|
@@ -322,7 +325,9 @@ export function parseHeaders(headers, obj) {
|
|
|
322
325
|
const key = util.headerNameToString(key2)
|
|
323
326
|
let val = obj[key]
|
|
324
327
|
|
|
325
|
-
|
|
328
|
+
// See the array branch above: presence check, not truthiness, so a
|
|
329
|
+
// stored empty string is not clobbered by a later duplicate.
|
|
330
|
+
if (key in obj) {
|
|
326
331
|
if (!Array.isArray(val)) {
|
|
327
332
|
val = [val]
|
|
328
333
|
obj[key] = val
|
|
@@ -332,7 +337,7 @@ export function parseHeaders(headers, obj) {
|
|
|
332
337
|
} else {
|
|
333
338
|
val.push(`${val2}`)
|
|
334
339
|
}
|
|
335
|
-
} else
|
|
340
|
+
} else {
|
|
336
341
|
obj[key] = Array.isArray(val2)
|
|
337
342
|
? val2.filter((x) => x != null).map((x) => `${x}`)
|
|
338
343
|
: `${val2}`
|
|
@@ -374,10 +379,14 @@ export function decorateError(err, opts, { statusCode, headers, trailers, body }
|
|
|
374
379
|
body = null
|
|
375
380
|
}
|
|
376
381
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
382
|
+
// A duplicated content-type response header arrives as an array (undici's
|
|
383
|
+
// parseHeaders collapses repeats); coerce to the first value before
|
|
384
|
+
// calling string methods, otherwise this throws and the catch below
|
|
385
|
+
// discards all decoration into an opaque AggregateError.
|
|
386
|
+
const contentType = Array.isArray(headers?.['content-type'])
|
|
387
|
+
? headers['content-type'][0]
|
|
388
|
+
: headers?.['content-type']
|
|
389
|
+
if (typeof body === 'string' && (!contentType || contentType.startsWith('application/json'))) {
|
|
381
390
|
try {
|
|
382
391
|
body = JSON.parse(body)
|
|
383
392
|
} catch {
|