tunnelfetch 1.4.1 → 1.6.1

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/README.md CHANGED
@@ -256,24 +256,34 @@ presents curl's TLS and HTTP/2 fingerprints by default, and `gzip, deflate` is w
256
256
  the default is already consistent. It stops being consistent the moment you dress the handshake up
257
257
  as a browser and leave the header behind.
258
258
 
259
- It is not a saving. Measured on the edge, decoding the same 256 KB page to the same bytes:
260
-
261
- | Implementation | Algorithm | ms/MB |
262
- |---|---|---|
263
- | `DecompressionStream` — the runtime's own C++ | inflate | **2.5** |
264
- | WASM brotli (`brotli-dec-wasm`) | brotli | 4.7 |
265
- | JS inflate (`fflate` / `pako`) | inflate | 7.5 / 8.2 |
266
- | JS brotli (`brotli`) | brotli | 19.7 |
267
-
268
- Two things fall out, and both are worth knowing before reaching for WebAssembly anywhere else in a
269
- Worker. **WASM is about 4x faster than JavaScript at the same algorithm** (brotli: 4.7 against
259
+ It is not a saving. Measured on the edge, decoding the same 256 KB page to the same bytes, **all
260
+ through the same `ReadableStream -> ReadableStream` shape a `decoders` entry actually has**:
261
+
262
+ | Implementation | Algorithm | ms/MB | vs native gzip |
263
+ |---|---|---|---|
264
+ | `DecompressionStream` — the runtime's own C++ | inflate | **2.75** | 1.0x |
265
+ | WASM zstd (bundled, decode-only build of facebook/zstd) | zstd | 5.5 | 2.0x |
266
+ | WASM brotli (bundled, decode-only build of google/brotli) | brotli | 7.0 | 2.5x |
267
+ | WASM brotli (`brotli-dec-wasm` from npm) | brotli | 10.5 | 3.8x |
268
+ | JS inflate (`fflate` / `pako`) | inflate | 7.5 / 8.2 | 2.7x / 3.0x |
269
+ | JS brotli (`brotli`) | brotli | 19.7 | 7.2x |
270
+
271
+ An earlier version of this table said 4.7 ms/MB for `brotli-dec-wasm`, and that figure was wrong in
272
+ a way worth naming: it was measured by driving the decoder in a bare loop, while the README's own
273
+ example wires it up as a `decoders` entry, which is a stream. The same decoder costs **4.7 in a
274
+ loop and 10.5 behind a `TransformStream` — the stream machinery is 121% on top**, and the number
275
+ that belongs here is the one matching the documented usage. Measured and used must be the same
276
+ thing.
277
+
278
+ Two things still fall out, both worth knowing before reaching for WebAssembly anywhere else in a
279
+ Worker. **WASM is roughly 3x faster than JavaScript at the same algorithm** (brotli: 7.0 against
270
280
  19.7) — so if a coding has no native path, WASM is the right way to add one. And **native is about
271
- 3x faster than JavaScript at the same algorithm** (inflate: 2.5 against 7.5–8.2) so where a
272
- native path already exists, nothing in userland improves on it. That is why `gzip` and `deflate`
273
- are not overridable: replacing them could only ever be slower, and doing it silently is the kind of
274
- quiet downgrade this package refuses everywhere else.
281
+ 3x faster than JavaScript** (inflate: 2.75 against 7.5–8.2), while WASM lands 2–2.5x above native —
282
+ so where a native path already exists, nothing in userland improves on it. That is why `gzip` and
283
+ `deflate` are not overridable: replacing them could only ever be slower, and doing it silently is
284
+ the kind of quiet downgrade this package refuses everywhere else.
275
285
 
276
- Brotli itself lands at 1.9x native inflate. That gap is the price of the coding, and the wire bytes
286
+ Brotli itself lands at 2.5x native inflate. That gap is the price of the coding, and the wire bytes
277
287
  it saves do not pay it back — see [What this cannot do](#what-this-cannot-do-and-why). Decoder names
278
288
  are validated as HTTP tokens, a decoder that throws fails the body closed rather than truncating it,
279
289
  and an unregistered coding is still refused.
@@ -293,9 +303,70 @@ freestanding WASM and both with known-answer tests in this repository. **Importi
293
303
  opt-in:** a bundler pulls them in only for code on this path, so the default identity carries none
294
304
  of it.
295
305
 
296
- `br` and `zstd` stay yours. They are not cryptography and there is no single right implementation,
297
- so the profile keeps refusing until you supply them a Chrome that advertises `br` and cannot read
298
- it is worse than one that says so.
306
+ Nothing else to supply: `br` and `zstd` are bundled too. They were held back at first, on the
307
+ grounds that there is no single right implementationmeasurement dissolved that. A decode-only
308
+ build of the reference C is 1.5x faster than the npm alternative at the interface this package
309
+ actually uses, and half the size.
310
+
311
+ The whole cost of the four blobs is **3 ms once per isolate** — module-scope instantiation lands in
312
+ startup, which this runtime does not bill, so only the first request in a fresh isolate sees
313
+ anything and every request after it sees nothing. Measured against an otherwise identical
314
+ deployment that imports none of them:
315
+
316
+ | | with all four WASM modules | importing none |
317
+ |---|---|---|
318
+ | first request in a fresh isolate | 3 ms | 0 ms |
319
+ | requests 2–5 | 0 ms | 0 ms |
320
+ | request 6 onward | 0 ms | 0 ms |
321
+
322
+ Per-byte decoding is separate and conditional: you pay it only when an origin actually serves `br`
323
+ or `zstd`. See the codec table above.
324
+
325
+ ### Customising an identity
326
+
327
+ Three levels, in the order you are likely to want them.
328
+
329
+ **Override one field.** `tls` merges per-field, so naming one thing keeps the rest of the profile:
330
+
331
+ ```js
332
+ new Client({ profile: chrome, tls: { alpn: ['http/1.1'] } });
333
+ // alpn replaced; extensionOrder, grease, ciphers, groups all still Chrome's
334
+ ```
335
+
336
+ Top-level fields (`headerOrder`, `http2Settings`, `http2PseudoHeaderOrder`, `http2HpackIndexing`)
337
+ replace wholesale, since a half-merged order is not an order.
338
+
339
+ **Derive a profile.** A profile is a plain frozen object, so spreading one is the whole mechanism —
340
+ no API to learn. This is the right way to change a User-Agent for every request:
341
+
342
+ ```js
343
+ const mine = { ...chrome, name: 'chrome+mine',
344
+ headers: [['User-Agent', 'mybot/1.0'], ['X-Tag', 'a']] };
345
+ new Client({ profile: mine, connect, proxy });
346
+ ```
347
+
348
+ **Write one from scratch.** Nothing about the built-ins is privileged:
349
+
350
+ ```js
351
+ const firefox = {
352
+ name: 'my-firefox/130',
353
+ tls: { alpn: ['h2', 'http/1.1'], ciphers: [0x1302, 0x1301],
354
+ extensionOrder: [0, 10, 11, 13, 16, 23, 43, 45, 51, 0xff01], grease: false },
355
+ headerOrder: ['host', 'user-agent', 'accept', 'accept-language', 'accept-encoding', '*', 'connection'],
356
+ headers: [['User-Agent', 'Mozilla/5.0 Firefox/130.0']],
357
+ http2Settings: [[1, 65536], [4, 131072], [5, 16384]],
358
+ http2PseudoHeaderOrder: [':method', ':path', ':authority', ':scheme'],
359
+ requires: [],
360
+ };
361
+ ```
362
+
363
+ `requires` applies to your profile exactly as it does to the built-ins: name a capability the
364
+ Client has not been given and construction is refused, naming what is missing. A custom identity
365
+ gets the same guard against advertising what it cannot perform.
366
+
367
+ Profile `headers` are defaults — a per-request header of the same name wins — while explicit
368
+ `Client` options win over the profile. So the precedence runs: per-request, then Client options,
369
+ then the profile.
299
370
 
300
371
  Verified end to end, not merely constructed:
301
372
 
@@ -356,7 +427,8 @@ pinned in `test/tls/fingerprint.test.js` and `test/http2/fingerprint.test.js`.
356
427
  | Signature algorithms | ECDSA and RSA-PSS/PKCS#1 over SHA-256/384/512 | `tls.sigSchemes` |
357
428
  | ALPN | `h2, http/1.1` | `tls.alpn` |
358
429
  | HTTP/2 `SETTINGS` ids **and order** | curl's: `MAX_CONCURRENT_STREAMS, INITIAL_WINDOW_SIZE, ENABLE_PUSH` | `http2Settings` |
359
- | h2 preface, `WINDOW_UPDATE`, pseudo-header order, HPACK representation | curl's, byte-for-byte | fixed |
430
+ | h2 preface, `WINDOW_UPDATE`, pseudo-header order | curl's, byte-for-byte | `http2ConnectionWindow`, `http2PseudoHeaderOrder` |
431
+ | HPACK representation | curl's (`:path` without indexing, the rest incremental) | `http2HpackIndexing` |
360
432
  | `Accept-Encoding` | `gzip, deflate` — curl's | `decoders` appends |
361
433
 
362
434
  Extension order matters because JA3 and JA4 hash the extension list **in wire order**, so it is most
@@ -416,7 +488,7 @@ res.tunnelfetch.httpVersion; // '2' if the server chose h2, '1.1' otherwise
416
488
  | `timeouts` | see below | `connectMs`, `handshakeMs`, `headersMs`, `idleMs`, `totalMs`. |
417
489
  | `cookies` | `false` | Enable a per-Client cookie jar. |
418
490
  | `maxRedirects` | `20` | |
419
- | `maxBodyBytes` | `Infinity` | Enforced from `Content-Length` before a byte is read. |
491
+ | `maxBodyBytes` | **`32 MiB`** | Enforced from `Content-Length` before a byte is read, on the raw stream, and on the DECODED output — including a decoder you registered. `Infinity` opts out; see [the note on the default](#the-body-cap-has-a-default-now). |
420
492
  | `decompress` | `true` | Decode `Content-Encoding` at all. gzip and deflate are built in. |
421
493
  | `decoders` | `{}` | Extra codings, e.g. `{ br: fn }`. Each is added to `Accept-Encoding`. See [`br`, `zstd`](#br-zstd-and-other-codings). |
422
494
  | `keepAlive` | `true` | |
@@ -430,6 +502,30 @@ carries a non-standard `tunnelfetch` property with `{proxied, proxy, tls, httpVe
430
502
  `client.close()` releases every pooled socket. A `Client` that is not closed leaks sockets for the
431
503
  lifetime of the isolate.
432
504
 
505
+ #### The body cap has a default now
506
+
507
+ **`maxBodyBytes` defaulted to `Infinity` through 1.5.0. From 1.6.0 it is 32 MiB, and that is a
508
+ breaking change** — a download larger than 32 MiB now needs an explicit `maxBodyBytes`, compressed
509
+ or not, because this option bounds the wire body as well as the decoded one.
510
+
511
+ The reason is that "no limit" is not a freedom on a runtime with a hard memory ceiling; it is a way
512
+ to be killed by a peer. A 53-byte brotli body decoding to 32 MB is measured here, not hypothetical,
513
+ and the bundled `br`/`zstd` decoders self-limit at 256 MiB — **twice the 128 MB a Workers isolate
514
+ gets**, so that fallback cannot fire before the isolate is already dead. A client whose entire
515
+ purpose is fetching URLs you do not control should not ship "unbounded" as the setting you get for
516
+ not having read this table.
517
+
518
+ 32 MiB is a quarter of the ceiling, so a body buffered to the cap by `.arrayBuffer()` still leaves
519
+ the isolate room to survive and report it, and it is two orders of magnitude above any page or API
520
+ response. If you are deliberately moving large files, say so:
521
+
522
+ ```js
523
+ new Client({ connect, proxy, maxBodyBytes: Infinity }); // or any number you have thought about
524
+ ```
525
+
526
+ The trade is deliberate: an unasked-for limit is discoverable the first time it bites, and names the
527
+ option in its error. An unasked-for OOM is neither.
528
+
433
529
  ### Trust — the `verify=` knob
434
530
 
435
531
  ```js
@@ -549,24 +645,20 @@ Not implemented, and not planned:
549
645
  - **A public-suffix list for cookies.** Only the "no dot in the domain" guard is implemented, so
550
646
  `Domain=com` is refused but `Domain=co.uk` is not. Documented rather than faked.
551
647
  - **IDNA.** Pass A-labels (punycode); a non-ASCII hostname is rejected with a message saying so.
552
- - **`br` and `zstd` out of the box.** The runtime's `DecompressionStream` accepts gzip, deflate and
553
- deflate-raw only — measured, not assumed. Neither is *unreachable*, though: register a decoder
554
- with [`decoders`](#br-zstd-and-other-codings) and the coding is advertised and decoded. Nothing
555
- ships built in, because the only way to get Brotli here is WebAssembly, and a 208 KB binary blob
556
- would cost this package both its zero dependencies and its ability to be imported without a
557
- bundler. Bringing your own makes that cost, and that supply chain, yours and visible.
558
-
559
- Leaving it off is safe rather than lossy: content negotiation means a server never sends what was
560
- not asked for, so a Brotli-serving origin simply returns gzip. What it costs is bandwidth — the
561
- same page measured 290 KB as gzip against 99 KB as `br` — and bandwidth is not what this platform
562
- bills. Measured on the edge, the trade runs the wrong way: WASM Brotli decodes at about
563
- **1.9x** the CPU of the runtime's native inflate, and even on the page with the largest wire
564
- saving in a 14-site survey, the 186 KB saved bought back ~1.2 ms while the extra decoding cost
565
- several times that. Harder compression makes it worse, not better — brotli quality 11, which is
566
- what a CDN serves from cache, is 16% smaller on the wire than quality 5 and **46% more expensive
567
- to decode**, because decompression work scales with the OUTPUT bytes and a denser encoding means
568
- more work per byte produced. The reason to turn `br` on is matching a browser's
569
- `Accept-Encoding`, not saving CPU.
648
+ - **`br` and `zstd` in the default identity.** The runtime's `DecompressionStream` accepts gzip,
649
+ deflate and deflate-raw only — measured, not assumed so both come from WebAssembly, and the
650
+ default entry point carries neither. Import
651
+ [`tunnelfetch/profile/chrome`](#the-chrome-identity-in-one-import) and they arrive wired in, or
652
+ register your own through [`decoders`](#br-zstd-and-other-codings). What the main entry will not
653
+ do is pull ~140 KB of compiled C into every bundle for a coding most callers never meet.
654
+
655
+ Leaving them off is safe rather than lossy: content negotiation means a server never sends what
656
+ was not asked for, so a Brotli-serving origin simply returns gzip. What it costs is bandwidth —
657
+ the same page measured 290 KB as gzip against 99 KB as `br` — and bandwidth is not what this
658
+ platform bills. On CPU the trade runs the other way: brotli decodes at 2.5x native inflate, and
659
+ harder compression is worse rather than better, because decompression work scales with the OUTPUT
660
+ bytes. The reason to turn `br` on is matching a browser's `Accept-Encoding`, not saving CPU.
661
+
570
662
  - **Streaming request bodies.** A request body is read fully into memory before the request is
571
663
  sent, because the framing has to be declared in a `Content-Length` this client can stand behind
572
664
  and because a body may have to be replayed on a redirect. Fine for the JSON an SDK sends; wrong
@@ -602,52 +694,63 @@ Fetching a size-controlled origin through a proxy, warm, medians over seven-plus
602
694
  isolate, gzip on the wire. The last column is the same numbers as a rate, which is the form worth
603
695
  carrying around:
604
696
 
605
- | | New connection (first request included) | Each further request, same connection |
697
+ | Body | Averaged over 5 pages | Reusing a connection | New connection |
698
+ | --- | --- | --- | --- |
699
+ | 1 KB | 3.2 ms | 1.7 ms | 9.2 ms |
700
+ | 16 KB | 4.6 ms | 3.1 ms | 10.6 ms |
701
+ | 64 KB | 8.2 ms | 6.7 ms | 14.2 ms |
702
+ | 256 KB | 18.2 ms | 16.7 ms | 24.2 ms |
703
+ | 1 MB | 54.8 ms | 53.3 ms | 60.8 ms |
704
+ | 4 MB | 119.8 ms | 118.3 ms | 125.8 ms |
705
+
706
+ The cold-start cost is a **total**, not something to add to a row above:
707
+
708
+ | First request in a fresh isolate | cost of that request | excess over the warm floor, whole ramp |
606
709
  | --- | --- | --- |
607
- | 1 KB body | 6.4 ms | 1.6 ms |
608
- | 16 KB body | 6.5 ms | 1.7 ms |
609
- | 64 KB body | 6.7 ms | 1.9 ms |
610
- | 256 KB body | 7.4 ms | 2.6 ms |
611
- | 1 MB body | 10.4 ms | 5.6 ms |
612
- | 4 MB body | 22.4 ms | 17.6 ms |
613
- | **First request in a fresh isolate** | 46 ms | |
614
- | **…after `warmup({ iterations: 5 })`** | 16 ms | |
615
-
616
- Re-measured for 1.4.0 against a size-controlled origin through a proxy, ten rounds per size, HTTP/2
617
- negotiated, gzip on the wire. The rows are not independent measurements — they are one model, fitted
618
- to the sweep and then checked back against it:
619
-
620
- > **≈ 6.4 ms to open a connection + 1.6 ms per further request + ~4 ms per MB of body**
621
-
622
- Each sweep request fetches five pages — one on a fresh connection and four reusing it — so the two
623
- terms were separated by varying the reuse count rather than assumed: two pages against ten gives
624
- 1.63 ms per further request, and the connection term falls out of the remainder. Fitted on 1 KB and
625
- 4 MB, the model predicts 32.9 ms for the 1 MB row against 35 measured, and 92.9 for the 4 MB row
626
- against 94.
627
-
628
- The ranges are real, not imprecision: absolute CPU on this platform varies by up to ~1.5× between
629
- isolates and runs — the same sweep repeated lands on faster and slower machines — so the values are
630
- medians and the spread is what repeated same-isolate measurement actually shows.
631
-
632
- **Reuse is the lever.** Thirty 16 KB pages from one host cost about 103 ms down one connection and
633
- about 300 ms opening thirty. That gap is the entire argument for holding a `Client` rather than
634
- calling `createFetch` per request, and it widens as pages get smaller.
635
-
636
- **HTTP/2 is more expensive in every cell and cheaper in none** 12 ms against 8 ms for one page on
637
- a new connection, 76 ms against 67 ms for thirty pages on one connection, changing only the offered
638
- ALPN against the same origin and proxy. The overhead is HPACK plus frame and stream bookkeeping,
639
- concentrated at connection setup: the preface, the `SETTINGS` exchange, and the first header block.
640
- Multiplexing, the thing HTTP/2 is *for* in a browser, buys latency a one-request-per-handler Worker
641
- cannot spend. Reach for it when a site refuses HTTP/1.1, and set `http2: false` on paths that do
642
- not need it. (These two rows came from the Workers GraphQL analytics API rather than `wrangler
643
- tail`, which would not survive the measurement network here; same edge CPU-time metric, quantiled
644
- per minute.)
645
-
646
- **The fresh-isolate rows are a ramp, not a step.** V8 tiers up per function per isolate, so the
647
- first executions run interpreted and the excess decays over roughly six requests: 61 ms of total
648
- excess above the warm floor without `warmup()`, 15 ms with it at five iterations — about 4.4 ms and
649
- 1.1 ms per request respectively, amortised over an isolate's early life. Warming costs 10 ms of
650
- startup at one iteration and 22 ms at five, against a 1 s budget, and does not lower the warm floor.
710
+ | without `warmup()` | **46 ms** | 61 ms (≈ 4.4 ms/request over an isolate's early life) |
711
+ | `warmup()` once | 22 ms | 40 ms (≈ 2.8 ms/request) |
712
+ | `warmup({ iterations: 5 })` | **16 ms** | 15 ms (≈ 1.1 ms/request) |
713
+
714
+ Both figures used to appear in the table above as "+46 ms" and "+16 ms", which turned a total into
715
+ an increment and doubled the documented cold start. The `+` is refutable from the numbers alone: a
716
+ first request that cost 9.2 + 16 would carry 16 ms of excess by itself, which is more than the 15 ms
717
+ of excess the *whole* ramp contains. These were measured on a small body; a cold isolate's first
718
+ 4 MB request has never been measured and is certainly worse, since far more of the decode loop runs
719
+ interpreted.
720
+
721
+ Measured through a proxy against a size-controlled origin, eight rounds per size, HTTP/2, gzip on
722
+ the wire. Connection and per-request terms were separated by varying the reuse count rather than
723
+ assumed two pages against ten gives **9.8 ms to open a connection** and **2.25 ms per further
724
+ request**, and the body cost is what is left.
725
+
726
+ **These figures replace ones that were measured wrong, and the mistake is worth describing.** The
727
+ origin they came from tiled a 150-byte HTML fragment, which gzip compressed **220:1** so a "1 MB
728
+ body" was four kilobytes on the wire, and every measurement taken against it priced decompression
729
+ while erasing the per-wire-byte cost of TLS records and streaming entirely. Real pages compress
730
+ around 4:1. The origin now tiles 154 KiB of real minified JavaScript, which lands at 2.76:1: gzip's
731
+ window is 32 KiB, so a repeat period that large does not compress away.
732
+
733
+ The correction is large. Body-heavy rows are **two to three times** what this table said through
734
+ 1.4.0, and no amount of care about medians or minimums would have caught it, because the numbers
735
+ were internally consistent they were answers to the wrong question.
736
+
737
+ **Read the two right-hand columns as derived, because they are.** Only the pooled column is measured
738
+ per size; "new connection" is the pooled figure plus a flat 7.5 ms and "averaged over 5 pages" is
739
+ the pooled figure plus 1.5 ms, which is why the deltas are identical to one decimal across a 4000×
740
+ range in body size. That 7.5 ms also does not agree with the 9.8 ms quoted just above it, and the
741
+ 2.25 ms per further request is larger than the entire 1.7 ms a pooled 1 KB request costs, which
742
+ would make a 1 KB body cost negative. The two came from different sweeps, and combining them is the
743
+ cross-sweep comparison this document tells you never to make. **Treat the connection term as
744
+ somewhere in 7–10 ms and do not do arithmetic with it.**
745
+
746
+ An independent check was quoted here as agreement and is not: a real 3.6 MB file from a CDN cost
747
+ 142 ms against the ~120 ms this table predicts for 4 MB. That is the model under-predicting by
748
+ roughly 20%, in the same direction as the error it had just replaced. It belongs here as a caution,
749
+ not as corroboration.
750
+
751
+ Two further cautions. The 2.76:1 content is slightly *less* compressible than a typical page, so
752
+ these are mildly conservative rather than optimistic. And CPU on this platform varies by up to ~1.5×
753
+ between isolates, so the shape matters more than any single figure.
651
754
 
652
755
  ### What the optional switches cost
653
756
 
@@ -661,9 +764,10 @@ the edge the same way as the rest — differencing two work counts, minimum of s
661
764
  | `tls.extensionOrder: 'shuffle'` | not measurable | shuffling ~11 items, once per handshake |
662
765
  | `headerOrder` | not measurable — the ordered list is *faster* than the platform `Headers` (1.6 µs against 3.8 µs) | per request |
663
766
  | `groups: { x25519mlkem768 }` | **+0.15 ms** with the bundled WASM, **+1.35 ms** with a pure-JS ML-KEM | per **connection**, not per request — amortised across every request that reuses it |
664
- | `ciphers: { chacha20 }` | **+2.0 ms/MB**, and only if the server *selects* it | per byte. Servers with AES hardware generally prefer AES-GCM, so the usual cost is zero and the offer is what matters |
665
- | `decoders: { br }` | **+4.4 ms/MB** | per byte, whenever an origin serves brotli. Harder compression is worse, not better: quality 11 is 16% smaller on the wire and 46% dearer to decode |
666
- | `profile: chrome` | the sum of the three above | |
767
+ | `ciphers: { chacha20 }` | **+2.95 ms/MB** (bundled WASM AEAD 4.89 against AES-GCM 1.95), and only if the server *selects* it | per byte. Servers with AES hardware generally prefer AES-GCM, so the usual cost is zero and the offer is what matters |
768
+ | `decoders: { br }` | **+4.2 ms/MB** over the native gzip path (7.0 against 2.75) | per byte, whenever an origin serves brotli |
769
+ | `decoders: { zstd }` | **+2.8 ms/MB** (5.5 against 2.75) | per byte, whenever an origin serves zstd |
770
+ | `profile: chrome` via `tunnelfetch/profile/chrome` | **+3 ms once per isolate** for four WASM modules, then the per-byte rows above as origins use them | |
667
771
 
668
772
  Two defaults moved in 1.4.0 and neither is visible in the table above them: matching curl's cipher
669
773
  order means AES-256-GCM is negotiated where AES-128-GCM used to be, measured at **+4%** per MB
@@ -682,31 +786,76 @@ the measurements above, with the charge split out so it is clear what is yours t
682
786
 
683
787
  | Workload | CPU/request | 10M/mo, cold | 10M/mo, warmed | 1B/mo, cold | 1B/mo, warmed |
684
788
  | --- | --- | --- | --- | --- | --- |
685
- | Platform `fetch` — reference; it cannot use a proxy | 0.3 ms | $5.00 | $5.00 | $307.40 | $307.40 |
686
- | Pooled connection, 16 KB pages | 3.3 ms | $5.93 | $5.28 | $454.60 | $389.60 |
687
- | Pooled connection, 1 MB pages | 9.2 ms | $7.11 | $6.46 | $572.60 | $507.60 |
688
- | New connection per request, 16 KB | 11 ms | $7.47 | $6.82 | $608.60 | $543.60 |
689
- | New connection per request, 1 MB | 14.5 ms | $8.17 | $7.52 | $678.60 | $613.60 |
690
- | New connection per request, 4 MB | 30 ms | $11.27 | $10.62 | $988.60 | $923.60 |
789
+ | Platform `fetch`, 16 KB — reference; it cannot use a proxy | 0.3 ms | $5.00 | $5.00 | $307.40 | $307.40 |
790
+ | Platform `fetch`, 4 MB same reference, measured | 3.2 ms | $5.04 | $5.04 | $365.40 | $365.40 |
791
+ | Pooled connection, 16 KB pages | 3.1 ms | $5.90 | $5.24 | $451.20 | $385.20 |
792
+ | New connection per request, 16 KB | 10.6 ms | $7.41 | $6.75 | $602.20 | $536.20 |
793
+ | Pooled connection, 1 MB pages | 53.3 ms | $15.94 | $15.28 | $1455.20 | $1389.20 |
794
+ | New connection per request, 1 MB | 60.8 ms | $17.45 | $16.79 | $1606.20 | $1540.20 |
795
+ | Pooled connection, 4 MB pages | 118.3 ms | $28.94 | $28.28 | $2755.20 | $2689.20 |
796
+ | New connection per request, 4 MB | 125.8 ms | $30.45 | $29.79 | $2906.20 | $2840.20 |
797
+
798
+ The reference row is given at two sizes because the platform's own `fetch` is **not flat** — it
799
+ scales at about 0.82 ms per decompressed MB, measured on a size ladder from one CDN so that only the
800
+ size changes. Quoting it as a single 0.3 ms and comparing that against a 4 MB row was a like-for-
801
+ unlike comparison, and it flattered this package's competition rather than this package.
802
+
803
+ These dollar figures follow the corrected CPU measurements above, so the body-heavy rows are **two
804
+ to three times** what this table said through 1.4.0. That correction is not a regression in the
805
+ package; it is the removal of an origin whose content compressed 220:1.
691
806
 
692
807
  "Cold" carries the measured fresh-isolate ramp of +4.4 ms per request amortised; "warmed" is the
693
- same workload with `warmup({ iterations: 5 })`, which brings the ramp down to +1.1 ms. The saving is
694
- $0.65/month at ten million requests and $65/month at a billion, identical across every row because
695
- the ramp is a property of the isolate rather than of the request. The reference row carries no ramp
696
- because the platform's own `fetch` has no JavaScript protocol stack to tier up.
808
+ same workload with `warmup({ iterations: 5 })`, which brings it to +1.1 ms. The saving is $0.66/month
809
+ at ten million requests and $66/month at a billion, identical across every row because the ramp is a
810
+ property of the isolate rather than of the request. The reference rows carry no ramp: the platform's
811
+ `fetch` has no JavaScript protocol stack to tier up.
812
+
813
+ #### What each Chrome-identity option costs
814
+
815
+ The rows above are the default identity: gzip on the wire, AES-256-GCM, x25519. The Chrome row
816
+ bundles every change together, which is not much use for deciding. Priced one at a time against a
817
+ pooled 1 MB workload at a billion requests a month, warmed:
818
+
819
+ | Change from the baseline | CPU/request | 1B/mo, warmed | Δ | Paid when |
820
+ | --- | --- | --- | --- | --- |
821
+ | baseline — gzip, AES-256-GCM, x25519 | 53.3 ms | $1,389 | — | always |
822
+ | origin serves `br` instead of gzip | 57.6 ms | $1,474 | **+$85** | the origin chooses `br` |
823
+ | server selects ChaCha20-Poly1305 | 56.3 ms | $1,448 | **+$59** | the server picks it over AES |
824
+ | origin serves `zstd` instead of gzip | 56.1 ms | $1,444 | **+$55** | the origin chooses `zstd` |
825
+ | X25519MLKEM768, 1 request per connection | 61.0 ms | $1,542 | **+$153** | every handshake |
826
+ | X25519MLKEM768, 20 requests per connection | 53.3 ms | $1,390 | **+$0.15** | the same handshake, amortised |
827
+
828
+ The last two rows are the same 0.15 ms of ML-KEM, and the difference between them is entirely
829
+ connection reuse — which is the point worth taking from this table. Post-quantum key exchange is
830
+ the cheapest thing here if you keep a `Client` alive and the most expensive if you do not, because
831
+ it is per **handshake** while everything else is per byte.
832
+
833
+ Three of the five are also **conditional and not yours to decide**. `br` and `zstd` cost nothing
834
+ until an origin chooses to serve them, and ChaCha20 costs nothing until a server prefers it over
835
+ AES-GCM — which servers with AES hardware generally do not. Offering them is what buys the
836
+ fingerprint; paying for them happens only when the other end takes you up on it.
837
+
838
+ The ChaCha20 figure is the **bundled WASM** AEAD measured against the WebCrypto AES-256-GCM it
839
+ replaces (4.89 against 1.95 ms/MB). An earlier version of this section quoted +2.0 ms/MB, which was
840
+ `node:crypto`'s ChaCha20 — a path this package does not use, because taking it would require
841
+ `nodejs_compat`.
697
842
 
698
843
  Four things fall out of it.
699
844
 
700
- **At ten million requests a month, none of this matters.** Every row lands between $5 and $11
701
- because the included quotas swallow it the included CPU works out to 3.0 ms per request at that
702
- volume, so anything that reuses connections is inside the base fee entirely, cold starts included.
845
+ **At ten million requests a month, small pages are free and big ones are not.** A pooled 16 KB
846
+ workload sits inside the base fee; a pooled 1 MB workload is $15/month. The included CPU works out
847
+ to 3.0 ms per request at that volume, which a 16 KB page fits into and a 1 MB page does not.
703
848
 
704
849
  **At a billion, $297 of every row is the request charge**, identical across all of them and
705
- unchangeable by anything this package does. Only the CPU is left to optimise, and there the
706
- difference between reusing connections and not is $154/month on 16 KB pages.
850
+ unchangeable by anything this package does. Only the CPU is left, and there the largest lever is
851
+ not connection reuse it is body size. Reuse saves $151/month on 1 MB pages; fetching 16 KB pages
852
+ instead of 1 MB ones saves $1,004.
707
853
 
708
- **Pooled and warmed, the whole userland stack costs about 27% more than the platform's own
709
- `fetch`** $390 against $307 for something the platform's `fetch` cannot do at all.
854
+ **Body-heavy work is where the userland stack actually costs something.** Pooled and warmed on
855
+ 16 KB pages it is 25% above the platform's own `fetch` $385 against $307. On 1 MB pages it is
856
+ **3.8x** — $1,389 against $365 — because every byte is decrypted, reassembled and decompressed in
857
+ JavaScript, and the platform does all three in the runtime where none of it is billed. If your
858
+ workload is large bodies, that ratio is the number to plan around, not the 16 KB one.
710
859
 
711
860
  That reference row is measured, not assumed, and it is not flat. Fetching real pages of different
712
861
  sizes from the same Worker, marginal cost per request on a reused connection:
@@ -727,7 +876,7 @@ these are small numbers, so the ratios bounce between 3× and 13× and are not m
727
876
  not worth quoting to one decimal place.
728
877
 
729
878
  **`warmup()` is free on Standard and usually worth it elsewhere.** Its own cost is startup CPU,
730
- which Workers Standard does not bill, so the $65/month the "warmed" columns save at a billion
879
+ which Workers Standard does not bill, so the $66/month the "warmed" columns save at a billion
731
880
  requests is a pure saving.
732
881
  Where startup CPU *is* billed — dynamic Worker loading, for instance — the 22 ms is charged once
733
882
  per isolate and spread across the requests that isolate serves: $25/month at a billion requests and
@@ -742,18 +891,73 @@ EC chain carries two P-384 links, so **an all-ECDSA chain validates in ~3.5 ms a
742
891
  an RSA one**. If you control the origin, its certificate's key type is worth a thought.
743
892
 
744
893
  For small responses, decoding is dominated by constructing the `DecompressionStream`, not by the
745
- bytes: ~2 ms for a 559-byte body, so for small JSON `decompress: false` can be cheaper than gzip.
894
+ bytes: ~2 ms for a 559-byte body. That is a real fixed cost, but the advice this README used to
895
+ draw from it — that `decompress: false` can be cheaper for small JSON — **is backwards for anything
896
+ that is not tiny, and it was never measured against the alternative.**
897
+
898
+ Cost here scales with **wire** bytes, not decoded bytes, because every wire byte is decrypted,
899
+ reframed and moved across JS stream boundaries before the decompressor ever sees it. Measured on
900
+ the edge: receiving a 4 MB body **uncompressed** costs the same as receiving the 1.5 MB gzip of it
901
+ *and inflating that*. Turning compression off trades a decompression you would have paid for 2.7×
902
+ the bytes through the entire receive pipeline. **Leave compression on.** The fixed ~2 ms only wins
903
+ below roughly the size where a single wire read covers the whole body.
904
+
905
+ ### Cost parity with the platform's `fetch` is not reachable, and here is the floor
906
+
907
+ Two independent investigations reached this separately, which is the main reason it is stated this
908
+ flatly.
909
+
910
+ `gz-native` — the runtime's own `DecompressionStream` inflating 1.5 MB of gzip into 4 MB, collected
911
+ natively, with **no JS drain and no receive stack whatsoever** — costs **16 ms**, reproduced across
912
+ five independent sweeps. The platform's entire 4 MB `fetch`, TLS and HTTP and inflate included,
913
+ costs about **3.6 ms**.
914
+
915
+ So the cheapest way this package could possibly turn that gzip into bytes is already **4.4× the
916
+ platform's whole request**, before one byte of TLS or HTTP/2 is touched. The asymmetry is not about
917
+ code quality: **Cloudflare bills the CPU of a `DecompressionStream` running in your isolate and does
918
+ not bill the equivalent gunzip inside its own `fetch`.** Nothing written in JavaScript goes below a
919
+ billed native floor.
920
+
921
+ The remaining ~30× is the JS-orchestrated record layer, HTTP/2 demultiplexing and stream pipeline —
922
+ roughly **80% of the per-request cost at 4 MB, against 20% for decode**. An earlier version of this
923
+ section put the emphasis on decoding; that was wrong, and it sent optimisation effort at the smaller
924
+ of the two.
925
+
926
+ What would close it is a primitive that does not exist: a `startTls` that verifies the **origin**
927
+ hostname rather than the `connect()` peer, which would let the platform's own `fetch` run inside the
928
+ tunnel. That is the missing piece this whole package exists to work around, and it is worth
929
+ understanding as a **capability gap in the runtime, not a performance bug here**.
746
930
 
747
931
  For large bodies the per-byte cost is not really per byte — it is per stream-boundary crossing.
748
932
  The runtime's `DecompressionStream` emits 4096-byte chunks and its sockets deliver reads of at
749
933
  most 4096 bytes, and every chunk that crosses between the runtime and JS costs tens of
750
- microseconds regardless of size. Both hot paths therefore drain their sources with BYOB reads
751
- into 64 KiB views (a BYOB read hands over everything already buffered in one crossing, and
752
- resolves partially filled the moment any byte exists, so streaming latency is unchanged). That
753
- rebuild took the decode stage from ~28 ms to ~6 ms per MB of decompressed output — measured by
754
- A/B-ing both implementations inside one isolate: 110 ms against 23 ms for the same 4 MB body.
755
- What remains is close to floor: inflate itself (~2 ms/MB) plus materialising the body into a JS
756
- string (~1.7 ms/MB), and that last term is the one cost the platform's own `fetch` also bills.
934
+ microseconds regardless of size measured here at about **17 µs per crossing**, from a ladder
935
+ that collects the same 1 MB in 4 KiB chunks (6.0 ms/MB) through 256 KiB chunks (1.67 ms/MB).
936
+ Both hot paths therefore drain their sources with BYOB reads, which hand over everything already
937
+ buffered in one crossing and resolve partially filled the moment any byte exists, so streaming
938
+ latency is unchanged.
939
+
940
+ The view they read into is **16 KiB, and the size was swept rather than assumed**. It matters more
941
+ than it looks. The input is pumped by a JS task on the same event loop as the puller, so the
942
+ decompressor usually holds only a chunk or two when a read arrives and the read comes back
943
+ partially filled — measured over a 1 MB body: 93 reads, *all 93 partial*, average fill 11.3 KiB.
944
+ A 64 KiB view therefore allocates 5.8 MB of throwaway buffer to carry 1 MB of data. Swept on the
945
+ edge, CPU per MB of decompressed output, all five interleaved inside one isolate:
946
+
947
+ | BYOB view | 4 KiB | 8 KiB | **16 KiB** | 32 KiB | 64 KiB |
948
+ |---|---|---|---|---|---|
949
+ | decode stage, ms/MB | 19.33 | 16.00 | **13.00** | 15.33 | 17.67 |
950
+
951
+ A clean U: too small pays per-read overhead, too large pays for allocation it never fills. The
952
+ 64 KiB that used to sit here was chosen from a probe that fed the decompressor through a native
953
+ `pipeTo` — which runs ahead and *does* fill a 64 KiB view (16 reads, none partial). That is a
954
+ regime the shipped wiring never enters. The probe and the product disagreed and the probe was
955
+ believed. Correcting it cut the stage **31%**, 18.0 → 12.3 ms/MB, A/B-ed in one isolate.
956
+
957
+ What remains is **not** close to floor, and an earlier version of this section wrongly said it was.
958
+ Native inflate of the same content costs 4.3 ms/MB against the stage's 12.3, so roughly **8 ms/MB
959
+ is this package's own plumbing** — the JS input pump and the output wrapper. Closing that needs a
960
+ redesign rather than a constant, and it is the largest single item left in the body path.
757
961
 
758
962
  Importing the package is free. The 121 bundled anchors are base64 strings indexed by a hash of the
759
963
  subject DN, and only the one anchor a chain lands on is ever decoded, so startup stays at ~2 ms for