@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 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?: boolean | null
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
- export interface RequestOptions extends DispatchOptions {
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) {
@@ -121,7 +121,10 @@ class CacheHandler extends DecoratorHandler {
121
121
  return super.onHeaders(statusCode, headers, resume)
122
122
  }
123
123
 
124
- const vary = {}
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
- const ttl = cacheControlDirectives.immutable
145
- ? 31556952
146
- : Number(cacheControlDirectives['s-maxage'] ?? cacheControlDirectives['max-age'])
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
- const rawCacheControl = opts?.headers?.['cache-control']
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 && opts?.headers?.pragma === 'no-cache') {
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 && opts.headers?.authorization && !entry.cacheControlDirectives?.public) {
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 && opts.headers?.['if-none-match']) {
350
+ if (entry && headers['if-none-match']) {
308
351
  if (
309
- typeof opts.headers['if-none-match'] === 'string' &&
352
+ typeof headers['if-none-match'] === 'string' &&
310
353
  entry.etag &&
311
- weakMatch(opts.headers['if-none-match'], entry.etag)
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 && opts.headers?.['if-modified-since']) {
364
+ } else if (entry && headers['if-modified-since']) {
322
365
  const lastModified = entry.headers?.['last-modified']
323
366
  if (
324
- typeof opts.headers['if-modified-since'] === 'string' &&
367
+ typeof headers['if-modified-since'] === 'string' &&
325
368
  lastModified &&
326
- new Date(lastModified) <= new Date(opts.headers['if-modified-since'])
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
  }
@@ -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
- dns.lookup(hostname, { all: true }, (err, records) => {
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
- const [err, val] = await resolve(hostname, { ttl })
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
- { ...opts, origin: url.origin, headers: { ...opts.headers, host } },
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) {
@@ -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
- #opts
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.#opts = opts
29
- this.#logger = opts.logger.child({ ureq: opts })
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
- this.#headers = headers
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.#opts,
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 ? dispatch(opts, new Handler(logOpts, opts, { handler })) : dispatch(opts, handler)
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
+ }