tunnelfetch 1.11.0 → 1.13.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 +420 -64
- package/README.zh-CN.md +114 -31
- package/package.json +1 -1
- package/src/connect.js +187 -0
- package/src/index.js +1 -0
- package/src/proxy/http-connect.js +2 -10
- package/src/proxy/socks5.js +2 -10
- package/src/proxy/tunnel.js +110 -0
- package/src/util/bytes.js +35 -18
- package/types/connect.d.ts +78 -0
- package/types/index.d.ts +1 -0
- package/types/proxy/tunnel.d.ts +11 -0
- package/types/util/bytes.d.ts +14 -10
package/README.md
CHANGED
|
@@ -149,6 +149,42 @@ Two related rules of §5.7 are **not** implemented, and are worth knowing if you
|
|
|
149
149
|
security: "Leave Secure Cookies Alone" (step 16), so a plain-named `Secure` cookie set over https
|
|
150
150
|
can still be overwritten from http, and the 4096-octet name-plus-value cap (step 4).
|
|
151
151
|
|
|
152
|
+
### Obtaining the socket factory
|
|
153
|
+
|
|
154
|
+
`connect` is an argument rather than an import on purpose: it is the one piece this package cannot
|
|
155
|
+
supply itself, and taking it from the caller is what keeps every layer above it testable over an
|
|
156
|
+
in-memory pipe. Resolve one robustly:
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
import { resolveConnect } from 'tunnelfetch';
|
|
160
|
+
|
|
161
|
+
const connect = await resolveConnect({
|
|
162
|
+
specifiers: ['cloudflare:sockets'],
|
|
163
|
+
});
|
|
164
|
+
const client = new Client({ connect, proxy: env.PROXY_URL });
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The first specifier that yields a callable export wins, and a total failure names every specifier
|
|
168
|
+
tried and why it failed — the mistake surfaces at startup, not as "connect is not a function" from
|
|
169
|
+
inside the TLS layer. Two traps are closed on the way:
|
|
170
|
+
|
|
171
|
+
1. The import is dynamic and its failure is caught. A specifier that only resolves on one runtime,
|
|
172
|
+
imported statically, makes the whole package unloadable everywhere else; a portable list naming
|
|
173
|
+
several runtimes is safe.
|
|
174
|
+
2. Bundlers cannot see through a variable specifier. On a bundled deploy pass `connect` straight
|
|
175
|
+
in, or hand `resolveConnect` an `importModule` that closes over a literal the bundler can see:
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
const connect = await resolveConnect({
|
|
179
|
+
importModule: () => import('cloudflare:sockets'),
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`normaliseSocket` flattens one runtime socket into a plain duplex, and `normalisingConnect` wraps a
|
|
184
|
+
factory so every socket it returns is flattened. They exist because on the edge runtime a socket's
|
|
185
|
+
`readable` and `writable` are prototype accessors, so `{ ...socket }` copies neither and the first
|
|
186
|
+
read fails far away, inside the TLS layer, with a complaint about `getReader`.
|
|
187
|
+
|
|
152
188
|
### Replacing the global
|
|
153
189
|
|
|
154
190
|
For libraries that only ever call the bare global:
|
|
@@ -783,29 +819,41 @@ the network, which is not billed.
|
|
|
783
819
|
|
|
784
820
|
### What a request costs
|
|
785
821
|
|
|
786
|
-
Fetching
|
|
787
|
-
|
|
788
|
-
|
|
822
|
+
Fetching `sizeorigin/` through a proxy, gzip on the wire, medians of n>=5 with every size in one
|
|
823
|
+
sweep and one isolate. The method is stated because the last version of this table did not state
|
|
824
|
+
one precisely enough to reproduce: **a warm page is `(reuse=4 - reuse=1) / 3`**, the cost of pages
|
|
825
|
+
two through four down a connection that is already open, and the last column is the fresh-connection
|
|
826
|
+
number the same sweep produced.
|
|
789
827
|
|
|
790
|
-
| Body |
|
|
791
|
-
| --- | --- | --- |
|
|
792
|
-
| 1 KB | **
|
|
793
|
-
| 16 KB | **3
|
|
794
|
-
| 64 KB | **
|
|
795
|
-
| 256 KB | **
|
|
796
|
-
| 1 MB | **
|
|
797
|
-
| 4 MB | **
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
828
|
+
| Body | Warm page | Per decompressed MB | First request on a new connection |
|
|
829
|
+
| --- | --- | --- | --- |
|
|
830
|
+
| 1 KB | **1.3 ms** | — | 8 ms |
|
|
831
|
+
| 16 KB | **2.3 ms** | 149 ms/MB | 9 ms |
|
|
832
|
+
| 64 KB | **6.0 ms** | 96 ms/MB | 13 ms |
|
|
833
|
+
| 256 KB | **14.3 ms** | 57 ms/MB | 28 ms |
|
|
834
|
+
| 1 MB | **36.3 ms** | 36 ms/MB | 59 ms |
|
|
835
|
+
| 4 MB | **102 ms** | 26 ms/MB | 135 ms |
|
|
836
|
+
|
|
837
|
+
The last column is not "warm page plus a handshake". At 4 MB it is 33 ms above the warm page while a
|
|
838
|
+
handshake costs single digits, because the *first* body through a connection also runs the decode
|
|
839
|
+
loop before V8 has tiered it up. Budget a new origin at that column, not at the first one.
|
|
840
|
+
|
|
841
|
+
**Re-measured August 2026, and the mid-sizes moved.** The 4 MB row reproduced almost exactly
|
|
842
|
+
(102 against a previously published 104); 64 KB through 1 MB came in 20-30% lower than the figures
|
|
843
|
+
this table used to carry. That is not the socket-view change two sections below — A/B-ing the old
|
|
844
|
+
and new view size in one isolate moved a warm 1 MB page by about 1 ms — so it is either day-to-day
|
|
845
|
+
variance beyond what a single sweep can see, or a difference in how the superseded table was taken.
|
|
846
|
+
The old numbers are not recoverable to check, which is the argument for stating the method here.
|
|
847
|
+
|
|
848
|
+
**Read the per-MB column before doing any arithmetic with this table.** It falls 5.7x from end to
|
|
849
|
+
end, so there is no such thing as a per-MB rate for this package. A least-squares line through the
|
|
850
|
+
16 KB and larger points is `6.30 + 24.30 x MB`, which predicts 6.33 ms for a 1 KB body against a
|
|
851
|
+
measured 1.3 — wrong by 4.9x. Any budget built on a single per-MB figure will be wrong at one end
|
|
852
|
+
or the other.
|
|
805
853
|
|
|
806
854
|
The reason is that V8 tiers up **inside a single request**. A 1 MB body runs the decode loop
|
|
807
|
-
interpreted
|
|
808
|
-
`
|
|
855
|
+
interpreted for much of its length; a 4 MB body pays that once and runs the rest optimised.
|
|
856
|
+
`36.3 + 3 x 21.9 = 102` fits, so the steady-state cost is about **22 ms/MB with a ~36 ms entry fee
|
|
809
857
|
per request**. For a size not in the table, interpolate within it rather than extrapolating from a
|
|
810
858
|
rate.
|
|
811
859
|
|
|
@@ -824,11 +872,6 @@ of excess the *whole* ramp contains. These were measured on a small body; a cold
|
|
|
824
872
|
4 MB request has never been measured and is certainly worse, since far more of the decode loop runs
|
|
825
873
|
interpreted.
|
|
826
874
|
|
|
827
|
-
Measured through a proxy against a size-controlled origin, eight rounds per size, HTTP/2, gzip on
|
|
828
|
-
the wire. Connection and per-request terms were separated by varying the reuse count rather than
|
|
829
|
-
assumed — two pages against ten gives **9.8 ms to open a connection** and **2.25 ms per further
|
|
830
|
-
request**, and the body cost is what is left.
|
|
831
|
-
|
|
832
875
|
**These figures replace ones that were measured wrong, and the mistake is worth describing.** The
|
|
833
876
|
origin they came from tiled a 150-byte HTML fragment, which gzip compressed **220:1** — so a "1 MB
|
|
834
877
|
body" was four kilobytes on the wire, and every measurement taken against it priced decompression
|
|
@@ -840,19 +883,21 @@ The correction is large. Body-heavy rows are **two to three times** what this ta
|
|
|
840
883
|
1.4.0, and no amount of care about medians or minimums would have caught it, because the numbers
|
|
841
884
|
were internally consistent — they were answers to the wrong question.
|
|
842
885
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
the
|
|
846
|
-
range in body size
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
886
|
+
That correction is the reason the table above now measures its fresh-connection column instead of
|
|
887
|
+
deriving one. Through 1.11.0 that column was the warm figure plus a flat 7.5 ms and a third column
|
|
888
|
+
was the warm figure plus 1.5 ms, which is why their deltas were identical to one decimal across a
|
|
889
|
+
4000x range in body size — a derived column cannot disagree with its source, so it cannot check it
|
|
890
|
+
either. Both are gone.
|
|
891
|
+
|
|
892
|
+
The connection term that falls out of the current sweep is **6–7 ms** (1 KB fresh 8 ms against a
|
|
893
|
+
1.3 ms warm page), consistent with the 7–10 ms this section used to advise treating it as, and still
|
|
894
|
+
not something to do arithmetic with: at 4 MB the gap between fresh and warm is 33 ms, and most of
|
|
895
|
+
that is V8 tiering rather than the handshake.
|
|
851
896
|
|
|
852
|
-
An independent check was quoted here as agreement and is not: a real 3.6 MB file from a CDN
|
|
853
|
-
142 ms
|
|
854
|
-
|
|
855
|
-
|
|
897
|
+
An independent check was once quoted here as agreement and is not: a real 3.6 MB file from a CDN
|
|
898
|
+
cost 142 ms where the model predicts about 94 ms. It was taken in a different sweep against a
|
|
899
|
+
different origin, so it does not refute the table either — it belongs here as a reminder that a
|
|
900
|
+
single cross-sweep reading cannot confirm or deny anything in this document.
|
|
856
901
|
|
|
857
902
|
Two further cautions. The 2.76:1 content is slightly *less* compressible than a typical page, so
|
|
858
903
|
these are mildly conservative rather than optimistic. And CPU on this platform varies by up to ~1.5×
|
|
@@ -875,6 +920,11 @@ the edge the same way as the rest — differencing two work counts, minimum of s
|
|
|
875
920
|
| `decoders: { zstd }` | **+2.8 ms/MB** (5.5 against 2.75) | per byte, whenever an origin serves zstd |
|
|
876
921
|
| `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 | |
|
|
877
922
|
|
|
923
|
+
**The socket read view moved in 1.12.0**, from 64 KiB to 16 KiB, alongside the proxy tunnel becoming
|
|
924
|
+
a byte stream. No API changed and `tls.pullBytes` still overrides it — but on a proxied connection
|
|
925
|
+
that override did nothing before 1.12.0, so anyone who had tuned it was tuning a value nothing read.
|
|
926
|
+
Both are covered below.
|
|
927
|
+
|
|
878
928
|
Two defaults moved in 1.4.0 and neither is visible in the table above them: matching curl's cipher
|
|
879
929
|
order means AES-256-GCM is negotiated where AES-128-GCM used to be, measured at **+4%** per MB
|
|
880
930
|
(1.50 against 1.45 ms/MB — hardware AES makes the extra rounds cheap), and the ordered header list
|
|
@@ -892,19 +942,29 @@ the measurements above, with the charge split out so it is clear what is yours t
|
|
|
892
942
|
|
|
893
943
|
| Workload | CPU/request | 10M/mo | 1B/mo |
|
|
894
944
|
| --- | --- | --- | --- |
|
|
895
|
-
| Platform `fetch`, 16 KB — reference; it cannot use a proxy | 0.3 ms | $
|
|
896
|
-
| Platform `fetch`, 4 MB — same reference, measured | 3.2 ms | $
|
|
897
|
-
| Pooled connection, 16 KB pages | 3
|
|
898
|
-
| New connection per request, 16 KB |
|
|
899
|
-
| Pooled connection, 1 MB pages |
|
|
900
|
-
| New connection per request, 1 MB |
|
|
901
|
-
| Pooled connection, 4 MB pages |
|
|
902
|
-
| New connection per request, 4 MB |
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
945
|
+
| Platform `fetch`, 16 KB — reference; it cannot use a proxy | 0.3 ms | $5.00 | $307.40 |
|
|
946
|
+
| Platform `fetch`, 4 MB — same reference, measured | 3.2 ms | $5.04 | $365.40 |
|
|
947
|
+
| Pooled connection, 16 KB pages | 2.3 ms | $5.00 | $347.40 |
|
|
948
|
+
| New connection per request, 16 KB | 9 ms | $6.20 | $481.40 |
|
|
949
|
+
| Pooled connection, 1 MB pages | 36.3 ms | $11.66 | $1,027.40 |
|
|
950
|
+
| New connection per request, 1 MB | 59 ms | $16.20 | $1,481.40 |
|
|
951
|
+
| Pooled connection, 4 MB pages | 102 ms | $24.80 | $2,341.40 |
|
|
952
|
+
| New connection per request, 4 MB | 135 ms | $31.40 | $3,001.40 |
|
|
953
|
+
| Pooled 4 MB, `maxBodyBytes: Infinity` | 70 ms | $18.40 | $1,701.40 |
|
|
954
|
+
|
|
955
|
+
The last row is the same workload with the decompression-bomb guard off, measured in its own sweep;
|
|
956
|
+
it is the largest configuration-level saving in this document and it is the caller taking
|
|
957
|
+
responsibility for bounding the body themselves. See "Passing the body through" below.
|
|
958
|
+
|
|
959
|
+
`$5 + max(0, requests - 10M) x $0.30/M + max(0, cpu_ms - 30M) x $0.02/M`, and nothing else. The
|
|
960
|
+
CPU column is the warm-page and fresh-connection columns of the table above; any figure here that
|
|
961
|
+
does not fall out of that table is a bug in this README.
|
|
962
|
+
|
|
963
|
+
**The `max(0, ...)` is new.** The previous version of this table billed every request and every
|
|
964
|
+
CPU millisecond, ignoring the allowance the sentence above it describes — so it overstated the
|
|
965
|
+
10M/mo column by up to 74% ($8.70 where the bill is $5.00) while being within 0.2% at 1B, where
|
|
966
|
+
the allowance is a rounding error. The overstatement was against this package, not for it, which
|
|
967
|
+
is presumably why it survived several readings.
|
|
908
968
|
|
|
909
969
|
The reference row is given at two sizes because the platform's own `fetch` is **not flat** — it
|
|
910
970
|
scales at about 0.82 ms per decompressed MB, measured on a size ladder from one CDN so that only the
|
|
@@ -929,12 +989,17 @@ pooled 1 MB workload at a billion requests a month, warmed:
|
|
|
929
989
|
|
|
930
990
|
| Change from the baseline | CPU/request | 1B/mo | Δ | Paid when |
|
|
931
991
|
| --- | --- | --- | --- | --- |
|
|
932
|
-
| baseline — gzip, AES-256-GCM, x25519 |
|
|
933
|
-
| origin serves `br` instead of gzip |
|
|
934
|
-
| server selects ChaCha20-Poly1305 |
|
|
935
|
-
| origin serves `zstd` instead of gzip |
|
|
936
|
-
| X25519MLKEM768, 1 request per connection | 59.2 ms | $1,
|
|
937
|
-
| X25519MLKEM768, 20 requests per connection |
|
|
992
|
+
| baseline — gzip, AES-256-GCM, x25519, warm | 36.3 ms | $1,027 | — | always |
|
|
993
|
+
| origin serves `br` instead of gzip | 40.5 ms | $1,111 | **+$84** | the origin chooses `br` |
|
|
994
|
+
| server selects ChaCha20-Poly1305 | 39.3 ms | $1,086 | **+$59** | the server picks it over AES |
|
|
995
|
+
| origin serves `zstd` instead of gzip | 39.1 ms | $1,083 | **+$56** | the origin chooses `zstd` |
|
|
996
|
+
| X25519MLKEM768, 1 request per connection | 59.2 ms | $1,484 | **+$3** | every handshake |
|
|
997
|
+
| X25519MLKEM768, 20 requests per connection | 36.3 ms | $1,028 | **+$0.15** | the same handshake, amortised |
|
|
998
|
+
|
|
999
|
+
The ML-KEM rows are measured against the **fresh-connection** 1 MB baseline of 59 ms ($1,481), not
|
|
1000
|
+
against the warm one at the top; the Δ column reflects that, which is why it is $3 rather than the
|
|
1001
|
+
$154 an earlier version showed. That $154 was the cost of not pooling, attributed to post-quantum
|
|
1002
|
+
key exchange.
|
|
938
1003
|
|
|
939
1004
|
The last two rows are the same 0.15 ms of ML-KEM, and the difference between them is entirely
|
|
940
1005
|
connection reuse — which is the point worth taking from this table. Post-quantum key exchange is
|
|
@@ -1020,7 +1085,7 @@ below roughly the size where a single wire read covers the whole body.
|
|
|
1020
1085
|
roughly a tenth of the CPU. This package exists because a V8 isolate cannot do that; it is not a
|
|
1021
1086
|
better way to do it.
|
|
1022
1087
|
|
|
1023
|
-
The arithmetic is worth being blunt about. A billion 4 MB requests a month costs about **$2,
|
|
1088
|
+
The arithmetic is worth being blunt about. A billion 4 MB requests a month costs about **$2,341** of
|
|
1024
1089
|
Workers CPU. That workload is roughly 386 requests a second and 4.5 Gbps sustained — **three
|
|
1025
1090
|
dedicated boxes** at Hetzner-class pricing carry it for around **$600**. So for large bodies, buying
|
|
1026
1091
|
servers is about **four times cheaper**, and the gap widens with body size.
|
|
@@ -1075,7 +1140,8 @@ billed native floor.
|
|
|
1075
1140
|
The remaining ~30× is the JS-orchestrated record layer, HTTP/2 demultiplexing and stream pipeline —
|
|
1076
1141
|
roughly **80% of the per-request cost at 4 MB, against 20% for decode**. An earlier version of this
|
|
1077
1142
|
section put the emphasis on decoding; that was wrong, and it sent optimisation effort at the smaller
|
|
1078
|
-
of the two.
|
|
1143
|
+
of the two. For which of those three layers the 80% belongs to, see "Which layer the receive path
|
|
1144
|
+
actually spends its megabyte on" below — the answer is not the record layer.
|
|
1079
1145
|
|
|
1080
1146
|
What would close it is a primitive that does not exist: a `startTls` that verifies the **origin**
|
|
1081
1147
|
hostname rather than the `connect()` peer, which would let the platform's own `fetch` run inside the
|
|
@@ -1087,9 +1153,12 @@ The runtime's `DecompressionStream` emits 4096-byte chunks and its sockets deliv
|
|
|
1087
1153
|
most 4096 bytes, and every chunk that crosses between the runtime and JS costs tens of
|
|
1088
1154
|
microseconds regardless of size — measured here at about **17 µs per crossing**, from a ladder
|
|
1089
1155
|
that collects the same 1 MB in 4 KiB chunks (6.0 ms/MB) through 256 KiB chunks (1.67 ms/MB).
|
|
1090
|
-
Both hot paths therefore drain their sources with BYOB reads, which
|
|
1091
|
-
|
|
1092
|
-
latency is unchanged.
|
|
1156
|
+
Both hot paths therefore drain their sources with BYOB reads, which collect several of those
|
|
1157
|
+
chunks into one crossing and resolve partially filled the moment any byte exists, so streaming
|
|
1158
|
+
latency is unchanged. How many they collect is the transport's decision and not the view's — a
|
|
1159
|
+
BYOB read never waits to fill — so a view sized far above what the transport actually hands over
|
|
1160
|
+
buys nothing and costs the allocation. Measured over a 4 MB body: **37 KB average fill on a direct
|
|
1161
|
+
socket, 8 KB through a proxy**.
|
|
1093
1162
|
|
|
1094
1163
|
The view they read into is **16 KiB, and the size was swept rather than assumed**. It matters more
|
|
1095
1164
|
than it looks. The input is pumped by a JS task on the same event loop as the puller, so the
|
|
@@ -1110,14 +1179,299 @@ believed. Correcting it cut the stage **31%**, 18.0 → 12.3 ms/MB, A/B-ed in on
|
|
|
1110
1179
|
|
|
1111
1180
|
What remains is **not** close to floor, and an earlier version of this section wrongly said it was.
|
|
1112
1181
|
Native inflate of the same content costs 4.3 ms/MB against the stage's 12.3, so roughly **8 ms/MB
|
|
1113
|
-
is this package's own plumbing** — the JS input pump and the output wrapper.
|
|
1114
|
-
|
|
1182
|
+
is this package's own plumbing** — the JS input pump and the output wrapper.
|
|
1183
|
+
|
|
1184
|
+
Most of that has since been closed by the native `IdentityTransformStream` relay, **but only for
|
|
1185
|
+
`maxBodyBytes: Infinity`**, and the default is 32 MiB. Re-measured on the edge, same op, same
|
|
1186
|
+
fixture, same isolate, differenced between a 1 MB and a 4 MB body:
|
|
1187
|
+
|
|
1188
|
+
| `maxBodyBytes` | decode stage, ms per decoded MB |
|
|
1189
|
+
|---|---|
|
|
1190
|
+
| `Infinity` — native relay, no JS in the byte path | **4.3** |
|
|
1191
|
+
| 32 MiB (the default) — pull-driven JS wrapper | **7.7** |
|
|
1192
|
+
|
|
1193
|
+
Both of those come from an isolated bench, and this document is mostly a record of isolated benches
|
|
1194
|
+
being wrong. This one is not: subtracting `depth=passthru` from `depth=full` on a real proxied
|
|
1195
|
+
request for a 4 MB body — the `Infinity` path — puts decoding at **18–29 ms**, against the 17 ms the
|
|
1196
|
+
4.3 figure predicts, and at 16–25% of the whole request, which is the "20% for decode" claimed
|
|
1197
|
+
further up. Two instruments, one answer.
|
|
1198
|
+
|
|
1199
|
+
Priced again on a **real proxied 4 MB request** rather than on a fixture, one isolate, n=11, warm
|
|
1200
|
+
page as `(reuse=4 - reuse=1)/3`:
|
|
1201
|
+
|
|
1202
|
+
| `maxBodyBytes` | warm 4 MB page | first request on a new connection |
|
|
1203
|
+
|---|---|---|
|
|
1204
|
+
| 16 MiB — the default | min 87, p50 97 ms | 147 ms |
|
|
1205
|
+
| `Infinity` | **min 68, p50 70 ms** | 130 ms |
|
|
1206
|
+
|
|
1207
|
+
So the bomb guard costs **20-27 ms on a 4 MB body**, 5-7 ms per decoded megabyte — half again what
|
|
1208
|
+
the fixture predicted, and the largest single item left in the body path. At a billion 4 MB requests
|
|
1209
|
+
a month that is about **$540**. It is the one lever in this document that is available by
|
|
1210
|
+
configuration rather than by a release. That is not a bug — the cap is enforced by counting bytes
|
|
1211
|
+
and counting requires seeing them in JS — but the size of it was not known before and it is worth
|
|
1212
|
+
saying out loud rather than leaving inside a comment. The counting itself is free (7.00 with it,
|
|
1213
|
+
7.33 without); it is the wrapper the counting forces that costs. If you are relaying bodies you
|
|
1214
|
+
already bound some other way, `maxBodyBytes: Infinity` is worth 43% of this stage.
|
|
1115
1215
|
|
|
1116
1216
|
Importing the package is free. The 121 bundled anchors are base64 strings indexed by a hash of the
|
|
1117
1217
|
subject DN, and only the one anchor a chain lands on is ever decoded, so startup stays at ~2 ms for
|
|
1118
1218
|
the 380 KB bundle (133 KB gzipped) and a request that imports but does not use the package costs
|
|
1119
1219
|
0 ms.
|
|
1120
1220
|
|
|
1221
|
+
### Which layer the receive path actually spends its megabyte on
|
|
1222
|
+
|
|
1223
|
+
Every figure above is measured **through a proxy, against a Cloudflare origin**, which is the shape
|
|
1224
|
+
this package exists for and also the shape that makes a per-layer answer impossible: two variables
|
|
1225
|
+
move at once. The `wire` ladder in `live/` removes both — one nginx origin that serves the same file
|
|
1226
|
+
over `http` and `https`, `Range` requests fixing the wire volume exactly, and no proxy — so the
|
|
1227
|
+
rungs differ by exactly one layer each.
|
|
1228
|
+
|
|
1229
|
+
Per megabyte of **wire** (not of decompressed body), differenced between a 1 MB and a 4 MB range on
|
|
1230
|
+
a **single request**, so no per-request work is inside the division. Three independent sweeps, run
|
|
1231
|
+
hours apart:
|
|
1232
|
+
|
|
1233
|
+
| rung | ms per wire MB | added by this layer |
|
|
1234
|
+
|---|---|---|
|
|
1235
|
+
| raw socket, BYOB reads, no TLS | 2.0 – 3.0 | — |
|
|
1236
|
+
| + the TLS record layer | 4.7 – 6.7 | +2.7 – 4.3 |
|
|
1237
|
+
| + HTTP/2 | 9.3 – 12.0 | **+4.7 – 6.0** |
|
|
1238
|
+
| + the `Client`, decoding off | 13.3 – 16.0 | +2.3 – 4.0 |
|
|
1239
|
+
|
|
1240
|
+
The origin serves `.gz` files with no `Content-Encoding`, so nothing decodes on any rung and the
|
|
1241
|
+
decode stage is not in this table — price it separately from the section above. The top rung runs
|
|
1242
|
+
`maxBodyBytes: Infinity`, which also keeps the body cap out of the number.
|
|
1243
|
+
|
|
1244
|
+
The absolute values move about 30% between sweeps, which is the run-to-run variance this document
|
|
1245
|
+
warns about everywhere else; what does not move is the ordering. **HTTP/2 demultiplexing was the
|
|
1246
|
+
largest single layer in all three sweeps** — on its own it costs about as much per wire megabyte as
|
|
1247
|
+
the socket and the entire TLS record layer beneath it cost together. (An earlier draft of this
|
|
1248
|
+
paragraph said "more than", on two sweeps. The third one does not support that, and the claim it
|
|
1249
|
+
does support is strong enough.)
|
|
1250
|
+
|
|
1251
|
+
Everything else in this document points the other way — "42 ms of a 106 ms 4 MB request is socket
|
|
1252
|
+
reads and record decryption" is the figure the `pullBytes` sweep left behind, and it is what sent
|
|
1253
|
+
the last two rounds of optimisation at the record layer. That figure is not wrong for the path it
|
|
1254
|
+
was taken on; it just never separated the socket from the parsing, and the separation is where the
|
|
1255
|
+
answer was.
|
|
1256
|
+
|
|
1257
|
+
### What the proxy costs, and what the origin costs
|
|
1258
|
+
|
|
1259
|
+
The ladder above runs without a proxy, which is what makes it a per-layer answer and also what
|
|
1260
|
+
makes it unlike the shape this package is for. Run the same rungs three ways — the same nginx origin
|
|
1261
|
+
direct and proxied, then a Cloudflare-fronted origin through the same proxy — and the two variables
|
|
1262
|
+
separate. Per megabyte of wire, differenced between a 1 MB and a 4 MB body at a single request,
|
|
1263
|
+
p50 of n=7-8, all twelve cells in one sweep:
|
|
1264
|
+
|
|
1265
|
+
| | socket | + the TLS record layer | total |
|
|
1266
|
+
|---|---|---|---|
|
|
1267
|
+
| nginx origin, direct | 2.3 | +7.7 | 10.0 |
|
|
1268
|
+
| nginx origin, **proxied** | **17.3** | +6.3 | 23.7 |
|
|
1269
|
+
| Cloudflare origin, **proxied** | **16.0** | +7.7 | 23.7 |
|
|
1270
|
+
|
|
1271
|
+
**The whole proxied-versus-direct gap is the socket rung, and the origin contributes nothing.** The
|
|
1272
|
+
two proxied rows agree to within noise, which kills the standing hypothesis that a Cloudflare
|
|
1273
|
+
origin's dynamic TLS record sizing was inflating these figures — it is not the origin. And the
|
|
1274
|
+
record layer costs the same 6-8 ms per wire megabyte on all three paths: it does not care what is
|
|
1275
|
+
in front of it.
|
|
1276
|
+
|
|
1277
|
+
The socket rung's 7x is two effects multiplying. Measured over a 4 MB body on the same build:
|
|
1278
|
+
|
|
1279
|
+
| | reads | average fill | ms per read |
|
|
1280
|
+
|---|---|---|---|
|
|
1281
|
+
| direct | 110 | 38 KB | 85 µs |
|
|
1282
|
+
| proxied | 494 | 8.5 KB | 140 µs |
|
|
1283
|
+
|
|
1284
|
+
**4.5x as many reads, each 1.6x dearer.** The fill is the proxy's relay pacing, not a view-size
|
|
1285
|
+
choice — `pullBytes` is already at the measured optimum for it, and raising the view does not raise
|
|
1286
|
+
the fill because a BYOB read never waits. There is nothing in this package to change here.
|
|
1287
|
+
|
|
1288
|
+
This is also the number that settles the Wasm question below. A Wasm record layer can only replace
|
|
1289
|
+
the `+6.3 to 7.7` column, minus the 1.67 ms/MB of AEAD that stays in WebCrypto — and it cannot touch
|
|
1290
|
+
the 16-17 ms socket rung at all, because a socket cannot write into linear memory (see below). The
|
|
1291
|
+
dominant term on the real path is the one Wasm has no access to.
|
|
1292
|
+
|
|
1293
|
+
### A knob that was never connected on the path this package exists for
|
|
1294
|
+
|
|
1295
|
+
Running the same ladder **with** a proxy turned up the reason those earlier figures were so much
|
|
1296
|
+
larger, and it was not the network.
|
|
1297
|
+
|
|
1298
|
+
`openTunnel` finishes a CONNECT (or SOCKS5) handshake holding a buffered reader, because the peer
|
|
1299
|
+
may have sent tunnel payload in the same chunk as the reply, and it handed that onward wrapped in
|
|
1300
|
+
`new ReadableStream({ pull })`. Correct, and a **plain** stream rather than a byte stream. The TLS
|
|
1301
|
+
record layer asks for a BYOB reader and quietly falls back to a default one when it cannot have it,
|
|
1302
|
+
so *every proxied connection* lost BYOB reads — and with them `tls.pullBytes`, whose only job is to
|
|
1303
|
+
size them.
|
|
1304
|
+
|
|
1305
|
+
Same origin, 1 MB through the record layer, n=15 in one isolate, before the fix:
|
|
1306
|
+
|
|
1307
|
+
| | `pullBytes: 16 KiB` | `pullBytes: 1 MiB` |
|
|
1308
|
+
|---|---|---|
|
|
1309
|
+
| direct | min 25, p50 31 | min 73, p50 90 |
|
|
1310
|
+
| **proxied** | min 95, p50 111 | min 101, p50 121 |
|
|
1311
|
+
|
|
1312
|
+
**2.9× on a direct socket and 6% through a proxy** — the knob was not being read. Which also means
|
|
1313
|
+
the sweep that chose the old 64 KiB default, captioned "against a real proxied socket", was four
|
|
1314
|
+
samples of one configuration; the clean U it reported was run-to-run noise.
|
|
1315
|
+
|
|
1316
|
+
`src/proxy/tunnel.js` makes the tunnel a byte stream and, once the handshake's leftovers are
|
|
1317
|
+
drained, hands the caller's own view straight to the socket — so a read through a tunnel costs what
|
|
1318
|
+
a read without one costs. With the knob reaching the code, the sweep is worth having:
|
|
1319
|
+
|
|
1320
|
+
| ms, 1 MB at the record layer, p50 | 8 KiB | **16 KiB** | 32 KiB | 64 KiB | 256 KiB |
|
|
1321
|
+
|---|---|---|---|---|---|
|
|
1322
|
+
| direct | 21 | 20 | 18 | 22 | 40 |
|
|
1323
|
+
| proxy A | 51 | 61 | 61 | 89 | 152 |
|
|
1324
|
+
| proxy B | 68 | 74 | — | 102 | — |
|
|
1325
|
+
|
|
1326
|
+
Monotonic on both proxies, shallow on the direct path, and the mechanism is the average-fill figure
|
|
1327
|
+
above: too *large* is what costs, because the view is a ceiling that a BYOB read never waits to
|
|
1328
|
+
reach. The default is now **16 KiB**, which is within ~20% of the best column on all three paths
|
|
1329
|
+
where 64 KiB was up to 45% off, and allocates a quarter as much.
|
|
1330
|
+
|
|
1331
|
+
End to end through the real `Client` and a proxy, n=13, 16 KiB against the old 64 KiB: **63 against
|
|
1332
|
+
78 ms** for 1 MB and **145 against 164 ms** for 4 MB on the median, and within noise on the minimum
|
|
1333
|
+
at 4 MB. Smaller than the record-layer sweep alone suggests, because HTTP/2 and the client plumbing
|
|
1334
|
+
above it do not scale with the view — but nothing measured anywhere favours the old value.
|
|
1335
|
+
|
|
1336
|
+
This is also a caution about the instrument rather than only about the code. The first attempt at
|
|
1337
|
+
the end-to-end A/B reported the two view sizes agreeing to the millisecond, which read as "the
|
|
1338
|
+
change does nothing"; the rig was not threading `pullBytes` into the `Client` rung at all, so both
|
|
1339
|
+
columns were the same configuration. Two columns agreeing *exactly* is not a null result, it is a
|
|
1340
|
+
wiring bug.
|
|
1341
|
+
|
|
1342
|
+
The record layer's share is now small enough to break down: of the 2.7–4.3 ms/MB it adds, WebCrypto
|
|
1343
|
+
AES-256-GCM over 16 KiB records is **1.67 ms/MB**, so the JavaScript record parsing itself is
|
|
1344
|
+
somewhere around **1–3 ms/MB**. That number closes a direction rather than opening one — see below.
|
|
1345
|
+
|
|
1346
|
+
The origin here sends 8 KiB DATA frames, so HTTP/2's share works out to roughly 35 µs per frame,
|
|
1347
|
+
which is the same order as the ~25–30 µs per stream-boundary crossing measured elsewhere in this
|
|
1348
|
+
document. It is not concentrated in any one call: coalescing queued DATA payloads into one enqueue
|
|
1349
|
+
was implemented and measured, and it is not the answer. See "Three optimisations that measured as
|
|
1350
|
+
nothing", below.
|
|
1351
|
+
|
|
1352
|
+
**And now the open question this ladder raises, stated rather than buried.** The whole stack here —
|
|
1353
|
+
socket, TLS, HTTP/2, `Client` — comes to 13–16 ms per wire megabyte. The proxied figure for the
|
|
1354
|
+
**record layer alone** is 38.5 ms for a 4 MB body, and that body is about 1.45 MB on the wire, so
|
|
1355
|
+
roughly 27 ms per wire megabyte for one rung. Two to five times the whole direct-socket stack, for
|
|
1356
|
+
a fraction of it.
|
|
1357
|
+
|
|
1358
|
+
Something in the proxied path costs several times what the same code costs on a direct socket, and
|
|
1359
|
+
the candidates have not been separated: the proxy adds a second TCP hop whose delivery pattern this
|
|
1360
|
+
package does not control, and the Cloudflare origin used there sizes its TLS records dynamically and
|
|
1361
|
+
chunks a gzip it generates on the fly. Both would raise the per-record and per-crossing counts
|
|
1362
|
+
without any code being slower.
|
|
1363
|
+
|
|
1364
|
+
**Every dollar figure in this document is derived from the proxied path**, so none of them are
|
|
1365
|
+
invalidated by the ladder — they measure what a real request costs, which is what a bill is made of.
|
|
1366
|
+
But they should not be read as measuring *this package's code*, and until the proxied path is
|
|
1367
|
+
decomposed the same way, "where the cost is" has an answer only for the direct one. Running the
|
|
1368
|
+
`wire` ladder against a proxy is the missing experiment; it needs credentials the rig takes as an
|
|
1369
|
+
`x-proxy` header and nothing else.
|
|
1370
|
+
|
|
1371
|
+
### Moving TLS into WebAssembly: measured, and closed
|
|
1372
|
+
|
|
1373
|
+
The idea was to put record framing and buffer management in Wasm and leave the AEAD in JavaScript,
|
|
1374
|
+
where `crypto.subtle` reaches AES-NI. The ladders above bound it, and not by finding Wasm slow —
|
|
1375
|
+
Wasm is fast. They bound **the prize**, and separately they make **the cost mandatory**.
|
|
1376
|
+
|
|
1377
|
+
**The prize.** Everything a Wasm record layer could replace is the JavaScript record parsing: the
|
|
1378
|
+
TLS rung minus its AEAD, since a software AES inside Wasm has no AES-NI to reach and would have to
|
|
1379
|
+
cross back to `crypto.subtle` anyway. The TLS rung measured +2.7 to 4.3 ms per wire MB in one sweep
|
|
1380
|
+
and +6.3 to 7.7 in another; the AEAD is 1.67. So the prize is somewhere in **1–6 ms per wire
|
|
1381
|
+
megabyte**, and the spread between sweeps is wider than most of it.
|
|
1382
|
+
|
|
1383
|
+
**The cost is not optional.** A socket cannot write into linear memory: a BYOB read detaches the
|
|
1384
|
+
view's buffer and a `WebAssembly.Memory` buffer is non-detachable, so the read is refused —
|
|
1385
|
+
*"Unable to use non-detachable ArrayBuffer"*, measured on the edge on both the direct and the
|
|
1386
|
+
proxied path. Bytes must land in a JavaScript buffer and be copied in. That copy measured
|
|
1387
|
+
**2.67–4.67 ms per megabyte**.
|
|
1388
|
+
|
|
1389
|
+
Those two ranges overlap. The exercise is somewhere between a wash and a modest win on one rung —
|
|
1390
|
+
in exchange for reimplementing TLS record framing in another language and re-deriving in Rust every
|
|
1391
|
+
byte of the ClientHello-ordering and cipher-offer work this package exists for.
|
|
1392
|
+
|
|
1393
|
+
And it aims at the wrong rung. On the proxied path the socket alone is **16–17 ms per wire MB**,
|
|
1394
|
+
two to three times the entire TLS rung, and it is precisely the part the non-detachable buffer puts
|
|
1395
|
+
out of reach.
|
|
1396
|
+
|
|
1397
|
+
**An earlier version of this analysis reached the opposite conclusion, and the error is worth
|
|
1398
|
+
naming.** It put the prize at 24–28 ms per wire MB — a sixfold win against a 4.67 ms boundary — from
|
|
1399
|
+
the "42 ms per 4 MB body at the record layer" figure that used to sit in `util/bytes.js`. That
|
|
1400
|
+
figure never separated the socket from the parsing on top of it, and it was taken on a proxied path
|
|
1401
|
+
where BYOB was silently disabled. Both errors inflated the denominator. The rule that killed the
|
|
1402
|
+
idea in the end — *a native layer wins when it replaces a JavaScript one and loses when it is added
|
|
1403
|
+
to one* — was stated correctly at the time; the layer being replaced was simply measured at ten
|
|
1404
|
+
times its size.
|
|
1405
|
+
|
|
1406
|
+
Two things the measurements *do* settle, neither of which changes that:
|
|
1407
|
+
|
|
1408
|
+
- The per-crossing cost does not scale with crossings at TLS granularity: splitting the same
|
|
1409
|
+
transfer into 256 crossings of 16 KiB measured identically to one crossing of 4 MB. Whatever a
|
|
1410
|
+
Wasm design cost, it would not be the number of times it crossed.
|
|
1411
|
+
- Getting a megabyte into linear memory and walking record-shaped headers over it is **cheap** — so
|
|
1412
|
+
cheap that the loop building the test fixture dominates the same measurement, which is why no
|
|
1413
|
+
single number for it is quoted here. Wasm is not the problem. There is just nothing on this path
|
|
1414
|
+
for it to win.
|
|
1415
|
+
|
|
1416
|
+
### Emscripten is not the problem people say it is
|
|
1417
|
+
|
|
1418
|
+
Cloudflare's Kitesurf post warns that with "Emscripten (for example) and its many layers of mocked
|
|
1419
|
+
dependencies, the compiled binary can get bulky and slow", which is a claim about a toolchain and
|
|
1420
|
+
cheap to check. `live/wasm/build.sh` builds the same three functions three ways — all `no_std` /
|
|
1421
|
+
freestanding, all importing nothing, all producing bit-identical output on the same input — and then
|
|
1422
|
+
runs the same comparison on a **real** primitive: the exact C this package already ships as its
|
|
1423
|
+
ChaCha20-Poly1305, recompiled by Emscripten.
|
|
1424
|
+
|
|
1425
|
+
| | module | record walk, 4 MB | ChaCha20-Poly1305 seal |
|
|
1426
|
+
|---|---|---|---|
|
|
1427
|
+
| C, `emcc -O3 -sSTANDALONE_WASM` | **460 B** | 8 ms | **5.00 ms/MB** |
|
|
1428
|
+
| Rust, `wasm32-unknown-unknown`, `no_std` | 632 B | 8 ms | — |
|
|
1429
|
+
| C, `clang --target=wasm32 -nostdlib` + `rust-lld` | 647 B | 8 ms | — |
|
|
1430
|
+
| C, wasi-sdk 25 `-nostdlib` — what ships | 8839 B | — | 5.67 ms/MB |
|
|
1431
|
+
| WebCrypto AES-256-GCM, for scale | — | — | 1.67 ms/MB |
|
|
1432
|
+
|
|
1433
|
+
Emscripten produced the **smallest** module of the three, and the record walk came back at the same
|
|
1434
|
+
millisecond for all three — that column is not a tie broken by rounding, it is three readings that
|
|
1435
|
+
never separated across eight interleaved rounds. On the cipher Emscripten came out slightly ahead of
|
|
1436
|
+
the wasi-sdk build this package ships, which is inside the noise and not a reason to add a second
|
|
1437
|
+
toolchain to the build.
|
|
1438
|
+
|
|
1439
|
+
The warning is about what you compile, not what compiles it: drag in a libc, a filesystem shim or a
|
|
1440
|
+
`main()` and Emscripten will emulate all of it, and the 8839-byte wasi-sdk row above is itself mostly
|
|
1441
|
+
libsodium rather than toolchain. But `-sSTANDALONE_WASM --no-entry` over code that calls nothing
|
|
1442
|
+
emits what LLVM would emit anyway. The shipped ChaCha20 stays on wasi-sdk; there is nothing here
|
|
1443
|
+
worth a build dependency.
|
|
1444
|
+
|
|
1445
|
+
### Three optimisations that measured as nothing
|
|
1446
|
+
|
|
1447
|
+
Recorded because each looked obviously right, and because what killed two of them was a counter
|
|
1448
|
+
disagreeing with a stopwatch. All three were implemented, measured on the edge, and reverted; none
|
|
1449
|
+
of them is in `src/`.
|
|
1450
|
+
|
|
1451
|
+
**Recycling the BYOB pull buffer.** `ByteReader` allocates a fresh view for every pull, so the
|
|
1452
|
+
`pullBytes` U-curve's right arm is allocation, not boundary crossings. Reusing one store and copying
|
|
1453
|
+
the fill out removes that arm completely — at `pullBytes: 1 MiB`, 89 ms → 20 ms for 4 MB. It is also
|
|
1454
|
+
**a wash at the then-default 64 KiB** (21 against 23) and *worse* below it, because the copy-out
|
|
1455
|
+
stops paying for itself. Since the right answer turned out to be a *smaller* view rather than a
|
|
1456
|
+
cheaper large one (see below), making large views cheap buys nothing, and the change was dropped.
|
|
1457
|
+
|
|
1458
|
+
**A native `pipeTo` for the decode stage's input.** The output side of `decompressionStage` already
|
|
1459
|
+
avoids JavaScript entirely on the uncapped path; the input side is still a JS loop reading the source
|
|
1460
|
+
and writing each chunk into the decompressor. Handing the source to `pipeTo` once the 2-byte deflate
|
|
1461
|
+
sniff is done removes that loop — and measured identically at 64 KiB and 16 KiB input chunks, ~10%
|
|
1462
|
+
better only at 4 KiB. Not worth the change: it moves the source's cancellation from a reader that
|
|
1463
|
+
can be cancelled directly to a pipe that can only be aborted through a signal, which is a real
|
|
1464
|
+
teardown-semantics change on the body path in exchange for nothing.
|
|
1465
|
+
|
|
1466
|
+
**Coalescing HTTP/2 DATA payloads.** One body-stream `pull()` hands over one DATA frame, so a body
|
|
1467
|
+
crosses the ReadableStream boundary once per frame — 128 times per MB at this origin. Merging queued
|
|
1468
|
+
payloads up to 64 KiB looked like a 40% cut to the HTTP/2 rung across one sweep. It is not: the chunk
|
|
1469
|
+
counters came back **identical** with and without it — 512 chunks of 8192 either way — because a
|
|
1470
|
+
consumer that keeps up finds exactly one payload queued at every pull, so the merge never runs. Held
|
|
1471
|
+
open with an artificial delay so the queue could build, 8x fewer enqueues bought 0.3–1.0 ms/MB of the
|
|
1472
|
+
4.7–6.0 that HTTP/2 costs. The apparent win was the minimum of nine integer-millisecond samples
|
|
1473
|
+
moving while the median did not.
|
|
1474
|
+
|
|
1121
1475
|
### Streaming APIs, and what they cost
|
|
1122
1476
|
|
|
1123
1477
|
An SSE response from an LLM API is the opposite shape to everything else measured here: a small body
|
|
@@ -1236,7 +1590,9 @@ CI runs a fixed seed on every commit, as a gate; the scheduled workflow runs thr
|
|
|
1236
1590
|
iterations with the run id as the seed, which is the half that searches new ground.
|
|
1237
1591
|
|
|
1238
1592
|
`probe/` holds a reproducible capability probe that emits machine-readable JSON, and
|
|
1239
|
-
`probe/results/` the measurements this design rests on. `live/` is the edge interop rig
|
|
1593
|
+
`probe/results/` the measurements this design rests on. `live/` is the edge interop rig, and
|
|
1594
|
+
`sizeorigin/` is the size-controlled origin the cost table is taken against — it lived outside the
|
|
1595
|
+
repository until August 2026, was deleted, and took the table's reproducibility with it.
|
|
1240
1596
|
|
|
1241
1597
|
Credentials are read from the environment only. The live suite fails loudly when it is not
|
|
1242
1598
|
configured rather than skipping: a green tick that means "we did not check" is worse than a red one.
|