tunnelfetch 1.11.0 → 1.12.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 CHANGED
@@ -783,29 +783,41 @@ the network, which is not billed.
783
783
 
784
784
  ### What a request costs
785
785
 
786
- Fetching a size-controlled origin through a proxy, warm, medians over seven-plus rounds on one
787
- isolate, gzip on the wire. The last column is the same numbers as a rate, which is the form worth
788
- carrying around:
786
+ Fetching `sizeorigin/` through a proxy, gzip on the wire, medians of n>=5 with every size in one
787
+ sweep and one isolate. The method is stated because the last version of this table did not state
788
+ one precisely enough to reproduce: **a warm page is `(reuse=4 - reuse=1) / 3`**, the cost of pages
789
+ two through four down a connection that is already open, and the last column is the fresh-connection
790
+ number the same sweep produced.
789
791
 
790
- | Body | Per request, reusing a connection | Per decompressed MB |
791
- | --- | --- | --- |
792
- | 1 KB | **0.5 ms** | — |
793
- | 16 KB | **3.5 ms** | 224 ms/MB |
794
- | 64 KB | **7.5 ms** | 120 ms/MB |
795
- | 256 KB | **20.5 ms** | 82 ms/MB |
796
- | 1 MB | **51.5 ms** | 51 ms/MB |
797
- | 4 MB | **104 ms** | 26 ms/MB |
798
-
799
- Opening a connection adds **7–12 ms** on top, once, however many requests follow it.
800
-
801
- **Read the per-MB column before doing any arithmetic with this table.** It falls 8.6x from end to
802
- end, so there is no such thing as a per-MB rate for this package. A least-squares line through these
803
- points is `9.17 + 24.86 x MB`, which predicts 9.17 ms for a 1 KB body against a measured 0.5 — wrong
804
- by 18x. Any budget built on a single per-MB figure will be badly wrong at one end or the other.
792
+ | Body | Warm page | Per decompressed MB | First request on a new connection |
793
+ | --- | --- | --- | --- |
794
+ | 1 KB | **1.3 ms** | — | 8 ms |
795
+ | 16 KB | **2.3 ms** | 149 ms/MB | 9 ms |
796
+ | 64 KB | **6.0 ms** | 96 ms/MB | 13 ms |
797
+ | 256 KB | **14.3 ms** | 57 ms/MB | 28 ms |
798
+ | 1 MB | **36.3 ms** | 36 ms/MB | 59 ms |
799
+ | 4 MB | **102 ms** | 26 ms/MB | 135 ms |
800
+
801
+ The last column is not "warm page plus a handshake". At 4 MB it is 33 ms above the warm page while a
802
+ handshake costs single digits, because the *first* body through a connection also runs the decode
803
+ loop before V8 has tiered it up. Budget a new origin at that column, not at the first one.
804
+
805
+ **Re-measured August 2026, and the mid-sizes moved.** The 4 MB row reproduced almost exactly
806
+ (102 against a previously published 104); 64 KB through 1 MB came in 20-30% lower than the figures
807
+ this table used to carry. That is not the socket-view change two sections below — A/B-ing the old
808
+ and new view size in one isolate moved a warm 1 MB page by about 1 ms — so it is either day-to-day
809
+ variance beyond what a single sweep can see, or a difference in how the superseded table was taken.
810
+ The old numbers are not recoverable to check, which is the argument for stating the method here.
811
+
812
+ **Read the per-MB column before doing any arithmetic with this table.** It falls 5.7x from end to
813
+ end, so there is no such thing as a per-MB rate for this package. A least-squares line through the
814
+ 16 KB and larger points is `6.30 + 24.30 x MB`, which predicts 6.33 ms for a 1 KB body against a
815
+ measured 1.3 — wrong by 4.9x. Any budget built on a single per-MB figure will be wrong at one end
816
+ or the other.
805
817
 
806
818
  The reason is that V8 tiers up **inside a single request**. A 1 MB body runs the decode loop
807
- interpreted the whole way; a 4 MB body pays that for its first megabyte and runs the rest optimised.
808
- `51.5 + 3 x 17.5 = 104` fits, so the steady-state cost is about **17.5 ms/MB with a ~51 ms entry fee
819
+ interpreted for much of its length; a 4 MB body pays that once and runs the rest optimised.
820
+ `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
821
  per request**. For a size not in the table, interpolate within it rather than extrapolating from a
810
822
  rate.
811
823
 
@@ -824,11 +836,6 @@ of excess the *whole* ramp contains. These were measured on a small body; a cold
824
836
  4 MB request has never been measured and is certainly worse, since far more of the decode loop runs
825
837
  interpreted.
826
838
 
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
839
  **These figures replace ones that were measured wrong, and the mistake is worth describing.** The
833
840
  origin they came from tiled a 150-byte HTML fragment, which gzip compressed **220:1** — so a "1 MB
834
841
  body" was four kilobytes on the wire, and every measurement taken against it priced decompression
@@ -840,19 +847,21 @@ The correction is large. Body-heavy rows are **two to three times** what this ta
840
847
  1.4.0, and no amount of care about medians or minimums would have caught it, because the numbers
841
848
  were internally consistent — they were answers to the wrong question.
842
849
 
843
- **Read the two right-hand columns as derived, because they are.** Only the pooled column is measured
844
- per size; "new connection" is the pooled figure plus a flat 7.5 ms and "averaged over 5 pages" is
845
- the pooled figure plus 1.5 ms, which is why the deltas are identical to one decimal across a 4000×
846
- range in body size. That 7.5 ms also does not agree with the 9.8 ms quoted just above it, and the
847
- 2.25 ms per further request is larger than the entire 1.7 ms a pooled 1 KB request costs, which
848
- would make a 1 KB body cost negative. The two came from different sweeps, and combining them is the
849
- cross-sweep comparison this document tells you never to make. **Treat the connection term as
850
- somewhere in 7–10 ms and do not do arithmetic with it.**
850
+ That correction is the reason the table above now measures its fresh-connection column instead of
851
+ deriving one. Through 1.11.0 that column was the warm figure plus a flat 7.5 ms and a third column
852
+ was the warm figure plus 1.5 ms, which is why their deltas were identical to one decimal across a
853
+ 4000x range in body size a derived column cannot disagree with its source, so it cannot check it
854
+ either. Both are gone.
851
855
 
852
- An independent check was quoted here as agreement and is not: a real 3.6 MB file from a CDN cost
853
- 142 ms against the ~120 ms this table predicts for 4 MB. That is the model under-predicting by
854
- roughly 20%, in the same direction as the error it had just replaced. It belongs here as a caution,
855
- not as corroboration.
856
+ The connection term that falls out of the current sweep is **6–7 ms** (1 KB fresh 8 ms against a
857
+ 1.3 ms warm page), consistent with the 7–10 ms this section used to advise treating it as, and still
858
+ not something to do arithmetic with: at 4 MB the gap between fresh and warm is 33 ms, and most of
859
+ that is V8 tiering rather than the handshake.
860
+
861
+ An independent check was once quoted here as agreement and is not: a real 3.6 MB file from a CDN
862
+ cost 142 ms where the model predicts about 94 ms. It was taken in a different sweep against a
863
+ different origin, so it does not refute the table either — it belongs here as a reminder that a
864
+ single cross-sweep reading cannot confirm or deny anything in this document.
856
865
 
857
866
  Two further cautions. The 2.76:1 content is slightly *less* compressible than a typical page, so
858
867
  these are mildly conservative rather than optimistic. And CPU on this platform varies by up to ~1.5×
@@ -875,6 +884,11 @@ the edge the same way as the rest — differencing two work counts, minimum of s
875
884
  | `decoders: { zstd }` | **+2.8 ms/MB** (5.5 against 2.75) | per byte, whenever an origin serves zstd |
876
885
  | `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
886
 
887
+ **The socket read view moved in 1.12.0**, from 64 KiB to 16 KiB, alongside the proxy tunnel becoming
888
+ a byte stream. No API changed and `tls.pullBytes` still overrides it — but on a proxied connection
889
+ that override did nothing before 1.12.0, so anyone who had tuned it was tuning a value nothing read.
890
+ Both are covered below.
891
+
878
892
  Two defaults moved in 1.4.0 and neither is visible in the table above them: matching curl's cipher
879
893
  order means AES-256-GCM is negotiated where AES-128-GCM used to be, measured at **+4%** per MB
880
894
  (1.50 against 1.45 ms/MB — hardware AES makes the extra rounds cheap), and the ordered header list
@@ -892,19 +906,29 @@ the measurements above, with the charge split out so it is clear what is yours t
892
906
 
893
907
  | Workload | CPU/request | 10M/mo | 1B/mo |
894
908
  | --- | --- | --- | --- |
895
- | Platform `fetch`, 16 KB — reference; it cannot use a proxy | 0.3 ms | $8.06 | $311.00 |
896
- | Platform `fetch`, 4 MB — same reference, measured | 3.2 ms | $8.64 | $369.00 |
897
- | Pooled connection, 16 KB pages | 3.5 ms | $8.70 | $375.00 |
898
- | New connection per request, 16 KB | 13.5 ms | $10.70 | $575.00 |
899
- | Pooled connection, 1 MB pages | 51.5 ms | $18.30 | $1,335.00 |
900
- | New connection per request, 1 MB | 61.5 ms | $20.30 | $1,535.00 |
901
- | Pooled connection, 4 MB pages | 104 ms | $28.80 | $2,385.00 |
902
- | New connection per request, 4 MB | 114 ms | $30.80 | $2,585.00 |
903
-
904
- These follow the CPU table above and nothing else. An earlier version of this section was computed
905
- from a superseded set of measurements and was left behind when that table was replaced, so the
906
- document quoted 118.3 ms and 104 ms for the same row in two places. Any figure here that does not
907
- fall out of the table above is a bug in this README.
909
+ | Platform `fetch`, 16 KB — reference; it cannot use a proxy | 0.3 ms | $5.00 | $307.40 |
910
+ | Platform `fetch`, 4 MB — same reference, measured | 3.2 ms | $5.04 | $365.40 |
911
+ | Pooled connection, 16 KB pages | 2.3 ms | $5.00 | $347.40 |
912
+ | New connection per request, 16 KB | 9 ms | $6.20 | $481.40 |
913
+ | Pooled connection, 1 MB pages | 36.3 ms | $11.66 | $1,027.40 |
914
+ | New connection per request, 1 MB | 59 ms | $16.20 | $1,481.40 |
915
+ | Pooled connection, 4 MB pages | 102 ms | $24.80 | $2,341.40 |
916
+ | New connection per request, 4 MB | 135 ms | $31.40 | $3,001.40 |
917
+ | Pooled 4 MB, `maxBodyBytes: Infinity` | 70 ms | $18.40 | $1,701.40 |
918
+
919
+ The last row is the same workload with the decompression-bomb guard off, measured in its own sweep;
920
+ it is the largest configuration-level saving in this document and it is the caller taking
921
+ responsibility for bounding the body themselves. See "Passing the body through" below.
922
+
923
+ `$5 + max(0, requests - 10M) x $0.30/M + max(0, cpu_ms - 30M) x $0.02/M`, and nothing else. The
924
+ CPU column is the warm-page and fresh-connection columns of the table above; any figure here that
925
+ does not fall out of that table is a bug in this README.
926
+
927
+ **The `max(0, ...)` is new.** The previous version of this table billed every request and every
928
+ CPU millisecond, ignoring the allowance the sentence above it describes — so it overstated the
929
+ 10M/mo column by up to 74% ($8.70 where the bill is $5.00) while being within 0.2% at 1B, where
930
+ the allowance is a rounding error. The overstatement was against this package, not for it, which
931
+ is presumably why it survived several readings.
908
932
 
909
933
  The reference row is given at two sizes because the platform's own `fetch` is **not flat** — it
910
934
  scales at about 0.82 ms per decompressed MB, measured on a size ladder from one CDN so that only the
@@ -929,12 +953,17 @@ pooled 1 MB workload at a billion requests a month, warmed:
929
953
 
930
954
  | Change from the baseline | CPU/request | 1B/mo | Δ | Paid when |
931
955
  | --- | --- | --- | --- | --- |
932
- | baseline — gzip, AES-256-GCM, x25519 | 51.5 ms | $1,335 | — | always |
933
- | origin serves `br` instead of gzip | 55.8 ms | $1,421 | **+$86** | the origin chooses `br` |
934
- | server selects ChaCha20-Poly1305 | 54.5 ms | $1,395 | **+$60** | the server picks it over AES |
935
- | origin serves `zstd` instead of gzip | 54.3 ms | $1,391 | **+$56** | the origin chooses `zstd` |
936
- | X25519MLKEM768, 1 request per connection | 59.2 ms | $1,489 | **+$154** | every handshake |
937
- | X25519MLKEM768, 20 requests per connection | 51.5 ms | $1,335 | **+$0.15** | the same handshake, amortised |
956
+ | baseline — gzip, AES-256-GCM, x25519, warm | 36.3 ms | $1,027 | — | always |
957
+ | origin serves `br` instead of gzip | 40.5 ms | $1,111 | **+$84** | the origin chooses `br` |
958
+ | server selects ChaCha20-Poly1305 | 39.3 ms | $1,086 | **+$59** | the server picks it over AES |
959
+ | origin serves `zstd` instead of gzip | 39.1 ms | $1,083 | **+$56** | the origin chooses `zstd` |
960
+ | X25519MLKEM768, 1 request per connection | 59.2 ms | $1,484 | **+$3** | every handshake |
961
+ | X25519MLKEM768, 20 requests per connection | 36.3 ms | $1,028 | **+$0.15** | the same handshake, amortised |
962
+
963
+ The ML-KEM rows are measured against the **fresh-connection** 1 MB baseline of 59 ms ($1,481), not
964
+ against the warm one at the top; the Δ column reflects that, which is why it is $3 rather than the
965
+ $154 an earlier version showed. That $154 was the cost of not pooling, attributed to post-quantum
966
+ key exchange.
938
967
 
939
968
  The last two rows are the same 0.15 ms of ML-KEM, and the difference between them is entirely
940
969
  connection reuse — which is the point worth taking from this table. Post-quantum key exchange is
@@ -1020,7 +1049,7 @@ below roughly the size where a single wire read covers the whole body.
1020
1049
  roughly a tenth of the CPU. This package exists because a V8 isolate cannot do that; it is not a
1021
1050
  better way to do it.
1022
1051
 
1023
- The arithmetic is worth being blunt about. A billion 4 MB requests a month costs about **$2,385** of
1052
+ The arithmetic is worth being blunt about. A billion 4 MB requests a month costs about **$2,341** of
1024
1053
  Workers CPU. That workload is roughly 386 requests a second and 4.5 Gbps sustained — **three
1025
1054
  dedicated boxes** at Hetzner-class pricing carry it for around **$600**. So for large bodies, buying
1026
1055
  servers is about **four times cheaper**, and the gap widens with body size.
@@ -1075,7 +1104,8 @@ billed native floor.
1075
1104
  The remaining ~30× is the JS-orchestrated record layer, HTTP/2 demultiplexing and stream pipeline —
1076
1105
  roughly **80% of the per-request cost at 4 MB, against 20% for decode**. An earlier version of this
1077
1106
  section put the emphasis on decoding; that was wrong, and it sent optimisation effort at the smaller
1078
- of the two.
1107
+ of the two. For which of those three layers the 80% belongs to, see "Which layer the receive path
1108
+ actually spends its megabyte on" below — the answer is not the record layer.
1079
1109
 
1080
1110
  What would close it is a primitive that does not exist: a `startTls` that verifies the **origin**
1081
1111
  hostname rather than the `connect()` peer, which would let the platform's own `fetch` run inside the
@@ -1087,9 +1117,12 @@ The runtime's `DecompressionStream` emits 4096-byte chunks and its sockets deliv
1087
1117
  most 4096 bytes, and every chunk that crosses between the runtime and JS costs tens of
1088
1118
  microseconds regardless of size — measured here at about **17 µs per crossing**, from a ladder
1089
1119
  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 hand over everything already
1091
- buffered in one crossing and resolve partially filled the moment any byte exists, so streaming
1092
- latency is unchanged.
1120
+ Both hot paths therefore drain their sources with BYOB reads, which collect several of those
1121
+ chunks into one crossing and resolve partially filled the moment any byte exists, so streaming
1122
+ latency is unchanged. How many they collect is the transport's decision and not the view's — a
1123
+ BYOB read never waits to fill — so a view sized far above what the transport actually hands over
1124
+ buys nothing and costs the allocation. Measured over a 4 MB body: **37 KB average fill on a direct
1125
+ socket, 8 KB through a proxy**.
1093
1126
 
1094
1127
  The view they read into is **16 KiB, and the size was swept rather than assumed**. It matters more
1095
1128
  than it looks. The input is pumped by a JS task on the same event loop as the puller, so the
@@ -1110,14 +1143,299 @@ believed. Correcting it cut the stage **31%**, 18.0 → 12.3 ms/MB, A/B-ed in on
1110
1143
 
1111
1144
  What remains is **not** close to floor, and an earlier version of this section wrongly said it was.
1112
1145
  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. Closing that needs a
1114
- redesign rather than a constant, and it is the largest single item left in the body path.
1146
+ is this package's own plumbing** — the JS input pump and the output wrapper.
1147
+
1148
+ Most of that has since been closed by the native `IdentityTransformStream` relay, **but only for
1149
+ `maxBodyBytes: Infinity`**, and the default is 32 MiB. Re-measured on the edge, same op, same
1150
+ fixture, same isolate, differenced between a 1 MB and a 4 MB body:
1151
+
1152
+ | `maxBodyBytes` | decode stage, ms per decoded MB |
1153
+ |---|---|
1154
+ | `Infinity` — native relay, no JS in the byte path | **4.3** |
1155
+ | 32 MiB (the default) — pull-driven JS wrapper | **7.7** |
1156
+
1157
+ Both of those come from an isolated bench, and this document is mostly a record of isolated benches
1158
+ being wrong. This one is not: subtracting `depth=passthru` from `depth=full` on a real proxied
1159
+ request for a 4 MB body — the `Infinity` path — puts decoding at **18–29 ms**, against the 17 ms the
1160
+ 4.3 figure predicts, and at 16–25% of the whole request, which is the "20% for decode" claimed
1161
+ further up. Two instruments, one answer.
1162
+
1163
+ Priced again on a **real proxied 4 MB request** rather than on a fixture, one isolate, n=11, warm
1164
+ page as `(reuse=4 - reuse=1)/3`:
1165
+
1166
+ | `maxBodyBytes` | warm 4 MB page | first request on a new connection |
1167
+ |---|---|---|
1168
+ | 16 MiB — the default | min 87, p50 97 ms | 147 ms |
1169
+ | `Infinity` | **min 68, p50 70 ms** | 130 ms |
1170
+
1171
+ So the bomb guard costs **20-27 ms on a 4 MB body**, 5-7 ms per decoded megabyte — half again what
1172
+ the fixture predicted, and the largest single item left in the body path. At a billion 4 MB requests
1173
+ a month that is about **$540**. It is the one lever in this document that is available by
1174
+ configuration rather than by a release. That is not a bug — the cap is enforced by counting bytes
1175
+ and counting requires seeing them in JS — but the size of it was not known before and it is worth
1176
+ saying out loud rather than leaving inside a comment. The counting itself is free (7.00 with it,
1177
+ 7.33 without); it is the wrapper the counting forces that costs. If you are relaying bodies you
1178
+ already bound some other way, `maxBodyBytes: Infinity` is worth 43% of this stage.
1115
1179
 
1116
1180
  Importing the package is free. The 121 bundled anchors are base64 strings indexed by a hash of the
1117
1181
  subject DN, and only the one anchor a chain lands on is ever decoded, so startup stays at ~2 ms for
1118
1182
  the 380 KB bundle (133 KB gzipped) and a request that imports but does not use the package costs
1119
1183
  0 ms.
1120
1184
 
1185
+ ### Which layer the receive path actually spends its megabyte on
1186
+
1187
+ Every figure above is measured **through a proxy, against a Cloudflare origin**, which is the shape
1188
+ this package exists for and also the shape that makes a per-layer answer impossible: two variables
1189
+ move at once. The `wire` ladder in `live/` removes both — one nginx origin that serves the same file
1190
+ over `http` and `https`, `Range` requests fixing the wire volume exactly, and no proxy — so the
1191
+ rungs differ by exactly one layer each.
1192
+
1193
+ Per megabyte of **wire** (not of decompressed body), differenced between a 1 MB and a 4 MB range on
1194
+ a **single request**, so no per-request work is inside the division. Three independent sweeps, run
1195
+ hours apart:
1196
+
1197
+ | rung | ms per wire MB | added by this layer |
1198
+ |---|---|---|
1199
+ | raw socket, BYOB reads, no TLS | 2.0 – 3.0 | — |
1200
+ | + the TLS record layer | 4.7 – 6.7 | +2.7 – 4.3 |
1201
+ | + HTTP/2 | 9.3 – 12.0 | **+4.7 – 6.0** |
1202
+ | + the `Client`, decoding off | 13.3 – 16.0 | +2.3 – 4.0 |
1203
+
1204
+ The origin serves `.gz` files with no `Content-Encoding`, so nothing decodes on any rung and the
1205
+ decode stage is not in this table — price it separately from the section above. The top rung runs
1206
+ `maxBodyBytes: Infinity`, which also keeps the body cap out of the number.
1207
+
1208
+ The absolute values move about 30% between sweeps, which is the run-to-run variance this document
1209
+ warns about everywhere else; what does not move is the ordering. **HTTP/2 demultiplexing was the
1210
+ largest single layer in all three sweeps** — on its own it costs about as much per wire megabyte as
1211
+ the socket and the entire TLS record layer beneath it cost together. (An earlier draft of this
1212
+ paragraph said "more than", on two sweeps. The third one does not support that, and the claim it
1213
+ does support is strong enough.)
1214
+
1215
+ Everything else in this document points the other way — "42 ms of a 106 ms 4 MB request is socket
1216
+ reads and record decryption" is the figure the `pullBytes` sweep left behind, and it is what sent
1217
+ the last two rounds of optimisation at the record layer. That figure is not wrong for the path it
1218
+ was taken on; it just never separated the socket from the parsing, and the separation is where the
1219
+ answer was.
1220
+
1221
+ ### What the proxy costs, and what the origin costs
1222
+
1223
+ The ladder above runs without a proxy, which is what makes it a per-layer answer and also what
1224
+ makes it unlike the shape this package is for. Run the same rungs three ways — the same nginx origin
1225
+ direct and proxied, then a Cloudflare-fronted origin through the same proxy — and the two variables
1226
+ separate. Per megabyte of wire, differenced between a 1 MB and a 4 MB body at a single request,
1227
+ p50 of n=7-8, all twelve cells in one sweep:
1228
+
1229
+ | | socket | + the TLS record layer | total |
1230
+ |---|---|---|---|
1231
+ | nginx origin, direct | 2.3 | +7.7 | 10.0 |
1232
+ | nginx origin, **proxied** | **17.3** | +6.3 | 23.7 |
1233
+ | Cloudflare origin, **proxied** | **16.0** | +7.7 | 23.7 |
1234
+
1235
+ **The whole proxied-versus-direct gap is the socket rung, and the origin contributes nothing.** The
1236
+ two proxied rows agree to within noise, which kills the standing hypothesis that a Cloudflare
1237
+ origin's dynamic TLS record sizing was inflating these figures — it is not the origin. And the
1238
+ record layer costs the same 6-8 ms per wire megabyte on all three paths: it does not care what is
1239
+ in front of it.
1240
+
1241
+ The socket rung's 7x is two effects multiplying. Measured over a 4 MB body on the same build:
1242
+
1243
+ | | reads | average fill | ms per read |
1244
+ |---|---|---|---|
1245
+ | direct | 110 | 38 KB | 85 µs |
1246
+ | proxied | 494 | 8.5 KB | 140 µs |
1247
+
1248
+ **4.5x as many reads, each 1.6x dearer.** The fill is the proxy's relay pacing, not a view-size
1249
+ choice — `pullBytes` is already at the measured optimum for it, and raising the view does not raise
1250
+ the fill because a BYOB read never waits. There is nothing in this package to change here.
1251
+
1252
+ This is also the number that settles the Wasm question below. A Wasm record layer can only replace
1253
+ the `+6.3 to 7.7` column, minus the 1.67 ms/MB of AEAD that stays in WebCrypto — and it cannot touch
1254
+ the 16-17 ms socket rung at all, because a socket cannot write into linear memory (see below). The
1255
+ dominant term on the real path is the one Wasm has no access to.
1256
+
1257
+ ### A knob that was never connected on the path this package exists for
1258
+
1259
+ Running the same ladder **with** a proxy turned up the reason those earlier figures were so much
1260
+ larger, and it was not the network.
1261
+
1262
+ `openTunnel` finishes a CONNECT (or SOCKS5) handshake holding a buffered reader, because the peer
1263
+ may have sent tunnel payload in the same chunk as the reply, and it handed that onward wrapped in
1264
+ `new ReadableStream({ pull })`. Correct, and a **plain** stream rather than a byte stream. The TLS
1265
+ record layer asks for a BYOB reader and quietly falls back to a default one when it cannot have it,
1266
+ so *every proxied connection* lost BYOB reads — and with them `tls.pullBytes`, whose only job is to
1267
+ size them.
1268
+
1269
+ Same origin, 1 MB through the record layer, n=15 in one isolate, before the fix:
1270
+
1271
+ | | `pullBytes: 16 KiB` | `pullBytes: 1 MiB` |
1272
+ |---|---|---|
1273
+ | direct | min 25, p50 31 | min 73, p50 90 |
1274
+ | **proxied** | min 95, p50 111 | min 101, p50 121 |
1275
+
1276
+ **2.9× on a direct socket and 6% through a proxy** — the knob was not being read. Which also means
1277
+ the sweep that chose the old 64 KiB default, captioned "against a real proxied socket", was four
1278
+ samples of one configuration; the clean U it reported was run-to-run noise.
1279
+
1280
+ `src/proxy/tunnel.js` makes the tunnel a byte stream and, once the handshake's leftovers are
1281
+ drained, hands the caller's own view straight to the socket — so a read through a tunnel costs what
1282
+ a read without one costs. With the knob reaching the code, the sweep is worth having:
1283
+
1284
+ | ms, 1 MB at the record layer, p50 | 8 KiB | **16 KiB** | 32 KiB | 64 KiB | 256 KiB |
1285
+ |---|---|---|---|---|---|
1286
+ | direct | 21 | 20 | 18 | 22 | 40 |
1287
+ | proxy A | 51 | 61 | 61 | 89 | 152 |
1288
+ | proxy B | 68 | 74 | — | 102 | — |
1289
+
1290
+ Monotonic on both proxies, shallow on the direct path, and the mechanism is the average-fill figure
1291
+ above: too *large* is what costs, because the view is a ceiling that a BYOB read never waits to
1292
+ reach. The default is now **16 KiB**, which is within ~20% of the best column on all three paths
1293
+ where 64 KiB was up to 45% off, and allocates a quarter as much.
1294
+
1295
+ End to end through the real `Client` and a proxy, n=13, 16 KiB against the old 64 KiB: **63 against
1296
+ 78 ms** for 1 MB and **145 against 164 ms** for 4 MB on the median, and within noise on the minimum
1297
+ at 4 MB. Smaller than the record-layer sweep alone suggests, because HTTP/2 and the client plumbing
1298
+ above it do not scale with the view — but nothing measured anywhere favours the old value.
1299
+
1300
+ This is also a caution about the instrument rather than only about the code. The first attempt at
1301
+ the end-to-end A/B reported the two view sizes agreeing to the millisecond, which read as "the
1302
+ change does nothing"; the rig was not threading `pullBytes` into the `Client` rung at all, so both
1303
+ columns were the same configuration. Two columns agreeing *exactly* is not a null result, it is a
1304
+ wiring bug.
1305
+
1306
+ The record layer's share is now small enough to break down: of the 2.7–4.3 ms/MB it adds, WebCrypto
1307
+ AES-256-GCM over 16 KiB records is **1.67 ms/MB**, so the JavaScript record parsing itself is
1308
+ somewhere around **1–3 ms/MB**. That number closes a direction rather than opening one — see below.
1309
+
1310
+ The origin here sends 8 KiB DATA frames, so HTTP/2's share works out to roughly 35 µs per frame,
1311
+ which is the same order as the ~25–30 µs per stream-boundary crossing measured elsewhere in this
1312
+ document. It is not concentrated in any one call: coalescing queued DATA payloads into one enqueue
1313
+ was implemented and measured, and it is not the answer. See "Three optimisations that measured as
1314
+ nothing", below.
1315
+
1316
+ **And now the open question this ladder raises, stated rather than buried.** The whole stack here —
1317
+ socket, TLS, HTTP/2, `Client` — comes to 13–16 ms per wire megabyte. The proxied figure for the
1318
+ **record layer alone** is 38.5 ms for a 4 MB body, and that body is about 1.45 MB on the wire, so
1319
+ roughly 27 ms per wire megabyte for one rung. Two to five times the whole direct-socket stack, for
1320
+ a fraction of it.
1321
+
1322
+ Something in the proxied path costs several times what the same code costs on a direct socket, and
1323
+ the candidates have not been separated: the proxy adds a second TCP hop whose delivery pattern this
1324
+ package does not control, and the Cloudflare origin used there sizes its TLS records dynamically and
1325
+ chunks a gzip it generates on the fly. Both would raise the per-record and per-crossing counts
1326
+ without any code being slower.
1327
+
1328
+ **Every dollar figure in this document is derived from the proxied path**, so none of them are
1329
+ invalidated by the ladder — they measure what a real request costs, which is what a bill is made of.
1330
+ But they should not be read as measuring *this package's code*, and until the proxied path is
1331
+ decomposed the same way, "where the cost is" has an answer only for the direct one. Running the
1332
+ `wire` ladder against a proxy is the missing experiment; it needs credentials the rig takes as an
1333
+ `x-proxy` header and nothing else.
1334
+
1335
+ ### Moving TLS into WebAssembly: measured, and closed
1336
+
1337
+ The idea was to put record framing and buffer management in Wasm and leave the AEAD in JavaScript,
1338
+ where `crypto.subtle` reaches AES-NI. The ladders above bound it, and not by finding Wasm slow —
1339
+ Wasm is fast. They bound **the prize**, and separately they make **the cost mandatory**.
1340
+
1341
+ **The prize.** Everything a Wasm record layer could replace is the JavaScript record parsing: the
1342
+ TLS rung minus its AEAD, since a software AES inside Wasm has no AES-NI to reach and would have to
1343
+ cross back to `crypto.subtle` anyway. The TLS rung measured +2.7 to 4.3 ms per wire MB in one sweep
1344
+ and +6.3 to 7.7 in another; the AEAD is 1.67. So the prize is somewhere in **1–6 ms per wire
1345
+ megabyte**, and the spread between sweeps is wider than most of it.
1346
+
1347
+ **The cost is not optional.** A socket cannot write into linear memory: a BYOB read detaches the
1348
+ view's buffer and a `WebAssembly.Memory` buffer is non-detachable, so the read is refused —
1349
+ *"Unable to use non-detachable ArrayBuffer"*, measured on the edge on both the direct and the
1350
+ proxied path. Bytes must land in a JavaScript buffer and be copied in. That copy measured
1351
+ **2.67–4.67 ms per megabyte**.
1352
+
1353
+ Those two ranges overlap. The exercise is somewhere between a wash and a modest win on one rung —
1354
+ in exchange for reimplementing TLS record framing in another language and re-deriving in Rust every
1355
+ byte of the ClientHello-ordering and cipher-offer work this package exists for.
1356
+
1357
+ And it aims at the wrong rung. On the proxied path the socket alone is **16–17 ms per wire MB**,
1358
+ two to three times the entire TLS rung, and it is precisely the part the non-detachable buffer puts
1359
+ out of reach.
1360
+
1361
+ **An earlier version of this analysis reached the opposite conclusion, and the error is worth
1362
+ naming.** It put the prize at 24–28 ms per wire MB — a sixfold win against a 4.67 ms boundary — from
1363
+ the "42 ms per 4 MB body at the record layer" figure that used to sit in `util/bytes.js`. That
1364
+ figure never separated the socket from the parsing on top of it, and it was taken on a proxied path
1365
+ where BYOB was silently disabled. Both errors inflated the denominator. The rule that killed the
1366
+ idea in the end — *a native layer wins when it replaces a JavaScript one and loses when it is added
1367
+ to one* — was stated correctly at the time; the layer being replaced was simply measured at ten
1368
+ times its size.
1369
+
1370
+ Two things the measurements *do* settle, neither of which changes that:
1371
+
1372
+ - The per-crossing cost does not scale with crossings at TLS granularity: splitting the same
1373
+ transfer into 256 crossings of 16 KiB measured identically to one crossing of 4 MB. Whatever a
1374
+ Wasm design cost, it would not be the number of times it crossed.
1375
+ - Getting a megabyte into linear memory and walking record-shaped headers over it is **cheap** — so
1376
+ cheap that the loop building the test fixture dominates the same measurement, which is why no
1377
+ single number for it is quoted here. Wasm is not the problem. There is just nothing on this path
1378
+ for it to win.
1379
+
1380
+ ### Emscripten is not the problem people say it is
1381
+
1382
+ Cloudflare's Kitesurf post warns that with "Emscripten (for example) and its many layers of mocked
1383
+ dependencies, the compiled binary can get bulky and slow", which is a claim about a toolchain and
1384
+ cheap to check. `live/wasm/build.sh` builds the same three functions three ways — all `no_std` /
1385
+ freestanding, all importing nothing, all producing bit-identical output on the same input — and then
1386
+ runs the same comparison on a **real** primitive: the exact C this package already ships as its
1387
+ ChaCha20-Poly1305, recompiled by Emscripten.
1388
+
1389
+ | | module | record walk, 4 MB | ChaCha20-Poly1305 seal |
1390
+ |---|---|---|---|
1391
+ | C, `emcc -O3 -sSTANDALONE_WASM` | **460 B** | 8 ms | **5.00 ms/MB** |
1392
+ | Rust, `wasm32-unknown-unknown`, `no_std` | 632 B | 8 ms | — |
1393
+ | C, `clang --target=wasm32 -nostdlib` + `rust-lld` | 647 B | 8 ms | — |
1394
+ | C, wasi-sdk 25 `-nostdlib` — what ships | 8839 B | — | 5.67 ms/MB |
1395
+ | WebCrypto AES-256-GCM, for scale | — | — | 1.67 ms/MB |
1396
+
1397
+ Emscripten produced the **smallest** module of the three, and the record walk came back at the same
1398
+ millisecond for all three — that column is not a tie broken by rounding, it is three readings that
1399
+ never separated across eight interleaved rounds. On the cipher Emscripten came out slightly ahead of
1400
+ the wasi-sdk build this package ships, which is inside the noise and not a reason to add a second
1401
+ toolchain to the build.
1402
+
1403
+ The warning is about what you compile, not what compiles it: drag in a libc, a filesystem shim or a
1404
+ `main()` and Emscripten will emulate all of it, and the 8839-byte wasi-sdk row above is itself mostly
1405
+ libsodium rather than toolchain. But `-sSTANDALONE_WASM --no-entry` over code that calls nothing
1406
+ emits what LLVM would emit anyway. The shipped ChaCha20 stays on wasi-sdk; there is nothing here
1407
+ worth a build dependency.
1408
+
1409
+ ### Three optimisations that measured as nothing
1410
+
1411
+ Recorded because each looked obviously right, and because what killed two of them was a counter
1412
+ disagreeing with a stopwatch. All three were implemented, measured on the edge, and reverted; none
1413
+ of them is in `src/`.
1414
+
1415
+ **Recycling the BYOB pull buffer.** `ByteReader` allocates a fresh view for every pull, so the
1416
+ `pullBytes` U-curve's right arm is allocation, not boundary crossings. Reusing one store and copying
1417
+ the fill out removes that arm completely — at `pullBytes: 1 MiB`, 89 ms → 20 ms for 4 MB. It is also
1418
+ **a wash at the then-default 64 KiB** (21 against 23) and *worse* below it, because the copy-out
1419
+ stops paying for itself. Since the right answer turned out to be a *smaller* view rather than a
1420
+ cheaper large one (see below), making large views cheap buys nothing, and the change was dropped.
1421
+
1422
+ **A native `pipeTo` for the decode stage's input.** The output side of `decompressionStage` already
1423
+ avoids JavaScript entirely on the uncapped path; the input side is still a JS loop reading the source
1424
+ and writing each chunk into the decompressor. Handing the source to `pipeTo` once the 2-byte deflate
1425
+ sniff is done removes that loop — and measured identically at 64 KiB and 16 KiB input chunks, ~10%
1426
+ better only at 4 KiB. Not worth the change: it moves the source's cancellation from a reader that
1427
+ can be cancelled directly to a pipe that can only be aborted through a signal, which is a real
1428
+ teardown-semantics change on the body path in exchange for nothing.
1429
+
1430
+ **Coalescing HTTP/2 DATA payloads.** One body-stream `pull()` hands over one DATA frame, so a body
1431
+ crosses the ReadableStream boundary once per frame — 128 times per MB at this origin. Merging queued
1432
+ payloads up to 64 KiB looked like a 40% cut to the HTTP/2 rung across one sweep. It is not: the chunk
1433
+ counters came back **identical** with and without it — 512 chunks of 8192 either way — because a
1434
+ consumer that keeps up finds exactly one payload queued at every pull, so the merge never runs. Held
1435
+ open with an artificial delay so the queue could build, 8x fewer enqueues bought 0.3–1.0 ms/MB of the
1436
+ 4.7–6.0 that HTTP/2 costs. The apparent win was the minimum of nine integer-millisecond samples
1437
+ moving while the median did not.
1438
+
1121
1439
  ### Streaming APIs, and what they cost
1122
1440
 
1123
1441
  An SSE response from an LLM API is the opposite shape to everything else measured here: a small body
@@ -1236,7 +1554,9 @@ CI runs a fixed seed on every commit, as a gate; the scheduled workflow runs thr
1236
1554
  iterations with the run id as the seed, which is the half that searches new ground.
1237
1555
 
1238
1556
  `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.
1557
+ `probe/results/` the measurements this design rests on. `live/` is the edge interop rig, and
1558
+ `sizeorigin/` is the size-controlled origin the cost table is taken against — it lived outside the
1559
+ repository until August 2026, was deleted, and took the table's reproducibility with it.
1240
1560
 
1241
1561
  Credentials are read from the environment only. The live suite fails loudly when it is not
1242
1562
  configured rather than skipping: a green tick that means "we did not check" is worse than a red one.
package/README.zh-CN.md CHANGED
@@ -517,17 +517,26 @@ CertificateError [CERT_PIN_MISMATCH]: no certificate in the chain matches any co
517
517
 
518
518
  ### 一个请求要多少
519
519
 
520
- 通过代理抓取一个尺寸可控的源站,热态,同一 isolate 7 轮以上取中位数,传输走 gzip。最后一列是同样的数字
521
- 换算成速率,那是更值得随身记住的形式:
520
+ 通过代理抓取 `sizeorigin/`,传输走 gzip,所有尺寸在**同一次扫描、同一个 isolate** 内跑完,n>=5 取中位数。
521
+ 方法写在这里,是因为上一版这张表没有把方法记清楚到能复现的程度:**一个热态页面 = `(reuse=4 - reuse=1) / 3`**,
522
+ 也就是连接已经打开之后第 2 到第 4 个页面的成本;最后一列是同一次扫描测出来的新建连接数字。
522
523
 
523
- | Body | 5 页均摊 | 复用连接 | 新建连接 |
524
+ | Body | 热态页面 | 每解压 MB | 新连接上的第一个请求 |
524
525
  | --- | --- | --- | --- |
525
- | 1 KB | 3.2 ms | 1.7 ms | 9.2 ms |
526
- | 16 KB | 4.6 ms | 3.1 ms | 10.6 ms |
527
- | 64 KB | 8.2 ms | 6.7 ms | 14.2 ms |
528
- | 256 KB | 18.2 ms | 16.7 ms | 24.2 ms |
529
- | 1 MB | 54.8 ms | 53.3 ms | 60.8 ms |
530
- | 4 MB | 119.8 ms | 118.3 ms | 125.8 ms |
526
+ | 1 KB | **1.3 ms** | | 8 ms |
527
+ | 16 KB | **2.3 ms** | 149 ms/MB | 9 ms |
528
+ | 64 KB | **6.0 ms** | 96 ms/MB | 13 ms |
529
+ | 256 KB | **14.3 ms** | 57 ms/MB | 28 ms |
530
+ | 1 MB | **36.3 ms** | 36 ms/MB | 59 ms |
531
+ | 4 MB | **102 ms** | 26 ms/MB | 135 ms |
532
+
533
+ 最后一列不是「热态页面 + 一次握手」。4 MB 那一行它比热态页面高 33 ms,而握手只值个位数——因为一条连接上的
534
+ **第一个** body 还要在 V8 把解码循环编译优化之前跑一遍。评估一个新源站要看这一列,不要看第一列。
535
+
536
+ **2026 年 8 月重测,中间几档动了。** 4 MB 那行几乎原样复现(102 对先前发布的 104);64 KB 到 1 MB 比这张表
537
+ 原先的数字低 20–30%。那**不是**下面讲的 socket 视图大小改动——在同一个 isolate 里 A/B 新旧视图大小,1 MB 热态
538
+ 页面只差约 1 ms——所以它要么是单次扫描看不到的日间波动,要么是被取代的那张表取数方式不同。旧数字已经无从复查,
539
+ 这正是把方法写在这里的理由。
531
540
 
532
541
  冷启动成本是**总数**,不是往上面某一行加的增量:
533
542
 
@@ -554,35 +563,45 @@ CertificateError [CERT_PIN_MISMATCH]: no certificate in the chain matches any co
554
563
  ### 这些折算成多少钱
555
564
 
556
565
  Workers Standard 每月 $5,含 1000 万请求和 3000 万 CPU 毫秒,超出部分每百万请求 $0.30、每百万 CPU 毫秒
557
- $0.02。把上面的实测代入,并把冷启动的影响单独列成两组列,这样任一负载「预热与否」的差别是看得见的:
558
-
559
- | 工作负载 | CPU/请求 | 1000 万,冷 | 1000 万,预热 | 10 亿,冷 | 10 亿,预热 |
560
- | --- | --- | --- | --- | --- | --- |
561
- | 平台 `fetch`,16 KB —— 参照;它用不了代理 | 0.3 ms | $5.00 | $5.00 | $307.40 | $307.40 |
562
- | 平台 `fetch`,4 MB —— 同一参照,实测 | 3.2 ms | $5.04 | $5.04 | $365.40 | $365.40 |
563
- | 连接复用,16 KB 页面 | 3.1 ms | $5.90 | $5.24 | $451.20 | $385.20 |
564
- | 每请求新建连接,16 KB | 10.6 ms | $7.41 | $6.75 | $602.20 | $536.20 |
565
- | 连接复用,1 MB 页面 | 53.3 ms | $15.94 | $15.28 | $1455.20 | $1389.20 |
566
- | 每请求新建连接,1 MB | 60.8 ms | $17.45 | $16.79 | $1606.20 | $1540.20 |
567
- | 连接复用,4 MB 页面 | 118.3 ms | $28.94 | $28.28 | $2755.20 | $2689.20 |
568
- | 每请求新建连接,4 MB | 125.8 ms | $30.45 | $29.79 | $2906.20 | $2840.20 |
566
+ $0.02。公式就是 `$5 + max(0, 请求数 - 1000万) x $0.30/百万 + max(0, CPU毫秒 - 3000万) x $0.02/百万`,
567
+ 没有别的:
568
+
569
+ | 工作负载 | CPU/请求 | 1000 万/月 | 10 亿/月 |
570
+ | --- | --- | --- | --- |
571
+ | 平台 `fetch`,16 KB —— 参照;它用不了代理 | 0.3 ms | $5.00 | $307.40 |
572
+ | 平台 `fetch`,4 MB —— 同一参照,实测 | 3.2 ms | $5.04 | $365.40 |
573
+ | 连接复用,16 KB 页面 | 2.3 ms | $5.00 | $347.40 |
574
+ | 每请求新建连接,16 KB | 9 ms | $6.20 | $481.40 |
575
+ | 连接复用,1 MB 页面 | 36.3 ms | $11.66 | $1,027.40 |
576
+ | 每请求新建连接,1 MB | 59 ms | $16.20 | $1,481.40 |
577
+ | 连接复用,4 MB 页面 | 102 ms | $24.80 | $2,341.40 |
578
+ | 每请求新建连接,4 MB | 135 ms | $31.40 | $3,001.40 |
579
+
580
+ 那两个 `max(0, ...)` 是新加的。上一版这张表把每一个请求、每一毫秒 CPU 都算了钱,忽略了它自己上面那句话
581
+ 描述的免费额度——于是 1000 万/月那一列最多高估了 74%($8.70,而实际账单是 $5.00),而在 10 亿那一列误差在
582
+ 0.2% 以内,因为那时免费额度只是个舍入误差。高估的方向是对本包不利的,大概这就是它活了这么久的原因。
569
583
 
570
584
  参照那一行给了两个尺寸,因为平台自己的 `fetch` **不是平的**——它按每解压 MB 约 0.82 ms 增长,这是在同一个 CDN 的尺寸阶梯上测的,只有大小在变。把它写成单一的 0.3 ms 再拿去和 4 MB 那一行比,是拿不同的东西相比,而且是**抬高了对手而不是抬高本包**。
571
585
 
572
- 这些美元数字跟随上面修正后的 CPU 测量,所以正文偏重的行是 1.4.0 及以前的**两到三倍**。那不是包变慢了,是移除了一个内容压缩比 220:1 的源站。
586
+ 这张表原先还按「冷 / 预热」分成两组列。那两组列现在去掉了:新的 CPU 数字全部是热态和新建连接的实测,冷 isolate
587
+ 下的同一批负载没有重测过,与其把上一版的冷启动增量套到新基线上凑出四列,不如只写实际测过的两列。冷启动的成本
588
+ 在上面那张 `warmup()` 表里,那是一个**总数**,不是往这里某一行上加的增量。
573
589
 
574
590
  #### Chrome 身份的每个选项各花多少
575
591
 
576
592
  上面那张表是默认身份:线上 gzip、AES-256-GCM、x25519。Chrome 那一行把所有变化捆在一起,对做决定没什么用。按"连接复用 + 1 MB 页面 + 十亿请求/月 + 已预热"逐项拆开:
577
593
 
578
- | 相对基线的变化 | CPU/请求 | 10 亿/月,已预热 | Δ | 何时才付 |
594
+ | 相对基线的变化 | CPU/请求 | 10 亿/月 | Δ | 何时才付 |
579
595
  | --- | --- | --- | --- | --- |
580
- | 基线 —— gzip、AES-256-GCM、x25519 | 53.3 ms | $1,389 | — | 总是 |
581
- | 源站发 `br` 而不是 gzip | 57.6 ms | $1,474 | **+$85** | 源站选择发 `br` |
582
- | 服务器选中 ChaCha20-Poly1305 | 56.3 ms | $1,448 | **+$59** | 服务器优先选它而非 AES |
583
- | 源站发 `zstd` 而不是 gzip | 56.1 ms | $1,444 | **+$55** | 源站选择发 `zstd` |
584
- | 协商 X25519MLKEM768,每连接 1 个请求 | 61.0 ms | $1,542 | **+$153** | 每次握手 |
585
- | 协商 X25519MLKEM768,每连接 20 个请求 | 53.3 ms | $1,390 | **+$0.15** | 同一次握手,摊薄后 |
596
+ | 基线 —— gzip、AES-256-GCM、x25519,热态 | 36.3 ms | $1,027 | — | 总是 |
597
+ | 源站发 `br` 而不是 gzip | 40.5 ms | $1,111 | **+$84** | 源站选择发 `br` |
598
+ | 服务器选中 ChaCha20-Poly1305 | 39.3 ms | $1,086 | **+$59** | 服务器优先选它而非 AES |
599
+ | 源站发 `zstd` 而不是 gzip | 39.1 ms | $1,083 | **+$56** | 源站选择发 `zstd` |
600
+ | 协商 X25519MLKEM768,每连接 1 个请求 | 59.2 ms | $1,484 | **+$3** | 每次握手 |
601
+ | 协商 X25519MLKEM768,每连接 20 个请求 | 36.3 ms | $1,028 | **+$0.15** | 同一次握手,摊薄后 |
602
+
603
+ ML-KEM 这两行是拿**新建连接**的 1 MB 基线 59 ms($1,481)来比的,不是拿表头那个热态基线;Δ 列反映的是这一点,
604
+ 所以是 $3 而不是旧版写的 $153。那 $153 是「不复用连接」的成本,被算到了后量子密钥交换头上。
586
605
 
587
606
  **最后两行是同样的 0.15 ms ML-KEM,差别完全来自连接复用**——这才是这张表最值得带走的一条。后量子密钥交换在你让 `Client` 活着时是这里最便宜的东西,在你不这么做时是最贵的,因为它是**每握手**,而其余全是每字节。
588
607
 
@@ -655,8 +674,40 @@ CPU 计费,所以十亿请求下「预热」两列省下的每月 $65 是净
655
674
  大 body 的"每字节成本"其实不按字节计——按的是流边界穿越次数。这个运行时的 `DecompressionStream` 以
656
675
  4096 字节为块产出输出,套接字单次交付也至多 4096 字节,而每一块在运行时与 JS 之间穿越一次都要几十微秒,
657
676
  与块大小无关——实测约 **17 µs 一次穿越**,来自同一个 1 MB 分别按 4 KiB(6.0 ms/MB)到 256 KiB
658
- (1.67 ms/MB)收集的阶梯。因此两条热路径都改为用 BYOB 读来抽干来源:它把已经缓冲的数据一次交付,且只要
659
- 有一个字节就立即以部分填充返回,流式延迟不变。
677
+ (1.67 ms/MB)收集的阶梯。因此两条热路径都改为用 BYOB 读来抽干来源:它把好几块合并成一次穿越,且只要
678
+ 有一个字节就立即以部分填充返回,流式延迟不变。合并多少是**传输层**决定的,不是视图决定的——BYOB 读从不
679
+ 等着填满——所以视图开得比传输层单次交给你的多,买不到任何东西,只会白付一次分配。4 MB body 上实测的平均
680
+ 填充:直连 37 KB,走代理 8 KB。
681
+
682
+ ### 一个在本包赖以存在的那条路径上从未接通的旋钮
683
+
684
+ `openTunnel` 做完 CONNECT(或 SOCKS5)握手时手里握着一个带缓冲的 reader——对端可能把隧道数据和应答塞在
685
+ 同一个 chunk 里——它把这个交出去时包成了 `new ReadableStream({ pull })`。逻辑正确,但那是**普通流,不是
686
+ byte stream**。TLS 记录层要 BYOB reader,要不到就静默降级成 default reader,于是*每一条走代理的连接*都丢了
687
+ BYOB,连带 `tls.pullBytes`(它唯一的职责就是决定 BYOB 视图多大)一起失效。
688
+
689
+ 同源、1 MB 过记录层、同一 isolate、n=15,修复前:
690
+
691
+ | | `pullBytes: 16 KiB` | `pullBytes: 1 MiB` |
692
+ |---|---|---|
693
+ | 直连 | min 25, p50 31 | min 73, p50 90 |
694
+ | **走代理** | min 95, p50 111 | min 101, p50 121 |
695
+
696
+ **直连 2.9×,代理 6%**——那个旋钮在代理路径上根本没被读到。连带后果:当初用来定 64 KiB 默认值、标着「对着
697
+ 真实代理 socket 扫出来」的那条 U 曲线,是同一个配置的四个采样。
698
+
699
+ `src/proxy/tunnel.js` 把隧道改成 byte stream,并且在握手剩余字节吐完之后**把调用方自己的视图直接递给
700
+ socket**。旋钮接上之后重扫(1 MB 过记录层,p50):
701
+
702
+ | | 8 KiB | **16 KiB** | 32 KiB | 64 KiB | 256 KiB |
703
+ |---|---|---|---|---|---|
704
+ | 直连 | 21 | 20 | 18 | 22 | 40 |
705
+ | 代理 A | 51 | 61 | 61 | 89 | 152 |
706
+ | 代理 B | 68 | 74 | — | 102 | — |
707
+
708
+ 两个独立代理上都单调。默认值因此从 64 KiB 改为 **16 KiB**:三条路径上都在最优列 20% 以内,而 64 KiB 最差
709
+ 偏离 45%,分配量还是四分之一,并且正好是一个 TLS 记录。端到端走真 Client + 代理、n=13:1 MB **63 对 78 ms**、
710
+ 4 MB **145 对 164 ms**(中位数),4 MB 的最小值在噪声内。
660
711
 
661
712
  读进去的那个视图是 **16 KiB,而且这个尺寸是扫出来的,不是拍的**。它比看上去重要:输入由一个 JS 任务在与
662
713
  拉取方相同的事件循环上泵送,所以读到达时解压器手上通常只压着一两块,读回来就是部分填充——实测一个 1 MB
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunnelfetch",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "A fetch-shaped HTTP client that can route through HTTP CONNECT / HTTPS / SOCKS5 proxies on runtimes with only raw TCP, such as Cloudflare Workers. Implements TLS in userland because the runtime cannot verify a tunnelled peer.",
5
5
  "keywords": [
6
6
  "fetch",
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { ProxyError, LimitError, codes } from '../errors.js';
15
15
  import { ByteReader, ByteWriter, latin1, utf8 } from '../util/bytes.js';
16
+ import { tunnelReadable } from './tunnel.js';
16
17
 
17
18
  const CRLFCRLF = utf8('\r\n\r\n');
18
19
  const MAX_REPLY_HEADER = 32 * 1024;
@@ -194,16 +195,7 @@ function replyError(reply, proxy, target, where) {
194
195
  */
195
196
  function tunnelFrom(socket, reader) {
196
197
  return {
197
- readable: new ReadableStream({
198
- async pull(controller) {
199
- const chunk = await reader.readSome();
200
- if (chunk === null) controller.close();
201
- else controller.enqueue(chunk);
202
- },
203
- cancel(reason) {
204
- return reader.cancel(reason);
205
- },
206
- }),
198
+ readable: tunnelReadable(socket, reader),
207
199
  writable: socket.writable,
208
200
  opened: socket.opened,
209
201
  close: () => socket.close?.(),
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { ProxyError, ConfigError, codes, hex8 } from '../errors.js';
16
16
  import { ByteReader, ByteWriter, concat, utf8 } from '../util/bytes.js';
17
+ import { tunnelReadable } from './tunnel.js';
17
18
 
18
19
  const VERSION = 0x05;
19
20
  const AUTH_VERSION = 0x01;
@@ -326,16 +327,7 @@ async function readExactly(reader, n, what, where) {
326
327
  /** Bytes that arrived alongside the reply are tunnel payload; the buffered reader carries them. */
327
328
  function tunnelFrom(socket, reader) {
328
329
  return {
329
- readable: new ReadableStream({
330
- async pull(controller) {
331
- const chunk = await reader.readSome();
332
- if (chunk === null) controller.close();
333
- else controller.enqueue(chunk);
334
- },
335
- cancel(reason) {
336
- return reader.cancel(reason);
337
- },
338
- }),
330
+ readable: tunnelReadable(socket, reader),
339
331
  writable: socket.writable,
340
332
  opened: socket.opened,
341
333
  close: () => socket.close?.(),
@@ -0,0 +1,110 @@
1
+ // The tunnel's readable half, and why it has to be a byte stream.
2
+ //
3
+ // Both dialects finish their handshake holding a buffered reader that may already carry tunnel
4
+ // payload: an HTTP CONNECT reply is read up to its blank line and a SOCKS5 reply to its fixed
5
+ // length, and in both cases the peer is free to have sent application bytes immediately behind it.
6
+ // Those bytes must be delivered first and in order, which is why the socket cannot simply be
7
+ // handed onward.
8
+ //
9
+ // What this replaces was `new ReadableStream({ pull })` — correct, and a plain stream rather than a
10
+ // byte stream. That distinction is invisible until you look at what reads it. The TLS record layer
11
+ // asks for a BYOB reader and quietly falls back to a default one when it cannot have it, so every
12
+ // connection through a proxy lost BYOB reads, and with them `tls.pullBytes`, whose entire job is to
13
+ // decide how much of the socket arrives per boundary crossing.
14
+ //
15
+ // Measured on the edge against one origin, 4 MB, differenced at a single request:
16
+ //
17
+ // direct 132 socket reads, 31.8 KB average fill
18
+ // proxied 484 socket reads, 8.7 KB average fill
19
+ //
20
+ // and the knob itself, 1 MB through the record layer, n=15 in one isolate:
21
+ //
22
+ // pullBytes 16 KiB pullBytes 1 MiB
23
+ // direct min 25 p50 31 min 73 p50 90 <- 2.9x, the knob works
24
+ // proxied min 95 p50 111 min 101 p50 121 <- 6%, the knob is not read
25
+ //
26
+ // So the U-curve recorded against "a real proxied socket" in util/bytes.js was four samples of one
27
+ // configuration. The knob was never reaching the code on that path.
28
+ //
29
+ // The fix is not to copy harder. Once the handshake's leftovers are drained this hands the caller's
30
+ // own view straight to the socket, so a read through a tunnel costs exactly what a read without one
31
+ // costs — no wrapper copy, and the same coalescing.
32
+
33
+ import { ByteReader } from '../util/bytes.js';
34
+
35
+ /**
36
+ * View size handed to a *default* reader of this stream. BYOB readers supply their own view and
37
+ * never see this; it exists so that a caller which does not do BYOB still gets socket-sized chunks
38
+ * rather than whatever the transport felt like emitting.
39
+ */
40
+ const TUNNEL_CHUNK = 65536;
41
+
42
+ /**
43
+ * Wrap a proxy handshake's buffered reader plus its socket as one byte stream.
44
+ *
45
+ * @param {{ readable: ReadableStream<Uint8Array> }} socket the raw transport
46
+ * @param {import('../util/bytes.js').ByteReader} reader the handshake's reader, possibly holding
47
+ * bytes that belong to the tunnel
48
+ * @returns {ReadableStream<Uint8Array>}
49
+ */
50
+ export function tunnelReadable(socket, reader) {
51
+ /** Taken once `reader` runs dry, after which every read goes straight to the socket. */
52
+ let direct = null;
53
+ /** Buffered stand-in for `reader` when the socket turns out not to be a byte stream. */
54
+ let buffered = reader;
55
+ let promoted = false;
56
+
57
+ // Whether a stream is BYOB-capable can only be discovered by asking it for a BYOB reader, and it
58
+ // cannot be asked while the handshake's reader holds the lock. So: release (safe only because
59
+ // the guard below proves nothing is buffered), ask, and on refusal re-take a buffered reader —
60
+ // which is exactly the shape that was here before, for transports that cannot do better. Every
61
+ // in-process stream in this package's tests is one of those, so this path is well covered.
62
+ const promote = () => {
63
+ if (promoted || buffered.buffered > 0 || buffered.atEof) return;
64
+ promoted = true;
65
+ buffered.releaseLock();
66
+ try {
67
+ direct = socket.readable.getReader({ mode: 'byob' });
68
+ } catch {
69
+ buffered = new ByteReader(socket.readable);
70
+ }
71
+ };
72
+
73
+ return new ReadableStream({
74
+ type: 'bytes',
75
+ autoAllocateChunkSize: TUNNEL_CHUNK,
76
+ async pull(controller) {
77
+ // autoAllocateChunkSize guarantees a byobRequest even for a default reader, so there is one
78
+ // path here rather than two.
79
+ const req = controller.byobRequest;
80
+ const view = req.view;
81
+ promote();
82
+ if (direct) {
83
+ const { value, done } = await direct.read(
84
+ new Uint8Array(view.buffer, view.byteOffset, view.byteLength),
85
+ );
86
+ if (done) {
87
+ // Close first: a BYOB request may only be answered with zero bytes once the stream is
88
+ // closed, and the read has detached the original view, so the answer has to be the
89
+ // zero-length view the reader handed back rather than a plain respond(0).
90
+ controller.close();
91
+ req.respondWithNewView(value ?? new Uint8Array(0));
92
+ return;
93
+ }
94
+ req.respondWithNewView(value);
95
+ return;
96
+ }
97
+ const chunk = await buffered.readSome(view.byteLength);
98
+ if (chunk === null) {
99
+ controller.close();
100
+ req.respond(0);
101
+ return;
102
+ }
103
+ new Uint8Array(view.buffer, view.byteOffset, view.byteLength).set(chunk);
104
+ req.respond(chunk.byteLength);
105
+ },
106
+ cancel(reason) {
107
+ return direct ? direct.cancel(reason) : buffered.cancel(reason);
108
+ },
109
+ });
110
+ }
package/src/util/bytes.js CHANGED
@@ -15,16 +15,29 @@ const EMPTY = new Uint8Array(0);
15
15
  * so a large view never delays delivery; it only lets bytes the transport has already buffered
16
16
  * arrive in one crossing instead of many.
17
17
  */
18
- // Swept on the edge against a real proxied socket, ms of CPU per 4 MB body at the record layer:
18
+ // Swept on the edge, ms of CPU for a 1 MB body at the record layer, p50 of n>=7 in one isolate,
19
+ // each column an independent path against the same origin:
19
20
  //
20
- // 16 KiB 42.0 64 KiB 38.5 256 KiB 46.5 1 MiB 57.0
21
+ // 8 KiB 16 KiB 32 KiB 64 KiB 256 KiB
22
+ // direct 21 20 18 22 40
23
+ // proxy A 51 61 61 89 152
24
+ // proxy B 68 74 - 102 -
21
25
  //
22
- // A U with its floor on the current value, and going LARGER is worse — 1 MiB costs 48% more than
23
- // the default, because allocating the view outgrows the boundary crossings it saves. Recorded so
24
- // the next person to reach for this knob does not have to re-run the sweep to find there is nothing
25
- // in it: the 42 ms this layer costs on a 4 MB body is the price of moving bytes off a real socket,
26
- // of which the AEAD is under 2 ms. It is not a tuning problem.
27
- const BYOB_PULL_BYTES = 65536;
26
+ // Monotonic on both proxies, and shallow on the direct path: too LARGE is what costs, because a
27
+ // BYOB read resolves the instant any byte exists and never waits to fill, so a view bigger than
28
+ // what the transport hands over per read is allocation that is never used. Average fill measured
29
+ // over a 4 MB body: 37 KB direct, 8 KB through a proxy which is why the two paths want different
30
+ // numbers and why the proxied one wants a small one.
31
+ //
32
+ // 16 KiB is within ~20% of the best figure on all three paths; the 64 KiB that used to sit here is
33
+ // up to 45% off. It is also exactly one TLS record, which is the unit the caller above asks for.
34
+ //
35
+ // The previous value came from a sweep captioned "against a real proxied socket" that reported a
36
+ // clean U with its floor at 64 KiB. That sweep measured nothing: openTunnel wrapped the socket in a
37
+ // plain ReadableStream, so the record layer could not take a BYOB reader on any proxied connection
38
+ // and this constant was never read on that path. The four numbers were four samples of one
39
+ // configuration. See proxy/tunnel.js, which is where that was fixed.
40
+ const BYOB_PULL_BYTES = 16384;
28
41
 
29
42
  /** Raised when the peer stops sending in the middle of a structure we must read whole. */
30
43
  export class UnexpectedEofError extends TunnelFetchError {
@@ -50,21 +63,25 @@ export class UnexpectedEofError extends TunnelFetchError {
50
63
  * be retained beyond the caller's immediate use if memory matters.
51
64
  *
52
65
  * When the source is a byte stream — on the target runtime, a socket's readable is one — the
53
- * reader pulls with BYOB reads into large fresh views instead of taking the source's own
54
- * chunking. This is measured, not stylistic: the runtime delivers socket data in chunks of at
55
- * most 4096 bytes, ~1200 of them for a 4 MB body, and every chunk is a runtime/JS boundary
56
- * crossing; a BYOB read hands over everything the transport has buffered (up to the view size)
57
- * in one crossing, and resolves with a partial fill the instant anything at all is available,
58
- * so delivery latency is unchanged. Sources that are not byte streams (every in-process
66
+ * reader pulls with BYOB reads instead of taking the source's own chunking. This is measured, not
67
+ * stylistic: the runtime delivers socket data in chunks of at most 4096 bytes, ~1200 of them for a
68
+ * 4 MB body, and every chunk is a runtime/JS boundary crossing; a BYOB read collects several of
69
+ * them into one.
70
+ *
71
+ * How MANY it collects is the transport's decision, not the view's. A BYOB read resolves the
72
+ * instant any byte is available and never waits to fill, so the view is a ceiling that is normally
73
+ * not reached: measured over a 4 MB body, 37 KB average fill on a direct socket and 8 KB through a
74
+ * proxy, whatever the view size. Sizing the view far above that buys nothing and costs the
75
+ * allocation — see BYOB_PULL_BYTES. Sources that are not byte streams (every in-process
59
76
  * ReadableStream in this package and its tests) take the default-reader path unchanged.
60
77
  */
61
78
  export class ByteReader {
62
79
  /**
63
80
  * @param {ReadableStream<Uint8Array>} readable
64
- * @param {number} [pullBytes] size of each BYOB view pulled from the source. Tunable because it
65
- * decides how many times a body crosses the runtime boundary on the way in, and that turned out
66
- * to be the largest single cost in a large response42 ms of a 106 ms 4 MB request is socket
67
- * reads and record decryption, of which the AEAD itself is under 2 ms.
81
+ * @param {number} [pullBytes] size of each BYOB view pulled from the source. Tunable because the
82
+ * right value depends on how much the transport hands over per read, and that differs by a
83
+ * factor of four between a direct socket and a proxied onesee BYOB_PULL_BYTES for the sweep.
84
+ * Ignored on sources that are not byte streams, which take the default-reader path.
68
85
  */
69
86
  constructor(readable, pullBytes = BYOB_PULL_BYTES) {
70
87
  this._pullBytes = pullBytes > 0 ? pullBytes : BYOB_PULL_BYTES;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Wrap a proxy handshake's buffered reader plus its socket as one byte stream.
3
+ *
4
+ * @param {{ readable: ReadableStream<Uint8Array> }} socket the raw transport
5
+ * @param {import('../util/bytes.js').ByteReader} reader the handshake's reader, possibly holding
6
+ * bytes that belong to the tunnel
7
+ * @returns {ReadableStream<Uint8Array>}
8
+ */
9
+ export function tunnelReadable(socket: {
10
+ readable: ReadableStream<Uint8Array>;
11
+ }, reader: import("../util/bytes.js").ByteReader): ReadableStream<Uint8Array>;
@@ -74,21 +74,25 @@ export class UnexpectedEofError extends TunnelFetchError {
74
74
  * be retained beyond the caller's immediate use if memory matters.
75
75
  *
76
76
  * When the source is a byte stream — on the target runtime, a socket's readable is one — the
77
- * reader pulls with BYOB reads into large fresh views instead of taking the source's own
78
- * chunking. This is measured, not stylistic: the runtime delivers socket data in chunks of at
79
- * most 4096 bytes, ~1200 of them for a 4 MB body, and every chunk is a runtime/JS boundary
80
- * crossing; a BYOB read hands over everything the transport has buffered (up to the view size)
81
- * in one crossing, and resolves with a partial fill the instant anything at all is available,
82
- * so delivery latency is unchanged. Sources that are not byte streams (every in-process
77
+ * reader pulls with BYOB reads instead of taking the source's own chunking. This is measured, not
78
+ * stylistic: the runtime delivers socket data in chunks of at most 4096 bytes, ~1200 of them for a
79
+ * 4 MB body, and every chunk is a runtime/JS boundary crossing; a BYOB read collects several of
80
+ * them into one.
81
+ *
82
+ * How MANY it collects is the transport's decision, not the view's. A BYOB read resolves the
83
+ * instant any byte is available and never waits to fill, so the view is a ceiling that is normally
84
+ * not reached: measured over a 4 MB body, 37 KB average fill on a direct socket and 8 KB through a
85
+ * proxy, whatever the view size. Sizing the view far above that buys nothing and costs the
86
+ * allocation — see BYOB_PULL_BYTES. Sources that are not byte streams (every in-process
83
87
  * ReadableStream in this package and its tests) take the default-reader path unchanged.
84
88
  */
85
89
  export class ByteReader {
86
90
  /**
87
91
  * @param {ReadableStream<Uint8Array>} readable
88
- * @param {number} [pullBytes] size of each BYOB view pulled from the source. Tunable because it
89
- * decides how many times a body crosses the runtime boundary on the way in, and that turned out
90
- * to be the largest single cost in a large response42 ms of a 106 ms 4 MB request is socket
91
- * reads and record decryption, of which the AEAD itself is under 2 ms.
92
+ * @param {number} [pullBytes] size of each BYOB view pulled from the source. Tunable because the
93
+ * right value depends on how much the transport hands over per read, and that differs by a
94
+ * factor of four between a direct socket and a proxied onesee BYOB_PULL_BYTES for the sweep.
95
+ * Ignored on sources that are not byte streams, which take the default-reader path.
92
96
  */
93
97
  constructor(readable: ReadableStream<Uint8Array>, pullBytes?: number);
94
98
  _pullBytes: number;