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 +69 -0
- package/package.json +1 -1
- package/src/util/deadline.js +23 -14
- package/types/util/deadline.d.ts +13 -0
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.
|
|
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",
|
package/src/util/deadline.js
CHANGED
|
@@ -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
|
|
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
|
|
package/types/util/deadline.d.ts
CHANGED
|
@@ -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 = {
|