@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/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) {
|
|
@@ -132,7 +139,12 @@ function wrapDispatch(dispatcher) {
|
|
|
132
139
|
}
|
|
133
140
|
|
|
134
141
|
if (globalThis.__nxt_undici_global_headers) {
|
|
135
|
-
|
|
142
|
+
// Run global headers through parseHeaders too, so they share the
|
|
143
|
+
// pipeline invariant (lowercased names, stringified values) instead
|
|
144
|
+
// of landing verbatim with mixed-case keys or non-string values.
|
|
145
|
+
// parseHeaders into a fresh object keeps Object.assign's overwrite
|
|
146
|
+
// semantics (its two-arg form would append instead).
|
|
147
|
+
Object.assign(headers, parseHeaders(globalThis.__nxt_undici_global_headers))
|
|
136
148
|
}
|
|
137
149
|
|
|
138
150
|
return dispatch(
|
|
@@ -163,7 +175,14 @@ function wrapDispatch(dispatcher) {
|
|
|
163
175
|
logger: opts.logger ?? null,
|
|
164
176
|
dns: opts.dns ?? true,
|
|
165
177
|
connect: opts.connect,
|
|
166
|
-
|
|
178
|
+
// A duplicated nxt-priority request header parses to an array; the
|
|
179
|
+
// scheduler and PRIORITY_TOS_MAP expect a scalar, so take the last
|
|
180
|
+
// (last-wins). opts.priority, when set, is already scalar.
|
|
181
|
+
priority:
|
|
182
|
+
opts.priority ??
|
|
183
|
+
(Array.isArray(headers['nxt-priority'])
|
|
184
|
+
? headers['nxt-priority'][headers['nxt-priority'].length - 1]
|
|
185
|
+
: headers['nxt-priority']),
|
|
167
186
|
lookup: opts.lookup ?? defaultLookup,
|
|
168
187
|
},
|
|
169
188
|
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,10 +122,16 @@ class CacheHandler extends DecoratorHandler {
|
|
|
104
122
|
}
|
|
105
123
|
|
|
106
124
|
for (const key of headers.vary.split(',').map((key) => key.trim().toLowerCase())) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
125
|
+
if (key === '*') {
|
|
126
|
+
// RFC 9111 §4.1: a Vary field containing '*' never matches.
|
|
127
|
+
return super.onHeaders(statusCode, headers, resume)
|
|
110
128
|
}
|
|
129
|
+
// Record every selecting header, using a null sentinel when it was
|
|
130
|
+
// absent from the request. RFC 9111 §4.1: absent-vs-present is a
|
|
131
|
+
// mismatch, so an empty vary object must NOT act as a wildcard that
|
|
132
|
+
// matches requests which later supply the header. headerValueEquals
|
|
133
|
+
// treats null/absent as equal only to another null/absent.
|
|
134
|
+
vary[key] = this.#key.headers[key] ?? null
|
|
111
135
|
}
|
|
112
136
|
}
|
|
113
137
|
|
|
@@ -118,8 +142,22 @@ class CacheHandler extends DecoratorHandler {
|
|
|
118
142
|
return super.onHeaders(statusCode, headers, resume)
|
|
119
143
|
}
|
|
120
144
|
|
|
145
|
+
// RFC 9111 §4.2.3: a response relayed by an upstream/shared cache may
|
|
146
|
+
// already be partway through its freshness lifetime. Subtract the
|
|
147
|
+
// advertised Age so we don't over-extend the TTL and serve stale content.
|
|
148
|
+
const age = Number(headers.age)
|
|
149
|
+
const lifetime = Math.min(ttl, this.#maxEntryTTL) - (Number.isFinite(age) && age > 0 ? age : 0)
|
|
150
|
+
if (lifetime <= 0) {
|
|
151
|
+
// Already stale on arrival — not worth caching.
|
|
152
|
+
return super.onHeaders(statusCode, headers, resume)
|
|
153
|
+
}
|
|
154
|
+
|
|
121
155
|
const start = contentRange ? contentRange.start : 0
|
|
122
|
-
|
|
156
|
+
// HEAD never delivers a body, so a Content-Length must not drive the
|
|
157
|
+
// stored byte window (end). Storing end = contentLength with an empty body
|
|
158
|
+
// would fail the store's body-length validation and emit error-level log
|
|
159
|
+
// spam on every cacheable HEAD response.
|
|
160
|
+
const end = contentRange ? contentRange.end : this.#key.method === 'HEAD' ? 0 : contentLength
|
|
123
161
|
|
|
124
162
|
if (end == null || end - start <= this.#maxEntrySize) {
|
|
125
163
|
const cachedAt = Date.now()
|
|
@@ -127,7 +165,7 @@ class CacheHandler extends DecoratorHandler {
|
|
|
127
165
|
body: [],
|
|
128
166
|
start,
|
|
129
167
|
end,
|
|
130
|
-
deleteAt: cachedAt +
|
|
168
|
+
deleteAt: cachedAt + lifetime * 1e3,
|
|
131
169
|
statusCode,
|
|
132
170
|
statusMessage: '',
|
|
133
171
|
headers,
|
|
@@ -202,23 +240,46 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
202
240
|
}
|
|
203
241
|
|
|
204
242
|
if (
|
|
205
|
-
|
|
206
|
-
cacheControlDirectives['max-
|
|
207
|
-
cacheControlDirectives['min-fresh'] ||
|
|
243
|
+
// != null: 'max-age=0' parses to 0 (falsy) but still demands revalidation.
|
|
244
|
+
cacheControlDirectives['max-age'] != null ||
|
|
208
245
|
cacheControlDirectives['no-cache'] ||
|
|
209
|
-
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')))
|
|
210
251
|
) {
|
|
211
252
|
// TODO (fix): Support all cache control directives...
|
|
212
253
|
return dispatch(opts, handler)
|
|
213
254
|
}
|
|
214
255
|
|
|
215
|
-
const store =
|
|
256
|
+
const store =
|
|
257
|
+
opts.cache.store ?? (DEFAULT_STORE ??= new SqliteCacheStore({ location: ':memory:' }))
|
|
216
258
|
|
|
217
259
|
// TODO (fix): enable range requests
|
|
218
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
|
+
|
|
219
280
|
let entry
|
|
220
281
|
try {
|
|
221
|
-
entry = store.get(
|
|
282
|
+
entry = store.get(key)
|
|
222
283
|
} catch (err) {
|
|
223
284
|
if (err.message === 'database is locked') {
|
|
224
285
|
// Database is busy. We don't bother trying again...
|
|
@@ -235,16 +296,34 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
235
296
|
}
|
|
236
297
|
|
|
237
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.
|
|
238
301
|
if (entry && opts.headers?.['if-none-match']) {
|
|
239
|
-
if (
|
|
240
|
-
|
|
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
|
+
)
|
|
241
312
|
}
|
|
242
313
|
// Etag didn't match — bypass to origin.
|
|
243
314
|
entry = undefined
|
|
244
315
|
} else if (entry && opts.headers?.['if-modified-since']) {
|
|
245
316
|
const lastModified = entry.headers?.['last-modified']
|
|
246
|
-
if (
|
|
247
|
-
|
|
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
|
+
)
|
|
248
327
|
}
|
|
249
328
|
// No last-modified or modified since — bypass to origin.
|
|
250
329
|
entry = undefined
|
|
@@ -264,7 +343,7 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
264
343
|
opts,
|
|
265
344
|
cacheControlDirectives['no-store']
|
|
266
345
|
? handler
|
|
267
|
-
: new CacheHandler(
|
|
346
|
+
: new CacheHandler(key, {
|
|
268
347
|
maxEntrySize: opts.cache.maxEntrySize,
|
|
269
348
|
maxEntryTTL: opts.cache.maxEntryTTL,
|
|
270
349
|
store,
|
|
@@ -278,11 +357,30 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
278
357
|
}
|
|
279
358
|
|
|
280
359
|
function serveFromCache(entry, opts, handler) {
|
|
281
|
-
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
|
+
}
|
|
282
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.
|
|
283
380
|
let aborted = false
|
|
381
|
+
let completed = false
|
|
284
382
|
const abort = (reason) => {
|
|
285
|
-
if (!aborted) {
|
|
383
|
+
if (!aborted && !completed) {
|
|
286
384
|
aborted = true
|
|
287
385
|
handler.onError(reason)
|
|
288
386
|
}
|
|
@@ -308,11 +406,13 @@ function serveFromCache(entry, opts, handler) {
|
|
|
308
406
|
return
|
|
309
407
|
}
|
|
310
408
|
}
|
|
311
|
-
|
|
312
|
-
handler.onComplete(trailers ?? {})
|
|
313
409
|
} catch (err) {
|
|
314
410
|
abort(err)
|
|
411
|
+
return
|
|
315
412
|
}
|
|
413
|
+
|
|
414
|
+
completed = true
|
|
415
|
+
handler.onComplete(trailers ?? {})
|
|
316
416
|
}
|
|
317
417
|
|
|
318
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)) {
|
|
@@ -144,18 +165,33 @@ export default () => (dispatch) => {
|
|
|
144
165
|
record.counter++
|
|
145
166
|
record.pending++
|
|
146
167
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
168
|
+
// Guarded so it runs exactly once: on the normal Handler callback, or
|
|
169
|
+
// if dispatch throws synchronously (otherwise record.pending would leak
|
|
170
|
+
// and skew load balancing toward the wrongly-busy record).
|
|
171
|
+
let settled = false
|
|
172
|
+
const onSettle = (err, statusCode) => {
|
|
173
|
+
if (settled) {
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
settled = true
|
|
177
|
+
record.pending--
|
|
151
178
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
179
|
+
if (err != null && err.name !== 'AbortError') {
|
|
180
|
+
record.expires = 0
|
|
181
|
+
} else if (statusCode != null && statusCode >= 500) {
|
|
182
|
+
record.errored++
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
return dispatch(
|
|
188
|
+
{ ...opts, origin: url.origin, headers: { ...opts.headers, host } },
|
|
189
|
+
new Handler(handler, onSettle),
|
|
190
|
+
)
|
|
191
|
+
} catch (err) {
|
|
192
|
+
onSettle(err)
|
|
193
|
+
throw err
|
|
194
|
+
}
|
|
159
195
|
} catch (err) {
|
|
160
196
|
handler.onError(err)
|
|
161
197
|
}
|
|
@@ -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
|
}
|
|
@@ -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 {
|