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.
Files changed (96) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +617 -0
  3. package/README.zh-CN.md +470 -0
  4. package/package.json +74 -0
  5. package/src/client/cookies.js +429 -0
  6. package/src/client/decode.js +346 -0
  7. package/src/client/redirect.js +249 -0
  8. package/src/client.js +704 -0
  9. package/src/errors.js +181 -0
  10. package/src/http1/chunked.js +289 -0
  11. package/src/http1/index.js +10 -0
  12. package/src/http1/request.js +143 -0
  13. package/src/http1/response.js +493 -0
  14. package/src/http2/connection.js +1170 -0
  15. package/src/http2/constants.js +129 -0
  16. package/src/http2/frames.js +291 -0
  17. package/src/http2/hpack.js +420 -0
  18. package/src/http2/huffman.js +203 -0
  19. package/src/http2/index.js +21 -0
  20. package/src/index.js +46 -0
  21. package/src/pool.js +256 -0
  22. package/src/proxy/direct.js +62 -0
  23. package/src/proxy/http-connect.js +206 -0
  24. package/src/proxy/index.js +197 -0
  25. package/src/proxy/socks5.js +344 -0
  26. package/src/tls/aead.js +263 -0
  27. package/src/tls/connect.js +407 -0
  28. package/src/tls/constants.js +334 -0
  29. package/src/tls/extensions.js +376 -0
  30. package/src/tls/handshake-messages.js +901 -0
  31. package/src/tls/handshake.js +568 -0
  32. package/src/tls/handshake12.js +507 -0
  33. package/src/tls/index.js +44 -0
  34. package/src/tls/keyschedule.js +473 -0
  35. package/src/tls/record.js +872 -0
  36. package/src/tls/tickets.js +145 -0
  37. package/src/tls/transcript.js +101 -0
  38. package/src/tls/wire.js +224 -0
  39. package/src/transport.js +296 -0
  40. package/src/trust/der.js +551 -0
  41. package/src/trust/index.js +375 -0
  42. package/src/trust/name.js +235 -0
  43. package/src/trust/ocsp.js +759 -0
  44. package/src/trust/path.js +595 -0
  45. package/src/trust/roots.js +454 -0
  46. package/src/trust/x509.js +902 -0
  47. package/src/util/bytes.js +470 -0
  48. package/src/util/deadline.js +266 -0
  49. package/src/warmup-fixture.js +85 -0
  50. package/src/warmup.js +243 -0
  51. package/types/client/cookies.d.ts +159 -0
  52. package/types/client/decode.d.ts +54 -0
  53. package/types/client/redirect.d.ts +96 -0
  54. package/types/client.d.ts +323 -0
  55. package/types/errors.d.ts +141 -0
  56. package/types/http1/chunked.d.ts +48 -0
  57. package/types/http1/index.d.ts +3 -0
  58. package/types/http1/request.d.ts +44 -0
  59. package/types/http1/response.d.ts +183 -0
  60. package/types/http2/connection.d.ts +282 -0
  61. package/types/http2/constants.d.ts +95 -0
  62. package/types/http2/frames.d.ts +116 -0
  63. package/types/http2/hpack.d.ts +99 -0
  64. package/types/http2/huffman.d.ts +21 -0
  65. package/types/http2/index.d.ts +5 -0
  66. package/types/index.d.ts +17 -0
  67. package/types/pool.d.ts +135 -0
  68. package/types/proxy/direct.d.ts +26 -0
  69. package/types/proxy/http-connect.d.ts +37 -0
  70. package/types/proxy/index.d.ts +62 -0
  71. package/types/proxy/socks5.d.ts +47 -0
  72. package/types/tls/aead.d.ts +67 -0
  73. package/types/tls/connect.d.ts +280 -0
  74. package/types/tls/constants.d.ts +275 -0
  75. package/types/tls/extensions.d.ts +195 -0
  76. package/types/tls/handshake-messages.d.ts +430 -0
  77. package/types/tls/handshake.d.ts +90 -0
  78. package/types/tls/handshake12.d.ts +35 -0
  79. package/types/tls/index.d.ts +9 -0
  80. package/types/tls/keyschedule.d.ts +272 -0
  81. package/types/tls/record.d.ts +361 -0
  82. package/types/tls/tickets.d.ts +66 -0
  83. package/types/tls/transcript.d.ts +52 -0
  84. package/types/tls/wire.d.ts +106 -0
  85. package/types/transport.d.ts +222 -0
  86. package/types/trust/der.d.ts +239 -0
  87. package/types/trust/index.d.ts +194 -0
  88. package/types/trust/name.d.ts +33 -0
  89. package/types/trust/ocsp.d.ts +138 -0
  90. package/types/trust/path.d.ts +139 -0
  91. package/types/trust/roots.d.ts +36 -0
  92. package/types/trust/x509.d.ts +401 -0
  93. package/types/util/bytes.d.ts +183 -0
  94. package/types/util/deadline.d.ts +133 -0
  95. package/types/warmup-fixture.d.ts +11 -0
  96. 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
+ }