tunnelfetch 1.4.1 → 1.6.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/README.md +315 -112
- package/README.zh-CN.md +185 -65
- package/package.json +1 -1
- package/src/client/decode.js +99 -19
- package/src/client.js +28 -5
- package/src/http2/connection.js +123 -30
- package/src/profile/chrome.js +23 -9
- package/src/profile/vendor/brotli-dec.js +156 -0
- package/src/profile/vendor/zstd-dec.js +152 -0
- package/types/client/decode.d.ts +3 -2
- package/types/client.d.ts +10 -6
- package/types/http2/connection.d.ts +22 -1
- package/types/profile/chrome.d.ts +3 -1
- package/types/profile/vendor/brotli-dec.d.ts +24 -0
- package/types/profile/vendor/zstd-dec.d.ts +26 -0
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
|
265
|
-
|
|
|
266
|
-
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
272
|
-
native path already exists, nothing in userland improves on it. That is why `gzip` and
|
|
273
|
-
are not overridable: replacing them could only ever be slower, and doing it silently is
|
|
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
|
|
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`
|
|
297
|
-
|
|
298
|
-
|
|
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 implementation — measurement 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
|
|
|
@@ -416,7 +487,7 @@ res.tunnelfetch.httpVersion; // '2' if the server chose h2, '1.1' otherwise
|
|
|
416
487
|
| `timeouts` | see below | `connectMs`, `handshakeMs`, `headersMs`, `idleMs`, `totalMs`. |
|
|
417
488
|
| `cookies` | `false` | Enable a per-Client cookie jar. |
|
|
418
489
|
| `maxRedirects` | `20` | |
|
|
419
|
-
| `maxBodyBytes` |
|
|
490
|
+
| `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
491
|
| `decompress` | `true` | Decode `Content-Encoding` at all. gzip and deflate are built in. |
|
|
421
492
|
| `decoders` | `{}` | Extra codings, e.g. `{ br: fn }`. Each is added to `Accept-Encoding`. See [`br`, `zstd`](#br-zstd-and-other-codings). |
|
|
422
493
|
| `keepAlive` | `true` | |
|
|
@@ -430,6 +501,30 @@ carries a non-standard `tunnelfetch` property with `{proxied, proxy, tls, httpVe
|
|
|
430
501
|
`client.close()` releases every pooled socket. A `Client` that is not closed leaks sockets for the
|
|
431
502
|
lifetime of the isolate.
|
|
432
503
|
|
|
504
|
+
#### The body cap has a default now
|
|
505
|
+
|
|
506
|
+
**`maxBodyBytes` defaulted to `Infinity` through 1.5.0. From 1.6.0 it is 32 MiB, and that is a
|
|
507
|
+
breaking change** — a download larger than 32 MiB now needs an explicit `maxBodyBytes`, compressed
|
|
508
|
+
or not, because this option bounds the wire body as well as the decoded one.
|
|
509
|
+
|
|
510
|
+
The reason is that "no limit" is not a freedom on a runtime with a hard memory ceiling; it is a way
|
|
511
|
+
to be killed by a peer. A 53-byte brotli body decoding to 32 MB is measured here, not hypothetical,
|
|
512
|
+
and the bundled `br`/`zstd` decoders self-limit at 256 MiB — **twice the 128 MB a Workers isolate
|
|
513
|
+
gets**, so that fallback cannot fire before the isolate is already dead. A client whose entire
|
|
514
|
+
purpose is fetching URLs you do not control should not ship "unbounded" as the setting you get for
|
|
515
|
+
not having read this table.
|
|
516
|
+
|
|
517
|
+
32 MiB is a quarter of the ceiling, so a body buffered to the cap by `.arrayBuffer()` still leaves
|
|
518
|
+
the isolate room to survive and report it, and it is two orders of magnitude above any page or API
|
|
519
|
+
response. If you are deliberately moving large files, say so:
|
|
520
|
+
|
|
521
|
+
```js
|
|
522
|
+
new Client({ connect, proxy, maxBodyBytes: Infinity }); // or any number you have thought about
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
The trade is deliberate: an unasked-for limit is discoverable the first time it bites, and names the
|
|
526
|
+
option in its error. An unasked-for OOM is neither.
|
|
527
|
+
|
|
433
528
|
### Trust — the `verify=` knob
|
|
434
529
|
|
|
435
530
|
```js
|
|
@@ -549,24 +644,20 @@ Not implemented, and not planned:
|
|
|
549
644
|
- **A public-suffix list for cookies.** Only the "no dot in the domain" guard is implemented, so
|
|
550
645
|
`Domain=com` is refused but `Domain=co.uk` is not. Documented rather than faked.
|
|
551
646
|
- **IDNA.** Pass A-labels (punycode); a non-ASCII hostname is rejected with a message saying so.
|
|
552
|
-
- **`br` and `zstd`
|
|
553
|
-
deflate-raw only — measured, not assumed
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
Leaving
|
|
560
|
-
not asked for, so a Brotli-serving origin simply returns gzip. What it costs is bandwidth —
|
|
561
|
-
same page measured 290 KB as gzip against 99 KB as `br` — and bandwidth is not what this
|
|
562
|
-
bills.
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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.
|
|
647
|
+
- **`br` and `zstd` in the default identity.** The runtime's `DecompressionStream` accepts gzip,
|
|
648
|
+
deflate and deflate-raw only — measured, not assumed — so both come from WebAssembly, and the
|
|
649
|
+
default entry point carries neither. Import
|
|
650
|
+
[`tunnelfetch/profile/chrome`](#the-chrome-identity-in-one-import) and they arrive wired in, or
|
|
651
|
+
register your own through [`decoders`](#br-zstd-and-other-codings). What the main entry will not
|
|
652
|
+
do is pull ~140 KB of compiled C into every bundle for a coding most callers never meet.
|
|
653
|
+
|
|
654
|
+
Leaving them off is safe rather than lossy: content negotiation means a server never sends what
|
|
655
|
+
was not asked for, so a Brotli-serving origin simply returns gzip. What it costs is bandwidth —
|
|
656
|
+
the same page measured 290 KB as gzip against 99 KB as `br` — and bandwidth is not what this
|
|
657
|
+
platform bills. On CPU the trade runs the other way: brotli decodes at 2.5x native inflate, and
|
|
658
|
+
harder compression is worse rather than better, because decompression work scales with the OUTPUT
|
|
659
|
+
bytes. The reason to turn `br` on is matching a browser's `Accept-Encoding`, not saving CPU.
|
|
660
|
+
|
|
570
661
|
- **Streaming request bodies.** A request body is read fully into memory before the request is
|
|
571
662
|
sent, because the framing has to be declared in a `Content-Length` this client can stand behind
|
|
572
663
|
and because a body may have to be replayed on a redirect. Fine for the JSON an SDK sends; wrong
|
|
@@ -602,52 +693,63 @@ Fetching a size-controlled origin through a proxy, warm, medians over seven-plus
|
|
|
602
693
|
isolate, gzip on the wire. The last column is the same numbers as a rate, which is the form worth
|
|
603
694
|
carrying around:
|
|
604
695
|
|
|
605
|
-
| |
|
|
696
|
+
| Body | Averaged over 5 pages | Reusing a connection | New connection |
|
|
697
|
+
| --- | --- | --- | --- |
|
|
698
|
+
| 1 KB | 3.2 ms | 1.7 ms | 9.2 ms |
|
|
699
|
+
| 16 KB | 4.6 ms | 3.1 ms | 10.6 ms |
|
|
700
|
+
| 64 KB | 8.2 ms | 6.7 ms | 14.2 ms |
|
|
701
|
+
| 256 KB | 18.2 ms | 16.7 ms | 24.2 ms |
|
|
702
|
+
| 1 MB | 54.8 ms | 53.3 ms | 60.8 ms |
|
|
703
|
+
| 4 MB | 119.8 ms | 118.3 ms | 125.8 ms |
|
|
704
|
+
|
|
705
|
+
The cold-start cost is a **total**, not something to add to a row above:
|
|
706
|
+
|
|
707
|
+
| First request in a fresh isolate | cost of that request | excess over the warm floor, whole ramp |
|
|
606
708
|
| --- | --- | --- |
|
|
607
|
-
|
|
|
608
|
-
|
|
|
609
|
-
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
709
|
+
| without `warmup()` | **46 ms** | 61 ms (≈ 4.4 ms/request over an isolate's early life) |
|
|
710
|
+
| `warmup()` once | 22 ms | 40 ms (≈ 2.8 ms/request) |
|
|
711
|
+
| `warmup({ iterations: 5 })` | **16 ms** | 15 ms (≈ 1.1 ms/request) |
|
|
712
|
+
|
|
713
|
+
Both figures used to appear in the table above as "+46 ms" and "+16 ms", which turned a total into
|
|
714
|
+
an increment and doubled the documented cold start. The `+` is refutable from the numbers alone: a
|
|
715
|
+
first request that cost 9.2 + 16 would carry 16 ms of excess by itself, which is more than the 15 ms
|
|
716
|
+
of excess the *whole* ramp contains. These were measured on a small body; a cold isolate's first
|
|
717
|
+
4 MB request has never been measured and is certainly worse, since far more of the decode loop runs
|
|
718
|
+
interpreted.
|
|
719
|
+
|
|
720
|
+
Measured through a proxy against a size-controlled origin, eight rounds per size, HTTP/2, gzip on
|
|
721
|
+
the wire. Connection and per-request terms were separated by varying the reuse count rather than
|
|
722
|
+
assumed — two pages against ten gives **9.8 ms to open a connection** and **2.25 ms per further
|
|
723
|
+
request**, and the body cost is what is left.
|
|
724
|
+
|
|
725
|
+
**These figures replace ones that were measured wrong, and the mistake is worth describing.** The
|
|
726
|
+
origin they came from tiled a 150-byte HTML fragment, which gzip compressed **220:1** — so a "1 MB
|
|
727
|
+
body" was four kilobytes on the wire, and every measurement taken against it priced decompression
|
|
728
|
+
while erasing the per-wire-byte cost of TLS records and streaming entirely. Real pages compress
|
|
729
|
+
around 4:1. The origin now tiles 154 KiB of real minified JavaScript, which lands at 2.76:1: gzip's
|
|
730
|
+
window is 32 KiB, so a repeat period that large does not compress away.
|
|
731
|
+
|
|
732
|
+
The correction is large. Body-heavy rows are **two to three times** what this table said through
|
|
733
|
+
1.4.0, and no amount of care about medians or minimums would have caught it, because the numbers
|
|
734
|
+
were internally consistent — they were answers to the wrong question.
|
|
735
|
+
|
|
736
|
+
**Read the two right-hand columns as derived, because they are.** Only the pooled column is measured
|
|
737
|
+
per size; "new connection" is the pooled figure plus a flat 7.5 ms and "averaged over 5 pages" is
|
|
738
|
+
the pooled figure plus 1.5 ms, which is why the deltas are identical to one decimal across a 4000×
|
|
739
|
+
range in body size. That 7.5 ms also does not agree with the 9.8 ms quoted just above it, and the
|
|
740
|
+
2.25 ms per further request is larger than the entire 1.7 ms a pooled 1 KB request costs, which
|
|
741
|
+
would make a 1 KB body cost negative. The two came from different sweeps, and combining them is the
|
|
742
|
+
cross-sweep comparison this document tells you never to make. **Treat the connection term as
|
|
743
|
+
somewhere in 7–10 ms and do not do arithmetic with it.**
|
|
744
|
+
|
|
745
|
+
An independent check was quoted here as agreement and is not: a real 3.6 MB file from a CDN cost
|
|
746
|
+
142 ms against the ~120 ms this table predicts for 4 MB. That is the model under-predicting by
|
|
747
|
+
roughly 20%, in the same direction as the error it had just replaced. It belongs here as a caution,
|
|
748
|
+
not as corroboration.
|
|
749
|
+
|
|
750
|
+
Two further cautions. The 2.76:1 content is slightly *less* compressible than a typical page, so
|
|
751
|
+
these are mildly conservative rather than optimistic. And CPU on this platform varies by up to ~1.5×
|
|
752
|
+
between isolates, so the shape matters more than any single figure.
|
|
651
753
|
|
|
652
754
|
### What the optional switches cost
|
|
653
755
|
|
|
@@ -661,9 +763,10 @@ the edge the same way as the rest — differencing two work counts, minimum of s
|
|
|
661
763
|
| `tls.extensionOrder: 'shuffle'` | not measurable | shuffling ~11 items, once per handshake |
|
|
662
764
|
| `headerOrder` | not measurable — the ordered list is *faster* than the platform `Headers` (1.6 µs against 3.8 µs) | per request |
|
|
663
765
|
| `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.
|
|
665
|
-
| `decoders: { br }` | **+4.
|
|
666
|
-
| `
|
|
766
|
+
| `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 |
|
|
767
|
+
| `decoders: { br }` | **+4.2 ms/MB** over the native gzip path (7.0 against 2.75) | per byte, whenever an origin serves brotli |
|
|
768
|
+
| `decoders: { zstd }` | **+2.8 ms/MB** (5.5 against 2.75) | per byte, whenever an origin serves zstd |
|
|
769
|
+
| `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
770
|
|
|
668
771
|
Two defaults moved in 1.4.0 and neither is visible in the table above them: matching curl's cipher
|
|
669
772
|
order means AES-256-GCM is negotiated where AES-128-GCM used to be, measured at **+4%** per MB
|
|
@@ -682,31 +785,76 @@ the measurements above, with the charge split out so it is clear what is yours t
|
|
|
682
785
|
|
|
683
786
|
| Workload | CPU/request | 10M/mo, cold | 10M/mo, warmed | 1B/mo, cold | 1B/mo, warmed |
|
|
684
787
|
| --- | --- | --- | --- | --- | --- |
|
|
685
|
-
| Platform `fetch
|
|
686
|
-
|
|
|
687
|
-
| Pooled connection,
|
|
688
|
-
| New connection per request, 16 KB |
|
|
689
|
-
|
|
|
690
|
-
| New connection per request,
|
|
788
|
+
| Platform `fetch`, 16 KB — reference; it cannot use a proxy | 0.3 ms | $5.00 | $5.00 | $307.40 | $307.40 |
|
|
789
|
+
| Platform `fetch`, 4 MB — same reference, measured | 3.2 ms | $5.04 | $5.04 | $365.40 | $365.40 |
|
|
790
|
+
| Pooled connection, 16 KB pages | 3.1 ms | $5.90 | $5.24 | $451.20 | $385.20 |
|
|
791
|
+
| New connection per request, 16 KB | 10.6 ms | $7.41 | $6.75 | $602.20 | $536.20 |
|
|
792
|
+
| Pooled connection, 1 MB pages | 53.3 ms | $15.94 | $15.28 | $1455.20 | $1389.20 |
|
|
793
|
+
| New connection per request, 1 MB | 60.8 ms | $17.45 | $16.79 | $1606.20 | $1540.20 |
|
|
794
|
+
| Pooled connection, 4 MB pages | 118.3 ms | $28.94 | $28.28 | $2755.20 | $2689.20 |
|
|
795
|
+
| New connection per request, 4 MB | 125.8 ms | $30.45 | $29.79 | $2906.20 | $2840.20 |
|
|
796
|
+
|
|
797
|
+
The reference row is given at two sizes because the platform's own `fetch` is **not flat** — it
|
|
798
|
+
scales at about 0.82 ms per decompressed MB, measured on a size ladder from one CDN so that only the
|
|
799
|
+
size changes. Quoting it as a single 0.3 ms and comparing that against a 4 MB row was a like-for-
|
|
800
|
+
unlike comparison, and it flattered this package's competition rather than this package.
|
|
801
|
+
|
|
802
|
+
These dollar figures follow the corrected CPU measurements above, so the body-heavy rows are **two
|
|
803
|
+
to three times** what this table said through 1.4.0. That correction is not a regression in the
|
|
804
|
+
package; it is the removal of an origin whose content compressed 220:1.
|
|
691
805
|
|
|
692
806
|
"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
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
807
|
+
same workload with `warmup({ iterations: 5 })`, which brings it to +1.1 ms. The saving is $0.66/month
|
|
808
|
+
at ten million requests and $66/month at a billion, identical across every row because the ramp is a
|
|
809
|
+
property of the isolate rather than of the request. The reference rows carry no ramp: the platform's
|
|
810
|
+
`fetch` has no JavaScript protocol stack to tier up.
|
|
811
|
+
|
|
812
|
+
#### What each Chrome-identity option costs
|
|
813
|
+
|
|
814
|
+
The rows above are the default identity: gzip on the wire, AES-256-GCM, x25519. The Chrome row
|
|
815
|
+
bundles every change together, which is not much use for deciding. Priced one at a time against a
|
|
816
|
+
pooled 1 MB workload at a billion requests a month, warmed:
|
|
817
|
+
|
|
818
|
+
| Change from the baseline | CPU/request | 1B/mo, warmed | Δ | Paid when |
|
|
819
|
+
| --- | --- | --- | --- | --- |
|
|
820
|
+
| baseline — gzip, AES-256-GCM, x25519 | 53.3 ms | $1,389 | — | always |
|
|
821
|
+
| origin serves `br` instead of gzip | 57.6 ms | $1,474 | **+$85** | the origin chooses `br` |
|
|
822
|
+
| server selects ChaCha20-Poly1305 | 56.3 ms | $1,448 | **+$59** | the server picks it over AES |
|
|
823
|
+
| origin serves `zstd` instead of gzip | 56.1 ms | $1,444 | **+$55** | the origin chooses `zstd` |
|
|
824
|
+
| X25519MLKEM768, 1 request per connection | 61.0 ms | $1,542 | **+$153** | every handshake |
|
|
825
|
+
| X25519MLKEM768, 20 requests per connection | 53.3 ms | $1,390 | **+$0.15** | the same handshake, amortised |
|
|
826
|
+
|
|
827
|
+
The last two rows are the same 0.15 ms of ML-KEM, and the difference between them is entirely
|
|
828
|
+
connection reuse — which is the point worth taking from this table. Post-quantum key exchange is
|
|
829
|
+
the cheapest thing here if you keep a `Client` alive and the most expensive if you do not, because
|
|
830
|
+
it is per **handshake** while everything else is per byte.
|
|
831
|
+
|
|
832
|
+
Three of the five are also **conditional and not yours to decide**. `br` and `zstd` cost nothing
|
|
833
|
+
until an origin chooses to serve them, and ChaCha20 costs nothing until a server prefers it over
|
|
834
|
+
AES-GCM — which servers with AES hardware generally do not. Offering them is what buys the
|
|
835
|
+
fingerprint; paying for them happens only when the other end takes you up on it.
|
|
836
|
+
|
|
837
|
+
The ChaCha20 figure is the **bundled WASM** AEAD measured against the WebCrypto AES-256-GCM it
|
|
838
|
+
replaces (4.89 against 1.95 ms/MB). An earlier version of this section quoted +2.0 ms/MB, which was
|
|
839
|
+
`node:crypto`'s ChaCha20 — a path this package does not use, because taking it would require
|
|
840
|
+
`nodejs_compat`.
|
|
697
841
|
|
|
698
842
|
Four things fall out of it.
|
|
699
843
|
|
|
700
|
-
**At ten million requests a month,
|
|
701
|
-
|
|
702
|
-
volume,
|
|
844
|
+
**At ten million requests a month, small pages are free and big ones are not.** A pooled 16 KB
|
|
845
|
+
workload sits inside the base fee; a pooled 1 MB workload is $15/month. The included CPU works out
|
|
846
|
+
to 3.0 ms per request at that volume, which a 16 KB page fits into and a 1 MB page does not.
|
|
703
847
|
|
|
704
848
|
**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
|
|
706
|
-
|
|
849
|
+
unchangeable by anything this package does. Only the CPU is left, and there the largest lever is
|
|
850
|
+
not connection reuse — it is body size. Reuse saves $151/month on 1 MB pages; fetching 16 KB pages
|
|
851
|
+
instead of 1 MB ones saves $1,004.
|
|
707
852
|
|
|
708
|
-
**
|
|
709
|
-
|
|
853
|
+
**Body-heavy work is where the userland stack actually costs something.** Pooled and warmed on
|
|
854
|
+
16 KB pages it is 25% above the platform's own `fetch` — $385 against $307. On 1 MB pages it is
|
|
855
|
+
**3.8x** — $1,389 against $365 — because every byte is decrypted, reassembled and decompressed in
|
|
856
|
+
JavaScript, and the platform does all three in the runtime where none of it is billed. If your
|
|
857
|
+
workload is large bodies, that ratio is the number to plan around, not the 16 KB one.
|
|
710
858
|
|
|
711
859
|
That reference row is measured, not assumed, and it is not flat. Fetching real pages of different
|
|
712
860
|
sizes from the same Worker, marginal cost per request on a reused connection:
|
|
@@ -727,7 +875,7 @@ these are small numbers, so the ratios bounce between 3× and 13× and are not m
|
|
|
727
875
|
not worth quoting to one decimal place.
|
|
728
876
|
|
|
729
877
|
**`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 $
|
|
878
|
+
which Workers Standard does not bill, so the $66/month the "warmed" columns save at a billion
|
|
731
879
|
requests is a pure saving.
|
|
732
880
|
Where startup CPU *is* billed — dynamic Worker loading, for instance — the 22 ms is charged once
|
|
733
881
|
per isolate and spread across the requests that isolate serves: $25/month at a billion requests and
|
|
@@ -742,18 +890,73 @@ EC chain carries two P-384 links, so **an all-ECDSA chain validates in ~3.5 ms a
|
|
|
742
890
|
an RSA one**. If you control the origin, its certificate's key type is worth a thought.
|
|
743
891
|
|
|
744
892
|
For small responses, decoding is dominated by constructing the `DecompressionStream`, not by the
|
|
745
|
-
bytes: ~2 ms for a 559-byte body
|
|
893
|
+
bytes: ~2 ms for a 559-byte body. That is a real fixed cost, but the advice this README used to
|
|
894
|
+
draw from it — that `decompress: false` can be cheaper for small JSON — **is backwards for anything
|
|
895
|
+
that is not tiny, and it was never measured against the alternative.**
|
|
896
|
+
|
|
897
|
+
Cost here scales with **wire** bytes, not decoded bytes, because every wire byte is decrypted,
|
|
898
|
+
reframed and moved across JS stream boundaries before the decompressor ever sees it. Measured on
|
|
899
|
+
the edge: receiving a 4 MB body **uncompressed** costs the same as receiving the 1.5 MB gzip of it
|
|
900
|
+
*and inflating that*. Turning compression off trades a decompression you would have paid for 2.7×
|
|
901
|
+
the bytes through the entire receive pipeline. **Leave compression on.** The fixed ~2 ms only wins
|
|
902
|
+
below roughly the size where a single wire read covers the whole body.
|
|
903
|
+
|
|
904
|
+
### Cost parity with the platform's `fetch` is not reachable, and here is the floor
|
|
905
|
+
|
|
906
|
+
Two independent investigations reached this separately, which is the main reason it is stated this
|
|
907
|
+
flatly.
|
|
908
|
+
|
|
909
|
+
`gz-native` — the runtime's own `DecompressionStream` inflating 1.5 MB of gzip into 4 MB, collected
|
|
910
|
+
natively, with **no JS drain and no receive stack whatsoever** — costs **16 ms**, reproduced across
|
|
911
|
+
five independent sweeps. The platform's entire 4 MB `fetch`, TLS and HTTP and inflate included,
|
|
912
|
+
costs about **3.6 ms**.
|
|
913
|
+
|
|
914
|
+
So the cheapest way this package could possibly turn that gzip into bytes is already **4.4× the
|
|
915
|
+
platform's whole request**, before one byte of TLS or HTTP/2 is touched. The asymmetry is not about
|
|
916
|
+
code quality: **Cloudflare bills the CPU of a `DecompressionStream` running in your isolate and does
|
|
917
|
+
not bill the equivalent gunzip inside its own `fetch`.** Nothing written in JavaScript goes below a
|
|
918
|
+
billed native floor.
|
|
919
|
+
|
|
920
|
+
The remaining ~30× is the JS-orchestrated record layer, HTTP/2 demultiplexing and stream pipeline —
|
|
921
|
+
roughly **80% of the per-request cost at 4 MB, against 20% for decode**. An earlier version of this
|
|
922
|
+
section put the emphasis on decoding; that was wrong, and it sent optimisation effort at the smaller
|
|
923
|
+
of the two.
|
|
924
|
+
|
|
925
|
+
What would close it is a primitive that does not exist: a `startTls` that verifies the **origin**
|
|
926
|
+
hostname rather than the `connect()` peer, which would let the platform's own `fetch` run inside the
|
|
927
|
+
tunnel. That is the missing piece this whole package exists to work around, and it is worth
|
|
928
|
+
understanding as a **capability gap in the runtime, not a performance bug here**.
|
|
746
929
|
|
|
747
930
|
For large bodies the per-byte cost is not really per byte — it is per stream-boundary crossing.
|
|
748
931
|
The runtime's `DecompressionStream` emits 4096-byte chunks and its sockets deliver reads of at
|
|
749
932
|
most 4096 bytes, and every chunk that crosses between the runtime and JS costs tens of
|
|
750
|
-
microseconds regardless of size
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
933
|
+
microseconds regardless of size — measured here at about **17 µs per crossing**, from a ladder
|
|
934
|
+
that collects the same 1 MB in 4 KiB chunks (6.0 ms/MB) through 256 KiB chunks (1.67 ms/MB).
|
|
935
|
+
Both hot paths therefore drain their sources with BYOB reads, which hand over everything already
|
|
936
|
+
buffered in one crossing and resolve partially filled the moment any byte exists, so streaming
|
|
937
|
+
latency is unchanged.
|
|
938
|
+
|
|
939
|
+
The view they read into is **16 KiB, and the size was swept rather than assumed**. It matters more
|
|
940
|
+
than it looks. The input is pumped by a JS task on the same event loop as the puller, so the
|
|
941
|
+
decompressor usually holds only a chunk or two when a read arrives and the read comes back
|
|
942
|
+
partially filled — measured over a 1 MB body: 93 reads, *all 93 partial*, average fill 11.3 KiB.
|
|
943
|
+
A 64 KiB view therefore allocates 5.8 MB of throwaway buffer to carry 1 MB of data. Swept on the
|
|
944
|
+
edge, CPU per MB of decompressed output, all five interleaved inside one isolate:
|
|
945
|
+
|
|
946
|
+
| BYOB view | 4 KiB | 8 KiB | **16 KiB** | 32 KiB | 64 KiB |
|
|
947
|
+
|---|---|---|---|---|---|
|
|
948
|
+
| decode stage, ms/MB | 19.33 | 16.00 | **13.00** | 15.33 | 17.67 |
|
|
949
|
+
|
|
950
|
+
A clean U: too small pays per-read overhead, too large pays for allocation it never fills. The
|
|
951
|
+
64 KiB that used to sit here was chosen from a probe that fed the decompressor through a native
|
|
952
|
+
`pipeTo` — which runs ahead and *does* fill a 64 KiB view (16 reads, none partial). That is a
|
|
953
|
+
regime the shipped wiring never enters. The probe and the product disagreed and the probe was
|
|
954
|
+
believed. Correcting it cut the stage **31%**, 18.0 → 12.3 ms/MB, A/B-ed in one isolate.
|
|
955
|
+
|
|
956
|
+
What remains is **not** close to floor, and an earlier version of this section wrongly said it was.
|
|
957
|
+
Native inflate of the same content costs 4.3 ms/MB against the stage's 12.3, so roughly **8 ms/MB
|
|
958
|
+
is this package's own plumbing** — the JS input pump and the output wrapper. Closing that needs a
|
|
959
|
+
redesign rather than a constant, and it is the largest single item left in the body path.
|
|
757
960
|
|
|
758
961
|
Importing the package is free. The 121 bundled anchors are base64 strings indexed by a hash of the
|
|
759
962
|
subject DN, and only the one anchor a chain lands on is ever decoded, so startup stays at ~2 ms for
|