tunnelfetch 1.0.1 → 1.1.1

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/src/client.js CHANGED
@@ -14,7 +14,7 @@ import { ConfigError, HttpError, TunnelFetchError, codes } from './errors.js';
14
14
  import { ByteReader, ByteWriter, UnexpectedEofError, concat, utf8 } from './util/bytes.js';
15
15
  import { serializeRequestHead } from './http1/request.js';
16
16
  import { bodyFraming, readResponseBody, readResponseHead } from './http1/response.js';
17
- import { ACCEPT_ENCODING, decodeBody } from './client/decode.js';
17
+ import { acceptEncodingFor, decodeBody } from './client/decode.js';
18
18
  import { CookieJar } from './client/cookies.js';
19
19
  import { DEFAULT_MAX_REDIRECTS, nextRequest, shouldRedirect } from './client/redirect.js';
20
20
  import { ConnectionPool, poolKey } from './pool.js';
@@ -71,8 +71,16 @@ const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
71
71
  * one across Clients or to persist it.
72
72
  * @property {number} [maxRedirects] default 20.
73
73
  * @property {number} [maxBodyBytes] enforced from Content-Length before a byte is read.
74
- * @property {boolean} [decompress] gzip/deflate. Default true. Never `br`; the runtime cannot
75
- * decompress it, so it is never advertised either.
74
+ * @property {boolean} [decompress] gzip/deflate. Default true.
75
+ * @property {Record<string, import('./client/decode.js').BodyDecoder>} [decoders] extra
76
+ * content-codings this client can read, e.g. `{ br: (s) => ... }`. Registering one is what
77
+ * makes advertising it honest, so each name is appended to Accept-Encoding — a client that
78
+ * asked for a coding it cannot decode would turn every such response into garbage. `br` and
79
+ * `zstd` are not built in because the runtime's DecompressionStream has neither and this
80
+ * package takes no dependencies; supply your own and the cost, and the supply chain, are
81
+ * yours and visible. Measured on the edge: WASM brotli decodes at about 2x native gzip, and
82
+ * the wire bytes it saves do not pay that back — see the README. The reason to turn it on is
83
+ * matching a browser's Accept-Encoding, not saving CPU.
76
84
  * @property {boolean} [keepAlive] default true.
77
85
  * @property {boolean} [http2] offer HTTP/2 via ALPN and speak it when the server selects it.
78
86
  * Default true. The goal is ACCESS, not speed — some sites treat HTTP/1.1 as a bot signal — and
@@ -93,7 +101,7 @@ export class Client {
93
101
  * @param {ClientOptions} [options]
94
102
  */
95
103
  constructor(options = {}) {
96
- this.options = { ...options };
104
+ this.options = snapshotOptions(options);
97
105
  this.pool = new ConnectionPool(options.pool);
98
106
  // HTTP/2 connections are NOT pooled the way h1 is: one connection multiplexes many concurrent
99
107
  // streams, so it is not checked out per request. It lives here, keyed exactly like the h1 pool,
@@ -118,6 +126,12 @@ export class Client {
118
126
  typeof options.now === 'number' ? { now: () => options.now } : {},
119
127
  );
120
128
  this._closed = false;
129
+ // Response bodies that have not finished arriving. `fetch` resolves at the response HEAD, so a
130
+ // caller holding a Response may still be streaming over a connection this Client owns — and on
131
+ // HTTP/2 that connection is SHARED and torn down by close(), not checked out of the pool the
132
+ // way an h1 socket is. Anything that decides when a Client is done needs to see these.
133
+ /** @type {Set<Promise<void>>} */
134
+ this._inflight = new Set();
121
135
  // Bound so a Client can be handed straight to an SDK expecting a bare function.
122
136
  this.fetch = this.fetch.bind(this);
123
137
  }
@@ -134,6 +148,19 @@ export class Client {
134
148
  return performFetch(this, input, init);
135
149
  }
136
150
 
151
+ /**
152
+ * Resolve once every response body handed out by this Client has finished, one way or another —
153
+ * read to the end, cancelled, or failed. Deliberately NOT folded into close(): close() is the
154
+ * forceful teardown, and a teardown that waits on the streams it is tearing down would hang on
155
+ * any body the caller abandoned. This is for callers that want the graceful order.
156
+ *
157
+ * Looping rather than a single Promise.all because a body settling can start another (a redirect
158
+ * drains its predecessor), and a set sampled once would miss the successor.
159
+ */
160
+ async idle() {
161
+ while (this._inflight.size) await Promise.all([...this._inflight]);
162
+ }
163
+
137
164
  /** Release every pooled socket and shared HTTP/2 connection. A Client that is finished must be
138
165
  * closed or sockets leak for the isolate's lifetime. */
139
166
  async close() {
@@ -156,11 +183,27 @@ export class Client {
156
183
  export function createFetch(options = {}) {
157
184
  return async function tunnelFetch(input, init) {
158
185
  const client = new Client(options);
186
+ let response;
159
187
  try {
160
- return await client.fetch(input, init);
161
- } finally {
188
+ response = await client.fetch(input, init);
189
+ } catch (err) {
162
190
  await client.close();
191
+ throw err;
163
192
  }
193
+ // NOT `finally { await client.close() }`. `fetch` resolves when the response HEAD arrives and
194
+ // the body is still on the wire, so closing here tore down the connection out from under the
195
+ // caller — invisibly on HTTP/1.1, where the socket is checked out of the pool and closeAll()
196
+ // could not reach it, but fatally on HTTP/2, where the connection is shared and close() ends
197
+ // every stream on it. `res.text()` then failed with HTTP2_PROTOCOL.
198
+ //
199
+ // Not awaited, or this would deadlock: the body only drains when the caller reads it, and the
200
+ // caller cannot read a Response that has not been returned yet.
201
+ //
202
+ // A caller who neither reads nor cancels the body would leave the Client open — the same leak
203
+ // as forgetting to close one — except that the idle deadline aborts a stalled body, which
204
+ // settles the completion and fires this anyway.
205
+ void client.idle().then(() => client.close().catch(() => {}));
206
+ return response;
164
207
  };
165
208
  }
166
209
 
@@ -422,6 +465,96 @@ function registerHttp2(client, key, conn) {
422
465
  * @param {unknown} err
423
466
  * @returns {boolean}
424
467
  */
468
+ /**
469
+ * Take a private, frozen copy of the security-relevant configuration.
470
+ *
471
+ * `{ ...options }` is a SHALLOW copy, so `client.options.trust` stayed the caller's own object: a
472
+ * caller could flip `revocation` or swap `pins` after construction and the next request would run
473
+ * under the new policy over a pool of connections verified under the old one. The pool key covers
474
+ * every field the verifier reads (see trustFingerprint), but a key computed from a mutable object
475
+ * is only as stable as the object.
476
+ *
477
+ * `proxy` is deliberately NOT frozen: openTunnel treats a frozen proxy as already normalised and
478
+ * skips parseProxy, so freezing a raw object here would bypass its validation entirely. Proxy
479
+ * identity is part of the pool key on its own, so a changed proxy gets a different key regardless.
480
+ *
481
+ * @param {ClientOptions} options
482
+ * @returns {Readonly<ClientOptions>}
483
+ */
484
+ function snapshotOptions(options) {
485
+ const o = { ...options };
486
+ if (o.trust && typeof o.trust === 'object') o.trust = deepCopyConfig(o.trust);
487
+ if (o.tls && typeof o.tls === 'object') o.tls = deepCopyConfig(o.tls);
488
+ if (o.timeouts && typeof o.timeouts === 'object') o.timeouts = Object.freeze({ ...o.timeouts });
489
+ return Object.freeze(o);
490
+ }
491
+
492
+ /**
493
+ * Recursively copy and freeze a configuration value so nothing the caller still holds can change
494
+ * what this Client trusts.
495
+ *
496
+ * A shallow freeze is not enough, and the gap was found in review: `Object.freeze([...anchors])`
497
+ * gives a frozen ARRAY whose elements are still the caller's `Uint8Array`s, and freezing a typed
498
+ * array does not freeze its bytes. Writing into the DER of a trust anchor after the first request
499
+ * therefore changed the certificate material this Client validated against — while the pool key
500
+ * stayed put, because `anchorDigest` memoises per array object. The same shape applies to `tls`,
501
+ * where `groups`, `ciphers` and `clientRandom` are all caller-owned mutable buffers.
502
+ *
503
+ * Only plain objects, arrays and byte arrays are copied. Everything else is passed through by
504
+ * REFERENCE, deliberately: `trust.verify` is a function whose identity is what distinguishes one
505
+ * custom policy from another in the pool key (see `customCounter`), and an AbortSignal or a
506
+ * CookieJar is a live object, not data. Copying either would break them.
507
+ *
508
+ * @template T
509
+ * @param {T} value
510
+ * @param {number} [depth]
511
+ * @returns {T}
512
+ */
513
+ function deepCopyConfig(value, depth = 0) {
514
+ // Certificate material is not deeply nested; a bound turns a pathological input into an error
515
+ // rather than a stack overflow, and nothing legitimate comes close to it.
516
+ if (depth > 8) {
517
+ throw new ConfigError(
518
+ codes.CONFIG_INVALID,
519
+ 'trust or tls configuration nests more than 8 levels deep; refusing to copy it',
520
+ );
521
+ }
522
+ if (ArrayBuffer.isView(value)) {
523
+ // Not frozen: V8 refuses to freeze a typed array that has elements. Immutability here comes
524
+ // from the copy being PRIVATE — the caller has no reference to it — rather than from
525
+ // Object.freeze. Copying the underlying buffer slice rather than the view also detaches it
526
+ // from any sibling view over the same memory.
527
+ const Ctor = /** @type {any} */ (value.constructor);
528
+ return new Ctor(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
529
+ }
530
+ if (Array.isArray(value)) {
531
+ return Object.freeze(value.map((v) => deepCopyConfig(v, depth + 1)));
532
+ }
533
+ if (value && typeof value === 'object') {
534
+ const proto = Object.getPrototypeOf(value);
535
+ // Only plain objects. A class instance is a live thing with behaviour, and rebuilding it from
536
+ // its own enumerable properties would produce something that merely looks like it.
537
+ if (proto !== Object.prototype && proto !== null) return value;
538
+ const out = {};
539
+ for (const [k, v] of Object.entries(value)) out[k] = deepCopyConfig(v, depth + 1);
540
+ return Object.freeze(out);
541
+ }
542
+ return value;
543
+ }
544
+
545
+ /**
546
+ * Remember a body until it settles. Never rejects: a failed body is still a finished one, and this
547
+ * set exists to answer "is anything still arriving", not "did it go well".
548
+ */
549
+ function trackBody(client, completed) {
550
+ const settled = completed.then(
551
+ () => {},
552
+ () => {},
553
+ );
554
+ client._inflight.add(settled);
555
+ settled.then(() => client._inflight.delete(settled));
556
+ }
557
+
425
558
  function serverNeverSawIt(err) {
426
559
  if (err instanceof UnexpectedEofError) return err.detail?.got === 0;
427
560
  return err instanceof TunnelFetchError && err.code === codes.TLS_TRUNCATED && err.detail?.got === 0;
@@ -493,6 +626,7 @@ async function sendAndReceive(client, conn, current, { key, deadlines, reused })
493
626
 
494
627
  // The connection goes back to the pool only when the body reaches the end its framing declared.
495
628
  // `completed` resolving false means the caller cancelled and the stream position is unknown.
629
+ trackBody(client, raw.completed);
496
630
  raw.completed.then(
497
631
  (ok) => {
498
632
  deadlines.dispose();
@@ -556,6 +690,7 @@ async function sendAndReceiveH2(client, h2, current, { deadlines }) {
556
690
  const raw = head.body;
557
691
  // Dispose the per-request deadline once the body is done, however it ends. Unlike h1 there is no
558
692
  // pool.release: the connection stays shared and alive for the next stream.
693
+ trackBody(client, raw.completed);
559
694
  raw.completed.then(
560
695
  () => deadlines.dispose(),
561
696
  () => deadlines.dispose(),
@@ -585,7 +720,7 @@ function buildH2Request(client, current, target) {
585
720
 
586
721
  if (!headers.has('accept')) headers.set('accept', '*/*');
587
722
  if (!headers.has('accept-encoding') && o.decompress !== false) {
588
- headers.set('accept-encoding', ACCEPT_ENCODING);
723
+ headers.set('accept-encoding', acceptEncodingFor(o.decoders));
589
724
  }
590
725
  if (client.jar) {
591
726
  const cookie = client.jar.headerFor(current.url);
@@ -624,7 +759,7 @@ function decodeResponseBody(body, headers, options) {
624
759
  if (options.decompress === false) return body;
625
760
  const encoding = headers.get('content-encoding');
626
761
  if (!encoding) return body;
627
- return decodeBody(body, encoding);
762
+ return decodeBody(body, encoding, options.decoders ?? null);
628
763
  }
629
764
 
630
765
  function buildResponse(headInfo, body, framing, conn) {
@@ -669,7 +804,7 @@ function buildHeaders(client, current, target) {
669
804
  if (!headers.has('accept-encoding') && client.options.decompress !== false) {
670
805
  // Never advertise br or zstd: the runtime has no DecompressionStream for either, so the
671
806
  // reward for asking would be a body we cannot decode.
672
- headers.set('accept-encoding', ACCEPT_ENCODING);
807
+ headers.set('accept-encoding', acceptEncodingFor(client.options.decoders));
673
808
  }
674
809
  if (client.jar) {
675
810
  const cookie = client.jar.headerFor(current.url);
@@ -117,10 +117,25 @@ export class Http2Retryable extends Http2Error {}
117
117
  * @property {number} [maxConcurrentStreams] our advertised SETTINGS_MAX_CONCURRENT_STREAMS.
118
118
  * @property {number} [maxHeaderTableSize] our advertised SETTINGS_HEADER_TABLE_SIZE.
119
119
  * @property {number} [maxHeaderListSize] self-protection cap on a decoded response header list.
120
+ * @property {number} [maxHeaderBlockBytes] cap on the RAW bytes of one HEADERS+CONTINUATION run,
121
+ * before HPACK decoding. Default 262144, matching the decoded cap. This is the bound that stops
122
+ * a CONTINUATION flood; `maxHeaderListSize` cannot, because it is only reachable once the whole
123
+ * block has been assembled in memory.
120
124
  * @property {(err: Error | null) => void} [onClose] called once when the connection dies, so a
121
125
  * registry can drop it.
122
126
  */
123
127
 
128
+ /**
129
+ * The declared content-length, or null when absent. A list with conflicting values is refused by
130
+ * the header layer before this is reached; a malformed single value is treated as absent rather
131
+ * than as zero, because guessing a length is the failure mode being closed here.
132
+ */
133
+ function contentLengthOf(headers) {
134
+ const raw = headers.get?.('content-length') ?? null;
135
+ if (raw === null) return null;
136
+ return /^\d+$/.test(raw.trim()) ? Number(raw.trim()) : null;
137
+ }
138
+
124
139
  export class Http2Connection {
125
140
  /**
126
141
  * @param {import('../tls/connect.js').ByteDuplex | { readable: ReadableStream<Uint8Array>,
@@ -169,7 +184,18 @@ export class Http2Connection {
169
184
  // Header-block continuation state: while assembling a HEADERS+CONTINUATION run, no other frame
170
185
  // may interleave (RFC 9113 s6.10). Non-null means "the next frame must be CONTINUATION on this
171
186
  // stream id".
172
- this._continuation = null; // { streamId, fragments: Uint8Array[], endStream, kind }
187
+ this._continuation = null; // { streamId, fragments: Uint8Array[], bytes, endStream, kind }
188
+ // A header block arrives as a HEADERS frame plus any number of CONTINUATION frames, and it is
189
+ // only decodable once the last one lands — so the fragments must be held. `maxHeaderListSize`
190
+ // bounds the DECODED list and is checked in _completeHeaderBlock, which is to say after the
191
+ // whole block is already in memory: a peer that simply never sets END_HEADERS never reaches
192
+ // it. That is the CONTINUATION flood (the class behind CVE-2024-27316 and its siblings), and
193
+ // it is bounded here instead, on the raw bytes, as they arrive.
194
+ //
195
+ // 262144 by default, the same figure as the decoded cap. HPACK does not expand — an indexed
196
+ // reference makes a small input decode LARGER, never the reverse — so a block whose raw size
197
+ // exceeds the decoded cap could not have produced an acceptable header list anyway.
198
+ this._maxHeaderBlockBytes = opts.maxHeaderBlockBytes ?? 262144;
173
199
  this._expectFirstSettings = true;
174
200
 
175
201
  this._fatal = null; // set once; rejects every stream and every future request
@@ -306,6 +332,9 @@ export class Http2Connection {
306
332
  /** @type {Uint8Array[]} */
307
333
  recvQueue: [],
308
334
  recvEnded: false,
335
+ /** @type {number | null} declared content-length, null when absent */
336
+ declaredLength: null,
337
+ receivedLength: 0,
309
338
  /** @type {Error | null} */
310
339
  bodyError: null,
311
340
  /** @type {(() => void) | null} */
@@ -368,7 +397,13 @@ export class Http2Connection {
368
397
  this._connSendWindow -= n;
369
398
  const end = offset >= body.byteLength;
370
399
  await this._write(dataFrame(streamId, slice, end));
371
- if (end) stream.localEnded = true;
400
+ if (end) {
401
+ stream.localEnded = true;
402
+ // The remote half may have ended long ago — a server is free to answer before the upload
403
+ // finishes — in which case nothing else will ever re-check, and the stream stays in the
404
+ // map for the life of the connection. Every other place that ends a half calls this.
405
+ this._maybeCloseStream(stream);
406
+ }
372
407
  }
373
408
  }
374
409
 
@@ -673,12 +708,43 @@ export class Http2Connection {
673
708
  if (flags & FLAG.END_HEADERS) {
674
709
  this._completeHeaderBlock(streamId, fragment, endStream);
675
710
  } else {
676
- this._continuation = { streamId, fragments: [fragment.slice()], endStream };
711
+ this._continuation = {
712
+ streamId,
713
+ fragments: [fragment.slice()],
714
+ bytes: fragment.length,
715
+ endStream,
716
+ };
717
+ // The opening frame alone can exceed the cap when SETTINGS_MAX_FRAME_SIZE is large.
718
+ if (!this._headerBlockWithinCap()) return;
677
719
  }
678
720
  }
679
721
 
722
+ /**
723
+ * Enforce the raw header-block cap, killing the connection when it is passed.
724
+ * Connection-level rather than stream-level on purpose: the fragments are HPACK input, and
725
+ * abandoning a partial block would leave the shared decoder desynchronised for every other
726
+ * stream — which RFC 9113 s4.3 makes a connection error in its own right.
727
+ * @returns {boolean} true when assembly may continue
728
+ */
729
+ _headerBlockWithinCap() {
730
+ const { bytes, streamId } = this._continuation;
731
+ if (bytes <= this._maxHeaderBlockBytes) return true;
732
+ this._continuation = null;
733
+ this._die(
734
+ new Http2Error(
735
+ codes.HTTP2_PROTOCOL,
736
+ `header block on stream ${streamId} reached ${bytes} bytes across HEADERS and ` +
737
+ `CONTINUATION frames, over the ${this._maxHeaderBlockBytes} byte cap`,
738
+ { streamId, bytes, cap: this._maxHeaderBlockBytes },
739
+ ),
740
+ );
741
+ return false;
742
+ }
743
+
680
744
  _onContinuation(flags, payload) {
681
745
  this._continuation.fragments.push(payload.slice());
746
+ this._continuation.bytes += payload.length;
747
+ if (!this._headerBlockWithinCap()) return;
682
748
  if (flags & FLAG.END_HEADERS) {
683
749
  const { streamId, fragments, endStream } = this._continuation;
684
750
  this._continuation = null;
@@ -734,6 +800,13 @@ export class Http2Connection {
734
800
  return;
735
801
  }
736
802
  stream.responseReceived = true;
803
+ // RFC 9113 s8.1.1: a message with a content-length that disagrees with the DATA delivered is
804
+ // malformed. h1 enforces this through its framing; h2 declares the length in a header and
805
+ // delimits with END_STREAM, so the two can disagree — and a body that silently differs from
806
+ // its declared length is exactly the ambiguity this package refuses everywhere else. Held on
807
+ // the stream and checked as DATA arrives and again at END_STREAM.
808
+ stream.declaredLength = contentLengthOf(head.headers);
809
+ stream.receivedLength = 0;
737
810
  if (endStream) {
738
811
  // No body and no trailers: the completion contract is satisfiable now, exactly like the h1
739
812
  // "complete at creation" case, so a caller that never reads the (empty) body still lets the
@@ -777,6 +850,13 @@ export class Http2Connection {
777
850
  }
778
851
 
779
852
  _onData(flags, streamId, payload) {
853
+ if (streamId === 0) {
854
+ // RFC 9113 s6.1: DATA is always associated with a stream, and a zero id MUST be a connection
855
+ // error. Absorbing it silently was letting a peer push bytes with no stream to charge them
856
+ // to, which is the shape of a smuggling primitive as much as a resource one.
857
+ this._die(new Http2Error(codes.HTTP2_PROTOCOL, 'DATA frame on stream 0'));
858
+ return;
859
+ }
780
860
  const stream = this._streams.get(streamId);
781
861
  // Flow control is accounted at the connection level for EVERY DATA frame, even one for a
782
862
  // stream we have already closed — the peer spent connection window to send it, and not
@@ -824,11 +904,40 @@ export class Http2Connection {
824
904
  if (overhead > 0) this._replenishConn(overhead);
825
905
  // The stream window was debited by flowLen; credit the overhead back on the stream too.
826
906
  if (overhead > 0) this._replenish(stream, overhead);
907
+ stream.receivedLength += data.byteLength;
908
+ // Caught on the way past rather than only at END_STREAM, so an over-long body is refused
909
+ // before the excess is queued for the caller.
910
+ if (stream.declaredLength !== null && stream.receivedLength > stream.declaredLength) {
911
+ this._resetStream(
912
+ stream,
913
+ H2_ERROR.PROTOCOL_ERROR,
914
+ new Http2Error(
915
+ codes.HTTP2_PROTOCOL,
916
+ `response body is longer than its content-length: ${stream.receivedLength} bytes so ` +
917
+ `far against a declared ${stream.declaredLength}`,
918
+ { declared: stream.declaredLength, received: stream.receivedLength },
919
+ ),
920
+ );
921
+ return;
922
+ }
827
923
  if (data.byteLength > 0) {
828
924
  stream.recvQueue.push(data.slice());
829
925
  this._wakePull(stream);
830
926
  }
831
927
  if (flags & FLAG.END_STREAM) {
928
+ if (stream.declaredLength !== null && stream.receivedLength !== stream.declaredLength) {
929
+ this._resetStream(
930
+ stream,
931
+ H2_ERROR.PROTOCOL_ERROR,
932
+ new Http2Error(
933
+ codes.HTTP2_PROTOCOL,
934
+ `response body ended at ${stream.receivedLength} bytes against a declared ` +
935
+ `content-length of ${stream.declaredLength}`,
936
+ { declared: stream.declaredLength, received: stream.receivedLength },
937
+ ),
938
+ );
939
+ return;
940
+ }
832
941
  stream.recvEnded = true;
833
942
  this._wakePull(stream);
834
943
  this._maybeCloseStream(stream);
package/src/pool.js CHANGED
@@ -52,15 +52,31 @@ export function poolKey({ scheme, hostname, port, proxy, trust, tls }) {
52
52
 
53
53
  function trustFingerprint(trust) {
54
54
  const mode = trust?.mode ?? 'system';
55
- if (mode === 'system') return 'system';
56
- if (mode === 'none') return 'none';
55
+ // The fingerprint must cover exactly the fields the VERIFIER reads for this mode no fewer, or
56
+ // a connection validated under one policy serves a request under another; no more, or the pool
57
+ // is split along axes that make no difference. src/trust/index.js states the set per mode by
58
+ // rejecting every other key outright (`forbidKeys`), so this mirrors those lists.
59
+ const rev = `rev=${trust?.revocation ?? 'staple'}`;
60
+ const pins = `pins=${[...(trust?.pins ?? [])].sort().join(',')}`;
61
+ // Anchors matter even when they are absent: omitting them means the system store, and a policy
62
+ // pinned against custom anchors is not the policy pinned against the system store.
63
+ const anchors =
64
+ trust?.anchors === undefined
65
+ ? 'anchors=system'
66
+ : `anchors=${trust.anchors.length}:${anchorDigest(trust.anchors)}`;
67
+
68
+ // 'none' forbids anchors/verify/revocation, so pins are the only thing that can vary — and they
69
+ // DO vary: pin-only trust still enforces them.
70
+ if (mode === 'none') return `none|${pins}`;
57
71
  if (mode === 'custom') {
58
72
  // Two different callbacks are two different policies and we cannot compare functions, so a
59
73
  // custom policy never shares a connection. Correct, and cheap: custom trust is rare.
74
+ // ('custom' forbids anchors/pins/revocation — the callback owns the whole decision.)
60
75
  return `custom:${customCounter(trust.verify)}`;
61
76
  }
62
- if (mode === 'pinned') return `pinned:${[...(trust.pins ?? [])].sort().join(',')}`;
63
- if (mode === 'anchors') return `anchors:${(trust.anchors ?? []).length}:${anchorDigest(trust.anchors)}`;
77
+ if (mode === 'system') return `system|${rev}`;
78
+ if (mode === 'anchors') return `anchors:${(trust.anchors ?? []).length}:${anchorDigest(trust.anchors)}|${rev}`;
79
+ if (mode === 'pinned') return `pinned|${pins}|${anchors}|${rev}`;
64
80
  return `unknown:${mode}`;
65
81
  }
66
82
 
@@ -1,13 +1,31 @@
1
+ /**
2
+ * The Accept-Encoding to send given the caller's extra decoders.
3
+ *
4
+ * Registering a decoder is what makes advertising its coding honest — the two must move together
5
+ * or the client asks for bytes it cannot read. Order is registration order after the built-ins,
6
+ * so a caller matching a browser can produce exactly `gzip, deflate, br, zstd`.
7
+ *
8
+ * @param {Record<string, BodyDecoder> | null | undefined} decoders
9
+ * @returns {string}
10
+ */
11
+ export function acceptEncodingFor(decoders: Record<string, BodyDecoder> | null | undefined): string;
1
12
  /**
2
13
  * Undo the response's Content-Encoding.
3
14
  *
15
+ /**
16
+ * A caller-supplied content decoder: raw coded bytes in, decoded bytes out. Streaming, so a body
17
+ * is never fully buffered on this client's behalf.
18
+ * @typedef {(stream: ReadableStream<Uint8Array>) => ReadableStream<Uint8Array>} BodyDecoder
19
+ */
20
+ /**
4
21
  * @param {ReadableStream<Uint8Array>} stream the raw body
5
22
  * @param {string|null|undefined} contentEncoding the Content-Encoding header value; a
6
23
  * comma-separated list names codings in the order the SERVER applied them, so decoding
7
24
  * applies them in reverse.
25
+ * @param {Record<string, BodyDecoder> | null} [decoders] caller-supplied codings
8
26
  * @returns {ReadableStream<Uint8Array>} decoded bytes
9
27
  */
10
- export function decodeBody(stream: ReadableStream<Uint8Array>, contentEncoding: string | null | undefined): ReadableStream<Uint8Array>;
28
+ export function decodeBody(stream: ReadableStream<Uint8Array>, contentEncoding: string | null | undefined, decoders?: Record<string, BodyDecoder> | null): ReadableStream<Uint8Array>;
11
29
  /**
12
30
  * Extract the charset parameter from a Content-Type value, handling quoting and other
13
31
  * parameters: `text/html; boundary=x; charset="ISO-8859-4"` -> 'iso-8859-4'.
@@ -46,9 +64,21 @@ export function charsetFor(contentType: string | null | undefined, bodyPrefix?:
46
64
  */
47
65
  export function decodeText(bytes: Uint8Array, charset?: string): string;
48
66
  /**
49
- * The exact Accept-Encoding value the request layer must send. The target runtime's
67
+ * What the request layer advertises with no extra decoders registered. The target runtime's
50
68
  * DecompressionStream supports ONLY gzip / deflate / deflate-raw (verified empirically);
51
69
  * advertising `br` or `zstd` would invite bytes we can never decode, turning every response
52
70
  * from a brotli-preferring CDN into garbage. Keep this list and decodeBody in lockstep.
71
+ *
72
+ * It is also exactly what curl sends, which matters because this client presents curl's TLS and
73
+ * HTTP/2 fingerprints by default: a browser-shaped handshake paired with a curl-shaped
74
+ * Accept-Encoding is a mismatch a bot detector can read straight off the wire.
53
75
  */
54
76
  export const ACCEPT_ENCODING: "gzip, deflate";
77
+ /**
78
+ * Undo the response's Content-Encoding.
79
+ *
80
+ * /**
81
+ * A caller-supplied content decoder: raw coded bytes in, decoded bytes out. Streaming, so a body
82
+ * is never fully buffered on this client's behalf.
83
+ */
84
+ export type BodyDecoder = (stream: ReadableStream<Uint8Array>) => ReadableStream<Uint8Array>;