@nxtedition/nxt-undici 7.4.3 → 7.5.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.
@@ -1,14 +1,17 @@
1
1
  import { Scheduler } from '@nxtedition/scheduler'
2
2
  import { DecoratorHandler } from '../utils.js'
3
+ import { traceWrite, traceSafe, traceUrl } from '../trace.js'
3
4
 
4
5
  class Handler extends DecoratorHandler {
5
6
  #scheduler
6
7
  #onIdle
8
+ #trace
7
9
 
8
- constructor(handler, scheduler, onIdle) {
10
+ constructor(handler, scheduler, onIdle, trace) {
9
11
  super(handler)
10
12
  this.#scheduler = scheduler
11
13
  this.#onIdle = onIdle
14
+ this.#trace = trace
12
15
  }
13
16
 
14
17
  onConnect(abort) {
@@ -30,8 +33,34 @@ class Handler extends DecoratorHandler {
30
33
  if (this.#scheduler) {
31
34
  const scheduler = this.#scheduler
32
35
  this.#scheduler = null
36
+
37
+ // Slot-release timestamp is captured BEFORE release(): it synchronously
38
+ // pumps queued dispatches, whose work must not inflate this request's
39
+ // holdMs. Emission happens after release + eviction, inside the same
40
+ // once-guard as release() (every terminal callback funnels here), so
41
+ // the end doc cannot double-fire and the writer never observes a
42
+ // handler that still holds the slot.
43
+ const trace = this.#trace
44
+ const released = trace !== null ? performance.now() : 0
45
+
33
46
  scheduler.release()
34
47
  this.#onIdle?.()
48
+
49
+ if (trace !== null) {
50
+ traceSafe(
51
+ trace.write,
52
+ {
53
+ id: trace.id,
54
+ key: trace.key,
55
+ priority: trace.priority,
56
+ phase: 'end',
57
+ pending: null,
58
+ waitMs: Math.round(trace.dispatched - trace.acquired),
59
+ holdMs: Math.round(released - trace.dispatched),
60
+ },
61
+ 'undici:priority',
62
+ )
63
+ }
35
64
  }
36
65
  }
37
66
  }
@@ -66,9 +95,30 @@ export default () => (dispatch) => {
66
95
  }
67
96
  }
68
97
 
69
- const priorityHandler = new Handler(handler, scheduler, onIdle)
70
- scheduler.acquire(
98
+ // Trace state (op 'undici:priority') is resolved once per request:
99
+ // capture-once keeps the queued/end pair on one writer, and when tracing
100
+ // is off the cost is one property read — no clock reads, no string work.
101
+ // `acquired` must be stamped BEFORE acquire(): a free slot invokes the
102
+ // callback synchronously and `dispatched` would otherwise predate it.
103
+ const write = traceWrite(opts.trace)
104
+ const trace =
105
+ write !== null
106
+ ? {
107
+ write,
108
+ id: opts.id ?? null,
109
+ key: traceUrl({ origin: key }),
110
+ priority: String(opts.priority).slice(0, 16),
111
+ acquired: performance.now(),
112
+ dispatched: 0,
113
+ }
114
+ : null
115
+
116
+ const priorityHandler = new Handler(handler, scheduler, onIdle, trace)
117
+ const acquired = scheduler.acquire(
71
118
  (priorityHandler) => {
119
+ if (trace !== null) {
120
+ trace.dispatched = performance.now()
121
+ }
72
122
  try {
73
123
  dispatch(opts, priorityHandler)
74
124
  } catch (err) {
@@ -78,5 +128,25 @@ export default () => (dispatch) => {
78
128
  opts.priority,
79
129
  priorityHandler,
80
130
  )
131
+
132
+ // acquire() returns false only when no slot was free and the request was
133
+ // queued — the breadcrumb for a request that enters the queue and never
134
+ // leaves. pending is the post-enqueue queue depth, so it counts this
135
+ // request.
136
+ if (!acquired && trace !== null) {
137
+ traceSafe(
138
+ trace.write,
139
+ {
140
+ id: trace.id,
141
+ key: trace.key,
142
+ priority: trace.priority,
143
+ phase: 'queued',
144
+ pending: scheduler.pending,
145
+ waitMs: null,
146
+ holdMs: null,
147
+ },
148
+ 'undici:priority',
149
+ )
150
+ }
81
151
  }
82
152
  }
@@ -1,5 +1,6 @@
1
1
  import assert from 'node:assert'
2
2
  import { DecoratorHandler, isDisturbed, parseURL, parseHeaders, buildURL } from '../utils.js'
3
+ import { traceWrite, traceSafe, traceUrl } from '../trace.js'
3
4
 
4
5
  const redirectableStatusCodes = [300, 301, 302, 303, 307, 308]
5
6
 
@@ -105,6 +106,13 @@ class Handler extends DecoratorHandler {
105
106
  }
106
107
  }
107
108
 
109
+ // The redirect decision is final past this point, so trace work stays off
110
+ // the non-redirect path entirely. Resolve the writer per emission (trace
111
+ // survives the opts spread across hops) and capture `from` before #opts
112
+ // is rebuilt below.
113
+ const write = traceWrite(this.#opts.trace)
114
+ const from = write !== null ? traceUrl(this.#opts) : null
115
+
108
116
  // Build the base URL by concatenating origin + path via buildURL rather
109
117
  // than `new URL(path, origin)`: the latter is unsafe when `path` is
110
118
  // protocol-relative (e.g. `//evil-host/x`, reachable via a request URL like
@@ -140,6 +148,24 @@ class Handler extends DecoratorHandler {
140
148
  body: null,
141
149
  }
142
150
  }
151
+
152
+ if (write !== null) {
153
+ traceSafe(
154
+ write,
155
+ {
156
+ id: this.#opts.id ?? null,
157
+ // Post-rewrite method for the next hop (303 may have swapped it above).
158
+ method: this.#opts.method ?? null,
159
+ statusCode,
160
+ from,
161
+ to: traceUrl(this.#opts),
162
+ // #count is 1-based: incremented above for this hop, so the first
163
+ // followed redirect emits count 1.
164
+ count: this.#count,
165
+ },
166
+ 'undici:redirect',
167
+ )
168
+ }
143
169
  }
144
170
 
145
171
  onData(chunk) {
@@ -8,6 +8,7 @@ import {
8
8
  parseHeaders,
9
9
  } from '../utils.js'
10
10
  import { RequestAbortedError } from '../errors.js'
11
+ import { traceWrite, traceSafe, traceErr, traceUrl } from '../trace.js'
11
12
 
12
13
  // Maximum number of >= 400 response body bytes buffered for replay in case
13
14
  // the response is not retried. Larger bodies are passed straight through and
@@ -16,6 +17,30 @@ const MAX_ERROR_BODY_SIZE = 256 * 1024
16
17
 
17
18
  function noop() {}
18
19
 
20
+ // Emit an `undici:retry` trace doc at the point a retry is actually scheduled.
21
+ // Cold path only — a retry already implies a failed attempt — so resolving the
22
+ // writer per emission (same resolution as the trace interceptor: explicit
23
+ // opts.trace wins, absent falls back to the global) costs nothing on the
24
+ // request hot path. `cause` is the triggering failure: the attempt's error, or
25
+ // the bare status code for a status-code retry without one.
26
+ function traceRetry(opts, retryCount, delay, cause) {
27
+ const write = traceWrite(opts.trace)
28
+ if (write !== null) {
29
+ traceSafe(
30
+ write,
31
+ {
32
+ id: opts.id ?? null,
33
+ method: opts.method ?? null,
34
+ url: traceUrl(opts),
35
+ retryCount,
36
+ delayMs: delay,
37
+ err: traceErr(cause),
38
+ },
39
+ 'undici:retry',
40
+ )
41
+ }
42
+ }
43
+
19
44
  // Subscribe `onAbort` to an EventEmitter-style OR EventTarget-style signal and
20
45
  // return an unsubscribe function; return null when it cannot be done safely.
21
46
  //
@@ -618,6 +643,7 @@ class Handler extends DecoratorHandler {
618
643
  Math.min(retryAfter, 60e3)
619
644
  : Math.min(10e3, retryCount * 1e3)
620
645
  this.#opts.logger?.debug({ statusCode, retryAfter, delay, retryCount }, 'retry delay')
646
+ traceRetry(this.#opts, retryCount, delay, err ?? statusCode)
621
647
  return this.#backoff(delay, opts)
622
648
  }
623
649
 
@@ -638,13 +664,17 @@ class Handler extends DecoratorHandler {
638
664
  'UND_ERR_SOCKET',
639
665
  ].includes(err.code)
640
666
  ) {
667
+ const delay = Math.min(10e3, retryCount * 1e3)
641
668
  this.#opts.logger?.debug({ err, retryCount }, 'retry delay')
642
- return this.#backoff(Math.min(10e3, retryCount * 1e3), opts)
669
+ traceRetry(this.#opts, retryCount, delay, err)
670
+ return this.#backoff(delay, opts)
643
671
  }
644
672
 
645
673
  if (err?.message && ['other side closed'].includes(err.message)) {
674
+ const delay = Math.min(10e3, retryCount * 1e3)
646
675
  this.#opts.logger?.debug({ err, retryCount }, 'retry delay')
647
- return this.#backoff(Math.min(10e3, retryCount * 1e3), opts)
676
+ traceRetry(this.#opts, retryCount, delay, err)
677
+ return this.#backoff(delay, opts)
648
678
  }
649
679
 
650
680
  return false
@@ -1,7 +1,38 @@
1
1
  import crypto from 'node:crypto'
2
2
  import { DecoratorHandler, parseContentRange } from '../utils.js'
3
+ import { traceWrite, traceSafe, traceUrl } from '../trace.js'
4
+
5
+ // Emit an `undici:verify` trace doc carrying the by-how-much/what-values
6
+ // detail of a verification failure, immediately before the error is delivered.
7
+ // The undici:request end doc already tags THAT the request failed; this doc is
8
+ // for fingerprinting truncation vs corruption. Cold path only — every call
9
+ // site is an already-failing branch — so the writer is resolved per emission
10
+ // (explicit opts.trace wins, absent falls back to the global) at zero cost on
11
+ // the per-chunk hot path. Hash values are origin-influenced strings (a
12
+ // duplicated Content-MD5 header keeps `expected` as an array of conflicting
13
+ // values, which String() flattens), so bound them.
14
+ function traceVerify(opts, kind, expectedSize, actualSize, expectedHash, actualHash) {
15
+ const write = traceWrite(opts.trace)
16
+ if (write !== null) {
17
+ traceSafe(
18
+ write,
19
+ {
20
+ id: opts.id ?? null,
21
+ method: opts.method ?? null,
22
+ url: traceUrl(opts),
23
+ kind,
24
+ expectedSize,
25
+ actualSize,
26
+ expectedHash: expectedHash != null ? String(expectedHash).slice(0, 64) : null,
27
+ actualHash: actualHash != null ? String(actualHash).slice(0, 64) : null,
28
+ },
29
+ 'undici:verify',
30
+ )
31
+ }
32
+ }
3
33
 
4
34
  class Handler extends DecoratorHandler {
35
+ #opts
5
36
  #verifyOpts
6
37
  #contentMD5
7
38
  #expectedSize
@@ -12,6 +43,8 @@ class Handler extends DecoratorHandler {
12
43
  constructor(opts, { handler }) {
13
44
  super(handler)
14
45
 
46
+ // Retained only for failure-time trace tagging (id/method/url).
47
+ this.#opts = opts
15
48
  this.#verifyOpts = opts.verify === true ? { hash: true, size: true } : opts.verify
16
49
  }
17
50
 
@@ -70,6 +103,7 @@ class Handler extends DecoratorHandler {
70
103
  expected: this.#expectedSize,
71
104
  actual: this.#pos,
72
105
  })
106
+ traceVerify(this.#opts, 'overrun', this.#expectedSize, this.#pos, null, null)
73
107
  super.onError(err)
74
108
  // Returning false only applies backpressure; the socket would stay
75
109
  // paused until bodyTimeout. Abort to release the connection now.
@@ -84,6 +118,7 @@ class Handler extends DecoratorHandler {
84
118
  const contentMD5 = this.#hasher?.digest('base64')
85
119
 
86
120
  if (this.#expectedSize != null && this.#pos !== this.#expectedSize) {
121
+ traceVerify(this.#opts, 'size', this.#expectedSize, this.#pos, null, null)
87
122
  super.onError(
88
123
  Object.assign(new Error('Response body size mismatch'), {
89
124
  expected: this.#expectedSize,
@@ -91,6 +126,7 @@ class Handler extends DecoratorHandler {
91
126
  }),
92
127
  )
93
128
  } else if (this.#contentMD5 != null && contentMD5 !== this.#contentMD5) {
129
+ traceVerify(this.#opts, 'hash', null, null, this.#contentMD5, contentMD5)
94
130
  super.onError(
95
131
  Object.assign(new Error('Response Content-MD5 mismatch'), {
96
132
  expected: this.#contentMD5,
package/lib/trace.js ADDED
@@ -0,0 +1,87 @@
1
+ // Trace plumbing shared by the interceptors. The writer contract and the
2
+ // generic helpers live in @nxtedition/trace; only the nxt-undici-specific
3
+ // pieces (dispatch-opts url tagging, undici-flavored option validation) are
4
+ // implemented here.
5
+ //
6
+ // Importing @nxtedition/trace statically is safe despite the package cycle
7
+ // (@nxtedition/trace flushes through nxt-undici): the trace package imports
8
+ // nxt-undici lazily at first flush precisely so consumers can depend on it
9
+ // statically. The only constraint is publish order — @nxtedition/trace must
10
+ // be published before a nxt-undici release that depends on it.
11
+ //
12
+ // The contract in short: a writer is `{ write }` where `write` is the trace
13
+ // function while tracing is enabled and null while disabled (it flips between
14
+ // the two at runtime), so call sites gate on the resolved fn at zero cost when
15
+ // off. `write` must not call back into the request being traced — emission
16
+ // happens inside dispatch/handler control flow, and reentrancy there is
17
+ // unsupported.
18
+
19
+ import { validateTrace as validateTraceWriter } from '@nxtedition/trace'
20
+ import { InvalidArgumentError } from './errors.js'
21
+
22
+ // installTrace is part of the surface: the per-thread default writer lives in
23
+ // the Symbol.for('@nxtedition/app/trace') slot and is mirrored
24
+ // module-locally inside @nxtedition/trace, so a
25
+ // writer must be installed through installTrace — a bare slot assignment only
26
+ // propagates on the next mirror refresh.
27
+ export { traceWrite, traceSafe, traceErr, installTrace } from '@nxtedition/trace'
28
+
29
+ /**
30
+ * @typedef {import('@nxtedition/trace').TraceWriter} TraceWriter
31
+ */
32
+
33
+ /**
34
+ * Validate an opts.trace value, rethrowing the package's plain Error as the
35
+ * InvalidArgumentError (UND_ERR_INVALID_ARG) that dispatch option validation
36
+ * is expected to throw. Returns the input unchanged.
37
+ *
38
+ * @param {unknown} trace
39
+ * @returns {TraceWriter | null | undefined}
40
+ */
41
+ export function validateTrace(trace) {
42
+ try {
43
+ return validateTraceWriter(trace)
44
+ } catch {
45
+ throw new InvalidArgumentError('invalid trace')
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Bounded origin+path tag for trace docs. Mirrors log.js's sanitizeOrigin
51
+ * userinfo guard: an origin string carrying `user:pass@host` credentials is
52
+ * reduced to URL#origin (which never contains userinfo) before it can reach
53
+ * the trace index; if such a string is not a parseable URL, prefer losing the
54
+ * value over risking embedded credentials. Never throws — evaluated while
55
+ * building docs inside handler control flow.
56
+ *
57
+ * @param {{ origin?: unknown, path?: unknown }} opts
58
+ * @returns {string | null}
59
+ */
60
+ export function traceUrl(opts) {
61
+ try {
62
+ const origin = opts.origin
63
+ let str
64
+ if (origin == null) {
65
+ str = ''
66
+ } else if (origin instanceof URL) {
67
+ // Real URL instances already expose a credential-free origin.
68
+ str = origin.origin
69
+ } else {
70
+ // Raw dispatch()/compose() callers may pass URL-like objects or arrays
71
+ // (defaultLookup resolves those deeper in the chain) — stringify rather
72
+ // than lose the doc.
73
+ str = typeof origin === 'string' ? origin : String(origin)
74
+ if (str.includes('@')) {
75
+ try {
76
+ str = new URL(str).origin
77
+ } catch {
78
+ str = '[redacted]'
79
+ }
80
+ }
81
+ }
82
+ const path = typeof opts.path === 'string' ? opts.path : ''
83
+ return `${str}${path}`.slice(0, 256)
84
+ } catch {
85
+ return null
86
+ }
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/nxt-undici",
3
- "version": "7.4.3",
3
+ "version": "7.5.0",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",
@@ -10,6 +10,7 @@
10
10
  ],
11
11
  "dependencies": {
12
12
  "@nxtedition/scheduler": "^4.1.1",
13
+ "@nxtedition/trace": "^1.0.0",
13
14
  "@nxtedition/undici": "^11.1.4",
14
15
  "fast-querystring": "^1.1.2",
15
16
  "http-errors": "^2.0.1",