@nxtedition/nxt-undici 7.4.2 → 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 +27 -1
- package/lib/index.js +15 -0
- package/lib/interceptor/cache.js +667 -115
- 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/proxy.js +4 -1
- 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/sqlite-cache-store.js +141 -15
- package/lib/trace.js +87 -0
- package/lib/utils.js +288 -8
- package/package.json +2 -2
|
@@ -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
|
}
|
package/lib/interceptor/proxy.js
CHANGED
|
@@ -108,10 +108,13 @@ function eqiLower(a, b) {
|
|
|
108
108
|
// allocation-free and letting the common (non-hop) header bail out in a single
|
|
109
109
|
// comparison.
|
|
110
110
|
/**
|
|
111
|
+
* Exported for reuse by the cache interceptor: RFC 9111 §3.1 forbids storing
|
|
112
|
+
* hop-by-hop fields, which is the same set a proxy must not retransmit.
|
|
113
|
+
*
|
|
111
114
|
* @param {string} key
|
|
112
115
|
* @returns {boolean}
|
|
113
116
|
*/
|
|
114
|
-
function isHopByHop(key) {
|
|
117
|
+
export function isHopByHop(key) {
|
|
115
118
|
switch (key.length) {
|
|
116
119
|
case 2:
|
|
117
120
|
return eqiLower(key, 'te')
|
|
@@ -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,
|
|
@@ -2,7 +2,7 @@ import { DatabaseSync } from 'node:sqlite'
|
|
|
2
2
|
import { parseRangeHeader, getFastNow } from './utils.js'
|
|
3
3
|
|
|
4
4
|
// Bump version when the URL key format or schema changes to invalidate old caches.
|
|
5
|
-
const VERSION =
|
|
5
|
+
const VERSION = 11
|
|
6
6
|
|
|
7
7
|
// Registry of live stores so process-level broadcasts (nxt:offPeak,
|
|
8
8
|
// nxt:clearCache) can reach them. Stores are held via WeakRef so that a store
|
|
@@ -51,8 +51,9 @@ function forEachStore(fn) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
54
|
+
* Synchronous get/set/delete cache store backed by node:sqlite. Note: this is
|
|
55
|
+
* NOT undici's stream-based CacheStore interface (createWriteStream); the
|
|
56
|
+
* cache interceptor in this package drives it directly.
|
|
56
57
|
*
|
|
57
58
|
* @typedef {{
|
|
58
59
|
* id: Readonly<number>,
|
|
@@ -66,6 +67,7 @@ function forEachStore(fn) {
|
|
|
66
67
|
* etag?: string
|
|
67
68
|
* cacheControlDirectives?: string
|
|
68
69
|
* cachedAt: number
|
|
70
|
+
* staleAt: number
|
|
69
71
|
* deleteAt: number
|
|
70
72
|
* }} SqliteStoreValue
|
|
71
73
|
*/
|
|
@@ -100,6 +102,21 @@ export class SqliteCacheStore {
|
|
|
100
102
|
*/
|
|
101
103
|
#evictQuery
|
|
102
104
|
|
|
105
|
+
/**
|
|
106
|
+
* @type {import('node:sqlite').StatementSync}
|
|
107
|
+
*/
|
|
108
|
+
#deleteByUrlQuery
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @type {import('node:sqlite').StatementSync}
|
|
112
|
+
*/
|
|
113
|
+
#supersedeFullQuery
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @type {import('node:sqlite').StatementSync}
|
|
117
|
+
*/
|
|
118
|
+
#supersede206Query
|
|
119
|
+
|
|
103
120
|
#insertBatch = []
|
|
104
121
|
#insertSeq = 0
|
|
105
122
|
#closed = false
|
|
@@ -136,6 +153,7 @@ export class SqliteCacheStore {
|
|
|
136
153
|
body BLOB NULL,
|
|
137
154
|
start INTEGER NOT NULL,
|
|
138
155
|
end INTEGER NOT NULL,
|
|
156
|
+
staleAt INTEGER NOT NULL,
|
|
139
157
|
deleteAt INTEGER NOT NULL,
|
|
140
158
|
statusCode INTEGER NOT NULL,
|
|
141
159
|
statusMessage TEXT NOT NULL,
|
|
@@ -148,6 +166,12 @@ export class SqliteCacheStore {
|
|
|
148
166
|
|
|
149
167
|
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_getValuesQuery ON cacheInterceptorV${VERSION}(url, method, start, deleteAt);
|
|
150
168
|
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_deleteExpiredValuesQuery ON cacheInterceptorV${VERSION}(deleteAt);
|
|
169
|
+
-- Covers the supersede-on-flush DELETEs (#supersedeFullQuery /
|
|
170
|
+
-- #supersede206Query), whose predicate is url+method+vary (+statusCode,
|
|
171
|
+
-- and start/end for 206) — so a re-cache supersedes via an index seek
|
|
172
|
+
-- instead of scanning every row for a hot url+method with many Vary
|
|
173
|
+
-- variants or partial windows.
|
|
174
|
+
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_supersedeQuery ON cacheInterceptorV${VERSION}(url, method, vary, statusCode, start, end);
|
|
151
175
|
`)
|
|
152
176
|
|
|
153
177
|
// Drop tables left behind by previous schema versions. gc(), clear() and
|
|
@@ -200,6 +224,7 @@ export class SqliteCacheStore {
|
|
|
200
224
|
body,
|
|
201
225
|
start,
|
|
202
226
|
end,
|
|
227
|
+
staleAt,
|
|
203
228
|
deleteAt,
|
|
204
229
|
statusCode,
|
|
205
230
|
statusMessage,
|
|
@@ -215,7 +240,7 @@ export class SqliteCacheStore {
|
|
|
215
240
|
AND start <= ?
|
|
216
241
|
AND deleteAt > ?
|
|
217
242
|
ORDER BY
|
|
218
|
-
|
|
243
|
+
id DESC
|
|
219
244
|
`)
|
|
220
245
|
|
|
221
246
|
this.#insertValueQuery = this.#db.prepare(`
|
|
@@ -225,6 +250,7 @@ export class SqliteCacheStore {
|
|
|
225
250
|
body,
|
|
226
251
|
start,
|
|
227
252
|
end,
|
|
253
|
+
staleAt,
|
|
228
254
|
deleteAt,
|
|
229
255
|
statusCode,
|
|
230
256
|
statusMessage,
|
|
@@ -233,7 +259,7 @@ export class SqliteCacheStore {
|
|
|
233
259
|
cacheControlDirectives,
|
|
234
260
|
vary,
|
|
235
261
|
cachedAt
|
|
236
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
262
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
237
263
|
`)
|
|
238
264
|
|
|
239
265
|
this.#deleteExpiredValuesQuery = this.#db.prepare(
|
|
@@ -246,6 +272,35 @@ export class SqliteCacheStore {
|
|
|
246
272
|
`DELETE FROM cacheInterceptorV${VERSION} WHERE id IN (SELECT id FROM cacheInterceptorV${VERSION} ORDER BY deleteAt ASC LIMIT ?)`,
|
|
247
273
|
)
|
|
248
274
|
|
|
275
|
+
// RFC 9111 §4.4 invalidation: a successful unsafe request invalidates every
|
|
276
|
+
// stored response for the URI, across methods and vary variants.
|
|
277
|
+
this.#deleteByUrlQuery = this.#db.prepare(
|
|
278
|
+
`DELETE FROM cacheInterceptorV${VERSION} WHERE url = ?`,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
// Supersede queries: a newly flushed row replaces prior rows for the same
|
|
282
|
+
// representation so hot keys don't accumulate dead duplicates until gc.
|
|
283
|
+
// Receipt order (insertion order), NOT cachedAt, decides who wins — the
|
|
284
|
+
// same semantics as upstream undici's update-in-place set(). cachedAt is
|
|
285
|
+
// backdated by the corrected initial age (RFC 9111 §4.2.3), so a
|
|
286
|
+
// replacement fetched through a relay advertising a large Age (or a 304
|
|
287
|
+
// freshening with a skewed origin Date) can carry an OLDER cachedAt than
|
|
288
|
+
// the stale row it replaces; keying supersede or the read sort on
|
|
289
|
+
// cachedAt would let the stale row win every read, forever. `vary IS ?`
|
|
290
|
+
// is NULL-safe and compares the serialized-JSON text, deterministic
|
|
291
|
+
// because both sides are produced by the same CacheHandler code path
|
|
292
|
+
// (key order = Vary header order). A new full (non-206) representation
|
|
293
|
+
// supersedes all prior full ones regardless of byte window; a 206 only
|
|
294
|
+
// supersedes the exact same window so distinct partials coexist, and
|
|
295
|
+
// never a 200 row (matchesValue routes range and non-range requests to
|
|
296
|
+
// different rows).
|
|
297
|
+
this.#supersedeFullQuery = this.#db.prepare(
|
|
298
|
+
`DELETE FROM cacheInterceptorV${VERSION} WHERE url = ? AND method = ? AND vary IS ? AND statusCode != 206`,
|
|
299
|
+
)
|
|
300
|
+
this.#supersede206Query = this.#db.prepare(
|
|
301
|
+
`DELETE FROM cacheInterceptorV${VERSION} WHERE url = ? AND method = ? AND vary IS ? AND statusCode = 206 AND start = ? AND end = ?`,
|
|
302
|
+
)
|
|
303
|
+
|
|
249
304
|
this.#ref = new WeakRef(this)
|
|
250
305
|
stores.add(this.#ref)
|
|
251
306
|
// The store itself doubles as the unregister token so close() can drop
|
|
@@ -371,7 +426,7 @@ export class SqliteCacheStore {
|
|
|
371
426
|
setImmediate(this.#flush)
|
|
372
427
|
}
|
|
373
428
|
|
|
374
|
-
|
|
429
|
+
const entry = {
|
|
375
430
|
// Monotonic per-store sequence used only to break cachedAt ties in
|
|
376
431
|
// #findValue (newest write wins). Not persisted — #flush ignores it.
|
|
377
432
|
seq: this.#insertSeq++,
|
|
@@ -380,6 +435,9 @@ export class SqliteCacheStore {
|
|
|
380
435
|
body,
|
|
381
436
|
start: value.start,
|
|
382
437
|
end: value.end,
|
|
438
|
+
// staleAt column is NOT NULL; default to deleteAt when the caller omits
|
|
439
|
+
// it (pre-v11 value shape) — matches this package's staleAt === deleteAt.
|
|
440
|
+
staleAt: value.staleAt ?? value.deleteAt,
|
|
383
441
|
deleteAt: value.deleteAt,
|
|
384
442
|
statusCode: value.statusCode,
|
|
385
443
|
statusMessage: value.statusMessage,
|
|
@@ -390,7 +448,56 @@ export class SqliteCacheStore {
|
|
|
390
448
|
: null,
|
|
391
449
|
vary: value.vary ? JSON.stringify(value.vary) : null,
|
|
392
450
|
cachedAt: value.cachedAt,
|
|
393
|
-
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Coalesce within the pending batch: a stampede of identical misses would
|
|
454
|
+
// otherwise commit N identical rows. Receipt order wins (see the
|
|
455
|
+
// #supersede* query comments): the entry being added supersedes every
|
|
456
|
+
// pending entry for the same representation.
|
|
457
|
+
for (let i = this.#insertBatch.length - 1; i >= 0; i--) {
|
|
458
|
+
const other = this.#insertBatch[i]
|
|
459
|
+
if (
|
|
460
|
+
other.url === entry.url &&
|
|
461
|
+
other.method === entry.method &&
|
|
462
|
+
other.vary === entry.vary &&
|
|
463
|
+
(entry.statusCode !== 206
|
|
464
|
+
? other.statusCode !== 206
|
|
465
|
+
: other.statusCode === 206 && other.start === entry.start && other.end === entry.end)
|
|
466
|
+
) {
|
|
467
|
+
this.#insertBatch.splice(i, 1)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
this.#insertBatch.push(entry)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Invalidates every stored response for the key's URI (RFC 9111 §4.4) —
|
|
476
|
+
* across methods and vary variants. Pending batched inserts for the URI are
|
|
477
|
+
* dropped as well: a batch entry flushed after the DELETE would otherwise
|
|
478
|
+
* resurrect the invalidated response. Note this only covers THIS process's
|
|
479
|
+
* batch — with a shared on-disk store, another process's in-flight batch
|
|
480
|
+
* can still transiently resurrect an entry (bounded by its ~10ms flush
|
|
481
|
+
* slice); cross-process invalidation is inherently best-effort.
|
|
482
|
+
*
|
|
483
|
+
* @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
|
|
484
|
+
*/
|
|
485
|
+
delete(key) {
|
|
486
|
+
assertCacheKey(key)
|
|
487
|
+
|
|
488
|
+
if (this.#closed) {
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const url = makeValueUrl(key)
|
|
493
|
+
|
|
494
|
+
for (let i = this.#insertBatch.length - 1; i >= 0; i--) {
|
|
495
|
+
if (this.#insertBatch[i].url === url) {
|
|
496
|
+
this.#insertBatch.splice(i, 1)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
this.#deleteByUrlQuery.run(url)
|
|
394
501
|
}
|
|
395
502
|
|
|
396
503
|
#flush = (final = false) => {
|
|
@@ -412,6 +519,7 @@ export class SqliteCacheStore {
|
|
|
412
519
|
body,
|
|
413
520
|
start,
|
|
414
521
|
end,
|
|
522
|
+
staleAt,
|
|
415
523
|
deleteAt,
|
|
416
524
|
statusCode,
|
|
417
525
|
statusMessage,
|
|
@@ -421,12 +529,21 @@ export class SqliteCacheStore {
|
|
|
421
529
|
vary,
|
|
422
530
|
cachedAt,
|
|
423
531
|
} = this.#insertBatch[n++]
|
|
532
|
+
// Supersede prior rows for the same representation (see the
|
|
533
|
+
// #supersede* query comments) inside the same transaction, so a
|
|
534
|
+
// re-cache replaces instead of accumulating.
|
|
535
|
+
if (statusCode === 206) {
|
|
536
|
+
this.#supersede206Query.run(url, method, vary, start, end)
|
|
537
|
+
} else {
|
|
538
|
+
this.#supersedeFullQuery.run(url, method, vary)
|
|
539
|
+
}
|
|
424
540
|
this.#insertValueQuery.run(
|
|
425
541
|
url,
|
|
426
542
|
method,
|
|
427
543
|
body,
|
|
428
544
|
start,
|
|
429
545
|
end,
|
|
546
|
+
staleAt,
|
|
430
547
|
deleteAt,
|
|
431
548
|
statusCode,
|
|
432
549
|
statusMessage,
|
|
@@ -534,16 +651,14 @@ export class SqliteCacheStore {
|
|
|
534
651
|
return undefined
|
|
535
652
|
}
|
|
536
653
|
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
// (tagged with a monotonic seq) are
|
|
541
|
-
// and within each source a higher
|
|
654
|
+
// Most recently WRITTEN representation wins — receipt order, not
|
|
655
|
+
// cachedAt (see the #supersede* query comments: cachedAt is backdated by
|
|
656
|
+
// the corrected initial age, so a fresher write can carry an older
|
|
657
|
+
// cachedAt). Pending batch entries (tagged with a monotonic seq) are
|
|
658
|
+
// always newer than any flushed DB row, and within each source a higher
|
|
659
|
+
// seq/id wins.
|
|
542
660
|
if (values.length > 1) {
|
|
543
661
|
values.sort((a, b) => {
|
|
544
|
-
if (a.cachedAt !== b.cachedAt) {
|
|
545
|
-
return b.cachedAt - a.cachedAt
|
|
546
|
-
}
|
|
547
662
|
const aBatch = a.seq != null
|
|
548
663
|
const bBatch = b.seq != null
|
|
549
664
|
if (aBatch !== bBatch) {
|
|
@@ -657,6 +772,7 @@ function makeResult(value) {
|
|
|
657
772
|
? JSON.parse(value.cacheControlDirectives)
|
|
658
773
|
: undefined,
|
|
659
774
|
cachedAt: value.cachedAt,
|
|
775
|
+
staleAt: value.staleAt,
|
|
660
776
|
deleteAt: value.deleteAt,
|
|
661
777
|
}
|
|
662
778
|
}
|
|
@@ -704,6 +820,16 @@ function assertCacheValue(value) {
|
|
|
704
820
|
}
|
|
705
821
|
}
|
|
706
822
|
|
|
823
|
+
// staleAt is optional for backward compatibility: pre-v11 callers of the
|
|
824
|
+
// public SqliteCacheStore.set() omit it, and set() defaults it to deleteAt
|
|
825
|
+
// (this package's staleAt === deleteAt semantics). Validate the type only
|
|
826
|
+
// when supplied.
|
|
827
|
+
if (value.staleAt !== undefined && typeof value.staleAt !== 'number') {
|
|
828
|
+
throw new TypeError(
|
|
829
|
+
`expected value.staleAt to be number, got ${printType(value.staleAt)} [${value.staleAt}]`,
|
|
830
|
+
)
|
|
831
|
+
}
|
|
832
|
+
|
|
707
833
|
if (typeof value.statusMessage !== 'string') {
|
|
708
834
|
throw new TypeError(
|
|
709
835
|
`expected value.statusMessage to be string, got ${printType(value.statusMessage)} [${value.statusMessage}]`,
|
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
|
+
}
|