@nxtedition/nxt-undici 7.4.1 → 7.4.2
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 +36 -8
- package/lib/index.js +1 -0
- package/lib/interceptor/cache.js +79 -40
- package/lib/interceptor/dns.js +73 -6
- package/lib/interceptor/log.js +184 -9
- package/lib/interceptor/pressure.js +11 -0
- package/lib/interceptor/proxy.js +42 -14
- package/lib/interceptor/redirect.js +35 -5
- package/lib/interceptor/request-body-factory.js +59 -8
- package/lib/interceptor/response-error.js +19 -2
- package/lib/interceptor/response-retry.js +273 -21
- package/lib/request.js +5 -1
- package/lib/sqlite-cache-store.js +86 -9
- package/lib/utils.js +38 -15
- package/package.json +1 -1
package/lib/index.d.ts
CHANGED
|
@@ -55,17 +55,25 @@ export interface LoggerLike {
|
|
|
55
55
|
info(obj: unknown, msg?: string): void
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export type BodyFactoryResult =
|
|
59
|
+
| Readable
|
|
60
|
+
| Uint8Array
|
|
61
|
+
| string
|
|
62
|
+
| Iterable<unknown>
|
|
63
|
+
| AsyncIterable<unknown>
|
|
64
|
+
|
|
65
|
+
/** Called with an options object (not a bare signal); the signal aborts when the
|
|
66
|
+
* request is destroyed before the factory resolves. May be async. */
|
|
67
|
+
export type BodyFactory = (opts: {
|
|
68
|
+
signal: AbortSignal
|
|
69
|
+
}) => BodyFactoryResult | Promise<BodyFactoryResult>
|
|
70
|
+
|
|
58
71
|
export interface DispatchOptions {
|
|
59
72
|
id?: string | null
|
|
60
73
|
origin?: string | null
|
|
61
74
|
path?: string | null
|
|
62
75
|
method?: string | null
|
|
63
|
-
body?:
|
|
64
|
-
| Readable
|
|
65
|
-
| Uint8Array
|
|
66
|
-
| string
|
|
67
|
-
| ((signal: AbortSignal) => Readable | Uint8Array | string | Iterable<unknown>)
|
|
68
|
-
| null
|
|
76
|
+
body?: Readable | Uint8Array | string | BodyFactory | null
|
|
69
77
|
query?: Record<string, unknown> | null
|
|
70
78
|
headers?: Record<string, string | string[] | null | undefined> | null
|
|
71
79
|
signal?: AbortSignal | null
|
|
@@ -80,8 +88,13 @@ export interface DispatchOptions {
|
|
|
80
88
|
retry?: RetryOptions | number | boolean | RetryFn | null
|
|
81
89
|
proxy?: ProxyOptions | boolean | null
|
|
82
90
|
cache?: CacheOptions | boolean | null
|
|
83
|
-
upgrade
|
|
91
|
+
/** Protocol to upgrade to (e.g. 'websocket'). undici requires a string and the
|
|
92
|
+
* dispatch handler must implement onUpgrade — only meaningful with raw
|
|
93
|
+
* dispatch()/compose(); request() has no upgrade support (see RequestOptions). */
|
|
94
|
+
upgrade?: string | null
|
|
84
95
|
follow?: number | FollowFn | boolean | null
|
|
96
|
+
/** Alias for `follow`; ignored when `follow` is also set. */
|
|
97
|
+
redirect?: number | FollowFn | boolean | null
|
|
85
98
|
error?: boolean | null
|
|
86
99
|
verify?: VerifyOptions | boolean | null
|
|
87
100
|
logger?: LoggerLike | null
|
|
@@ -121,6 +134,8 @@ export interface ProxyOptions {
|
|
|
121
134
|
export interface CacheOptions {
|
|
122
135
|
store?: CacheStore
|
|
123
136
|
maxEntrySize?: number
|
|
137
|
+
/** Upper bound on an entry's freshness lifetime, in seconds (default 30 days). */
|
|
138
|
+
maxEntryTTL?: number
|
|
124
139
|
}
|
|
125
140
|
|
|
126
141
|
export interface VerifyOptions {
|
|
@@ -130,7 +145,16 @@ export interface VerifyOptions {
|
|
|
130
145
|
|
|
131
146
|
export interface DnsOptions {
|
|
132
147
|
ttl?: number
|
|
148
|
+
/** How long (ms) a failed lookup is negative-cached; requests inside this
|
|
149
|
+
* window fail fast without hitting the resolver again (default 1000). */
|
|
150
|
+
negativeTTL?: number
|
|
133
151
|
balance?: 'hash'
|
|
152
|
+
/** Custom resolver, `dns.lookup`-compatible (called with `{ all: true }`). */
|
|
153
|
+
lookup?: (
|
|
154
|
+
hostname: string,
|
|
155
|
+
options: { all: boolean },
|
|
156
|
+
callback: (err: Error | null, addresses: { address: string; family: number }[]) => void,
|
|
157
|
+
) => void
|
|
134
158
|
}
|
|
135
159
|
|
|
136
160
|
export interface LogInterceptorOptions {
|
|
@@ -247,10 +271,14 @@ export interface CacheStore {
|
|
|
247
271
|
close(): void
|
|
248
272
|
}
|
|
249
273
|
|
|
250
|
-
|
|
274
|
+
/** `upgrade` is omitted: request()'s internal handler has no onUpgrade, so any
|
|
275
|
+
* upgrade attempt rejects with InvalidArgumentError — use dispatch() instead. */
|
|
276
|
+
export interface RequestOptions extends Omit<DispatchOptions, 'upgrade'> {
|
|
251
277
|
url?: URLLike | null
|
|
252
278
|
dispatch?: DispatchFn | null
|
|
253
279
|
dispatcher?: Dispatcher | null
|
|
280
|
+
/** highWaterMark for the response body readable (non-negative number). */
|
|
281
|
+
highWaterMark?: number | null
|
|
254
282
|
}
|
|
255
283
|
|
|
256
284
|
export interface ResponseData {
|
package/lib/index.js
CHANGED
|
@@ -29,6 +29,7 @@ export const cache = {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export { parseHeaders } from './utils.js'
|
|
32
|
+
export { SqliteCacheStore } from './sqlite-cache-store.js'
|
|
32
33
|
export { Client, Pool, Agent, getGlobalDispatcher, setGlobalDispatcher } from '@nxtedition/undici'
|
|
33
34
|
|
|
34
35
|
function defaultLookup(origin, opts, callback) {
|
package/lib/interceptor/cache.js
CHANGED
|
@@ -121,7 +121,10 @@ class CacheHandler extends DecoratorHandler {
|
|
|
121
121
|
return super.onHeaders(statusCode, headers, resume)
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
// Null prototype: selector names come from the response Vary header and
|
|
125
|
+
// request header names are caller-controlled — on a plain `{}` a
|
|
126
|
+
// `__proto__` key would hit the prototype setter and be silently dropped.
|
|
127
|
+
const vary = Object.create(null)
|
|
125
128
|
if (headers.vary) {
|
|
126
129
|
if (typeof headers.vary !== 'string') {
|
|
127
130
|
return super.onHeaders(statusCode, headers, resume)
|
|
@@ -141,9 +144,15 @@ class CacheHandler extends DecoratorHandler {
|
|
|
141
144
|
}
|
|
142
145
|
}
|
|
143
146
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
// RFC 8246: 'immutable' means the body won't change during the freshness
|
|
148
|
+
// lifetime — it does not define or extend that lifetime. An explicit
|
|
149
|
+
// s-maxage/max-age always wins; immutable only supplies a (long) default
|
|
150
|
+
// when no explicit lifetime is present. Capped by maxEntryTTL below.
|
|
151
|
+
const ttl = Number(
|
|
152
|
+
cacheControlDirectives['s-maxage'] ??
|
|
153
|
+
cacheControlDirectives['max-age'] ??
|
|
154
|
+
(cacheControlDirectives.immutable ? 31556952 : NaN),
|
|
155
|
+
)
|
|
147
156
|
if (!ttl || !Number.isFinite(ttl) || ttl <= 0) {
|
|
148
157
|
return super.onHeaders(statusCode, headers, resume)
|
|
149
158
|
}
|
|
@@ -167,6 +176,23 @@ class CacheHandler extends DecoratorHandler {
|
|
|
167
176
|
|
|
168
177
|
if (end == null || end - start <= this.#maxEntrySize) {
|
|
169
178
|
const cachedAt = Date.now()
|
|
179
|
+
// Snapshot the headers: the same object is delivered downstream to the
|
|
180
|
+
// caller (request() resolves with it before the body finishes), and the
|
|
181
|
+
// entry isn't serialized to the store until onComplete. Without a copy,
|
|
182
|
+
// any mutation the caller makes to res.headers while the body streams
|
|
183
|
+
// would be persisted into the shared cache and replayed to every later
|
|
184
|
+
// request. parseHeaders values are strings or string arrays, so a
|
|
185
|
+
// one-level copy with array values sliced is a full snapshot. Use
|
|
186
|
+
// Object.keys so only own enumerable header fields are copied — never
|
|
187
|
+
// inherited properties from the prototype chain. Null prototype: on a
|
|
188
|
+
// plain `{}` a header literally named `__proto__` would hit the
|
|
189
|
+
// Object.prototype setter instead of becoming a data property (silent
|
|
190
|
+
// drop / prototype-pollution vector).
|
|
191
|
+
const storedHeaders = Object.create(null)
|
|
192
|
+
for (const name of Object.keys(headers)) {
|
|
193
|
+
const val = headers[name]
|
|
194
|
+
storedHeaders[name] = Array.isArray(val) ? val.slice() : val
|
|
195
|
+
}
|
|
170
196
|
this.#value = {
|
|
171
197
|
body: [],
|
|
172
198
|
start,
|
|
@@ -174,7 +200,7 @@ class CacheHandler extends DecoratorHandler {
|
|
|
174
200
|
deleteAt: cachedAt + lifetime * 1e3,
|
|
175
201
|
statusCode,
|
|
176
202
|
statusMessage: '',
|
|
177
|
-
headers,
|
|
203
|
+
headers: storedHeaders,
|
|
178
204
|
cacheControlDirectives,
|
|
179
205
|
etag: isEtagUsable(headers.etag) ? headers.etag : '',
|
|
180
206
|
vary,
|
|
@@ -229,7 +255,45 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
229
255
|
return dispatch(opts, handler)
|
|
230
256
|
}
|
|
231
257
|
|
|
232
|
-
|
|
258
|
+
// TODO (fix): enable range requests
|
|
259
|
+
|
|
260
|
+
// Build the key the same way for lookups and stores: makeCacheKey
|
|
261
|
+
// stringifies the origin (e.g. URL objects), so using raw opts on the get
|
|
262
|
+
// path while the set path normalizes would make the cache permanently miss.
|
|
263
|
+
const key = undici.util.cache.makeCacheKey(opts)
|
|
264
|
+
|
|
265
|
+
// makeCacheKey preserves request header names verbatim. Vary selector names
|
|
266
|
+
// are lowercased (in onHeaders and matchesValue), so lowercase the key's
|
|
267
|
+
// header names once here — the same key feeds both the get and set paths, so
|
|
268
|
+
// this keeps Vary matching symmetric even when a caller supplies non-lowercase
|
|
269
|
+
// header names (the standalone interceptors.cache() composition; the wrapped
|
|
270
|
+
// pipeline already normalizes). A fresh object avoids mutating opts.headers.
|
|
271
|
+
// Header names are caller-controlled, so build a null-prototype map (a
|
|
272
|
+
// `__proto__` key on a plain object would silently overwrite the prototype
|
|
273
|
+
// instead of setting a property) and copy own keys only.
|
|
274
|
+
if (key.headers && typeof key.headers === 'object') {
|
|
275
|
+
const lower = Object.create(null)
|
|
276
|
+
for (const name of Object.keys(key.headers)) {
|
|
277
|
+
lower[name.toLowerCase()] = key.headers[name]
|
|
278
|
+
}
|
|
279
|
+
key.headers = lower
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// All request-directive and conditional-header guards below MUST read from
|
|
283
|
+
// the lowercased key.headers, not raw opts.headers — otherwise a caller
|
|
284
|
+
// supplying capitalized names (e.g. `Authorization`, `Cache-Control` via the
|
|
285
|
+
// standalone composition) would silently skip the guards while the store
|
|
286
|
+
// side (CacheHandler) reads the lowercased form, e.g. serving a non-public
|
|
287
|
+
// cached response to an authorized request (RFC 9111 §3.5).
|
|
288
|
+
const headers = key.headers ?? {}
|
|
289
|
+
|
|
290
|
+
// Cache-Control is a list-typed field: duplicated field lines arrive as an
|
|
291
|
+
// array and combine into a single comma-separated value per RFC 9110 §5.2,
|
|
292
|
+
// keeping the raw-string directive checks below working.
|
|
293
|
+
let rawCacheControl = headers['cache-control']
|
|
294
|
+
if (Array.isArray(rawCacheControl)) {
|
|
295
|
+
rawCacheControl = rawCacheControl.join(', ')
|
|
296
|
+
}
|
|
233
297
|
const cacheControlDirectives = parseCacheControl(rawCacheControl) ?? {}
|
|
234
298
|
// cache-control-parser does not recognise 'only-if-cached', so check the raw string.
|
|
235
299
|
const onlyIfCached =
|
|
@@ -237,7 +301,7 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
237
301
|
|
|
238
302
|
// RFC 9111 Section 5.4: Pragma: no-cache should be treated as
|
|
239
303
|
// Cache-Control: no-cache when Cache-Control is absent.
|
|
240
|
-
if (rawCacheControl == null &&
|
|
304
|
+
if (rawCacheControl == null && headers.pragma === 'no-cache') {
|
|
241
305
|
cacheControlDirectives['no-cache'] = true
|
|
242
306
|
}
|
|
243
307
|
|
|
@@ -262,27 +326,6 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
262
326
|
const store =
|
|
263
327
|
opts.cache.store ?? (DEFAULT_STORE ??= new SqliteCacheStore({ location: ':memory:' }))
|
|
264
328
|
|
|
265
|
-
// TODO (fix): enable range requests
|
|
266
|
-
|
|
267
|
-
// Build the key the same way for lookups and stores: makeCacheKey
|
|
268
|
-
// stringifies the origin (e.g. URL objects), so using raw opts on the get
|
|
269
|
-
// path while the set path normalizes would make the cache permanently miss.
|
|
270
|
-
const key = undici.util.cache.makeCacheKey(opts)
|
|
271
|
-
|
|
272
|
-
// makeCacheKey preserves request header names verbatim. Vary selector names
|
|
273
|
-
// are lowercased (in onHeaders and matchesValue), so lowercase the key's
|
|
274
|
-
// header names once here — the same key feeds both the get and set paths, so
|
|
275
|
-
// this keeps Vary matching symmetric even when a caller supplies non-lowercase
|
|
276
|
-
// header names (the standalone interceptors.cache() composition; the wrapped
|
|
277
|
-
// pipeline already normalizes). A fresh object avoids mutating opts.headers.
|
|
278
|
-
if (key.headers && typeof key.headers === 'object') {
|
|
279
|
-
const lower = {}
|
|
280
|
-
for (const name in key.headers) {
|
|
281
|
-
lower[name.toLowerCase()] = key.headers[name]
|
|
282
|
-
}
|
|
283
|
-
key.headers = lower
|
|
284
|
-
}
|
|
285
|
-
|
|
286
329
|
let entry
|
|
287
330
|
try {
|
|
288
331
|
entry = store.get(key)
|
|
@@ -297,18 +340,18 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
297
340
|
|
|
298
341
|
// RFC 9111 Section 3.5: A shared cache must not use a cached response to a
|
|
299
342
|
// request with Authorization unless the response includes a public directive.
|
|
300
|
-
if (entry &&
|
|
343
|
+
if (entry && headers.authorization && !entry.cacheControlDirectives?.public) {
|
|
301
344
|
entry = undefined
|
|
302
345
|
}
|
|
303
346
|
|
|
304
347
|
// RFC 9110 Section 13: Evaluate conditional request headers against cached entry.
|
|
305
348
|
// typeof guards: duplicated conditional headers arrive as arrays — treat
|
|
306
349
|
// them as non-matching and bypass to origin rather than crashing.
|
|
307
|
-
if (entry &&
|
|
350
|
+
if (entry && headers['if-none-match']) {
|
|
308
351
|
if (
|
|
309
|
-
typeof
|
|
352
|
+
typeof headers['if-none-match'] === 'string' &&
|
|
310
353
|
entry.etag &&
|
|
311
|
-
weakMatch(
|
|
354
|
+
weakMatch(headers['if-none-match'], entry.etag)
|
|
312
355
|
) {
|
|
313
356
|
return serveFromCache(
|
|
314
357
|
{ statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
|
|
@@ -318,12 +361,12 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
318
361
|
}
|
|
319
362
|
// Etag didn't match — bypass to origin.
|
|
320
363
|
entry = undefined
|
|
321
|
-
} else if (entry &&
|
|
364
|
+
} else if (entry && headers['if-modified-since']) {
|
|
322
365
|
const lastModified = entry.headers?.['last-modified']
|
|
323
366
|
if (
|
|
324
|
-
typeof
|
|
367
|
+
typeof headers['if-modified-since'] === 'string' &&
|
|
325
368
|
lastModified &&
|
|
326
|
-
new Date(lastModified) <= new Date(
|
|
369
|
+
new Date(lastModified) <= new Date(headers['if-modified-since'])
|
|
327
370
|
) {
|
|
328
371
|
return serveFromCache(
|
|
329
372
|
{ statusCode: 304, headers: entry.headers, cachedAt: entry.cachedAt },
|
|
@@ -335,11 +378,7 @@ export default () => (dispatch) => (opts, handler) => {
|
|
|
335
378
|
entry = undefined
|
|
336
379
|
}
|
|
337
380
|
|
|
338
|
-
if (
|
|
339
|
-
opts.headers?.['if-match'] ||
|
|
340
|
-
opts.headers?.['if-unmodified-since'] ||
|
|
341
|
-
opts.headers?.['if-range']
|
|
342
|
-
) {
|
|
381
|
+
if (headers['if-match'] || headers['if-unmodified-since'] || headers['if-range']) {
|
|
343
382
|
// TODO (fix): evaluate these conditional headers against cached entry.
|
|
344
383
|
return dispatch(opts, handler)
|
|
345
384
|
}
|
package/lib/interceptor/dns.js
CHANGED
|
@@ -44,6 +44,27 @@ class Handler extends DecoratorHandler {
|
|
|
44
44
|
|
|
45
45
|
const SWEEP_INTERVAL = 30e3
|
|
46
46
|
|
|
47
|
+
// A cached (or in-flight-shared) lookup error must never be handed to more
|
|
48
|
+
// than one caller as-is: downstream decorateError call sites (response-retry,
|
|
49
|
+
// response-error) mutate the error they receive (err.req, err.res,
|
|
50
|
+
// err.statusCode), so a shared object would leak one request's decoration
|
|
51
|
+
// into another's. Give each caller a fresh Error carrying the identifying
|
|
52
|
+
// dns fields, with the original attached as `cause`.
|
|
53
|
+
function makeLookupError(err) {
|
|
54
|
+
const wrapped = new Error(err.message, { cause: err })
|
|
55
|
+
wrapped.code = err.code
|
|
56
|
+
if (err.errno !== undefined) {
|
|
57
|
+
wrapped.errno = err.errno
|
|
58
|
+
}
|
|
59
|
+
if (err.syscall !== undefined) {
|
|
60
|
+
wrapped.syscall = err.syscall
|
|
61
|
+
}
|
|
62
|
+
if (err.hostname !== undefined) {
|
|
63
|
+
wrapped.hostname = err.hostname
|
|
64
|
+
}
|
|
65
|
+
return wrapped
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
// Error codes that mean the IP itself is unreachable/bad, so the selected
|
|
48
69
|
// record should be invalidated immediately (expires = 0), forcing a re-resolve.
|
|
49
70
|
// Anything else surfaced on the response path — a headers/body timeout, a
|
|
@@ -67,12 +88,16 @@ const CONNECTION_ERROR_CODES = new Set([
|
|
|
67
88
|
|
|
68
89
|
export default () => (dispatch) => {
|
|
69
90
|
const cache = new Map()
|
|
91
|
+
const negatives = new Map()
|
|
70
92
|
const promises = new Map()
|
|
71
93
|
|
|
72
94
|
// The `cache` Map is otherwise only ever written, never trimmed, so a process
|
|
73
95
|
// touching many distinct hostnames over its lifetime would leak entries that
|
|
74
96
|
// can never be selected again. Sweep dead entries (all records expired and
|
|
75
97
|
// none in flight) at most once per SWEEP_INTERVAL to bound the O(n) cost.
|
|
98
|
+
// Negative entries are swept on the same cadence (they are also deleted
|
|
99
|
+
// eagerly on the next successful lookup / overwritten on the next failure,
|
|
100
|
+
// so the sweep only matters for hostnames that are never touched again).
|
|
76
101
|
let lastSweep = 0
|
|
77
102
|
function sweep(now) {
|
|
78
103
|
if (now - lastSweep < SWEEP_INTERVAL) {
|
|
@@ -84,18 +109,33 @@ export default () => (dispatch) => {
|
|
|
84
109
|
cache.delete(hostname)
|
|
85
110
|
}
|
|
86
111
|
}
|
|
112
|
+
for (const [hostname, negative] of negatives) {
|
|
113
|
+
if (negative.expires < now) {
|
|
114
|
+
negatives.delete(hostname)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
87
117
|
}
|
|
88
118
|
|
|
89
|
-
function resolve(hostname, { ttl }) {
|
|
119
|
+
function resolve(hostname, { ttl, negativeTTL, lookup }) {
|
|
90
120
|
let promise = promises.get(hostname)
|
|
91
121
|
if (!promise) {
|
|
92
122
|
promise = new Promise((resolve) => {
|
|
93
|
-
|
|
123
|
+
lookup(hostname, { all: true }, (err, records) => {
|
|
94
124
|
promises.delete(hostname)
|
|
95
125
|
|
|
96
126
|
if (err) {
|
|
127
|
+
// Negative cache: remember the failure for a short while so a hot
|
|
128
|
+
// caller of an unresolvable host fails fast instead of issuing a
|
|
129
|
+
// lookup storm (response-retry retries ENOTFOUND/EAI_AGAIN up to
|
|
130
|
+
// `retry` (default 8) times, so without this every logical
|
|
131
|
+
// request produced ~9 lookups). EAI_AGAIN is transient by
|
|
132
|
+
// definition, but the same small TTL applies — the window only
|
|
133
|
+
// needs to absorb a retry burst, and a short TTL keeps recovery
|
|
134
|
+
// fast either way.
|
|
135
|
+
negatives.set(hostname, { err, expires: getFastNow() + negativeTTL })
|
|
97
136
|
resolve([err, null])
|
|
98
137
|
} else {
|
|
138
|
+
negatives.delete(hostname)
|
|
99
139
|
const now = getFastNow()
|
|
100
140
|
const val = records.map(({ address }) => {
|
|
101
141
|
return {
|
|
@@ -125,6 +165,8 @@ export default () => (dispatch) => {
|
|
|
125
165
|
}
|
|
126
166
|
|
|
127
167
|
const ttl = opts.dns.ttl ?? 2e3
|
|
168
|
+
const negativeTTL = opts.dns.negativeTTL ?? 1e3
|
|
169
|
+
const lookup = opts.dns.lookup ?? dns.lookup
|
|
128
170
|
const url = new URL(opts.path ?? '', opts.origin)
|
|
129
171
|
const balance = opts.dns.balance
|
|
130
172
|
|
|
@@ -141,9 +183,19 @@ export default () => (dispatch) => {
|
|
|
141
183
|
let records = cache.get(hostname)
|
|
142
184
|
|
|
143
185
|
if (records == null || records.every((x) => x.expires < now)) {
|
|
144
|
-
|
|
186
|
+
// A fresh negative entry means a lookup for this hostname failed less
|
|
187
|
+
// than negativeTTL ago — fail fast instead of hitting the resolver
|
|
188
|
+
// again. Fresh positive records (checked above) take precedence, so a
|
|
189
|
+
// failed pre-emptive refresh never fails requests that can still be
|
|
190
|
+
// served from cache.
|
|
191
|
+
const negative = negatives.get(hostname)
|
|
192
|
+
if (negative != null && negative.expires >= now) {
|
|
193
|
+
throw makeLookupError(negative.err)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const [err, val] = await resolve(hostname, { ttl, negativeTTL, lookup })
|
|
145
197
|
if (err) {
|
|
146
|
-
throw err
|
|
198
|
+
throw makeLookupError(err)
|
|
147
199
|
}
|
|
148
200
|
records = val
|
|
149
201
|
}
|
|
@@ -190,7 +242,7 @@ export default () => (dispatch) => {
|
|
|
190
242
|
// refreshed records land in cache for the next request, smoothing
|
|
191
243
|
// out DNS lookup latency. `resolve()` dedupes via `promises`.
|
|
192
244
|
if (records.some((x) => x.expires < now + ttl / 2)) {
|
|
193
|
-
resolve(hostname, { ttl })
|
|
245
|
+
resolve(hostname, { ttl, negativeTTL, lookup })
|
|
194
246
|
}
|
|
195
247
|
|
|
196
248
|
url.hostname = net.isIPv6(record.address) ? `[${record.address}]` : record.address
|
|
@@ -224,8 +276,23 @@ export default () => (dispatch) => {
|
|
|
224
276
|
}
|
|
225
277
|
|
|
226
278
|
try {
|
|
279
|
+
// origin is rewritten to the resolved IP, so pin the host header to
|
|
280
|
+
// the logical hostname — but never clobber an explicit user-supplied
|
|
281
|
+
// host (virtual hosting). Lowercase lookup matches the pipeline
|
|
282
|
+
// invariant (parseHeaders) and priority.js, which reads the same key.
|
|
283
|
+
// Host is a singular header, so only a single non-empty string value
|
|
284
|
+
// is preserved (same rule as priority.js) — an array (duplicate Host
|
|
285
|
+
// field-lines) or an empty string falls back to the origin-derived
|
|
286
|
+
// host.
|
|
227
287
|
return dispatch(
|
|
228
|
-
{
|
|
288
|
+
{
|
|
289
|
+
...opts,
|
|
290
|
+
origin: url.origin,
|
|
291
|
+
headers: {
|
|
292
|
+
...opts.headers,
|
|
293
|
+
host: (typeof opts.headers?.host === 'string' && opts.headers.host) || host,
|
|
294
|
+
},
|
|
295
|
+
},
|
|
229
296
|
new Handler(handler, onSettle),
|
|
230
297
|
)
|
|
231
298
|
} catch (err) {
|
package/lib/interceptor/log.js
CHANGED
|
@@ -1,10 +1,144 @@
|
|
|
1
|
-
import { DecoratorHandler } from '../utils.js'
|
|
1
|
+
import { DecoratorHandler, parseHeaders } from '../utils.js'
|
|
2
2
|
|
|
3
3
|
const kGlobalIndex = Symbol.for('@nxtedition/nxt-undici#globalIndex')
|
|
4
4
|
const kGlobalArray = Symbol.for('@nxtedition/nxt-undici#globalArray')
|
|
5
5
|
|
|
6
|
+
const REDACTED = '[redacted]'
|
|
7
|
+
|
|
8
|
+
// Header names (lowercase) whose values must never reach the logs.
|
|
9
|
+
const SECRET_HEADERS = new Set(['authorization', 'proxy-authorization', 'cookie', 'set-cookie'])
|
|
10
|
+
|
|
11
|
+
// Allocation-free pre-scan: true when `headers` is a plain object that the
|
|
12
|
+
// parse + redact path would reproduce verbatim — every key already lowercase,
|
|
13
|
+
// every value a string (or array of strings), no secret header present. In
|
|
14
|
+
// that case the original object can be logged as-is: log bindings only read
|
|
15
|
+
// it (pino serializes child bindings eagerly), nothing in the pipeline
|
|
16
|
+
// mutates a caller's headers object in place. Uses a `for..in` + `Object.hasOwn`
|
|
17
|
+
// guard rather than `Object.keys` so the scan itself allocates nothing while
|
|
18
|
+
// still ignoring inherited props exactly as `Object.keys` would.
|
|
19
|
+
function isCleanHeaderObject(headers) {
|
|
20
|
+
for (const key in headers) {
|
|
21
|
+
if (!Object.hasOwn(headers, key)) {
|
|
22
|
+
continue
|
|
23
|
+
}
|
|
24
|
+
if (SECRET_HEADERS.has(key) || key.toLowerCase() !== key) {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
const val = headers[key]
|
|
28
|
+
if (Array.isArray(val)) {
|
|
29
|
+
for (const item of val) {
|
|
30
|
+
if (typeof item !== 'string') {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} else if (typeof val !== 'string') {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Return a loggable view of `headers` with credential values replaced by a
|
|
42
|
+
// redaction marker. Copy-on-write: the common case (already-lowercased plain
|
|
43
|
+
// object, string values, nothing to redact) returns the original object as-is,
|
|
44
|
+
// allocating no new headers object (the pre-scan itself is allocation-free
|
|
45
|
+
// too). Only when something actually needs work — flat-array
|
|
46
|
+
// form ([name, value, name, value, ...] with Buffer or string entries from
|
|
47
|
+
// onHeaders/onUpgrade), a secret header, a non-lowercase name, or a
|
|
48
|
+
// non-string value — do we build a sanitized copy via parseHeaders, which
|
|
49
|
+
// lowercases names, stringifies values (Buffers included, so no
|
|
50
|
+
// `{type:'Buffer',data:[...]}` blobs in bindings), skips null/undefined, and
|
|
51
|
+
// merges duplicate names into arrays instead of overwriting earlier values.
|
|
52
|
+
function sanitizeHeaders(headers) {
|
|
53
|
+
if (headers == null || typeof headers !== 'object') {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!Array.isArray(headers) && isCleanHeaderObject(headers)) {
|
|
58
|
+
return headers
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sanitized = parseHeaders(headers)
|
|
62
|
+
|
|
63
|
+
for (const name of SECRET_HEADERS) {
|
|
64
|
+
if (name in sanitized) {
|
|
65
|
+
sanitized[name] = REDACTED
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return sanitized
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Normalize the request origin for logging without leaking userinfo
|
|
73
|
+
// credentials embedded as `http://user:pass@host`. Copy-on-write: userinfo
|
|
74
|
+
// requires an '@', so a string without one is returned as-is — no URL
|
|
75
|
+
// allocation. Real `URL` instances already expose a credential-free
|
|
76
|
+
// `origin`; arbitrary URL-like objects do NOT get that fast path, since a
|
|
77
|
+
// plain `{ origin: 'http://user:pass@host' }` would bypass the userinfo
|
|
78
|
+
// check. Everything else is stringified, and only strings that could carry
|
|
79
|
+
// userinfo are parsed and reduced to URL#origin (which never contains
|
|
80
|
+
// userinfo); if such a string is not a parseable URL, prefer losing the
|
|
81
|
+
// value over risking embedded credentials.
|
|
82
|
+
function sanitizeOrigin(origin) {
|
|
83
|
+
if (origin == null) {
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
if (origin instanceof URL) {
|
|
87
|
+
return origin.origin
|
|
88
|
+
}
|
|
89
|
+
const str = typeof origin === 'string' ? origin : String(origin)
|
|
90
|
+
if (!str.includes('@')) {
|
|
91
|
+
return str
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return new URL(str).origin
|
|
95
|
+
} catch {
|
|
96
|
+
return REDACTED
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Summarize the request body (type + size) instead of embedding its content.
|
|
101
|
+
// Bodies may contain credentials or be arbitrarily large, and pino serializes
|
|
102
|
+
// child bindings eagerly — never put the payload itself into the log record.
|
|
103
|
+
function describeBody(body) {
|
|
104
|
+
if (body == null) {
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
if (typeof body === 'string') {
|
|
108
|
+
return `string(${Buffer.byteLength(body)} bytes)`
|
|
109
|
+
}
|
|
110
|
+
if (Buffer.isBuffer(body)) {
|
|
111
|
+
return `Buffer(${body.byteLength} bytes)`
|
|
112
|
+
}
|
|
113
|
+
if (ArrayBuffer.isView(body)) {
|
|
114
|
+
return `${body.constructor?.name ?? 'TypedArray'}(${body.byteLength} bytes)`
|
|
115
|
+
}
|
|
116
|
+
if (typeof body === 'object' && typeof body.byteLength === 'number') {
|
|
117
|
+
return `${body.constructor?.name ?? 'ArrayBuffer'}(${body.byteLength} bytes)`
|
|
118
|
+
}
|
|
119
|
+
if (typeof body === 'function') {
|
|
120
|
+
return 'function'
|
|
121
|
+
}
|
|
122
|
+
return body.constructor?.name ?? typeof body
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Small, sanitized summary of the request opts used for all `ureq` log
|
|
126
|
+
// bindings. Built once per request instead of binding the live opts object,
|
|
127
|
+
// which both leaked credentials/bodies into logs and paid eager pino
|
|
128
|
+
// serialization of the full opts (including the entire body) per request.
|
|
129
|
+
function sanitizeRequest(opts) {
|
|
130
|
+
return {
|
|
131
|
+
id: opts.id,
|
|
132
|
+
origin: sanitizeOrigin(opts.origin),
|
|
133
|
+
path: opts.path,
|
|
134
|
+
method: opts.method,
|
|
135
|
+
headers: sanitizeHeaders(opts.headers),
|
|
136
|
+
body: describeBody(opts.body),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
6
140
|
class Handler extends DecoratorHandler {
|
|
7
|
-
#
|
|
141
|
+
#ureq
|
|
8
142
|
#logger
|
|
9
143
|
|
|
10
144
|
#abort
|
|
@@ -25,8 +159,8 @@ class Handler extends DecoratorHandler {
|
|
|
25
159
|
constructor(logOpts, opts, { handler }) {
|
|
26
160
|
super(handler)
|
|
27
161
|
|
|
28
|
-
this.#
|
|
29
|
-
this.#logger = opts.logger.child({ ureq:
|
|
162
|
+
this.#ureq = sanitizeRequest(opts)
|
|
163
|
+
this.#logger = opts.logger.child({ ureq: this.#ureq })
|
|
30
164
|
|
|
31
165
|
if (logOpts?.bindings) {
|
|
32
166
|
this.#logger = this.#logger.child(logOpts?.bindings)
|
|
@@ -59,7 +193,7 @@ class Handler extends DecoratorHandler {
|
|
|
59
193
|
|
|
60
194
|
this.#logger.debug(
|
|
61
195
|
{
|
|
62
|
-
ures: { statusCode, headers },
|
|
196
|
+
ures: { statusCode, headers: sanitizeHeaders(headers) },
|
|
63
197
|
elapsedTime: this.#timing.headers,
|
|
64
198
|
},
|
|
65
199
|
'upstream request upgrade',
|
|
@@ -76,7 +210,9 @@ class Handler extends DecoratorHandler {
|
|
|
76
210
|
onHeaders(statusCode, headers, resume) {
|
|
77
211
|
this.#timing.headers = performance.now() - this.#created
|
|
78
212
|
this.#statusCode = statusCode
|
|
79
|
-
|
|
213
|
+
// Only used for log records; store the sanitized copy so set-cookie etc.
|
|
214
|
+
// never end up in retained (error-level) logs.
|
|
215
|
+
this.#headers = sanitizeHeaders(headers)
|
|
80
216
|
|
|
81
217
|
return super.onHeaders(statusCode, headers, resume)
|
|
82
218
|
}
|
|
@@ -95,7 +231,7 @@ class Handler extends DecoratorHandler {
|
|
|
95
231
|
this.#timing.end = performance.now() - this.#created
|
|
96
232
|
|
|
97
233
|
const data = {
|
|
98
|
-
ureq: this.#
|
|
234
|
+
ureq: this.#ureq,
|
|
99
235
|
ures: {
|
|
100
236
|
statusCode: this.#statusCode,
|
|
101
237
|
headers: this.#headers,
|
|
@@ -161,7 +297,46 @@ class Handler extends DecoratorHandler {
|
|
|
161
297
|
this[kGlobalIndex] = -1
|
|
162
298
|
}
|
|
163
299
|
}
|
|
300
|
+
|
|
301
|
+
// Finalization for a request whose inner dispatch threw synchronously:
|
|
302
|
+
// undici never took ownership of the handler, so no terminal callback
|
|
303
|
+
// (onError/onComplete) will ever arrive. Log the failure and deregister
|
|
304
|
+
// from the in-flight registry. Deliberately does NOT forward onError —
|
|
305
|
+
// the dispatch entry below rethrows and an outer interceptor (lookup)
|
|
306
|
+
// delivers the error to the original handler chain, so forwarding here
|
|
307
|
+
// would double-deliver it.
|
|
308
|
+
onDispatchError(err) {
|
|
309
|
+
if (this[kGlobalIndex] === -1) {
|
|
310
|
+
// A terminal callback already ran before the error escaped dispatch
|
|
311
|
+
// (e.g. onError was delivered and the error was then rethrown):
|
|
312
|
+
// already logged and deregistered.
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
this.#timing.end = performance.now() - this.#created
|
|
317
|
+
|
|
318
|
+
this.#logger.error({ err, elapsedTime: this.#timing.end }, 'upstream request failed')
|
|
319
|
+
|
|
320
|
+
this.onDone()
|
|
321
|
+
}
|
|
164
322
|
}
|
|
165
323
|
|
|
166
|
-
export default (logOpts) => (dispatch) => (opts, handler) =>
|
|
167
|
-
opts.logger
|
|
324
|
+
export default (logOpts) => (dispatch) => (opts, handler) => {
|
|
325
|
+
if (!opts.logger) {
|
|
326
|
+
return dispatch(opts, handler)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const logHandler = new Handler(logOpts, opts, { handler })
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
return dispatch(opts, logHandler)
|
|
333
|
+
} catch (err) {
|
|
334
|
+
// An inner interceptor threw synchronously at dispatch time (e.g. proxy
|
|
335
|
+
// loop detection). The error escapes past the already-registered handler,
|
|
336
|
+
// which would otherwise stay in the global in-flight registry forever.
|
|
337
|
+
// Finalize it and rethrow so outer interceptors observe the same error
|
|
338
|
+
// as before.
|
|
339
|
+
logHandler.onDispatchError(err)
|
|
340
|
+
throw err
|
|
341
|
+
}
|
|
342
|
+
}
|