@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.
- package/lib/index.d.ts +11 -0
- package/lib/index.js +15 -0
- package/lib/interceptor/cache.js +243 -23
- package/lib/interceptor/dns.js +101 -2
- package/lib/interceptor/log.js +126 -59
- package/lib/interceptor/lookup.js +53 -1
- package/lib/interceptor/pressure.js +86 -3
- package/lib/interceptor/priority.js +73 -3
- package/lib/interceptor/redirect.js +26 -0
- package/lib/interceptor/response-retry.js +32 -2
- package/lib/interceptor/response-verify.js +36 -0
- package/lib/trace.js +87 -0
- package/package.json +2 -1
|
@@ -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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|