tunnelfetch 1.8.0 → 1.8.2

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
@@ -819,16 +819,21 @@ Workers Standard bills $5/month including 10 million requests and 30 million CPU
819
819
  $0.30 per additional million requests and $0.02 per additional million CPU milliseconds. Applying
820
820
  the measurements above, with the charge split out so it is clear what is yours to change:
821
821
 
822
- | Workload | CPU/request | 10M/mo, cold | 10M/mo, warmed | 1B/mo, cold | 1B/mo, warmed |
823
- | --- | --- | --- | --- | --- | --- |
824
- | Platform `fetch`, 16 KB — reference; it cannot use a proxy | 0.3 ms | $5.00 | $5.00 | $307.40 | $307.40 |
825
- | Platform `fetch`, 4 MB — same reference, measured | 3.2 ms | $5.04 | $5.04 | $365.40 | $365.40 |
826
- | Pooled connection, 16 KB pages | 3.1 ms | $5.90 | $5.24 | $451.20 | $385.20 |
827
- | New connection per request, 16 KB | 10.6 ms | $7.41 | $6.75 | $602.20 | $536.20 |
828
- | Pooled connection, 1 MB pages | 53.3 ms | $15.94 | $15.28 | $1455.20 | $1389.20 |
829
- | New connection per request, 1 MB | 60.8 ms | $17.45 | $16.79 | $1606.20 | $1540.20 |
830
- | Pooled connection, 4 MB pages | 118.3 ms | $28.94 | $28.28 | $2755.20 | $2689.20 |
831
- | New connection per request, 4 MB | 125.8 ms | $30.45 | $29.79 | $2906.20 | $2840.20 |
822
+ | Workload | CPU/request | 10M/mo | 1B/mo |
823
+ | --- | --- | --- | --- |
824
+ | Platform `fetch`, 16 KB — reference; it cannot use a proxy | 0.3 ms | $8.06 | $311.00 |
825
+ | Platform `fetch`, 4 MB — same reference, measured | 3.2 ms | $8.64 | $369.00 |
826
+ | Pooled connection, 16 KB pages | 3.5 ms | $8.70 | $375.00 |
827
+ | New connection per request, 16 KB | 13.5 ms | $10.70 | $575.00 |
828
+ | Pooled connection, 1 MB pages | 51.5 ms | $18.30 | $1,335.00 |
829
+ | New connection per request, 1 MB | 61.5 ms | $20.30 | $1,535.00 |
830
+ | Pooled connection, 4 MB pages | 104 ms | $28.80 | $2,385.00 |
831
+ | New connection per request, 4 MB | 114 ms | $30.80 | $2,585.00 |
832
+
833
+ These follow the CPU table above and nothing else. An earlier version of this section was computed
834
+ from a superseded set of measurements and was left behind when that table was replaced, so the
835
+ document quoted 118.3 ms and 104 ms for the same row in two places. Any figure here that does not
836
+ fall out of the table above is a bug in this README.
832
837
 
833
838
  The reference row is given at two sizes because the platform's own `fetch` is **not flat** — it
834
839
  scales at about 0.82 ms per decompressed MB, measured on a size ladder from one CDN so that only the
@@ -851,14 +856,14 @@ The rows above are the default identity: gzip on the wire, AES-256-GCM, x25519.
851
856
  bundles every change together, which is not much use for deciding. Priced one at a time against a
852
857
  pooled 1 MB workload at a billion requests a month, warmed:
853
858
 
854
- | Change from the baseline | CPU/request | 1B/mo, warmed | Δ | Paid when |
859
+ | Change from the baseline | CPU/request | 1B/mo | Δ | Paid when |
855
860
  | --- | --- | --- | --- | --- |
856
- | baseline — gzip, AES-256-GCM, x25519 | 53.3 ms | $1,389 | — | always |
857
- | origin serves `br` instead of gzip | 57.6 ms | $1,474 | **+$85** | the origin chooses `br` |
858
- | server selects ChaCha20-Poly1305 | 56.3 ms | $1,448 | **+$59** | the server picks it over AES |
859
- | origin serves `zstd` instead of gzip | 56.1 ms | $1,444 | **+$55** | the origin chooses `zstd` |
860
- | X25519MLKEM768, 1 request per connection | 61.0 ms | $1,542 | **+$153** | every handshake |
861
- | X25519MLKEM768, 20 requests per connection | 53.3 ms | $1,390 | **+$0.15** | the same handshake, amortised |
861
+ | baseline — gzip, AES-256-GCM, x25519 | 51.5 ms | $1,335 | — | always |
862
+ | origin serves `br` instead of gzip | 55.8 ms | $1,421 | **+$86** | the origin chooses `br` |
863
+ | server selects ChaCha20-Poly1305 | 54.5 ms | $1,395 | **+$60** | the server picks it over AES |
864
+ | origin serves `zstd` instead of gzip | 54.3 ms | $1,391 | **+$56** | the origin chooses `zstd` |
865
+ | X25519MLKEM768, 1 request per connection | 59.2 ms | $1,489 | **+$154** | every handshake |
866
+ | X25519MLKEM768, 20 requests per connection | 51.5 ms | $1,335 | **+$0.15** | the same handshake, amortised |
862
867
 
863
868
  The last two rows are the same 0.15 ms of ML-KEM, and the difference between them is entirely
864
869
  connection reuse — which is the point worth taking from this table. Post-quantum key exchange is
@@ -1042,6 +1047,75 @@ subject DN, and only the one anchor a chain lands on is ever decoded, so startup
1042
1047
  the 380 KB bundle (133 KB gzipped) and a request that imports but does not use the package costs
1043
1048
  0 ms.
1044
1049
 
1050
+ ### Streaming APIs: turn HTTP/2 off
1051
+
1052
+ An SSE response from an LLM API is the opposite shape to everything else measured here: a small body
1053
+ arriving as hundreds of tiny events rather than a large one arriving in a few chunks. Over HTTP/2
1054
+ each of those events is a DATA frame with flow control and a `WINDOW_UPDATE` behind it; over
1055
+ HTTP/1.1 it is a chunked-encoding chunk and nothing else. These APIs do not multiplex, so the
1056
+ cheaper framing simply wins:
1057
+
1058
+ ```js
1059
+ new Client({ connect, proxy, http2: false });
1060
+ ```
1061
+
1062
+ Measured against a real streaming endpoint through a proxy, 20K tokens in and 8K tokens out:
1063
+
1064
+ | | CPU/request | per 1M requests |
1065
+ | --- | --- | --- |
1066
+ | HTTP/2 | 32 ms | $0.94 |
1067
+ | **HTTP/1.1** | **28 ms** | **$0.86** |
1068
+ | platform `fetch` — reference; it cannot use a proxy | 5 ms | $0.40 |
1069
+
1070
+ **13% for one option, and nothing is given up** — a request that never opens a second stream gains
1071
+ nothing from multiplexing.
1072
+
1073
+ Two things about this shape are worth knowing because they are counter-intuitive:
1074
+
1075
+ **The cost is flat in output length.** 512 tokens and 8000 tokens cost within 2 ms of each other.
1076
+ Events are batched by the server and V8 tiers up inside the request, and the two together flatten
1077
+ the curve completely. An earlier version of this section modelled it as linear from two nearby
1078
+ points and predicted a break-even 15x lower than the truth; do not extrapolate this from a slope.
1079
+
1080
+ **The request body is cheap.** A 20K-token prompt is ~108 KB of JSON to serialise, buffer, encrypt
1081
+ and frame, and it costs 4 ms on h1 or 6 ms on h2 — 15–20% of the request, not the dominant term.
1082
+
1083
+ For scale: at these sizes the model's own bill is about **$0.0088 per request**, so this package's
1084
+ CPU is **0.011%** of what you pay. Turn h2 off because it is free, not because it will show up on an
1085
+ invoice.
1086
+
1087
+ ### Streaming APIs, and what they cost
1088
+
1089
+ An SSE response from an LLM API is the opposite shape to everything else measured here: a small body
1090
+ arriving as one event per output token rather than a large one in a few chunks. The per-chunk costs
1091
+ that are a footnote for a 4 MB page are the whole bill here.
1092
+
1093
+ Measured against a real streaming endpoint through a proxy, with the API's own `usage` block as the
1094
+ token count rather than an estimate from response size:
1095
+
1096
+ | | per event | per 1M output tokens |
1097
+ | --- | --- | --- |
1098
+ | this package | **250–310 µs** | $5,000–6,200 |
1099
+ | platform `fetch` — reference; it cannot use a proxy | ~105 µs | $2,100 |
1100
+
1101
+ Events map to output tokens roughly 1:1, so a 128K-token completion is about **35 s of CPU**. That
1102
+ is spread across the minutes the model takes to generate it — utilisation is 2–5%, so it is a
1103
+ billing question, not a capacity one.
1104
+
1105
+ **As a share of what you pay, it is constant.** CPU and the model's output charge both scale with
1106
+ output tokens, so the ratio does not move with length: at $0.6/M output it is **~0.9% of the model
1107
+ bill**, at any size. Input tokens appear only in the denominator and push it lower.
1108
+
1109
+ A range is given rather than a figure because this measurement repeats to about ±20%. Two things
1110
+ were tried and did not survive that: HTTP/1.1 came out ahead of HTTP/2 in one sweep and behind in
1111
+ the next, and turning decoding off measured both faster and slower. **Neither is a recommendation.**
1112
+
1113
+ Splitting the gap to the platform's own `fetch`: decoding is a small part of it and the transport is
1114
+ ~155 µs per event, spread across the record layer, framing, the byte reader, the body stream and the
1115
+ deadline wrapper — a few tens of microseconds each, with no single layer to remove. That is why the
1116
+ `IdentityTransformStream` win on large bodies has no equivalent here: there, one JavaScript layer
1117
+ could be deleted outright; here there are seven, each small.
1118
+
1045
1119
  ### Plan limits
1046
1120
 
1047
1121
  On the paid plan the 30 s default CPU limit is not the binding constraint — that is roughly 3 000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunnelfetch",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
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",
@@ -178,22 +178,31 @@ export class DeadlineController {
178
178
  * Reject as soon as the signal aborts, resolve when `promise` settles first.
179
179
  * The abort reason is preserved so the caller sees the typed TimeoutError, not a generic abort.
180
180
  */
181
+ /**
182
+ * A promise that rejects when this controller aborts, created once and reused.
183
+ *
184
+ * `race` used to register and unregister an abort listener PER CALL, which is per chunk on a body
185
+ * stream. That is invisible on a 4 MB response — a few dozen chunks — and it is the dominant
186
+ * per-event cost on an SSE stream, where a single completion can be a hundred thousand chunks of
187
+ * a few hundred bytes each. The signal does not change over the life of the controller, so one
188
+ * registration is enough.
189
+ *
190
+ * Pre-observed, because a rejection nobody has awaited yet is an unhandled rejection.
191
+ */
192
+ get _abortPromise() {
193
+ if (!this.__abortP) {
194
+ this.__abortP = new Promise((_, reject) => {
195
+ if (this.aborted) reject(this.signal.reason);
196
+ else this.signal.addEventListener('abort', () => reject(this.signal.reason), { once: true });
197
+ });
198
+ this.__abortP.catch(() => {});
199
+ }
200
+ return this.__abortP;
201
+ }
202
+
181
203
  race(promise) {
182
204
  if (this.aborted) return Promise.reject(this.signal.reason);
183
- return new Promise((resolve, reject) => {
184
- const onAbort = () => reject(this.signal.reason);
185
- this.signal.addEventListener('abort', onAbort, { once: true });
186
- promise.then(
187
- (v) => {
188
- this.signal.removeEventListener('abort', onAbort);
189
- resolve(v);
190
- },
191
- (e) => {
192
- this.signal.removeEventListener('abort', onAbort);
193
- reject(e);
194
- },
195
- );
196
- });
205
+ return Promise.race([promise, this._abortPromise]);
197
206
  }
198
207
  }
199
208
 
@@ -106,6 +106,19 @@ export class DeadlineController {
106
106
  * Reject as soon as the signal aborts, resolve when `promise` settles first.
107
107
  * The abort reason is preserved so the caller sees the typed TimeoutError, not a generic abort.
108
108
  */
109
+ /**
110
+ * A promise that rejects when this controller aborts, created once and reused.
111
+ *
112
+ * `race` used to register and unregister an abort listener PER CALL, which is per chunk on a body
113
+ * stream. That is invisible on a 4 MB response — a few dozen chunks — and it is the dominant
114
+ * per-event cost on an SSE stream, where a single completion can be a hundred thousand chunks of
115
+ * a few hundred bytes each. The signal does not change over the life of the controller, so one
116
+ * registration is enough.
117
+ *
118
+ * Pre-observed, because a rejection nobody has awaited yet is an unhandled rejection.
119
+ */
120
+ get _abortPromise(): Promise<any>;
121
+ __abortP: Promise<any> | undefined;
109
122
  race(promise: any): Promise<any>;
110
123
  }
111
124
  export type DeadlineOptions = {