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
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.
|