tunnelfetch 1.0.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/LICENSE +28 -0
- package/README.md +617 -0
- package/README.zh-CN.md +470 -0
- package/package.json +74 -0
- package/src/client/cookies.js +429 -0
- package/src/client/decode.js +346 -0
- package/src/client/redirect.js +249 -0
- package/src/client.js +704 -0
- package/src/errors.js +181 -0
- package/src/http1/chunked.js +289 -0
- package/src/http1/index.js +10 -0
- package/src/http1/request.js +143 -0
- package/src/http1/response.js +493 -0
- package/src/http2/connection.js +1170 -0
- package/src/http2/constants.js +129 -0
- package/src/http2/frames.js +291 -0
- package/src/http2/hpack.js +420 -0
- package/src/http2/huffman.js +203 -0
- package/src/http2/index.js +21 -0
- package/src/index.js +46 -0
- package/src/pool.js +256 -0
- package/src/proxy/direct.js +62 -0
- package/src/proxy/http-connect.js +206 -0
- package/src/proxy/index.js +197 -0
- package/src/proxy/socks5.js +344 -0
- package/src/tls/aead.js +263 -0
- package/src/tls/connect.js +407 -0
- package/src/tls/constants.js +334 -0
- package/src/tls/extensions.js +376 -0
- package/src/tls/handshake-messages.js +901 -0
- package/src/tls/handshake.js +568 -0
- package/src/tls/handshake12.js +507 -0
- package/src/tls/index.js +44 -0
- package/src/tls/keyschedule.js +473 -0
- package/src/tls/record.js +872 -0
- package/src/tls/tickets.js +145 -0
- package/src/tls/transcript.js +101 -0
- package/src/tls/wire.js +224 -0
- package/src/transport.js +296 -0
- package/src/trust/der.js +551 -0
- package/src/trust/index.js +375 -0
- package/src/trust/name.js +235 -0
- package/src/trust/ocsp.js +759 -0
- package/src/trust/path.js +595 -0
- package/src/trust/roots.js +454 -0
- package/src/trust/x509.js +902 -0
- package/src/util/bytes.js +470 -0
- package/src/util/deadline.js +266 -0
- package/src/warmup-fixture.js +85 -0
- package/src/warmup.js +243 -0
- package/types/client/cookies.d.ts +159 -0
- package/types/client/decode.d.ts +54 -0
- package/types/client/redirect.d.ts +96 -0
- package/types/client.d.ts +323 -0
- package/types/errors.d.ts +141 -0
- package/types/http1/chunked.d.ts +48 -0
- package/types/http1/index.d.ts +3 -0
- package/types/http1/request.d.ts +44 -0
- package/types/http1/response.d.ts +183 -0
- package/types/http2/connection.d.ts +282 -0
- package/types/http2/constants.d.ts +95 -0
- package/types/http2/frames.d.ts +116 -0
- package/types/http2/hpack.d.ts +99 -0
- package/types/http2/huffman.d.ts +21 -0
- package/types/http2/index.d.ts +5 -0
- package/types/index.d.ts +17 -0
- package/types/pool.d.ts +135 -0
- package/types/proxy/direct.d.ts +26 -0
- package/types/proxy/http-connect.d.ts +37 -0
- package/types/proxy/index.d.ts +62 -0
- package/types/proxy/socks5.d.ts +47 -0
- package/types/tls/aead.d.ts +67 -0
- package/types/tls/connect.d.ts +280 -0
- package/types/tls/constants.d.ts +275 -0
- package/types/tls/extensions.d.ts +195 -0
- package/types/tls/handshake-messages.d.ts +430 -0
- package/types/tls/handshake.d.ts +90 -0
- package/types/tls/handshake12.d.ts +35 -0
- package/types/tls/index.d.ts +9 -0
- package/types/tls/keyschedule.d.ts +272 -0
- package/types/tls/record.d.ts +361 -0
- package/types/tls/tickets.d.ts +66 -0
- package/types/tls/transcript.d.ts +52 -0
- package/types/tls/wire.d.ts +106 -0
- package/types/transport.d.ts +222 -0
- package/types/trust/der.d.ts +239 -0
- package/types/trust/index.d.ts +194 -0
- package/types/trust/name.d.ts +33 -0
- package/types/trust/ocsp.d.ts +138 -0
- package/types/trust/path.d.ts +139 -0
- package/types/trust/roots.d.ts +36 -0
- package/types/trust/x509.d.ts +401 -0
- package/types/util/bytes.d.ts +183 -0
- package/types/util/deadline.d.ts +133 -0
- package/types/warmup-fixture.d.ts +11 -0
- package/types/warmup.d.ts +45 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Deadlines.
|
|
2
|
+
//
|
|
3
|
+
// The design constraint here is measured, not assumed: on the target runtime `Date.now()` and
|
|
4
|
+
// `performance.now()` are frozen for the whole of a synchronous execution slice and only advance
|
|
5
|
+
// across I/O. A timeout implemented by comparing clock readings in a loop therefore either never
|
|
6
|
+
// fires or fires at an unrelated moment. Everything below is driven by timer callbacks and stream
|
|
7
|
+
// events instead, and no code path decides "has it been long enough?" by reading a clock.
|
|
8
|
+
//
|
|
9
|
+
// The idle deadline is the important one. For a streaming response, "how long since the last byte"
|
|
10
|
+
// is the signal that something is wrong; "how long in total" is not — a legitimate download or a
|
|
11
|
+
// slow SSE feed can run for minutes. Total is a backstop, idle is the control.
|
|
12
|
+
|
|
13
|
+
import { TimeoutError, codes } from '../errors.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} DeadlineOptions
|
|
17
|
+
* @property {number} [connectMs] TCP connect (and proxy handshake) must complete within this
|
|
18
|
+
* @property {number} [handshakeMs] TLS handshake must complete within this
|
|
19
|
+
* @property {number} [headersMs] response status line + headers must arrive within this
|
|
20
|
+
* @property {number} [idleMs] maximum gap between body chunks
|
|
21
|
+
* @property {number} [totalMs] hard ceiling on the whole request; a backstop, not the control
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// The idle default is deliberately generous, and the reasoning is asymmetry rather than taste.
|
|
25
|
+
// Idle time is free on this runtime — it bills CPU, not wall clock — and once response headers
|
|
26
|
+
// have arrived a connection no longer occupies one of the six per-invocation slots reserved for
|
|
27
|
+
// requests still awaiting headers. So an over-long idle timeout costs a connection that is not
|
|
28
|
+
// being paid for, while an over-short one aborts a request that was going to succeed. Nor can the
|
|
29
|
+
// value be tuned to a peer's heartbeat: streaming APIs that send keep-alive events do not commit
|
|
30
|
+
// to an interval, and a server generating a long response before its first token is legitimately
|
|
31
|
+
// silent for exactly as long as it takes. Err long.
|
|
32
|
+
export const DEFAULT_DEADLINES = Object.freeze({
|
|
33
|
+
connectMs: 10_000,
|
|
34
|
+
handshakeMs: 15_000,
|
|
35
|
+
// The one phase that does hold a header-wait slot, hence tighter than idle. A peer that buffers
|
|
36
|
+
// a whole slow response before sending its head needs this raised; streaming peers do not.
|
|
37
|
+
headersMs: 30_000,
|
|
38
|
+
idleMs: 60_000,
|
|
39
|
+
totalMs: 0, // 0 means no ceiling: streaming responses legitimately run long
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Owns every timer for one request and exposes a single AbortSignal that fires when any of them
|
|
44
|
+
* elapses. Callers abort on the signal rather than each racing their own promise, so a timeout in
|
|
45
|
+
* one phase tears down the whole connection instead of leaking a socket into the background.
|
|
46
|
+
*/
|
|
47
|
+
export class DeadlineController {
|
|
48
|
+
/**
|
|
49
|
+
* @param {DeadlineOptions} options
|
|
50
|
+
* @param {{ signal?: AbortSignal, setTimer?: typeof setTimeout, clearTimer?: typeof clearTimeout }} [env]
|
|
51
|
+
*/
|
|
52
|
+
constructor(options = {}, env = {}) {
|
|
53
|
+
this.options = { ...DEFAULT_DEADLINES, ...options };
|
|
54
|
+
// Wrapped rather than captured bare: on the target runtime the timer builtins are bound to
|
|
55
|
+
// the global object and a detached `setTimeout` reference throws "Illegal invocation".
|
|
56
|
+
// Node tolerates the detached form, so this only ever fails in production — which is where
|
|
57
|
+
// the live rig found it.
|
|
58
|
+
this._setTimer = env.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
59
|
+
this._clearTimer = env.clearTimer ?? ((id) => clearTimeout(id));
|
|
60
|
+
this._controller = new AbortController();
|
|
61
|
+
/** @type {any} */
|
|
62
|
+
this._phaseTimer = null;
|
|
63
|
+
this._phaseName = null;
|
|
64
|
+
/** @type {any} */
|
|
65
|
+
this._idleTimer = null;
|
|
66
|
+
this._totalTimer = null;
|
|
67
|
+
this._settled = false;
|
|
68
|
+
/** @type {TimeoutError|null} */
|
|
69
|
+
this.error = null;
|
|
70
|
+
|
|
71
|
+
if (this.options.totalMs > 0) {
|
|
72
|
+
this._totalTimer = this._setTimer(
|
|
73
|
+
() => this._fire(codes.TIMEOUT_TOTAL, 'total', this.options.totalMs),
|
|
74
|
+
this.options.totalMs,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// An externally supplied signal (the caller's AbortSignal) must tear us down too.
|
|
79
|
+
const outer = env.signal;
|
|
80
|
+
if (outer) {
|
|
81
|
+
if (outer.aborted) this._abortFromOuter(outer);
|
|
82
|
+
else {
|
|
83
|
+
this._onOuterAbort = () => this._abortFromOuter(outer);
|
|
84
|
+
outer.addEventListener('abort', this._onOuterAbort, { once: true });
|
|
85
|
+
this._outer = outer;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get signal() {
|
|
91
|
+
return this._controller.signal;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get aborted() {
|
|
95
|
+
return this._controller.signal.aborted;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_abortFromOuter(outer) {
|
|
99
|
+
if (this._settled) return;
|
|
100
|
+
this._settled = true;
|
|
101
|
+
this._clearAll();
|
|
102
|
+
this._controller.abort(outer.reason ?? new Error('aborted by caller'));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_fire(code, phase, ms) {
|
|
106
|
+
if (this._settled) return;
|
|
107
|
+
this._settled = true;
|
|
108
|
+
this._clearAll();
|
|
109
|
+
this.error = new TimeoutError(
|
|
110
|
+
code,
|
|
111
|
+
`${phase} deadline of ${ms}ms elapsed`,
|
|
112
|
+
{ phase, ms },
|
|
113
|
+
);
|
|
114
|
+
this._controller.abort(this.error);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_clearAll() {
|
|
118
|
+
for (const t of [this._phaseTimer, this._idleTimer, this._totalTimer]) {
|
|
119
|
+
if (t !== null && t !== undefined) this._clearTimer(t);
|
|
120
|
+
}
|
|
121
|
+
this._phaseTimer = this._idleTimer = this._totalTimer = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Start a bounded phase. Returns a function that ends it. Only one phase runs at a time; a new
|
|
126
|
+
* phase implicitly ends the previous one, which matches the actual sequence
|
|
127
|
+
* connect -> handshake -> headers and keeps callers from having to unwind by hand.
|
|
128
|
+
*/
|
|
129
|
+
beginPhase(name) {
|
|
130
|
+
this.endPhase();
|
|
131
|
+
const ms = this.options[`${name}Ms`];
|
|
132
|
+
if (!ms || ms <= 0) return () => {};
|
|
133
|
+
this._phaseName = name;
|
|
134
|
+
const code =
|
|
135
|
+
name === 'connect' ? codes.TIMEOUT_CONNECT
|
|
136
|
+
: name === 'handshake' ? codes.TIMEOUT_HANDSHAKE
|
|
137
|
+
: codes.TIMEOUT_HEADERS;
|
|
138
|
+
this._phaseTimer = this._setTimer(() => this._fire(code, name, ms), ms);
|
|
139
|
+
return () => this.endPhase();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
endPhase() {
|
|
143
|
+
if (this._phaseTimer !== null) {
|
|
144
|
+
this._clearTimer(this._phaseTimer);
|
|
145
|
+
this._phaseTimer = null;
|
|
146
|
+
this._phaseName = null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Arm the idle deadline. Call `touch()` on every byte that arrives; each touch restarts the
|
|
152
|
+
* timer. Nothing here reads a clock, which is what makes it work on a runtime whose clock is
|
|
153
|
+
* frozen between I/O events.
|
|
154
|
+
*/
|
|
155
|
+
beginIdle() {
|
|
156
|
+
this.endPhase();
|
|
157
|
+
this.touch();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
touch() {
|
|
161
|
+
const ms = this.options.idleMs;
|
|
162
|
+
if (!ms || ms <= 0 || this._settled) return;
|
|
163
|
+
if (this._idleTimer !== null) this._clearTimer(this._idleTimer);
|
|
164
|
+
this._idleTimer = this._setTimer(() => this._fire(codes.TIMEOUT_IDLE, 'idle', ms), ms);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Release every timer. Safe to call more than once; must be called on every exit path. */
|
|
168
|
+
dispose() {
|
|
169
|
+
this._settled = true;
|
|
170
|
+
this._clearAll();
|
|
171
|
+
if (this._outer && this._onOuterAbort) {
|
|
172
|
+
this._outer.removeEventListener('abort', this._onOuterAbort);
|
|
173
|
+
this._outer = null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Reject as soon as the signal aborts, resolve when `promise` settles first.
|
|
179
|
+
* The abort reason is preserved so the caller sees the typed TimeoutError, not a generic abort.
|
|
180
|
+
*/
|
|
181
|
+
race(promise) {
|
|
182
|
+
if (this.aborted) return Promise.reject(this.signal.reason);
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
const onAbort = () => reject(this.signal.reason);
|
|
185
|
+
this.signal.addEventListener('abort', onAbort, { once: true });
|
|
186
|
+
promise.then(
|
|
187
|
+
(v) => {
|
|
188
|
+
this.signal.removeEventListener('abort', onAbort);
|
|
189
|
+
resolve(v);
|
|
190
|
+
},
|
|
191
|
+
(e) => {
|
|
192
|
+
this.signal.removeEventListener('abort', onAbort);
|
|
193
|
+
reject(e);
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Wrap a body stream so every chunk touches the idle deadline and an abort surfaces as the typed
|
|
202
|
+
* timeout error rather than a bare AbortError.
|
|
203
|
+
*
|
|
204
|
+
* The stream is consumed with a plain reader rather than piped through a TransformStream because
|
|
205
|
+
* a transform would buffer a chunk ahead, which is exactly the wrong behaviour for an idle
|
|
206
|
+
* deadline: the timer must be reset by data reaching the consumer, not by data reaching a queue.
|
|
207
|
+
*
|
|
208
|
+
* @param {ReadableStream<Uint8Array>} source
|
|
209
|
+
* @param {DeadlineController} deadlines
|
|
210
|
+
*/
|
|
211
|
+
export function withIdleDeadline(source, deadlines) {
|
|
212
|
+
const reader = source.getReader();
|
|
213
|
+
return new ReadableStream({
|
|
214
|
+
async pull(controller) {
|
|
215
|
+
try {
|
|
216
|
+
const { value, done } = await deadlines.race(reader.read());
|
|
217
|
+
if (done) {
|
|
218
|
+
deadlines.dispose();
|
|
219
|
+
controller.close();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
deadlines.touch();
|
|
223
|
+
controller.enqueue(value);
|
|
224
|
+
} catch (e) {
|
|
225
|
+
deadlines.dispose();
|
|
226
|
+
try {
|
|
227
|
+
await reader.cancel(e);
|
|
228
|
+
} catch {
|
|
229
|
+
/* the source may already be errored */
|
|
230
|
+
}
|
|
231
|
+
controller.error(e);
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
async cancel(reason) {
|
|
235
|
+
deadlines.dispose();
|
|
236
|
+
try {
|
|
237
|
+
await reader.cancel(reason);
|
|
238
|
+
} catch {
|
|
239
|
+
/* already gone */
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* One-shot deadline for a promise that has no stream behind it, e.g. a socket's `opened`.
|
|
247
|
+
* Prefer DeadlineController when several phases share a teardown.
|
|
248
|
+
*/
|
|
249
|
+
export function withDeadline(promise, ms, code, what, env = {}) {
|
|
250
|
+
if (!ms || ms <= 0) return promise;
|
|
251
|
+
const setTimer = env.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
252
|
+
const clearTimer = env.clearTimer ?? ((id) => clearTimeout(id));
|
|
253
|
+
return new Promise((resolve, reject) => {
|
|
254
|
+
const t = setTimer(() => reject(new TimeoutError(code, `${what} did not complete within ${ms}ms`, { ms, what })), ms);
|
|
255
|
+
promise.then(
|
|
256
|
+
(v) => {
|
|
257
|
+
clearTimer(t);
|
|
258
|
+
resolve(v);
|
|
259
|
+
},
|
|
260
|
+
(e) => {
|
|
261
|
+
clearTimer(t);
|
|
262
|
+
reject(e);
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// GENERATED by scripts/gen-warmup-fixture.mjs — do not edit by hand; rerun the script.
|
|
2
|
+
//
|
|
3
|
+
// A recorded TLS 1.3 handshake + one HTTP/1.1 exchange between this package's own client and the
|
|
4
|
+
// offline suite's honest test server, over an in-memory pipe. warmup() replays the server's bytes
|
|
5
|
+
// through the real parsers, key schedule, AEAD, trust layer and HTTP head parser.
|
|
6
|
+
//
|
|
7
|
+
// SECURITY NOTE — on the private key below: CLIENT_PRIV_PKCS8 is the fixture client's ephemeral
|
|
8
|
+
// X25519 key, and the chain here is synthetic with a reserved-name subject (warmup.invalid,
|
|
9
|
+
// RFC 2606). Nothing trusts any of this material: the chain anchors only to its own baked root,
|
|
10
|
+
// which is passed explicitly as an anchors-mode trust config inside warmup() and never enters the
|
|
11
|
+
// bundled store; the "traffic" it decrypts is this fixture itself. Possession of the key or chain
|
|
12
|
+
// grants exactly nothing. It exists so the replay is deterministic without calling
|
|
13
|
+
// getRandomValues/generateKey, which the target runtime forbids at module (global) scope.
|
|
14
|
+
|
|
15
|
+
const B = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
|
16
|
+
|
|
17
|
+
export const WARMUP_HOSTNAME = "warmup.invalid";
|
|
18
|
+
export const WARMUP_NOW = 1893456000000; // fixed epoch ms inside the chain's validity window
|
|
19
|
+
|
|
20
|
+
const CLIENT_PRIV =
|
|
21
|
+
"MC4CAQAwBQYDK2VuBCIEIPjlXa5xRQTO0OmW85dWmeM6C0AjCqfOKH5MJlnIpT9b";
|
|
22
|
+
const CLIENT_PUB =
|
|
23
|
+
"vbmT3r0piIyf5GavMXh1RqIsCml4dFibAAgovDW+Vww=";
|
|
24
|
+
const CLIENT_RANDOM =
|
|
25
|
+
"AwoRGB8mLTQ7QklQV15lbHN6gYiPlp2kq7K5wMfO1dw=";
|
|
26
|
+
const SESSION_ID =
|
|
27
|
+
"BRAbJjE8R1JdaHN+iZSfqrXAy9bh7PcCDRgjLjlET1o=";
|
|
28
|
+
const CLIENT_HELLO =
|
|
29
|
+
"AQAA9AMDAwoRGB8mLTQ7QklQV15lbHN6gYiPlp2kq7K5wMfO1dwgBRAbJjE8R1JdaHN+iZSfqrXAy9bh7PcCDRgjLjlET1oADBMB" +
|
|
30
|
+
"EwLAK8AvwCzAMAEAAJ8AAAATABEAAA53YXJtdXAuaW52YWxpZAAFAAUBAAAAAAAKAAoACAAdABcAGAAZAA0AFgAUBAMFAwYDCAQI" +
|
|
31
|
+
"BQgGCAcEAQUBBgEAEAALAAkIaHR0cC8xLjEAKwAFBAMEAwMAMwAmACQAHQAgvbmT3r0piIyf5GavMXh1RqIsCml4dFibAAgovDW+" +
|
|
32
|
+
"VwwALQACAQEAFwAAAAsAAgEA/wEAAQA=";
|
|
33
|
+
const SERVER_BYTES =
|
|
34
|
+
"FgMBAHoCAAB2AwPR9xYqSLPpiCwNco2cXVmtsxGRHpcVn9pgzYTTNfuygSAFEBsmMTxHUl1oc36JlJ+qtcDL1uHs9wINGCMuOURP" +
|
|
35
|
+
"WhMBAAAuACsAAgMEADMAJAAdACB+60XRWGB4e1t9Dy9U4hH3KMr8mYswz6ypnzfDjwHbdRcDAwdYnGQEkD2xWtrlMBiqAi7aAE5y" +
|
|
36
|
+
"OFQ7yBVgD+JYtaKoaQ5E6XGWU8HFO8TwqJAN1k1PERiecKl0Pdj1ne7lLDUxJ8w4rJMjXxuc8vSyfN1KjTxoBXvZ4hKuJeolN4Sp" +
|
|
37
|
+
"nVeUNtFXfNnLNuk1UDJZ/UWsZKATQM2PQEAgSXJA5bogqMD3w81B9FHF/6TxFiWu1uhB9AavIgwiRrO1XJPt/toozSJl/CMfvJNp" +
|
|
38
|
+
"+ukMVYeKACc8Vu9tYYi1gZRbVh9jDpWfW4x5Qsvwb+GgWdvDeDe4+P5nff2cmq2KrlUyGXaezYZiusNpYsRtGDLz+mcH1Gj99m4v" +
|
|
39
|
+
"fa4bGGz4lZiZG03M3CYJ6xbTfXn9JnRvhMjNxU4PxN1ntYYjuAPF+1GgKcjpTDWWY2sWNycZXY9nIk7AwG2+bl062gwmTHcSYXut" +
|
|
40
|
+
"1G7+WSzncPbzsdN00JJFjJDYMuH+mvtCqRaaOLu3A1Doqmdf79yfBUZN9pNA+DsFTYYaUGD4yaOzp+KguRAuVxd3yA0fsPRmz276" +
|
|
41
|
+
"oDmTCkNqgH8AZ3LPBHuW2b/DKG8Md4i6/p1pjHWa3Odjh8AIv6fc59deRwt9w6++FHODQC+NLa+2YFkuPWjxcWvYNBNulTQddz2y" +
|
|
42
|
+
"hPIBu8T7cL4lFgK0sgif13Q3ZSRMNbqdmS5T2weodLVnmODNs3o3pA42Q6yt1yPnoKtdk+yQuN2xvOBZb3h3ZMhIMcFHHeGlHoiU" +
|
|
43
|
+
"uogypE89jOsorucWK6yroBvewBOA4bx8TtUC46LkGSe5VTqatsixkODqd2RUMISXj+jE/g+niaOUWSQ4ONVucJBd6SgWwCT+EkJb" +
|
|
44
|
+
"oxH2ZyfInSNqRUAu5bUsb6P9kodpAtp2FKbLmCALoKL6K7RcHLfPtDh6Ejv8zHay7UGSINZtoKFhkVO5cpoNFoBgxAQud01RVh09" +
|
|
45
|
+
"BCz0IhBw8ZIsK+4GLd43ffHjO4Oqx5JQUIXpRO4i5/wqRxFac8SfmqUqkDJ4EqZlbZ0VO151hkslyHUjwb540kFtsO4+6pFw4dZt" +
|
|
46
|
+
"Hu9h4qW6wspnm2GWJb1KZtHeQfOD3bNvNCsLPxCSgTkGyXZAzhW1uMzXXElONkw+vaE5y/9W2vadqLR1TMzwYsW3K2qc/zIq7/wx" +
|
|
47
|
+
"q0nFJubW9Gty2D2gF3Zikfhid6YDoUYVg5Wy5uXayXR/4Mss7b4VF0ikUCsVn9JVwSony5o4TlHa4Zh9nzmJFXoVKQwmOeQ0/T6g" +
|
|
48
|
+
"ap86oZRdXSpMZDg0LarqVOOsU4zyuK0a9Ts8TTmoDxz5wpVRKI87mDb1kN/T8YfDM+Kaf181nhQM+jfXUtj3g2SjrbfmOgDUWcy2" +
|
|
49
|
+
"6solXDjF0XWEe42tcQm44k0518zHUL9ItudBsKkLZpFcfmTDHON7SOUjydsLEB5vMd9m/rfDl6c9nGZyUic+LLjpocc41zvyN/cx" +
|
|
50
|
+
"qZl/dwFxrHooPEYhaiqOxSbn75doMygIhjwLQHMPrG72d6Ym1UrMcUwhi/4oad6xE7tlIGLbJqv1RCgg6dij5tGpPOvXoBcvQVEs" +
|
|
51
|
+
"AWpq8pTXEpru5yydwrX9akoC1J6pKuhwYudrzfA3jvPTcCnHWAKS3PG+hQ59JLuxVUrw6f9A3/jXUUuYk3XkGuKLw924STdQS+9d" +
|
|
52
|
+
"i34c7DB6TcDaoq9XMMTs4DsU/aaILIUztt1rgjEyRxx16Kjm8qot5g2ldz/suM7iHSe2Dd+MOvdGmA2cnF6EW2VFMDbbRUe1BL6m" +
|
|
53
|
+
"a8Sa62xZPgF+hZrK+cQNs1MYIVjrzdbDxwePWSl+R87dgNBtplZKh/fp4as9tM/SD7kLh/TxeTEBIuuf5VKPGQcHLt4ifYh+4eIj" +
|
|
54
|
+
"h9IKJchum7/53DW+13efK2qVXtjAz8izCDmL8My5QrfeWwKhppGzSILDPtEce/MUYu5bUNo4n8AHomWf2fpaeUn104bDHMH+0ook" +
|
|
55
|
+
"cpAJki15og5ZrxzkUG6oNtCGgZqfogV3FDRw4olaEkUQY7cHTi7CZkR3oRNiOMJu/fsi6XbksXxNDW9y+ugUwciW9UjqxkfLh7I4" +
|
|
56
|
+
"/eETaNRbuVYjcVSypTEuC1dqjXnxFucHN0NyANn1PrIm7qBOoPnrLhq5wYpuLZ9qlfiXWun3oBlO4MyRh0YepClhx/qT74KZryFB" +
|
|
57
|
+
"0AyYzMolgyvR6OiZ3ajxEqo2pUgfKtDlo1AqGmydH16HScaaIgG8R2nj30FUE4BOJUr8XWm4Nk9yVuI0LPrVSaIb7RlpfskAVAGu" +
|
|
58
|
+
"/wF/YR9Itk9GhGiQ4pw50hgi/MnzQYixea9bUV+1Lfe4aA+/WRoQYsAu2jO+4VsuhwSCv5zTQPllr+rkNmvwjEnX7IAl138dk+/Y" +
|
|
59
|
+
"gA16PAWCQUkrk6Z1vZFjl3qfYelCwuIWT7HnfW8ZTYjvDZ5OgnLtm55MKjdrwX6xhxXocZ8+SiurIx9FVQdvFJg3vvAPCYnislUf" +
|
|
60
|
+
"wbIQQlCI0JWcyL0iI/JmIE2YWsegK1pvD/3PmB1fUiePd0wDado3LpdO39yUYA6rE4ynHUpwrmnCPlqEOh0XAwMBGacbGrwvRADw" +
|
|
61
|
+
"I/oOygBJY1Cl6puADo3n6DTofw4wr1ihuyxX6c2Br8Fj9R57WG8ALIp0CQNNy7mJ8YynOlKP1AeQspIelnQPzVo8l4n7ThvlMayx" +
|
|
62
|
+
"xDMnJHdIBCSKg8rUQdxi/zM/z1lnORCzQMJXi2A786I+FTa/2f+y9ln2uDRD6LEKlrz71onlf8o2h2x+cqwYUBdQPeZqa6gkFhWJ" +
|
|
63
|
+
"5QXq0jK/iViKEIyduX7wZL9Pf26PKvsuiFUTNwBMvEJdAkSkmhxbR4SeWdMuvxQ/cvIn42Id6HaOqdsEueNrv/dnskCqqAHDGFLe" +
|
|
64
|
+
"h+IQgpHZdFNYrFPlhhDLqIHLk6MZZLfS3QLmFhVTSMn4xpQ809ZMvGT3NXWTOB7GFwMDABM00dpa951QoPFgnJv9SpPMaxoa";
|
|
65
|
+
const ROOT_DER =
|
|
66
|
+
"MIIC2jCCAcKgAwIBAgIBATANBgkqhkiG9w0BAQsFADAeMRwwGgYDVQQDDBNXYXJtdXAgRml4dHVyZSBSb290MB4XDTI1MDEwMTAw" +
|
|
67
|
+
"MDAwMFoXDTM1MDEwMTAwMDAwMFowHjEcMBoGA1UEAwwTV2FybXVwIEZpeHR1cmUgUm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEP" +
|
|
68
|
+
"ADCCAQoCggEBAMlyqJWB5tdme7o+TptR4T2er2MD5gsVcRmg1XWAIHnjY5Wy+cQ9dn670JtY5My0WBX+RSDPFsRzPnimzyLm2Jzs" +
|
|
69
|
+
"J8xpOMDtfE3/NUjxLSHrhdxJ+gFqoB0j7faIzLar1EXMdlhGCmFtXXoMQyuKhlMRzqP3VpH0N91OkGueQFlJAsuazEf9zm0v6PnV" +
|
|
70
|
+
"1QuH4X4+/wRYeAjrLmksLkLhI2h/QZ/EwiVwcLQUvPb4kSHeemZKRRXByLAqOBj6g/MqFRkhqiYQmDB4upjBcYHC8Ae9b02hP8vQ" +
|
|
71
|
+
"q9Dv5pRGloGk7olcviiSTj3YTIFd5Cd7xQZXyE42dJa6C9bZHkgZHPMCAwEAAaMjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B" +
|
|
72
|
+
"Af8EBAMCAgQwDQYJKoZIhvcNAQELBQADggEBAB62L1/vofc1iZN1EoyIdHfgQZS7EtrjO4kkpHJPb2e1AmgvSCGSBC1OBaqlgpcT" +
|
|
73
|
+
"gSfmwSY7g059w18gaEkBhjm5czUmt+IM1DWir6hLPI//6QizsQo0AatePnMvJYuny9VMG2HWbpY17IKxT3/sG4zID7XtDjycuBkf" +
|
|
74
|
+
"uy6DN6Qrp0+aqYMfXs28HWwDgf3b7ZjEAwquSQ+dG8Ml36fd8hjydf2VjOl0h5C3xfc88sEEv0BAks11cYjHDT6jHNYYbXfWcesk" +
|
|
75
|
+
"nflRe2my0pLsKJFzVhQvpWl6Tud5+Im99hi0GMI1L17hMv9YlCGR+vIQsP78y/WTIEHMiFTpCWd1NTY=";
|
|
76
|
+
|
|
77
|
+
export const WARMUP_FIXTURE = {
|
|
78
|
+
clientPrivPkcs8: () => B(CLIENT_PRIV),
|
|
79
|
+
clientPubRaw: () => B(CLIENT_PUB),
|
|
80
|
+
clientRandom: () => B(CLIENT_RANDOM),
|
|
81
|
+
legacySessionId: () => B(SESSION_ID),
|
|
82
|
+
clientHello: () => B(CLIENT_HELLO),
|
|
83
|
+
serverBytes: () => B(SERVER_BYTES),
|
|
84
|
+
rootDer: () => B(ROOT_DER),
|
|
85
|
+
};
|
package/src/warmup.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Opt-in JIT warmup: replay a recorded TLS 1.3 handshake, proxy CONNECT exchange and HTTP
|
|
2
|
+
// response through this package's REAL drivers — record layer, negotiation, key schedule, AEAD,
|
|
3
|
+
// trust and HTTP parsing — so a fresh isolate's first real request runs code V8 has already
|
|
4
|
+
// executed and tiered.
|
|
5
|
+
//
|
|
6
|
+
// Why this exists. On the primary target runtime, per-request CPU is billed (and on the free
|
|
7
|
+
// plan, enforced at 10 ms) while module-scope evaluation is a separate startup budget — and V8
|
|
8
|
+
// compiles and optimises per function per isolate, ramping over the first ~dozen executions.
|
|
9
|
+
// Measured on the edge, a fresh isolate's first proxied request costs ~46 ms of request CPU
|
|
10
|
+
// against a ~10 ms tiered floor, with ~60-80 ms of excess spread across the early ramp.
|
|
11
|
+
// Executing the hot path at module scope moves that excess into the startup budget.
|
|
12
|
+
//
|
|
13
|
+
// The trade, stated so a reader can decide rather than cargo-cult:
|
|
14
|
+
// * On plans that do not bill startup CPU (standard Workers), warmup converts billed request
|
|
15
|
+
// milliseconds into unbilled startup milliseconds. It still spends real wall time at isolate
|
|
16
|
+
// start and consumes part of the hard startup CPU limit (1 s), so it is wrong where the
|
|
17
|
+
// startup budget is already tight.
|
|
18
|
+
// * On deployment modes that bill startup CPU as well (Cloudflare's dynamic Worker loading,
|
|
19
|
+
// for example), warmup is the same work still paid for, plus wall time — a pure loss. A
|
|
20
|
+
// library must not make that choice for its consumer, which is why nothing in this package
|
|
21
|
+
// ever calls warmup() itself: it runs only if the consumer imports and calls it, typically
|
|
22
|
+
// at module scope of their worker:
|
|
23
|
+
//
|
|
24
|
+
// import { warmup } from 'tunnelfetch';
|
|
25
|
+
// await warmup();
|
|
26
|
+
//
|
|
27
|
+
// What it deliberately is NOT:
|
|
28
|
+
// * Not caching. Every call decodes the fixture and derives everything afresh and retains
|
|
29
|
+
// nothing; not calling warmup() yields byte-identical behaviour, just slower first
|
|
30
|
+
// executions. Nothing derived from any trust configuration is kept — the replay verifies its
|
|
31
|
+
// own synthetic chain against its own baked root through an explicit anchors-mode config
|
|
32
|
+
// that never touches the bundled store.
|
|
33
|
+
// * Not a network client. No socket, no randomness, no timers. The runtime forbids
|
|
34
|
+
// getRandomValues, key generation, timers and I/O at global scope — which is exactly where
|
|
35
|
+
// this is meant to run — so the replay's nondeterminism was fixed at recording time
|
|
36
|
+
// (scripts/gen-warmup-fixture.mjs) and shipped as bytes, and the "transport" is a hand-made
|
|
37
|
+
// reader/writer pair over those bytes: the platform's stream classes are never touched,
|
|
38
|
+
// because reading them is one of the operations global scope forbids.
|
|
39
|
+
//
|
|
40
|
+
// Coverage, honestly stated: the proxy CONNECT layer, the whole TLS client (connectTls: record
|
|
41
|
+
// layer, both negotiation paths' shared code, transcript, key schedule, AEAD, CertificateVerify
|
|
42
|
+
// and chain validation) and HTTP head parsing/serialisation all run for real. What cannot run
|
|
43
|
+
// at global scope stays cold: the platform-stream plumbing (plaintextDuplex readers, body
|
|
44
|
+
// streams, gzip DecompressionStream, chunked decoding) and the Client facade above
|
|
45
|
+
// openConnection (its URL/pool/deadline glue). The measured effect on the ramp lives with the
|
|
46
|
+
// bench rig, not here.
|
|
47
|
+
|
|
48
|
+
import { openHttpConnect } from './proxy/http-connect.js';
|
|
49
|
+
import { connectTls } from './tls/connect.js';
|
|
50
|
+
import { buildClientHello, generateKeyShare } from './tls/handshake-messages.js';
|
|
51
|
+
import { TLS12, TLS13 } from './tls/constants.js';
|
|
52
|
+
import { verifyChain } from './trust/index.js';
|
|
53
|
+
import { ByteReader, concat, equal, utf8 } from './util/bytes.js';
|
|
54
|
+
import { serializeRequestHead } from './http1/request.js';
|
|
55
|
+
import { bodyFraming, readResponseHead } from './http1/response.js';
|
|
56
|
+
import { charsetFor, decodeText } from './client/decode.js';
|
|
57
|
+
import { WARMUP_FIXTURE, WARMUP_HOSTNAME, WARMUP_NOW } from './warmup-fixture.js';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A hand-made "readable": satisfies exactly the surface ByteReader uses (getReader().read()),
|
|
61
|
+
* delivering the given chunks then failing loudly. Not a platform ReadableStream on purpose —
|
|
62
|
+
* reading one of those is forbidden in the global scope this module targets.
|
|
63
|
+
* @param {Uint8Array[]} chunks
|
|
64
|
+
*/
|
|
65
|
+
function cannedReadable(chunks) {
|
|
66
|
+
let i = 0;
|
|
67
|
+
return {
|
|
68
|
+
getReader: () => ({
|
|
69
|
+
read: async () => {
|
|
70
|
+
if (i < chunks.length) return { value: chunks[i++], done: false };
|
|
71
|
+
// Past the recording: the parsers and the fixture disagree. Failing beats hanging the
|
|
72
|
+
// caller's startup on a promise that never settles.
|
|
73
|
+
throw new Error('warmup fixture exhausted: the recording and the replay disagree');
|
|
74
|
+
},
|
|
75
|
+
releaseLock() {},
|
|
76
|
+
cancel: async () => {},
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The matching "writable": collects what the client sends. Same duck-typing rationale. */
|
|
82
|
+
function sinkWritable() {
|
|
83
|
+
const written = [];
|
|
84
|
+
return {
|
|
85
|
+
written,
|
|
86
|
+
getWriter: () => ({
|
|
87
|
+
write: async (chunk) => { written.push(chunk); },
|
|
88
|
+
close: async () => {},
|
|
89
|
+
abort: async () => {},
|
|
90
|
+
releaseLock() {},
|
|
91
|
+
}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A ByteReader over already-decrypted bytes; running past them is a loud error. */
|
|
96
|
+
function preloadedReader(bytes) {
|
|
97
|
+
const reader = new ByteReader(cannedReadable([]));
|
|
98
|
+
reader.unshift(bytes);
|
|
99
|
+
return reader;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const CONNECT_REPLY = 'HTTP/1.1 200 Connection established\r\n\r\n';
|
|
103
|
+
|
|
104
|
+
/** Tag failures with the replay stage, so `{ ok: false }` names where the runtime said no. */
|
|
105
|
+
async function step(name, fn) {
|
|
106
|
+
try {
|
|
107
|
+
return await fn();
|
|
108
|
+
} catch (e) {
|
|
109
|
+
if (e && typeof e === 'object' && e.warmupStage === undefined) e.warmupStage = name;
|
|
110
|
+
throw e;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One full replay: proxy CONNECT, then the recorded TLS handshake, then the HTTP exchange. */
|
|
115
|
+
async function replayOnce() {
|
|
116
|
+
const F = WARMUP_FIXTURE;
|
|
117
|
+
const hostname = WARMUP_HOSTNAME;
|
|
118
|
+
|
|
119
|
+
// --- drift detector, before anything tries to decrypt -------------------------------------
|
|
120
|
+
// The recording is only replayable while the ClientHello the package builds is byte-identical
|
|
121
|
+
// to the one recorded. Any change to the offer (ciphers, groups, extensions, ALPN) fails HERE
|
|
122
|
+
// with instructions, rather than three stages later as an opaque AEAD error.
|
|
123
|
+
const { fixedPair } = await step('drift-check', async () => {
|
|
124
|
+
const clientPriv = await crypto.subtle.importKey(
|
|
125
|
+
'pkcs8', F.clientPrivPkcs8(), { name: 'X25519' }, false, ['deriveBits']);
|
|
126
|
+
const clientPub = await crypto.subtle.importKey(
|
|
127
|
+
'raw', F.clientPubRaw(), { name: 'X25519' }, true, []);
|
|
128
|
+
const pair = async () => ({ publicKey: clientPub, privateKey: clientPriv });
|
|
129
|
+
const share = await generateKeyShare(0x001d, { generateKeyPair: pair });
|
|
130
|
+
const probe = buildClientHello({
|
|
131
|
+
hostname,
|
|
132
|
+
keyShares: [share],
|
|
133
|
+
random: F.clientRandom(),
|
|
134
|
+
legacySessionId: F.legacySessionId(),
|
|
135
|
+
versions: [TLS13, TLS12],
|
|
136
|
+
});
|
|
137
|
+
if (!equal(probe.message, F.clientHello())) {
|
|
138
|
+
throw new Error('warmup fixture drift: buildClientHello no longer matches the recording; ' +
|
|
139
|
+
'rerun scripts/gen-warmup-fixture.mjs');
|
|
140
|
+
}
|
|
141
|
+
return { fixedPair: pair };
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// --- proxy CONNECT layer, for real ---------------------------------------------------------
|
|
145
|
+
await step('proxy-connect', async () => {
|
|
146
|
+
const tunnel = await openHttpConnect({
|
|
147
|
+
proxy: { protocol: 'http', hostname, port: 3128, username: 'warm', password: 'up' },
|
|
148
|
+
target: { hostname, port: 443 },
|
|
149
|
+
connect: () => ({
|
|
150
|
+
readable: cannedReadable([utf8(CONNECT_REPLY)]),
|
|
151
|
+
writable: sinkWritable(),
|
|
152
|
+
opened: Promise.resolve({}),
|
|
153
|
+
close: async () => {},
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
if (tunnel.socket == null) throw new Error('warmup: CONNECT replay produced no tunnel');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// --- the whole TLS client over the recorded bytes ------------------------------------------
|
|
160
|
+
// The transport is hand-made (see cannedReadable): the real record layer, drivers, key
|
|
161
|
+
// schedule, AEAD, trust and negotiation all execute exactly as in production.
|
|
162
|
+
const tlsSink = sinkWritable();
|
|
163
|
+
const session = await step('tls-handshake', () => connectTls({
|
|
164
|
+
transport: { readable: cannedReadable([F.serverBytes()]), writable: tlsSink },
|
|
165
|
+
hostname,
|
|
166
|
+
verifyPeer: (chain, host) => verifyChain({
|
|
167
|
+
chain, hostname: host, trust: { mode: 'anchors', anchors: [F.rootDer()] }, now: WARMUP_NOW,
|
|
168
|
+
}),
|
|
169
|
+
options: { clientRandom: F.clientRandom(), legacySessionId: F.legacySessionId() },
|
|
170
|
+
deps: { generateKeyPair: fixedPair },
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
// --- HTTP over the session's record layer directly -----------------------------------------
|
|
174
|
+
// session.readable/writable are platform streams (forbidden here); session.record is not.
|
|
175
|
+
return step('http-exchange', async () => {
|
|
176
|
+
const request = serializeRequestHead({
|
|
177
|
+
method: 'GET',
|
|
178
|
+
target: '/',
|
|
179
|
+
headers: [
|
|
180
|
+
['host', hostname], ['accept', '*/*'], ['accept-encoding', 'gzip, deflate'],
|
|
181
|
+
['connection', 'keep-alive'],
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
await session.record.writeAppData(request);
|
|
185
|
+
const plain = [];
|
|
186
|
+
for (;;) {
|
|
187
|
+
const chunk = await session.record.readAppData();
|
|
188
|
+
if (chunk === null) break; // the recorded close_notify
|
|
189
|
+
plain.push(chunk);
|
|
190
|
+
}
|
|
191
|
+
const reader = preloadedReader(concat(plain));
|
|
192
|
+
const head = await readResponseHead(reader);
|
|
193
|
+
const framing = bodyFraming({ status: head.status, method: 'GET', headers: head.headers });
|
|
194
|
+
if (framing.kind !== 'content-length') {
|
|
195
|
+
throw new Error(`warmup: recorded response framing is ${framing.kind}, expected content-length`);
|
|
196
|
+
}
|
|
197
|
+
const body = await reader.readExactly(framing.length, 'warmup response body');
|
|
198
|
+
const text = decodeText(body, charsetFor(head.headers.get('content-type'), body));
|
|
199
|
+
// Deliberately NO session.close(): shutdown is the one record-layer path that arms a timer,
|
|
200
|
+
// and timers are forbidden where this runs. The session owns no real resources to release.
|
|
201
|
+
return { status: head.status, bodyBytes: text.length, wrote: tlsSink.written.length };
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* What one warmup() call reports. `ok` is the only field a caller usually needs; the rest exists
|
|
207
|
+
* so a failure names the exact problem rather than being a silent no-op.
|
|
208
|
+
* @typedef {object} WarmupReport
|
|
209
|
+
* @property {boolean} ok every iteration completed
|
|
210
|
+
* @property {number} iterations how many replays ran to completion
|
|
211
|
+
* @property {string | null} error first failure, if any — warmup() itself never throws
|
|
212
|
+
*/
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Warm the hot path by replaying a recorded proxy + TLS + HTTP exchange through the real code.
|
|
216
|
+
* See the module comment for what this buys, what it costs, and when NOT to call it. Never
|
|
217
|
+
* called by the package itself; call it from module scope of your worker if — and only if —
|
|
218
|
+
* your deployment does not bill startup CPU.
|
|
219
|
+
*
|
|
220
|
+
* Safe by construction: no network, no randomness, no timers, nothing cached, and it never
|
|
221
|
+
* throws — a runtime that forbids more than expected yields `{ ok: false, error }` and the
|
|
222
|
+
* package behaves exactly as if warmup() had never been called.
|
|
223
|
+
*
|
|
224
|
+
* @param {{ iterations?: number }} [opts] replay count, default 5, clamped to 1..10. One pass
|
|
225
|
+
* moves the hot functions out of the interpreter; more passes push V8's tiering further down
|
|
226
|
+
* the ramp at proportionally more startup cost. Measured startup cost is roughly 10-20 ms per
|
|
227
|
+
* iteration on current edge hardware, against the 1 s startup budget.
|
|
228
|
+
* @returns {Promise<WarmupReport>}
|
|
229
|
+
*/
|
|
230
|
+
export async function warmup({ iterations = 5 } = {}) {
|
|
231
|
+
const n = Math.min(10, Math.max(1, Number.isFinite(iterations) ? Math.floor(iterations) : 5));
|
|
232
|
+
let done = 0;
|
|
233
|
+
try {
|
|
234
|
+
for (let i = 0; i < n; i++) {
|
|
235
|
+
await replayOnce();
|
|
236
|
+
done++;
|
|
237
|
+
}
|
|
238
|
+
return { ok: true, iterations: done, error: null };
|
|
239
|
+
} catch (e) {
|
|
240
|
+
const at = e && typeof e === 'object' && e.warmupStage ? `${e.warmupStage}: ` : '';
|
|
241
|
+
return { ok: false, iterations: done, error: `${at}${e?.message ?? String(e)}` };
|
|
242
|
+
}
|
|
243
|
+
}
|