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
package/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tunnelfetch contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ Trust anchors in src/trust/roots.js are derived from the Mozilla / Common CA
26
+ Database (CCADB) and are used under the Community Data License Agreement –
27
+ Permissive, Version 2.0 (https://cdla.dev/permissive-2-0/), which requires
28
+ attribution to CCADB. That attribution is carried in the generated file.
package/README.md ADDED
@@ -0,0 +1,617 @@
1
+ # tunnelfetch
2
+
3
+ [English](README.md) · [简体中文](README.zh-CN.md)
4
+
5
+ A `fetch`-shaped HTTP client that can route through an HTTP CONNECT, HTTPS, or SOCKS5 proxy on
6
+ runtimes that expose only raw TCP — principally Cloudflare Workers (`workerd`).
7
+
8
+ Zero dependencies. ESM. No build step. No `node:` imports anywhere in `src/`.
9
+
10
+ ```js
11
+ import { Client } from 'tunnelfetch';
12
+ import { connect } from 'cloudflare:sockets';
13
+
14
+ const client = new Client({ connect, proxy: 'http://user:pass@proxy.example:8080' });
15
+ const res = await client.fetch('https://api.example.com/v1/things');
16
+ const data = await res.json();
17
+ await client.close();
18
+ ```
19
+
20
+ ## Why this exists
21
+
22
+ On Cloudflare Workers there is no supported way to send an HTTPS request through a third-party
23
+ proxy. The reasons are structural, and each was measured on the edge rather than inferred:
24
+
25
+ 1. **`fetch()` has no proxy option.** No `proxy`, no `agent`, no `dispatcher`. The runtime's
26
+ outbound routing controls (`fetcher`, `globalOutbound`) point at other Workers, not at proxies.
27
+ 2. **`node:net` / `node:tls` do not help.** They are real, but they are implemented on top of the
28
+ same `cloudflare:sockets` API and inherit every one of its limits.
29
+ 3. **`cloudflare:sockets` `connect()` gives raw TCP**, so a CONNECT or SOCKS5 handshake is
30
+ perfectly possible — but the TLS *inside* that tunnel is not.
31
+
32
+ That third point is the whole problem. `startTls()` verifies the peer certificate against the
33
+ hostname passed to `connect()`. Inside a tunnel that hostname is **the proxy**, not the origin, so
34
+ the runtime checks the wrong identity. The `expectedServerHostname` option looks like the fix and
35
+ is not: workerd's own source calls it "not currently supported", logs every use, and carries an
36
+ autogate to start rejecting it outright. Measured on the edge on 2026-07-31:
37
+
38
+ | Experiment | Result |
39
+ | --- | --- |
40
+ | `connect(A)` → `startTls()` | handshake completes, data flows |
41
+ | `connect(A)` → `startTls({expectedServerHostname: B})` | handshake **still** completes — the option moves SNI, not the identity gate |
42
+ | `connect(A)` → `startTls({expectedServerHostname: "probe.invalid"})` | still completes |
43
+ | CONNECT tunnel to origin → `startTls({expectedServerHostname: origin})` | **`TLS Handshake Failed`** |
44
+
45
+ The tunnel case fails closed, which is the right failure — but it leaves no route. And
46
+ `getPeerCertificate()` throws `not implemented`, `SocketInfo` carries only addresses, and
47
+ `rejectUnauthorized: false` throws, so the certificate can be neither inspected nor re-checked
48
+ afterwards.
49
+
50
+ So the only way to make a proxied HTTPS request from a Worker, and the only way to offer
51
+ httpx-style `verify=` at all, is to implement TLS in userland. That is what this package does.
52
+
53
+ **Verified end to end on the Cloudflare edge**, through five different third-party proxies:
54
+ TLS 1.3 (`0x0304`), `TLS_AES_128_GCM_SHA256`, X25519, ALPN negotiating `h2` or `http/1.1`, HTTP/2
55
+ and chunked and content-length framing, gzip decoded, chains validated against 121 bundled CCADB
56
+ roots.
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ npm install tunnelfetch
62
+ ```
63
+
64
+ The package ships plain ESM under `src/`. There is no build output and no `nodejs_compat`
65
+ requirement — the live rig deploys with no compatibility flags at all.
66
+
67
+ TypeScript declarations ship in `types/`, generated from the JSDoc in the source and committed, so
68
+ nothing needs building on install. They are not decoration: `trust` is a discriminated union, which
69
+ makes several ways of getting security configuration wrong into compile errors rather than runtime
70
+ ones.
71
+
72
+ ```ts
73
+ new Client({ trust: { mode: 'pinned' } });
74
+ // ^ Property 'pins' is missing but required in type 'PinnedTrust'
75
+
76
+ new Client({ trust: { mode: 'none' } });
77
+ // ^ Property 'insecureAcceptAnyCertificate' is missing but required
78
+ ```
79
+
80
+ ## Usage
81
+
82
+ ### As a custom `fetch`
83
+
84
+ The OpenAI and Anthropic SDKs, and most libraries worth proxying, accept a `fetch` function. That
85
+ shape is the primary deliverable.
86
+
87
+ ```js
88
+ import { Client } from 'tunnelfetch';
89
+ import { connect } from 'cloudflare:sockets';
90
+ import Anthropic from '@anthropic-ai/sdk';
91
+
92
+ const transport = new Client({ connect, proxy: env.PROXY_URL });
93
+
94
+ const client = new Anthropic({
95
+ apiKey: env.ANTHROPIC_API_KEY,
96
+ fetch: transport.fetch, // already bound; the pool survives across calls
97
+ });
98
+ ```
99
+
100
+ `client.fetch` is bound in the constructor precisely so it can be handed to an SDK by reference.
101
+ Prefer it over `createFetch` here: `createFetch` opens and closes a connection per call, which on a
102
+ CPU-metered runtime costs a full TLS handshake every time — measured at roughly 10 ms against
103
+ 0.9 ms for a request on an already-open connection. `createFetch` is for one-off calls, matching
104
+ httpx's module-level helpers.
105
+
106
+ ### With connection reuse and a cookie jar
107
+
108
+ ```js
109
+ const client = new Client({
110
+ connect,
111
+ proxy: 'socks5://user:pass@proxy.example:1080',
112
+ cookies: true,
113
+ });
114
+
115
+ for (const url of urls) {
116
+ const res = await client.fetch(url);
117
+ await handle(await res.text());
118
+ }
119
+ await client.close(); // required: releases pooled sockets
120
+ ```
121
+
122
+ Measured on the edge: first request 678 ms, second to the same origin 135 ms.
123
+
124
+ The jar is deliberately minimal — it does RFC 6265 domain and path matching, `Secure`, host-only
125
+ cookies, expiry and `Max-Age`, and nothing else. It does enforce the **`__Host-` and `__Secure-`
126
+ name prefixes**, because those are not a convenience: the name is the server's claim that the
127
+ cookie was set with particular attributes, and a client that ignores the claim silently removes a
128
+ protection the server is relying on. A `Set-Cookie` that breaks its own prefix is refused whole,
129
+ never repaired — repairing it would manufacture exactly the proof the server must not get.
130
+
131
+ Prefix matching is case-**in**sensitive, which is a MUST in RFC 6265bis §5.4 and not an obvious
132
+ choice: servers routinely compare cookie names case-insensitively, so a client matching
133
+ case-sensitively will store `__SeCuRe-SID` without applying any of the rules and the server cannot
134
+ tell it from the real one. Matching case-sensitively is CVE-2024-5699.
135
+
136
+ Two related rules of §5.7 are **not** implemented, and are worth knowing if you rely on the jar for
137
+ security: "Leave Secure Cookies Alone" (step 16), so a plain-named `Secure` cookie set over https
138
+ can still be overwritten from http, and the 4096-octet name-plus-value cap (step 4).
139
+
140
+ ### Replacing the global
141
+
142
+ For libraries that only ever call the bare global:
143
+
144
+ ```js
145
+ import { install } from 'tunnelfetch';
146
+ const uninstall = install({ connect, proxy: env.PROXY_URL });
147
+ try { await thirdPartyLibrary(); } finally { uninstall(); }
148
+ ```
149
+
150
+ This never happens on import. Silently replacing a global makes every unrelated failure in the
151
+ process look like a bug in this package.
152
+
153
+ ### Warming a fresh isolate
154
+
155
+ V8 compiles and optimises per function per isolate, so the first request through a fresh isolate
156
+ runs the TLS and HTTP paths interpreted — 46 ms against a warm floor of about 10 ms, with the
157
+ excess decaying over roughly six requests. `warmup()` replays a recorded handshake through the real
158
+ drivers at module scope, so the first real request meets code the engine has already tiered.
159
+
160
+ ```js
161
+ import { warmup } from 'tunnelfetch';
162
+
163
+ await warmup(); // module scope, once per isolate
164
+ export default { async fetch(req, env) { /* ... */ } };
165
+ ```
166
+
167
+ It is opt-in and nothing in this package ever calls it, because the trade is not the same for
168
+ everyone. Standard Workers do not bill startup CPU, so this converts billed request milliseconds
169
+ into unbilled ones and is free money. Where startup CPU *is* billed — Cloudflare's dynamic Worker
170
+ loading, for instance — it is not free but is usually still worth it: the startup cost is paid once
171
+ per isolate and amortises over every request that isolate serves, so it pays for itself past about
172
+ **7 requests per isolate** and loses below that. It also costs real wall time at isolate start,
173
+ which matters if your startup budget is already tight. A library should not make that choice for
174
+ its consumer.
175
+
176
+ It caches nothing and holds no state: the replay validates its own synthetic chain against its own
177
+ baked root through an explicit anchors-mode configuration, never consulting the bundled store, and
178
+ not calling `warmup()` leaves behaviour byte-identical, only slower at first. A Worker that imports
179
+ but never calls it is measurably unaffected. See the cost table for what each iteration count buys.
180
+
181
+ ### Server-sent events
182
+
183
+ SSE has no code of its own here: it is a `text/event-stream` body like any other. What matters is
184
+ that bodies genuinely stream, and they do — measured on the edge through a proxy, a 592 KB
185
+ response arrives as 442 separate chunks, the first at the same instant as the headers.
186
+
187
+ ```js
188
+ const client = new Client({
189
+ connect,
190
+ proxy: env.PROXY_URL,
191
+ timeouts: { idleMs: 60_000, totalMs: 0 },
192
+ });
193
+ const res = await client.fetch(url, { headers: { accept: 'text/event-stream' } });
194
+ for await (const chunk of res.body) {
195
+ // events arrive as they are written, not when the response ends
196
+ }
197
+ ```
198
+
199
+ Two settings are worth choosing deliberately. `idleMs` is the gap between chunks, not the total
200
+ duration — raise it above your feed's heartbeat interval, or a quiet-but-alive stream will be cut.
201
+ `totalMs` defaults to off, which is what a long-lived stream wants; turn it on only as a backstop.
202
+
203
+ Abandoning a stream part-way never returns the connection to the pool: its position is unknown,
204
+ and reusing it would splice the remains of one response onto the next request.
205
+
206
+ ### HTTP/2 — access, not speed
207
+
208
+ The client offers `h2` and `http/1.1` in ALPN by default and speaks whichever the server selects.
209
+ There is no separate API: a request that lands on an `h2` connection just reports `httpVersion: '2'`
210
+ in its `tunnelfetch` detail. Set `http2: false` to offer only `http/1.1`.
211
+
212
+ **The reason to implement HTTP/2 here is access, not performance, and on this runtime it costs
213
+ _more_ CPU than HTTP/1.1, not less.** Two things make that true. HPACK is header compression work
214
+ that HTTP/1.1 simply does not do; and multiplexing buys latency a Worker handler — which usually
215
+ issues one request and awaits it — cannot spend. So if you are reaching for HTTP/2 expecting a
216
+ speed-up on a CPU-metered platform, it is the wrong lever. What it buys is reaching sites that treat
217
+ HTTP/1.1 as a bot signal. That was measured, not assumed — one proxy, one browser `User-Agent`, one
218
+ set of headers, changing only the protocol:
219
+
220
+ ```
221
+ stackoverflow.com --http1.1 -> 403 "Just a moment..." (Cloudflare challenge, cf-mitigated: challenge)
222
+ --http2 -> 200, 291 KB of real content
223
+ ```
224
+
225
+ Across a ten-site sample HTTP/2 changed the outcome on exactly one: four sites were blocked
226
+ identically on both protocols and five were fine either way. So the honest expectation is "unlocks
227
+ roughly one site in ten", not "solves bot detection".
228
+
229
+ **And that expectation has a shelf life.** Re-measured the same day this landed, from the same
230
+ proxies, that site now challenges HTTP/2 as well — `curl --http2` is refused there exactly as this
231
+ package is, while the same proxies still fetch other sites normally. So the capability is real and
232
+ correct (ALPN negotiates `h2`, and this client is treated identically to curl's), but the specific
233
+ access it was built to win did not survive a day. Bot detection is adversarial and moves; a
234
+ protocol is a window, not a property. Do not adopt HTTP/2 here on the strength of one site's
235
+ behaviour — measure your own targets, and expect the answer to change. Our TLS fingerprint and curl's produced
236
+ identical outcomes on every reachable host in that sample, so JA3-style TLS shaping is not what
237
+ gates access here — but curl's **HTTP/2** fingerprint passed where HTTP/1.1 was challenged. So the
238
+ `SETTINGS` frame values, the initial window sizes, the connection `WINDOW_UPDATE`, and the
239
+ pseudo-header order are matched byte-for-byte to curl (8.7.1 / nghttp2), captured off the wire.
240
+ This is empirical: a naïve h2 fingerprint can fail exactly where curl's succeeds, which would waste
241
+ the whole exercise.
242
+
243
+ Everything an HTTP/1.1 body has, an HTTP/2 body keeps: streaming (SSE works unchanged), trailers,
244
+ gzip decoding, and the idle deadline wrapping the raw body before any decode. The one thing that is
245
+ structurally different is under the hood — a single h2 connection multiplexes every concurrent
246
+ request to an origin rather than being checked out one request at a time. `install()`, redirects,
247
+ cookies, and `verify=` all behave identically.
248
+
249
+ ```js
250
+ const client = new Client({ connect, proxy: env.PROXY_URL });
251
+ const res = await client.fetch('https://example.org/', {
252
+ headers: { 'user-agent': 'Mozilla/5.0 (…) Chrome/140.0.0.0 Safari/537.36' },
253
+ });
254
+ res.tunnelfetch.httpVersion; // '2' if the server chose h2, '1.1' otherwise
255
+ ```
256
+
257
+ ## API
258
+
259
+ ### `new Client(options)`
260
+
261
+ | Option | Default | Meaning |
262
+ | --- | --- | --- |
263
+ | `connect` | — | Socket factory. On Workers, the `connect` export of `cloudflare:sockets`. Required for anything the platform's `fetch` cannot serve. |
264
+ | `proxy` | `null` | URL string or object. `http:`, `https:`, `socks5:`, `socks5h:`. |
265
+ | `trust` | `{mode:'system'}` | Certificate policy; see below. |
266
+ | `tls` | `{}` | Handshake options (`alpn`, `groups`, `ciphers`, `offerGroups`). |
267
+ | `timeouts` | see below | `connectMs`, `handshakeMs`, `headersMs`, `idleMs`, `totalMs`. |
268
+ | `cookies` | `false` | Enable a per-Client cookie jar. |
269
+ | `maxRedirects` | `20` | |
270
+ | `maxBodyBytes` | `Infinity` | Enforced from `Content-Length` before a byte is read. |
271
+ | `decompress` | `true` | gzip/deflate. Never `br` — see limits. |
272
+ | `keepAlive` | `true` | |
273
+ | `http2` | `true` | Offer `h2` in ALPN and speak it if the server selects it. See [HTTP/2](#http2--access-not-speed). |
274
+ | `forceTunnel` | `false` | Never delegate to the platform's `fetch`. |
275
+ | `nativeFetch` | `globalThis.fetch` | Delegation target. |
276
+
277
+ `client.fetch(input, init)` takes and returns the platform's `Request`/`Response`. The response
278
+ carries a non-standard `tunnelfetch` property with `{proxied, proxy, tls, httpVersion, framing}`.
279
+
280
+ `client.close()` releases every pooled socket. A `Client` that is not closed leaks sockets for the
281
+ lifetime of the isolate.
282
+
283
+ ### Trust — the `verify=` knob
284
+
285
+ ```js
286
+ { mode: 'system' } // bundled CCADB roots (default)
287
+ { mode: 'anchors', anchors: [pemOrDer, ...] } // exactly these, nothing else
288
+ { mode: 'pinned', pins: ['sha256/BASE64...'] } // full validation plus an SPKI pin set
289
+ { mode: 'custom', verify: async (chain, host) => {} } // your policy; throw to reject
290
+ { mode: 'none', insecureAcceptAnyCertificate: true } // no verification at all
291
+ ```
292
+
293
+ Revocation is checked via **stapled OCSP** (RFC 6960 over the TLS `status_request` extension):
294
+ every hello asks the server to staple, and a stapled response must parse strictly, match the
295
+ validated certificate's issuer and serial, carry a verified signature from the issuing CA or an
296
+ authorised responder, and be inside its freshness window — a verified `revoked` (or `unknown`)
297
+ always fails the connection. A *missing* staple is tolerated by default, because most servers do
298
+ not staple and hard-failing would break the majority of the web; callers whose peers do staple
299
+ can demand one:
300
+
301
+ ```js
302
+ { mode: 'system', revocation: 'require-staple' } // absence becomes OCSP_REQUIRED
303
+ ```
304
+
305
+ There is deliberately no value that ignores a revoked verdict.
306
+
307
+ `mode: 'none'` requires the second flag; it cannot be reached by a typo. A pin mismatch reports
308
+ the pins it actually saw, so the right one can be copied out of a log:
309
+
310
+ ```
311
+ CertificateError [CERT_PIN_MISMATCH]: no certificate in the chain matches any configured pin
312
+ (observed: sha256/uOmwqBIvMM6bY2khsu8Tmp+ltdXst3nxA6Z3ZuKeAWA=, sha256/ZSagvDzj…)
313
+ ```
314
+
315
+ ### When the platform's `fetch` is used instead
316
+
317
+ A request is delegated to the platform's own `fetch` only when it can be satisfied **identically**:
318
+ no proxy, default trust, no TLS options, no `forceTunnel`. Anything else runs through this stack.
319
+ Handing a request that asked for a pinned certificate to an implementation using a different trust
320
+ store would answer a question the caller never asked.
321
+
322
+ Delegation is usually what you want when it applies: it is faster, costs no metered CPU, speaks
323
+ HTTP/3 — which this package cannot — and reaches origins raw sockets are forbidden from dialling.
324
+
325
+ ### Timeouts
326
+
327
+ | Deadline | Default | |
328
+ | --- | --- | --- |
329
+ | `connectMs` | 10 000 | TCP connect and proxy handshake |
330
+ | `handshakeMs` | 15 000 | TLS handshake |
331
+ | `headersMs` | 30 000 | status line and headers |
332
+ | `idleMs` | 60 000 | **gap between body chunks** |
333
+ | `totalMs` | `0` (off) | whole-request ceiling |
334
+
335
+ The idle deadline is the control, not the total: for a streaming response "how long since the last
336
+ byte" is the signal that something is wrong, while "how long in total" is not. Every timer is
337
+ driven by stream events rather than by reading a clock, because on this runtime `Date.now()` is
338
+ frozen for the whole of a synchronous slice and only advances across I/O — a deadline implemented
339
+ by polling the clock would either never fire or fire at an unrelated moment.
340
+
341
+ These are liveness controls, not cost controls. The runtime bills CPU, not wall clock, so a
342
+ connection waiting on a slow peer is free; and after the response head arrives it stops occupying
343
+ one of the six slots an invocation may have simultaneously awaiting headers. That asymmetry is why
344
+ `idleMs` defaults long: too long merely holds an unbilled connection, too short kills a request
345
+ that would have succeeded. Do not try to tune it to a peer's keep-alive interval — streaming APIs
346
+ that send keep-alive events generally do not commit to one, and a peer that is computing a long
347
+ answer before its first byte is legitimately silent for as long as that takes.
348
+
349
+ `headersMs` is the one phase that does hold a header-wait slot, so it is tighter. Raise it for a
350
+ peer that buffers an entire slow response before sending its head; a peer that streams sends its
351
+ head immediately and never needs it.
352
+
353
+ ## What this cannot do, and why
354
+
355
+ This section is the important one. Each limit is deliberate.
356
+
357
+ **Direct (unproxied) connections reach very little of the web.** `connect()` refuses Cloudflare's
358
+ own address ranges, and a large share of the internet sits behind them — including, today,
359
+ `example.com`. Refusal takes 0–6 ms with `proxy request failed, cannot connect to the specified
360
+ address`. The practical consequence: **certificate pinning and custom anchors are only available
361
+ through a proxy**, because direct mode cannot reach most origins at all.
362
+
363
+ **TLS 1.3 and 1.2 only, AEAD only.** Negotiable: TLS 1.3 with AES-128/256-GCM, and TLS 1.2 with
364
+ ECDHE + AES-GCM. Key exchange X25519, P-256, P-384, P-521. Signatures ECDSA, RSA-PSS,
365
+ RSA-PKCS#1 (SHA-256 and up), Ed25519.
366
+
367
+ Not implemented, and not planned:
368
+
369
+ - **CBC cipher suites, in any TLS version.** They are MAC-then-encrypt, and resisting Lucky13
370
+ requires constant-time padding validation. JavaScript cannot promise constant time — JIT tiering
371
+ and GC see to that — so shipping CBC would mean shipping a padding oracle in the name of
372
+ compatibility. This applies to TLS 1.2's CBC suites exactly as it does to TLS 1.0/1.1.
373
+ - **TLS 1.0 / 1.1.** RC4 is broken and everything else there is CBC. Browsers have refused these
374
+ since 2020. Note the distinction: a server that *also* supports 1.0/1.1 is fine, because we will
375
+ negotiate 1.2 or 1.3 with it. Only a server that supports *nothing else* is out of reach.
376
+ - **RSA key transport.** No forward secrecy.
377
+ - **ChaCha20-Poly1305.** It buys nothing: a server can only pick a suite we offered, TLS 1.3
378
+ mandates AES-128-GCM, and AES-GCM is universal in TLS 1.2 deployments. WebCrypto has no
379
+ ChaCha20, so offering it would mean a `node:crypto` dependency for zero compatibility gain.
380
+ - **Client certificates (mTLS), session resumption, 0-RTT, renegotiation.** A `HelloRequest` is
381
+ refused rather than honoured.
382
+ - **Revocation fetching (CRL downloads, OCSP responder queries).** Both need network round trips
383
+ mid-handshake, through the proxy, and an OCSP query tells the CA which origins you visit.
384
+ Revocation *is* checked from a **stapled** OCSP response when the server sends one (see Trust
385
+ above); what is not implemented, and not planned, is going to fetch what the server did not
386
+ staple.
387
+ - **Certificate policy processing** (`policyConstraints`, `inhibitAnyPolicy`). Because they are
388
+ always critical, their presence causes a rejection rather than being mis-validated.
389
+ - **Name constraints beyond dNSName and iPAddress.** A *critical* constraint extension naming an
390
+ unsupported type is rejected; a non-critical one is ignored, as RFC 5280 permits.
391
+ - **A public-suffix list for cookies.** Only the "no dot in the domain" guard is implemented, so
392
+ `Domain=com` is refused but `Domain=co.uk` is not. Documented rather than faked.
393
+ - **IDNA.** Pass A-labels (punycode); a non-ASCII hostname is rejected with a message saying so.
394
+ - **`br` and `zstd` content encodings.** The runtime has `DecompressionStream` for gzip, deflate
395
+ and deflate-raw only. The client therefore never advertises `br` — asking for it would return
396
+ bytes that cannot be decoded.
397
+ - **HTTP/3.** ALPN offers `h2` and `http/1.1` (see [HTTP/2](#http2--access-not-speed)); it does
398
+ not offer `h3`, which is QUIC over UDP and unreachable from a runtime that exposes only raw TCP.
399
+ A server selecting anything the client did not offer fails closed — there is no fallback-and-retry
400
+ at any layer.
401
+ - **Server push, HTTP/2 priority, and h2c.** Push is disabled in our SETTINGS and a `PUSH_PROMISE`
402
+ is a connection error; the RFC 9113 priority scheme is deprecated and PRIORITY frames are ignored;
403
+ and h2 runs only over ALPN-negotiated TLS, never cleartext with prior knowledge.
404
+
405
+ **Sockets cannot cross request contexts.** The pool is per-`Client` and per-invocation by design;
406
+ there is no cross-request connection cache, because the runtime does not permit one.
407
+
408
+ **Concurrency.** The platform allows six connections simultaneously awaiting response headers.
409
+ A crawler wanting more parallelism must pipeline within that limit.
410
+
411
+ ## Cost on a live Worker
412
+
413
+ Every number here was measured on the Cloudflare edge with `wrangler tail`, through a real proxy,
414
+ grouped per isolate so that a first execution is never averaged together with a warm one. Workers
415
+ bill CPU time, not wall time, and the overwhelming majority of a request here is spent waiting on
416
+ the network, which is not billed.
417
+
418
+ ### What a request costs
419
+
420
+ Fetching a size-controlled origin through a proxy, warm, medians over seven-plus rounds on one
421
+ isolate, gzip on the wire. The last column is the same numbers as a rate, which is the form worth
422
+ carrying around:
423
+
424
+ | | New connection | Each further request, same connection | Marginal rate |
425
+ | --- | --- | --- | --- |
426
+ | 1 KB body | 11 ms | 2–3 ms | — |
427
+ | 16 KB body | 11 ms | 3 ms | — |
428
+ | 64 KB body | 11 ms | 2–4 ms | — |
429
+ | 256 KB body | 7 ms | 3.4 ms | ~13 ms/MB |
430
+ | 1 MB body | 11 ms | 6–12 ms | ~9 ms/MB |
431
+ | 4 MB body | 31 ms | 21–35 ms | ~7 ms/MB |
432
+ | **Same 16 KB page over HTTP/2** | 12 ms | 2.2 ms | — |
433
+ | **First request in a fresh isolate** | 46 ms | — | — |
434
+ | **…after `warmup({ iterations: 5 })`** | 16 ms | — | — |
435
+
436
+ One model fits every body-size row to within its spread:
437
+
438
+ > **≈ 9.5 ms to open a connection + 2 ms per request + 5–8 ms per MB of body**
439
+
440
+ The connection term recovered independently from each row lands between 5 and 11 ms, agreeing with
441
+ the 9–12 ms measured for a new connection by other means. Most of it is the TLS handshake and
442
+ certificate chain validation; almost none of it is parsing (see below).
443
+
444
+ The ranges are real, not imprecision: absolute CPU on this platform varies by up to ~1.5× between
445
+ isolates and runs — the same sweep repeated lands on faster and slower machines — so the values are
446
+ medians and the spread is what repeated same-isolate measurement actually shows.
447
+
448
+ **Reuse is the lever.** Thirty 16 KB pages from one host cost about 103 ms down one connection and
449
+ about 300 ms opening thirty. That gap is the entire argument for holding a `Client` rather than
450
+ calling `createFetch` per request, and it widens as pages get smaller.
451
+
452
+ **HTTP/2 is more expensive in every cell and cheaper in none** — 12 ms against 8 ms for one page on
453
+ a new connection, 76 ms against 67 ms for thirty pages on one connection, changing only the offered
454
+ ALPN against the same origin and proxy. The overhead is HPACK plus frame and stream bookkeeping,
455
+ concentrated at connection setup: the preface, the `SETTINGS` exchange, and the first header block.
456
+ Multiplexing, the thing HTTP/2 is *for* in a browser, buys latency a one-request-per-handler Worker
457
+ cannot spend. Reach for it when a site refuses HTTP/1.1, and set `http2: false` on paths that do
458
+ not need it. (These two rows came from the Workers GraphQL analytics API rather than `wrangler
459
+ tail`, which would not survive the measurement network here; same edge CPU-time metric, quantiled
460
+ per minute.)
461
+
462
+ **The fresh-isolate rows are a ramp, not a step.** V8 tiers up per function per isolate, so the
463
+ first executions run interpreted and the excess decays over roughly six requests: 61 ms of total
464
+ excess above the warm floor without `warmup()`, 15 ms with it at five iterations — about 4.4 ms and
465
+ 1.1 ms per request respectively, amortised over an isolate's early life. Warming costs 10 ms of
466
+ startup at one iteration and 22 ms at five, against a 1 s budget, and does not lower the warm floor.
467
+
468
+ ### What that costs in dollars
469
+
470
+ Workers Standard bills $5/month including 10 million requests and 30 million CPU milliseconds, then
471
+ $0.30 per additional million requests and $0.02 per additional million CPU milliseconds. Applying
472
+ the measurements above, with the charge split out so it is clear what is yours to change:
473
+
474
+ | Workload | CPU/request | 10M/mo, cold | 10M/mo, warmed | 1B/mo, cold | 1B/mo, warmed |
475
+ | --- | --- | --- | --- | --- | --- |
476
+ | Platform `fetch` — reference; it cannot use a proxy | 0.3 ms | $5.00 | $5.00 | $307.40 | $307.40 |
477
+ | Pooled connection, 16 KB pages | 3.3 ms | $5.93 | $5.28 | $454.60 | $389.60 |
478
+ | Pooled connection, 1 MB pages | 9.2 ms | $7.11 | $6.46 | $572.60 | $507.60 |
479
+ | New connection per request, 16 KB | 11 ms | $7.47 | $6.82 | $608.60 | $543.60 |
480
+ | New connection per request, 1 MB | 14.5 ms | $8.17 | $7.52 | $678.60 | $613.60 |
481
+ | New connection per request, 4 MB | 30 ms | $11.27 | $10.62 | $988.60 | $923.60 |
482
+
483
+ "Cold" carries the measured fresh-isolate ramp of +4.4 ms per request amortised; "warmed" is the
484
+ same workload with `warmup({ iterations: 5 })`, which brings the ramp down to +1.1 ms. The saving is
485
+ $0.65/month at ten million requests and $65/month at a billion, identical across every row because
486
+ the ramp is a property of the isolate rather than of the request. The reference row carries no ramp
487
+ because the platform's own `fetch` has no JavaScript protocol stack to tier up.
488
+
489
+ Four things fall out of it.
490
+
491
+ **At ten million requests a month, none of this matters.** Every row lands between $5 and $11
492
+ because the included quotas swallow it — the included CPU works out to 3.0 ms per request at that
493
+ volume, so anything that reuses connections is inside the base fee entirely, cold starts included.
494
+
495
+ **At a billion, $297 of every row is the request charge**, identical across all of them and
496
+ unchangeable by anything this package does. Only the CPU is left to optimise, and there the
497
+ difference between reusing connections and not is $154/month on 16 KB pages.
498
+
499
+ **Pooled and warmed, the whole userland stack costs about 27% more than the platform's own
500
+ `fetch`** — $390 against $307 — for something the platform's `fetch` cannot do at all.
501
+
502
+ That reference row is measured, not assumed, and it is not flat. Fetching real pages of different
503
+ sizes from the same Worker, marginal cost per request on a reused connection:
504
+
505
+ | Page | Size | Platform `fetch` | This package, proxied | Ratio |
506
+ | --- | --- | --- | --- | --- |
507
+ | `example.com` | 0.6 KB | 0.2 ms | 3.8 ms | 12.8× |
508
+ | `news.ycombinator.com` | 35 KB | 0.3 ms | 1.8 ms | 5.5× |
509
+ | `www.wikipedia.org` | 118 KB | 0.5 ms | 1.5 ms | 3.0× |
510
+ | `github.com` | 591 KB | 2.0 ms | 14.0 ms | 7.0× |
511
+
512
+ The platform's `fetch` scales with body size too — it is not a flat millisecond — because it still
513
+ has to materialise the body as a JS value, which is the one per-byte cost both clients pay. What it
514
+ does not pay for is TLS, HTTP framing and decompression, all of which happen in the runtime and are
515
+ never billed to the caller. These four rows are noisy: CPU time is reported at 1 ms granularity and
516
+ these are small numbers, so the ratios bounce between 3× and 13× and are not monotonic in size (the
517
+ 118 KB page measured cheaper than the 35 KB one). The direction is solid; the individual ratios are
518
+ not worth quoting to one decimal place.
519
+
520
+ **`warmup()` is free on Standard and usually worth it elsewhere.** Its own cost is startup CPU,
521
+ which Workers Standard does not bill, so the $65/month the "warmed" columns save at a billion
522
+ requests is a pure saving.
523
+ Where startup CPU *is* billed — dynamic Worker loading, for instance — the 22 ms is charged once
524
+ per isolate and spread across the requests that isolate serves: $25/month at a billion requests and
525
+ the ~17.8 requests per isolate measured here, against $65 saved. It stops paying for itself below
526
+ about 7 requests per isolate.
527
+
528
+ ### Where the cost is, and is not
529
+
530
+ Chain validation is signature verification, not parsing. Parsing a whole chain is ~158 µs; a single
531
+ ECDSA P-384 verify is 665–816 µs, about 12× a P-256 verify and 27× an RSA-2048 verify. A typical
532
+ EC chain carries two P-384 links, so **an all-ECDSA chain validates in ~3.5 ms against ~0.8 ms for
533
+ an RSA one**. If you control the origin, its certificate's key type is worth a thought.
534
+
535
+ For small responses, decoding is dominated by constructing the `DecompressionStream`, not by the
536
+ bytes: ~2 ms for a 559-byte body, so for small JSON `decompress: false` can be cheaper than gzip.
537
+
538
+ For large bodies the per-byte cost is not really per byte — it is per stream-boundary crossing.
539
+ The runtime's `DecompressionStream` emits 4096-byte chunks and its sockets deliver reads of at
540
+ most 4096 bytes, and every chunk that crosses between the runtime and JS costs tens of
541
+ microseconds regardless of size. Both hot paths therefore drain their sources with BYOB reads
542
+ into 64 KiB views (a BYOB read hands over everything already buffered in one crossing, and
543
+ resolves partially filled the moment any byte exists, so streaming latency is unchanged). That
544
+ rebuild took the decode stage from ~28 ms to ~6 ms per MB of decompressed output — measured by
545
+ A/B-ing both implementations inside one isolate: 110 ms against 23 ms for the same 4 MB body.
546
+ What remains is close to floor: inflate itself (~2 ms/MB) plus materialising the body into a JS
547
+ string (~1.7 ms/MB), and that last term is the one cost the platform's own `fetch` also bills.
548
+
549
+ Importing the package is free. The 121 bundled anchors are base64 strings indexed by a hash of the
550
+ subject DN, and only the one anchor a chain lands on is ever decoded, so startup stays at ~2 ms for
551
+ the 380 KB bundle (133 KB gzipped) and a request that imports but does not use the package costs
552
+ 0 ms.
553
+
554
+ ### Plan limits
555
+
556
+ On the paid plan the 30 s default CPU limit is not the binding constraint — that is roughly 3 000
557
+ connections or 1 GB of body in one invocation, and the limit of six connections simultaneously
558
+ awaiting response headers binds long before CPU does.
559
+
560
+ The free plan documents 10 ms of CPU per invocation, which a new connection (10–15 ms) sits at or
561
+ above and a pooled request (2–4 ms) fits inside comfortably. In practice the runtime tolerates
562
+ occasional overage and carries unused budget forward, so single requests well past 10 ms do
563
+ complete — measured, several hundred milliseconds completed and the runtime terminated a request
564
+ around 2 s with `exceededCpu`. **That is not the same as showing sustained use above 10 ms is
565
+ viable**, because those probes were low-rate and bursty, exactly the shape such an allowance
566
+ forgives. If you intend to run this on the free plan, reuse connections and measure your own
567
+ sustained average rather than trusting either the documented number or the burst behaviour.
568
+
569
+ ## Runtime requirements
570
+
571
+ WebCrypto (X25519, ECDH P-256/384/521, ECDSA, RSA-PSS, RSASSA-PKCS1, HKDF, HMAC, AES-GCM),
572
+ WHATWG Streams including BYOB readers, `TextEncoder`/`TextDecoder`, `DecompressionStream`, `URL`,
573
+ `Headers`, `Request`, `Response`, `AbortSignal`, `btoa`. All present on workerd, Node ≥ 20, Deno
574
+ and Bun. The only runtime-specific piece is the `connect` function you supply.
575
+
576
+ ## Testing
577
+
578
+ ```bash
579
+ npm test # offline, hermetic, no network
580
+ npm run test:live # explicit; needs TUNNELFETCH_PROXY in the environment
581
+ ```
582
+
583
+ The offline suite never touches the network and is enforced not to: a repo-hygiene suite fails the
584
+ build if any test file names a routable host, if `src/` imports from `node:`, if it contains a URL,
585
+ a vendor name, `Math.random`, or a `console.*` call, or if any module under `src/` has no test.
586
+
587
+ Every byte-consuming parser is run through `underAllChunkings`, which feeds identical bytes whole,
588
+ one byte at a time, and at several pseudo-random split points, and asserts all runs agree. A parser
589
+ that behaves differently under different chunk shapes has a bug that only appears under real
590
+ network fragmentation, which is the kind of bug that cannot be reproduced from a report.
591
+
592
+ The TLS key schedule and record layer are pinned byte-for-byte against **RFC 8448** "Example
593
+ Handshake Traces for TLS 1.3": the AEAD reproduces the RFC's exact ciphertext records, and the
594
+ record layer replays the RFC's full wire images in both directions. Both TLS drivers are tested
595
+ against independently written test servers — the 1.2 server is built on `node:crypto` rather than
596
+ on this package's own primitives, so a client bug cannot be cancelled out by the same bug on the
597
+ server side.
598
+
599
+ `probe/` holds a reproducible capability probe that emits machine-readable JSON, and
600
+ `probe/results/` the measurements this design rests on. `live/` is the edge interop rig.
601
+
602
+ Credentials are read from the environment only. The live suite fails loudly when it is not
603
+ configured rather than skipping: a green tick that means "we did not check" is worse than a red one.
604
+
605
+ ## Attribution
606
+
607
+ Trust anchors are derived from the Mozilla / Common CA Database (CCADB), used under
608
+ CDLA-Permissive-2.0. Regenerate with `npm run roots:refresh`; the generated module records its
609
+ source, retrieval date, upstream SHA-256 and anchor count, because a stale root store is a silent
610
+ availability bug that surfaces months later as "TLS randomly fails".
611
+
612
+ [`jawj/subtls`](https://github.com/jawj/subtls) (MIT) is a valuable proof-of-concept demonstration
613
+ that TLS 1.3 over WebCrypto is workable on this class of runtime, and was read as a reference.
614
+
615
+ ## License
616
+
617
+ MIT.