tunnelfetch 1.8.1 → 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
@@ -1047,6 +1047,75 @@ subject DN, and only the one anchor a chain lands on is ever decoded, so startup
1047
1047
  the 380 KB bundle (133 KB gzipped) and a request that imports but does not use the package costs
1048
1048
  0 ms.
1049
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
+
1050
1119
  ### Plan limits
1051
1120
 
1052
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.1",
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 = {