Releases: latentharbor/tunnelfetch
Release list
v1.12.0 — the proxy tunnel is a byte stream, and a knob that was never connected
The bug
openTunnel finishes a CONNECT (or SOCKS5) handshake holding a buffered reader — the peer may have
sent tunnel payload in the same chunk as the reply — and handed that onward as
new ReadableStream({ pull }). Correct, and a plain stream rather than a byte stream.
The TLS record layer asks for a BYOB reader and silently falls back to a default one when it cannot
have it. So every proxied connection lost BYOB reads, and with them tls.pullBytes, whose only
job is to size them.
Measured on the edge, 1 MB through the record layer, n=15 in one isolate:
pullBytes: 16 KiB |
pullBytes: 1 MiB |
|
|---|---|---|
| direct | min 25, p50 31 | min 73, p50 90 |
| proxied | min 95, p50 111 | min 101, p50 121 |
2.9× on a direct socket and 6% through a proxy — the knob was not being read.
The fix
src/proxy/tunnel.js makes the tunnel a byte stream and, once the handshake's leftovers are
drained, hands the caller's own view straight to the socket. Both dialects share it. A transport
whose readable is not a byte stream keeps the previous buffered path.
The default moved: 64 KiB → 16 KiB
The old value came from a sweep captioned "against a real proxied socket" whose knob was never
reaching the code on that path — four samples of one configuration. Re-swept with it connected,
ms for 1 MB at the record layer, p50:
| 8 KiB | 16 KiB | 32 KiB | 64 KiB | 256 KiB | |
|---|---|---|---|---|---|
| direct | 21 | 20 | 18 | 22 | 40 |
| proxy A | 51 | 61 | 61 | 89 | 152 |
| proxy B | 68 | 74 | — | 102 | — |
A BYOB read resolves the instant any byte exists and never waits to fill, so the view is a ceiling
rather than a target — average fill over 4 MB is 37 KB direct and 8 KB through a proxy, and
everything above that is allocation that is never used.
End to end through the Client and a proxy, n=13: 63 against 78 ms for 1 MB and 145 against
164 ms for 4 MB on the median.
No API changed
tls.pullBytes still overrides the default. If you had tuned it for a proxied connection, it was
not being read before this release.
Also
- The cost tables are re-measured against a rebuilt, now-committed
sizeorigin/, with the method
stated: a warm page is(reuse=4 - reuse=1)/3. The 4 MB row reproduced; the mid sizes came in
20–30% lower for reasons that are not this release's changes, and the README says so. - The pricing table had an arithmetic bug independent of any of this — it billed every request and
every CPU millisecond, ignoring the included allowance its own prose describes, overstating the
10M/month column by up to 74%. maxBodyBytes: Infinityis now priced on a real request rather than a fixture: it is worth
20–27 ms on a 4 MB body, roughly $540/month at a billion such requests.
v1.11.0 — drop status_request, and set Proxy-Connection
Two more places where an identity could be described but not presented.
tls.omitExtensions
The subtractive counterpart to extraExtensions, and status_request (5) is what it exists for —
the one extension this package sends that curl does not, so a sample without it could not be matched
at all. Ordering cannot help (orderExtensions arranges what was built), and there was no other
way to unbuild it.
Dropping it gives up OCSP stapling, the only revocation signal this package consumes. With
trust.revocation: 'require-staple' that is not a stricter policy but one that can never be
satisfied — no staple is requested, so none arrives, so every connection fails on a certificate never
asked to carry one. Refused at configuration time rather than at handshake time, where it would look
like a server problem.
proxy.proxyConnection
Replaces a hard-coded Proxy-Connection: keep-alive on the CONNECT request. The header is
pre-standard, clients disagree about it, and while the origin never sees it the proxy always
does — so for anyone matching a client's behaviour at the proxy it is part of the fingerprint.
Default unchanged. null omits the header, which is not the same as sending close.
Both omissions are reproduced on a HelloRetryRequest retry, because a second hello that changed its
extension set would be malformed under RFC 8446 §4.1.2 and a signal in itself.
1260 offline tests pass.
v1.9.0 — extensions can be added, and unperformable cipher offers are refused
Two gaps in the fingerprint story, both of which let a caller ask for one identity and get another
without being told.
extensionOrder can only arrange extensions, never add them
The builder filters to the extensions it actually generated and sorts those. buildClientHello has
always taken extraExtensions, but nothing threaded it from the public config — its one caller was
the HelloRetryRequest cookie. tls.extraExtensions now does.
This matters because profiles.chrome presents a Chromium cipher list, group list and GREASE
placement over an extension set that is curl's. Chromium sends five this package never builds:
signed_certificate_timestamp (18), compress_certificate (27), session_ticket (35),
application_settings (17613) and encrypted_client_hello (65037). A JA3 or JA4 hash sees that.
A test reads that list straight out of the committed Chromium capture and asserts it, so the gap
cannot drift quietly. Supplied extensions are ordered like any other and reproduced on an HRR retry
— a second hello that changed its extension set would be malformed (RFC 8446 §4.1.2) and a signal in
itself.
tls.ciphers was taken verbatim
A list containing a CBC or RSA-key-exchange suite put a number on the wire that a server could
select, after which the AEAD layer had nothing to build: the failure landed mid-handshake rather
than at configuration. Such a list is now refused up front.
Behaviour change: an explicit list containing TLS_CHACHA20_POLY1305_SHA256 with no injected
implementation is now refused rather than silently filtered. The old behaviour kept the suite off
the wire — right outcome, wrong means: a caller who wrote the three-suite Chromium list got a
two-suite hello and was never told the fingerprint was not the one they asked for. Presenting an
unrequested identity is the worst outcome available to a package like this one.
profiles.chrome is unaffected — it declares requires: ['cipher:chacha20'], so applyProfile
refuses it before connect is reached.
1255 offline tests pass.
v1.8.4 — remove a retracted SSE section that reached main by a branching mistake
The README carried two streaming-cost sections disagreeing by about 80×: one reading 28 ms
for "20K in / 8K out" (3.5 µs per event), the other 250–310 µs per event.
The first is wrong. Its PR was closed as refuted — the "8K out" label is the artifact:
max_completion_tokens never bound with the prompt used, so that request streamed about fifty
events, not eight thousand. 28 ms was the cost of a fifty-event stream wearing an
eight-thousand-token label.
It reached main anyway. git branch -D refused while that branch was still checked out, printed
Aborting, and the next git checkout -b branched from it — so the refuted commit rode into main
inside the following PR. The claim was publicly retracted and the code carrying it shipped
regardless. Closing a PR retracts the proposal, not the working tree.
Removed with it: "turn HTTP/2 off", which did not reproduce, and "the request body is cheap at
4–6 ms", measured on the same mislabelled request.
What survives is the section measured with the API's own usage block as the token count. There is
now one.
1251 offline tests pass.
v1.8.3 — the streaming cost table was wrong by a factor of a million
The table shipped in 1.8.2 read "$5,000–6,200 per 1M output tokens". The real figure is
$0.0050–0.0062 — the same digits with the decimal point six places out, from writing a
per-million-requests number into a per-million-tokens column.
1M output tokens × 280 µs = 280,000 ms of CPU
280,000 ms × $0.02 per M CPU-ms = $0.0056
Half a cent, against $0.60 of model charge for the same tokens.
Why it survived: the "~0.9% of the model bill" figure beside it was computed from the correct
per-request numbers and is unaffected. The ratio a reader would sanity-check against was right, and
only the absolute column was wrong, so the table read as internally consistent.
The corrected table carries CPU seconds as well as dollars, so the two check each other: 250–310
seconds of CPU per million output tokens, at $0.02 per million CPU-ms, cannot come to thousands of
dollars.
If you read 1.8.2's streaming section, re-read this one. Nothing else changed.
1251 offline tests pass.
v1.8.2 — one abort listener per stream instead of one per chunk
DeadlineController.race added and removed an abort listener on every call, and it is called once
per body chunk. Invisible across the few dozen chunks of a 4 MB response; pure waste on an SSE
stream, which is one chunk per output token. The signal cannot change over the controller's
lifetime, so one registration is enough.
Measured in one isolate, 20k against 80k iterations differenced: 2.53 µs → 0.90 µs per call, −64%
(the floor, with no deadline machinery at all, is 0.23 µs).
This does not solve the cost of streaming. The gap to the platform's own fetch on an SSE
stream is ~155 µs per event; this is 1.6 of it, about 209 ms out of ~35 s on a 128,000-event
completion. It ships because re-registering a listener that cannot change is a waste that should not
have been there, not because it moves a bill.
A streaming-cost section, with two claims deliberately absent
Measured against a real streaming endpoint through a proxy, using the API's own usage block as the
token count rather than an estimate from response size: 250–310 µs per event against ~105 µs for
the platform's fetch. Events map to output tokens roughly 1:1, and because CPU and the model's
output charge both scale with output tokens, the ratio is constant — ~0.9% of the model bill at
any length.
Two claims from an earlier draft are absent because they did not survive re-measurement:
- "Cost is flat in output length" was an artifact.
max_completion_tokensnever bound with the
prompt used, so 512, 2000 and 8000 returned an identical 43 events and 11,834 bytes — three
budgets measuring the same workload. - "HTTP/1.1 saves 13%" did not reproduce; h1 led one sweep and trailed the next, inside a ~20%
run-to-run spread.
The section states that spread and gives a range rather than a figure.
1251 offline tests pass.
v1.8.1 — the price tables now follow the CPU table
Documentation and measurement rig only. No behaviour change.
The cost table was replaced in 1.8.0 with a clean measurement. The two dollar tables downstream of it
were not, so the README quoted 118.3 ms and 104 ms for the same row in two places and priced a
billion 4 MB requests off superseded numbers. Both are now derived from the CPU table and nothing
else, and the section says so.
The per-option table is rebased onto the current 51.5 ms baseline. Its deltas were always measured as
CPU differences and are unchanged; only the absolute dollars moved.
Also lands the measurement ops that produced 1.7.0 and 1.8.0, which were live on the edge but never
committed — including the real-socket depth ladder, which is the only decomposition here that turned
out to be trustworthy: against it the isolated benches under-price the record layer by 14× and
over-price HTTP/2 by 2×.
1251 offline tests pass.
v1.8.0 — passthrough, and a cost table that admits it is not linear
decompress: 'passthrough'
Asks for gzip as usual and hands the coded body back with its Content-Encoding and
Content-Length intact. On a 4 MB body through a proxy, measured end to end: 118 ms decoding
against 82.5 ms passing through — 30% — with 2.76× fewer wire bytes at the same time.
It is not a variation on decompress: false. That one also drops gzip from Accept-Encoding, so
the origin sends plaintext and the wire grows 2.76×: it loses on both sides.
It only helps a caller who never needs the plaintext. If you parse the body, the decode is work
you owe and passthrough merely moves it downstream. It is not a general optimisation.
The cost table is replaced
Same origin, same corpus, same proxy, current build, repetitions differenced so the connection
cancels:
| Body | per request, pooled | per decompressed MB |
|---|---|---|
| 1 KB | 0.5 ms | — |
| 16 KB | 3.5 ms | 224 |
| 64 KB | 7.5 ms | 120 |
| 256 KB | 20.5 ms | 82 |
| 1 MB | 51.5 ms | 51 |
| 4 MB | 104 ms | 26 |
The per-MB column is new and it is the point. It falls 8.6× end to end, so there is no per-MB
rate for this package. A least-squares line is 9.17 + 24.86 × MB, which predicts 9.17 ms for a
1 KB body against a measured 0.5 — wrong by 18×. Interpolate within the table; do not extrapolate
from a rate.
The reason is that V8 tiers up inside a single request: a 4 MB body pays interpreted execution
for its first megabyte and runs the rest optimised. 51.5 + 3 × 17.5 = 104 fits.
A "when not to use this package" section
The docs priced the package without ever saying when the answer is not to use it.
A billion 4 MB requests is ~$2,385 of Workers CPU. That load is ~386 req/s and 4.5 Gbps, which three
dedicated boxes carry for ~$600 — servers are ~4× cheaper for large bodies. Two things flip it:
egress, which Workers does not bill and which can dwarf everything elsewhere; and body size, under
~128 KB where Workers wins on price and gives away geography and operations.
If you can run Node, run Node — undici with a ProxyAgent does this over native TLS at a
tenth of the CPU, and then this package is unnecessary. It exists because a V8 isolate cannot, not
because it is a better way.
1251 offline tests pass.
v1.7.0 — native primitives where the runtime has them
Two places where Cloudflare ships a native implementation of something this package was doing in
JavaScript. Both feature-detected, because this package also runs on Node, Deno and Bun.
The decode stage relays through IdentityTransformStream when there is no cap
Measured on the edge, CPU per MB of decompressed output, every shape interleaved in one isolate:
| decode shape | ms/MB |
|---|---|
| this package's wrapper, BYOB + counting | 7.00 |
a standard new TransformStream() hop |
13.33 |
an IdentityTransformStream hop |
3.67 |
DecompressionStream + native collect (floor) |
3.33 |
The native identity hop is within noise of the floor. The standard TransformStream — the
obvious way to write the same thing — is nearly twice as expensive as doing nothing at all. One is
C++ and one is JavaScript, and nothing in the API surface says so.
On the shipped path: 7.33 → 4.00 ms/MB, −45%, or 29 ms → 16 ms of decode on a 4 MB body.
This applies only when maxBodyBytes is Infinity. The cap is enforced by counting bytes,
counting requires seeing them in JS, and seeing them in JS is exactly the cost being removed — the
two are mutually exclusive. A caller who passed Infinity has already taken responsibility for
bounding the body, so that caller gets the fast path.
timingSafeEqual prefers crypto.subtle.timingSafeEqual
A correctness fix, not a performance one. The hand-written version is a JS loop, and a JS loop
cannot promise constant time — V8 may vectorise it, exit early, or branch on data. It guards TLS
Finished verification. The native one is compiled.
Bound once at module load with a probe rather than a typeof check, so a property that exists but
throws is found at startup rather than inside a handshake.
Measured and rejected, recorded so nobody repeats them
- Routing the HTTP/2 body through an
IdentityTransformStream: +54%. It adds a layer rather
than replacing one — native is only cheaper when it displaces JavaScript. - Returning a
PromisefromdecodeBody: 4.33, worse than the relay, and it breaks the API. - Removing the cap's counting: saves nothing.
FixedLengthStreamcannot serve as a byte cap — its own docs say too few bytes is an error too,
so every body under the limit would fail.
On the tests
The four new ones inject a stand-in for IdentityTransformStream, because it does not exist in the
runtime the offline suite runs in — without that the path would have shipped having executed nowhere
but the edge. One caught a real bug before commit: pipeTo's default aborts the destination with
the source's error, so a truncated gzip reached the consumer as a bare zlib message instead of a
typed error naming the coding.
1248 offline tests pass.
v1.6.5 — the Chrome identity now sends PRIORITY, because Chromium does
Chromium sets the PRIORITY flag on its request HEADERS — flags 0x25, carrying 80 00 00 00 ff:
exclusive, dependency 0, weight byte 255. This package never set it, so profiles.chrome produced a
frame layout that differed from Chromium's even where every value above it matched. 1.6.2 found
it with the h2 capture and wrote it down; 1.6.5 emits it.
If you use tunnelfetch/profile/chrome over HTTP/2, your request HEADERS frame changes in this
release. That is the fix. profiles.curl is unaffected — curl sends no priority, and the default
stays "do not send".
RFC 9113 §5.3.2 deprecates the mechanism and this package still ignores every PRIORITY frame it
receives. Sending it is a statement about identity, not a request to be prioritised.
The values are read out of the committed capture, not chosen.
The test is the point
Three bugs in this area have had one shape:
http2ConnectionWindow— declared in a profile, never read; Chrome sent curl's window- the SETTINGS flight — curl 8.21.0's ClientHello above curl 8.7.1's values
- the flow-control window — advertised 64 KiB, accounted 10 MiB; every large body hung
Every one would have been caught by a test that folds the profile the way the Client folds it and
then reads the bytes off the wire. That is what this release adds, for both profiles, and it was
checked against both failure shapes: removing the profile's declaration fails it, and removing the
key from applyProfile's copy list — the http2ConnectionWindow bug replayed exactly — fails it
too.
Verified against a real origin as well, because the last frame-level change shipped a hang: the
Chrome identity fetched 9,141,067 bytes over h2 from a CDN, status 200.
1244 offline tests pass.