tunnelfetch 1.6.5 → 1.8.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
@@ -716,14 +716,27 @@ Fetching a size-controlled origin through a proxy, warm, medians over seven-plus
716
716
  isolate, gzip on the wire. The last column is the same numbers as a rate, which is the form worth
717
717
  carrying around:
718
718
 
719
- | Body | Averaged over 5 pages | Reusing a connection | New connection |
720
- | --- | --- | --- | --- |
721
- | 1 KB | 3.2 ms | 1.7 ms | 9.2 ms |
722
- | 16 KB | 4.6 ms | 3.1 ms | 10.6 ms |
723
- | 64 KB | 8.2 ms | 6.7 ms | 14.2 ms |
724
- | 256 KB | 18.2 ms | 16.7 ms | 24.2 ms |
725
- | 1 MB | 54.8 ms | 53.3 ms | 60.8 ms |
726
- | 4 MB | 119.8 ms | 118.3 ms | 125.8 ms |
719
+ | Body | Per request, reusing a connection | Per decompressed MB |
720
+ | --- | --- | --- |
721
+ | 1 KB | **0.5 ms** | |
722
+ | 16 KB | **3.5 ms** | 224 ms/MB |
723
+ | 64 KB | **7.5 ms** | 120 ms/MB |
724
+ | 256 KB | **20.5 ms** | 82 ms/MB |
725
+ | 1 MB | **51.5 ms** | 51 ms/MB |
726
+ | 4 MB | **104 ms** | 26 ms/MB |
727
+
728
+ Opening a connection adds **7–12 ms** on top, once, however many requests follow it.
729
+
730
+ **Read the per-MB column before doing any arithmetic with this table.** It falls 8.6x from end to
731
+ end, so there is no such thing as a per-MB rate for this package. A least-squares line through these
732
+ points is `9.17 + 24.86 x MB`, which predicts 9.17 ms for a 1 KB body against a measured 0.5 — wrong
733
+ by 18x. Any budget built on a single per-MB figure will be badly wrong at one end or the other.
734
+
735
+ The reason is that V8 tiers up **inside a single request**. A 1 MB body runs the decode loop
736
+ interpreted the whole way; a 4 MB body pays that for its first megabyte and runs the rest optimised.
737
+ `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
738
+ per request**. For a size not in the table, interpolate within it rather than extrapolating from a
739
+ rate.
727
740
 
728
741
  The cold-start cost is a **total**, not something to add to a row above:
729
742
 
@@ -924,6 +937,49 @@ the edge: receiving a 4 MB body **uncompressed** costs the same as receiving the
924
937
  the bytes through the entire receive pipeline. **Leave compression on.** The fixed ~2 ms only wins
925
938
  below roughly the size where a single wire read covers the whole body.
926
939
 
940
+ ### When not to use this package
941
+
942
+ **If you can run Node, run Node — and then you do not need this package at all.** `undici` with a
943
+ `ProxyAgent` does the same job over `node:tls`, which verifies whatever hostname you name, in C, at
944
+ roughly a tenth of the CPU. This package exists because a V8 isolate cannot do that; it is not a
945
+ better way to do it.
946
+
947
+ The arithmetic is worth being blunt about. A billion 4 MB requests a month costs about **$2,385** of
948
+ Workers CPU. That workload is roughly 386 requests a second and 4.5 Gbps sustained — **three
949
+ dedicated boxes** at Hetzner-class pricing carry it for around **$600**. So for large bodies, buying
950
+ servers is about **four times cheaper**, and the gap widens with body size.
951
+
952
+ Two things flip that back:
953
+
954
+ * **Egress.** Workers does not bill bandwidth. If those bytes have to leave your own servers again,
955
+ 1.45 PB/month is free on an unmetered box and about **$130,000** on AWS. Price your transfer
956
+ before pricing your CPU — it can dwarf everything above.
957
+ * **Body size.** Under roughly **128 KB** the per-request cost is small enough that Workers wins on
958
+ price *and* gives you 300+ locations, no operations and no capacity planning. At 16 KB the same
959
+ billion requests is **$375**.
960
+
961
+ So: small bodies at the edge is what this package is for. Large bodies through a proxy, from a
962
+ runtime that could have used native TLS, is the case where it is the wrong tool and the bill says so.
963
+
964
+ ### Passing the body through instead of decoding it
965
+
966
+ If you are forwarding a response rather than reading it, `decompress: 'passthrough'` asks for gzip as
967
+ usual and hands back the **coded** bytes with their `Content-Encoding` and `Content-Length` intact,
968
+ so the next hop can relay them:
969
+
970
+ ```js
971
+ new Client({ connect, proxy, decompress: 'passthrough' });
972
+ ```
973
+
974
+ Measured end to end on a 4 MB body: **118 ms decoding against 82.5 ms passing through, 30% cheaper**,
975
+ with 2.76x fewer bytes on the wire at the same time.
976
+
977
+ **This only helps if you never need the plaintext.** If you parse, extract or transform the body, the
978
+ decode is work you owe — passthrough just moves it downstream, where you or your caller pays the same.
979
+ It is not a general optimisation. Note also that `decompress: false` is *not* a weaker version of
980
+ this: it also drops gzip from `Accept-Encoding`, so the origin sends plaintext and the wire grows
981
+ 2.76x. It loses on both sides.
982
+
927
983
  ### Cost parity with the platform's `fetch` is not reachable, and here is the floor
928
984
 
929
985
  Two independent investigations reached this separately, which is the main reason it is stated this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunnelfetch",
3
- "version": "1.6.5",
3
+ "version": "1.8.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",
@@ -190,17 +190,53 @@ function decompressionStage(source, coding, maxBytes = Infinity) {
190
190
  })();
191
191
  ready.catch(() => {});
192
192
 
193
+ // The fast path: hand the decompressor's output to the runtime and never touch a chunk in JS.
194
+ //
195
+ // Measured on the edge, CPU per MB of decompressed output, all shapes interleaved in one isolate:
196
+ //
197
+ // this wrapper, BYOB reads + counting 7.00
198
+ // the same wrapper with counting removed 7.33 (counting is free; the WRAPPER is not)
199
+ // a standard `new TransformStream()` hop 13.33 (JS-backed: every 4 KiB chunk queues)
200
+ // an IdentityTransformStream hop 3.67 (native, and BYOB on its readable)
201
+ // DecompressionStream + native collect 3.33 (the floor)
202
+ //
203
+ // So a native identity hop is 48% cheaper than this wrapper and lands within noise of the floor,
204
+ // while the standard TransformStream — the obvious way to write the same thing — is nearly twice
205
+ // as expensive as doing nothing at all. The difference is that one is C++ and one is JavaScript;
206
+ // nothing about the API surface says so.
207
+ //
208
+ // It applies only when there is no cap, and that is not a limitation to be worked around: the cap
209
+ // is enforced by counting bytes, counting requires seeing them in JS, and seeing them in JS is
210
+ // exactly the cost being removed. The two are mutually exclusive. `maxBodyBytes: Infinity` is
211
+ // already a caller saying they will bound the body themselves, so giving that caller the fast
212
+ // path is coherent — you gave up the guard, you get the speed — rather than a compromise.
213
+ if (maxBytes === Infinity && typeof globalThis.IdentityTransformStream === 'function') {
214
+ const relay = new globalThis.IdentityTransformStream();
215
+ (async () => {
216
+ const ds = await ready;
217
+ if (ds === null) {
218
+ await relay.writable.close().catch(() => {});
219
+ return;
220
+ }
221
+ // preventAbort: pipeTo's default is to abort the destination with the SOURCE's error, which
222
+ // would hand the consumer a bare zlib message from a stream it never asked about. Keeping
223
+ // the abort here is what lets the coding be named, the same as on the wrapper path.
224
+ await ds.readable.pipeTo(relay.writable, { preventAbort: true });
225
+ if (pumpDone) await pumpDone;
226
+ await relay.writable.close();
227
+ })().catch(async (e) => {
228
+ // The consumer sees the same typed error it would have seen through the wrapper; the relay is
229
+ // errored rather than closed so a truncated body can never read as a complete one.
230
+ await srcReader.cancel(wrapCoding(coding, e)).catch(() => {});
231
+ await relay.writable.abort(wrapCoding(coding, e)).catch(() => {});
232
+ });
233
+ return relay.readable;
234
+ }
235
+
193
236
  /** @type {ReadableStreamBYOBReader | ReadableStreamDefaultReader<Uint8Array> | null} */
194
237
  let out = null;
195
238
  let byob = false;
196
- const wrap = (e) =>
197
- e instanceof HttpError
198
- ? e
199
- : new HttpError(
200
- codes.HTTP_CONTENT_ENCODING,
201
- `decoding "${coding}" failed: ${e?.message ?? e}`,
202
- { coding },
203
- );
239
+ const wrap = (e) => wrapCoding(coding, e);
204
240
 
205
241
  return new ReadableStream({
206
242
  async pull(c) {
@@ -334,6 +370,14 @@ function capDecodedOutput(source, coding, maxBytes) {
334
370
  });
335
371
  }
336
372
 
373
+ /** Name the coding in a decode failure, so a caller sees which one misbehaved rather than a bare
374
+ * zlib message from somewhere downstream. */
375
+ function wrapCoding(coding, e) {
376
+ return e instanceof HttpError
377
+ ? e
378
+ : new HttpError(codes.HTTP_CONTENT_ENCODING, `decoding "${coding}" failed: ${e?.message ?? e}`, { coding });
379
+ }
380
+
337
381
  /** Byte at logical offset `i` across the buffered head chunks. */
338
382
  function firstBytes(chunks, i) {
339
383
  for (const c of chunks) {
package/src/client.js CHANGED
@@ -98,7 +98,11 @@ const DEFAULT_MAX_BODY_BYTES = 32 * 1024 * 1024;
98
98
  * again on the DECODED output — a compressed body within the cap can decompress far past it, and
99
99
  * a registered decoder's output is bounded by it too. Pass `Infinity` to opt out, which is the
100
100
  * right choice for streaming large files and the wrong one for fetching URLs you do not control.
101
- * @property {boolean} [decompress] gzip/deflate. Default true.
101
+ * @property {boolean | 'passthrough'} [decompress] gzip/deflate. Default true. `false` neither
102
+ * advertises nor decodes a coding, so the origin sends plaintext and the wire grows.
103
+ * `'passthrough'` advertises gzip as usual but hands the CODED body back with its
104
+ * `Content-Encoding` intact — 30% cheaper on a 4 MB body and 2.76x fewer wire bytes, and correct
105
+ * only for a caller that forwards the body rather than reading it.
102
106
  * @property {Record<string, import('./client/decode.js').BodyDecoder>} [decoders] extra
103
107
  * content-codings this client can read, e.g. `{ br: (s) => ... }`. Registering one is what
104
108
  * makes advertising it honest, so each name is appended to Accept-Encoding — a client that
@@ -744,7 +748,7 @@ async function sendAndReceive(client, conn, current, { key, deadlines, reused })
744
748
  deadlines.beginIdle();
745
749
  const guarded = framing.kind === 'none' ? raw : withIdleDeadline(raw, deadlines);
746
750
  const decoded = decodeResponseBody(guarded, headInfo.headers, o);
747
- return buildResponse(headInfo, decoded, framing, conn);
751
+ return buildResponse(headInfo, decoded, framing, conn, o.decompress === 'passthrough');
748
752
  }
749
753
 
750
754
  // ------------------------------------------------------------------ one request/response over h2
@@ -801,7 +805,7 @@ async function sendAndReceiveH2(client, h2, current, { deadlines }) {
801
805
  const guarded = withIdleDeadline(raw, deadlines);
802
806
  const decoded = decodeResponseBody(guarded, head.headers, o);
803
807
  const framing = { kind: 'h2', keepAliveEligible: false };
804
- return buildResponse(head, decoded, framing, h2);
808
+ return buildResponse(head, decoded, framing, h2, client.options.decompress === 'passthrough');
805
809
  }
806
810
 
807
811
  /**
@@ -859,22 +863,39 @@ function wantsKeepAlive(headInfo) {
859
863
  }
860
864
 
861
865
  function decodeResponseBody(body, headers, options) {
862
- if (options.decompress === false) return body;
866
+ // `false` means "do not ask for a coding and do not decode one". `'passthrough'` means "ask for
867
+ // gzip as usual, then hand the CODED bytes back with their Content-Encoding intact".
868
+ //
869
+ // The two are not variations on each other. `false` also drops gzip from Accept-Encoding, so the
870
+ // origin sends plaintext and the wire grows 2.76x on typical content — it loses on both sides.
871
+ // Passthrough is the combination a forwarding proxy actually wants, and until now it could only
872
+ // be assembled by setting `decompress: false` and then writing the Accept-Encoding header back on
873
+ // by hand, per request, which nothing documented.
874
+ //
875
+ // Measured end to end on a 4 MB body through a proxy: 118 ms decoding, 82.5 ms passing through —
876
+ // 30% — and 2.76x fewer bytes on the wire at the same time. But it only helps a caller who never
877
+ // needs the plaintext: if you parse the body, the decode is work you owe, not overhead, and
878
+ // passthrough merely moves it downstream.
879
+ if (options.decompress === false || options.decompress === 'passthrough') return body;
863
880
  const encoding = headers.get('content-encoding');
864
881
  if (!encoding) return body;
865
882
  return decodeBody(body, encoding, options.decoders ?? null, options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
866
883
  }
867
884
 
868
- function buildResponse(headInfo, body, framing, conn) {
885
+ function buildResponse(headInfo, body, framing, conn, coded = false) {
869
886
  const headers = new Headers();
870
887
  for (const [k, v] of headInfo.headers) {
871
888
  if (k === 'set-cookie') continue;
872
889
  headers.append(k, v);
873
890
  }
874
891
  for (const c of headInfo.setCookie ?? []) headers.append('set-cookie', c);
875
- if (headers.has('content-encoding')) {
892
+ if (headers.has('content-encoding') && !coded) {
876
893
  // The bytes handed to the caller are decoded, so a byte count describing the encoded form
877
894
  // would be a lie. Content-Encoding stays as information about what was on the wire.
895
+ //
896
+ // Under `decompress: 'passthrough'` the bytes are NOT decoded, so the length describes exactly
897
+ // what the caller holds and keeping it is what makes forwarding possible: a proxy that relays
898
+ // this Response needs a Content-Length that agrees with its body.
878
899
  headers.delete('content-length');
879
900
  }
880
901
 
@@ -265,6 +265,7 @@ export async function connectTls({ transport, hostname, verifyPeer, options = {}
265
265
  const record = new RecordLayer(transport, {
266
266
  maxHandshakeMessage: options.maxHandshakeMessage,
267
267
  maxKeyUpdates: options.maxKeyUpdates,
268
+ pullBytes: options.pullBytes,
268
269
  // ChaCha20-Poly1305 has no WebCrypto path here; its seal/open are injected via deps.aead and
269
270
  // threaded to every createAead. Null for the AES-GCM-only default, which needs nothing.
270
271
  aeadImpls: deps.aead ?? null,
package/src/tls/record.js CHANGED
@@ -118,7 +118,7 @@ export class RecordLayer {
118
118
  * @param {RecordLayerOptions} [opts]
119
119
  */
120
120
  constructor({ readable, writable }, opts = {}) {
121
- this._r = new ByteReader(readable);
121
+ this._r = new ByteReader(readable, opts.pullBytes);
122
122
  this._w = new ByteWriter(writable);
123
123
  this._maxHandshakeMessage = opts.maxHandshakeMessage ?? 1 << 17;
124
124
  this._maxKeyUpdates = opts.maxKeyUpdates ?? 32;
package/src/util/bytes.js CHANGED
@@ -15,6 +15,15 @@ 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:
19
+ //
20
+ // 16 KiB 42.0 64 KiB 38.5 256 KiB 46.5 1 MiB 57.0
21
+ //
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.
18
27
  const BYOB_PULL_BYTES = 65536;
19
28
 
20
29
  /** Raised when the peer stops sending in the middle of a structure we must read whole. */
@@ -50,8 +59,15 @@ export class UnexpectedEofError extends TunnelFetchError {
50
59
  * ReadableStream in this package and its tests) take the default-reader path unchanged.
51
60
  */
52
61
  export class ByteReader {
53
- /** @param {ReadableStream<Uint8Array>} readable */
54
- constructor(readable) {
62
+ /**
63
+ * @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 response — 42 ms of a 106 ms 4 MB request is socket
67
+ * reads and record decryption, of which the AEAD itself is under 2 ms.
68
+ */
69
+ constructor(readable, pullBytes = BYOB_PULL_BYTES) {
70
+ this._pullBytes = pullBytes > 0 ? pullBytes : BYOB_PULL_BYTES;
55
71
  /** @type {ReadableStreamBYOBReader | null} */
56
72
  this._byob = null;
57
73
  try {
@@ -100,7 +116,7 @@ export class ByteReader {
100
116
  async _pull() {
101
117
  if (this._eof) return false;
102
118
  const { value, done } = this._byob
103
- ? await this._byob.read(new Uint8Array(BYOB_PULL_BYTES))
119
+ ? await this._byob.read(new Uint8Array(this._pullBytes))
104
120
  : await this._reader.read();
105
121
  if (done) {
106
122
  this._eof = true;
@@ -398,11 +414,39 @@ export function equal(a, b) {
398
414
  */
399
415
  export function timingSafeEqual(a, b) {
400
416
  if (a.byteLength !== b.byteLength) return false;
417
+ // Prefer the runtime's own, which is compiled rather than interpreted and therefore actually has
418
+ // the property this function is named for. `crypto.subtle.timingSafeEqual` is a non-standard
419
+ // Cloudflare extension, so it is FEATURE-DETECTED rather than assumed — this package runs on
420
+ // Node, Deno and Bun as well, and inferring a capability from a runtime name is the mistake it
421
+ // refuses to make everywhere else. Detected once, at module load, because doing it per call would
422
+ // put a property lookup on a path whose whole point is uniform timing.
423
+ if (NATIVE_TIMING_SAFE_EQUAL) return NATIVE_TIMING_SAFE_EQUAL(a, b);
401
424
  let diff = 0;
402
425
  for (let i = 0; i < a.byteLength; i++) diff |= a[i] ^ b[i];
403
426
  return diff === 0;
404
427
  }
405
428
 
429
+ /**
430
+ * The runtime's constant-time compare, bound once, or null where there is none.
431
+ *
432
+ * Bound with a probe rather than a typeof check: a property that exists but throws on real input
433
+ * would otherwise be discovered inside a TLS Finished verification, where the failure mode is a
434
+ * dead connection on a path that is supposed to be the careful one.
435
+ */
436
+ const NATIVE_TIMING_SAFE_EQUAL = (() => {
437
+ const fn = globalThis.crypto?.subtle?.timingSafeEqual;
438
+ if (typeof fn !== 'function') return null;
439
+ try {
440
+ const one = Uint8Array.of(1, 2, 3);
441
+ const two = Uint8Array.of(1, 2, 4);
442
+ if (fn.call(globalThis.crypto.subtle, one, one) !== true) return null;
443
+ if (fn.call(globalThis.crypto.subtle, one, two) !== false) return null;
444
+ } catch {
445
+ return null;
446
+ }
447
+ return (a, b) => fn.call(globalThis.crypto.subtle, a, b);
448
+ })();
449
+
406
450
  const HEX = '0123456789abcdef';
407
451
  /**
408
452
  * @param {Uint8Array} bytes
package/types/client.d.ts CHANGED
@@ -58,7 +58,11 @@ export function install(options?: ClientOptions): () => void;
58
58
  * again on the DECODED output — a compressed body within the cap can decompress far past it, and
59
59
  * a registered decoder's output is bounded by it too. Pass `Infinity` to opt out, which is the
60
60
  * right choice for streaming large files and the wrong one for fetching URLs you do not control.
61
- * @property {boolean} [decompress] gzip/deflate. Default true.
61
+ * @property {boolean | 'passthrough'} [decompress] gzip/deflate. Default true. `false` neither
62
+ * advertises nor decodes a coding, so the origin sends plaintext and the wire grows.
63
+ * `'passthrough'` advertises gzip as usual but hands the CODED body back with its
64
+ * `Content-Encoding` intact — 30% cheaper on a 4 MB body and 2.76x fewer wire bytes, and correct
65
+ * only for a caller that forwards the body rather than reading it.
62
66
  * @property {Record<string, import('./client/decode.js').BodyDecoder>} [decoders] extra
63
67
  * content-codings this client can read, e.g. `{ br: (s) => ... }`. Registering one is what
64
68
  * makes advertising it honest, so each name is appended to Accept-Encoding — a client that
@@ -239,9 +243,13 @@ export type ClientOptions = {
239
243
  */
240
244
  maxBodyBytes?: number | undefined;
241
245
  /**
242
- * gzip/deflate. Default true.
246
+ * gzip/deflate. Default true. `false` neither
247
+ * advertises nor decodes a coding, so the origin sends plaintext and the wire grows.
248
+ * `'passthrough'` advertises gzip as usual but hands the CODED body back with its
249
+ * `Content-Encoding` intact — 30% cheaper on a 4 MB body and 2.76x fewer wire bytes, and correct
250
+ * only for a caller that forwards the body rather than reading it.
243
251
  */
244
- decompress?: boolean | undefined;
252
+ decompress?: boolean | "passthrough" | undefined;
245
253
  /**
246
254
  * extra
247
255
  * content-codings this client can read, e.g. `{ br: (s) => ... }`. Registering one is what
@@ -83,8 +83,15 @@ export class UnexpectedEofError extends TunnelFetchError {
83
83
  * ReadableStream in this package and its tests) take the default-reader path unchanged.
84
84
  */
85
85
  export class ByteReader {
86
- /** @param {ReadableStream<Uint8Array>} readable */
87
- constructor(readable: ReadableStream<Uint8Array>);
86
+ /**
87
+ * @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 response — 42 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
+ */
93
+ constructor(readable: ReadableStream<Uint8Array>, pullBytes?: number);
94
+ _pullBytes: number;
88
95
  /** @type {ReadableStreamBYOBReader | null} */
89
96
  _byob: ReadableStreamBYOBReader | null;
90
97
  _reader: ReadableStreamBYOBReader | ReadableStreamDefaultReader<Uint8Array<ArrayBufferLike>> | null;