tunnelfetch 1.6.1 → 1.6.3

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
@@ -426,11 +426,23 @@ pinned in `test/tls/fingerprint.test.js` and `test/http2/fingerprint.test.js`.
426
426
  | Supported groups | `x25519, secp256r1, secp384r1, secp521r1` | `tls.groups` |
427
427
  | Signature algorithms | ECDSA and RSA-PSS/PKCS#1 over SHA-256/384/512 | `tls.sigSchemes` |
428
428
  | ALPN | `h2, http/1.1` | `tls.alpn` |
429
- | HTTP/2 `SETTINGS` ids **and order** | curl's: `MAX_CONCURRENT_STREAMS, INITIAL_WINDOW_SIZE, ENABLE_PUSH` | `http2Settings` |
429
+ | HTTP/2 `SETTINGS` ids **and order** | curl's: `MAX_CONCURRENT_STREAMS, INITIAL_WINDOW_SIZE, ENABLE_PUSH`. The **values** differ by default — see below | `http2Settings` |
430
430
  | h2 preface, `WINDOW_UPDATE`, pseudo-header order | curl's, byte-for-byte | `http2ConnectionWindow`, `http2PseudoHeaderOrder` |
431
431
  | HPACK representation | curl's (`:path` without indexing, the rest incremental) | `http2HpackIndexing` |
432
432
  | `Accept-Encoding` | `gzip, deflate` — curl's | `decoders` appends |
433
433
 
434
+ **The default `SETTINGS` values are this package's, not curl's, and that is a deliberate trade.**
435
+ `profiles.curl` carries curl 8.21.0's real `INITIAL_WINDOW_SIZE` of **64 KiB**, captured and pinned
436
+ in `test/tls/_captured-h2.js`. The connection default without a profile is **10 MiB**, which is what
437
+ curl 8.7.1 sent and what this package keeps for throughput: a 64 KiB stream window means far more
438
+ `WINDOW_UPDATE` round trips on a large body, and the cost work in this README shows body transfer is
439
+ where the money is. **That throughput difference has not been measured**, so if you are moving large
440
+ bodies under `profiles.curl`, measure it before relying on it — or set `http2Settings` yourself.
441
+
442
+ Until 1.6.2 the profile carried curl **8.7.1's** window while presenting curl **8.21.0's**
443
+ ClientHello: one named client, two source versions, and a split identity that only a capture could
444
+ find, because each half was individually true of some curl.
445
+
434
446
  Extension order matters because JA3 and JA4 hash the extension list **in wire order**, so it is most
435
447
  of what a fingerprinter reads. `pre_shared_key` is forced last whatever you ask for: RFC 8446
436
448
  §4.2.11 defines the binder transcript as the hello truncated just before the binders, which is a
package/README.zh-CN.md CHANGED
@@ -334,11 +334,20 @@ curl(8.7.1 / nghttp2),照线上抓包原样复刻。这么做是实证需
334
334
  | supported_groups | `x25519, secp256r1, secp384r1, secp521r1` | `tls.groups` |
335
335
  | 签名算法 | SHA-256/384/512 上的 ECDSA 与 RSA-PSS/PKCS#1 | `tls.sigSchemes` |
336
336
  | ALPN | `h2, http/1.1` | `tls.alpn` |
337
- | HTTP/2 `SETTINGS` 的 id **与顺序** | curl 的:`MAX_CONCURRENT_STREAMS, INITIAL_WINDOW_SIZE, ENABLE_PUSH` | `http2Settings` |
337
+ | HTTP/2 `SETTINGS` 的 id **与顺序** | curl 的:`MAX_CONCURRENT_STREAMS, INITIAL_WINDOW_SIZE, ENABLE_PUSH`。**取值**默认不同——见下 | `http2Settings` |
338
338
  | h2 前导、`WINDOW_UPDATE`、伪头顺序 | curl 的,逐字节一致 | `http2ConnectionWindow`、`http2PseudoHeaderOrder` |
339
339
  | HPACK 表示 | curl 的(`:path` 不索引,其余 incremental) | `http2HpackIndexing` |
340
340
  | `Accept-Encoding` | `gzip, deflate`——curl 的 | `decoders` 会追加 |
341
341
 
342
+ **默认的 `SETTINGS` 取值是本包自己的,不是 curl 的,这是一个有意的取舍。** `profiles.curl` 带的是
343
+ curl 8.21.0 实测的 `INITIAL_WINDOW_SIZE` = **64 KiB**,抓包固化在 `test/tls/_captured-h2.js`。而不加
344
+ profile 时连接的默认值是 **10 MiB**——那是 curl 8.7.1 发的值,本包为吞吐保留它:64 KiB 的流窗口意味着大
345
+ body 上要多得多的 `WINDOW_UPDATE` 往返,而本文档的成本部分已经说明,钱就花在 body 传输上。**这个吞吐差异
346
+ 我没有实测过**,所以如果你在 `profiles.curl` 下搬大 body,先测再依赖,或者自己设 `http2Settings`。
347
+
348
+ 1.6.2 之前,这个 profile 带着 curl **8.7.1** 的窗口,却呈现 curl **8.21.0** 的 ClientHello:同一个具名
349
+ 客户端,两个来源版本——一个只有抓包才能发现的身份分裂,因为两半各自都真的属于某个 curl。
350
+
342
351
  扩展顺序之所以要紧,是因为 JA3 和 JA4 哈希的正是**线上顺序**的扩展列表,那是指纹识别读到的主要内容。`pre_shared_key` 无论你怎么配都强制排最后:RFC 8446 §4.2.11 把 binder 的转录定义为"截到 binder 之前的那段 hello",只有后面不跟东西时这个范围才成立。
343
352
 
344
353
  **默认值刻意与 curl 不同的地方**,以及为什么不能照抄:ClientHello 是一份**要约**,服务器可以接受其中任何一项。声明你做不到的事,等于拿指纹不一致换一次握手失败——后者更糟,而且是静默的。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunnelfetch",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
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",
@@ -201,7 +201,21 @@ export class Http2Connection {
201
201
 
202
202
  // Our advertised settings. The defaults ARE the fingerprint (see constants.js); overrides
203
203
  // exist for tests, and shifting them shifts what the server sees, so production leaves them.
204
- this._ourInitialWindow = opts.initialWindowSize ?? CLIENT_INITIAL_WINDOW_SIZE;
204
+ // The window we ADVERTISE and the window we ACCOUNT FOR must be the same number, and until
205
+ // 1.6.3 they were two independent settings that silently disagreed.
206
+ //
207
+ // `opts.settings` is the SETTINGS flight, which is how a fingerprint profile states its
208
+ // identity; SETTINGS_INITIAL_WINDOW_SIZE (id 4) inside it is what the peer is told. This field
209
+ // is what `_replenish` measures against — it only emits a WINDOW_UPDATE once the consumer has
210
+ // drained half of it. Take them from different places and a profile advertising 64 KiB against
211
+ // a default accounting of 10 MiB sets a replenish threshold of 5 MiB that a 64 KiB window can
212
+ // never reach: the peer sends one window's worth, stops, and no WINDOW_UPDATE ever comes. Every
213
+ // response larger than the advertised window hangs until the idle deadline fires.
214
+ //
215
+ // That is not hypothetical — `profiles.curl` gained curl 8.21.0's real 65536 in 1.6.2 and this
216
+ // is what it did. One source of truth: if the flight names id 4, that IS the window.
217
+ const advertised = (opts.settings ?? []).find(([id]) => id === 0x4)?.[1];
218
+ this._ourInitialWindow = opts.initialWindowSize ?? advertised ?? CLIENT_INITIAL_WINDOW_SIZE;
205
219
  this._ourConnWindow = opts.connectionWindow ?? CLIENT_CONNECTION_WINDOW;
206
220
  this._ourMaxConcurrent = opts.maxConcurrentStreams ?? CLIENT_MAX_CONCURRENT_STREAMS;
207
221
  this._ourHeaderTableSize = opts.maxHeaderTableSize ?? DEFAULT_HEADER_TABLE_SIZE;
package/src/profiles.js CHANGED
@@ -10,8 +10,17 @@
10
10
  // it declares what it REQUIRES — because a profile that quietly drops the half of itself this
11
11
  // runtime cannot perform would recreate exactly the incoherence it exists to prevent.
12
12
  //
13
- // The values are captured, not recalled. curl 8.21.0 / OpenSSL 3.6.3 and Chromium, both read off
14
- // the wire on 2026-08-01. See test/tls/fingerprint.test.js and test/tls/grease.test.js.
13
+ // The values are captured, not recalled and as of 1.6.2 the captures are COMMITTED, in
14
+ // test/tls/_captured-hellos.js, recorded by scripts/capture-clienthello.mjs from curl 8.21.0 /
15
+ // OpenSSL 3.6.3 and from Chromium. Until then the claim had no artifact behind it: the tests
16
+ // compared the builder against the constants below, which catches drift and cannot catch the
17
+ // constants being wrong about the client they name.
18
+ //
19
+ // Two things that check found, both now asserted rather than assumed:
20
+ // * curl's TLS backend is part of its identity. macOS system curl (8.7.1, LibreSSL) sends SEVEN
21
+ // extensions where the OpenSSL build sends twelve, so "curl" alone does not name a fingerprint.
22
+ // * Chromium leads signature_algorithms with ML-DSA-44/65/87 (0x0904/5/6), which this package
23
+ // cannot verify and therefore must not offer. The omission is correct and was undocumented.
15
24
 
16
25
  import { ConfigError, codes } from './errors.js';
17
26
  import { CURL_EXTENSION_ORDER, SHUFFLE_EXTENSIONS } from './tls/handshake-messages.js';
@@ -44,7 +53,14 @@ export const curl = Object.freeze({
44
53
  headerOrder: CURL_HEADER_ORDER,
45
54
  headers: Object.freeze([['User-Agent', 'curl/8.21.0']]),
46
55
  // Captured: MAX_CONCURRENT_STREAMS, INITIAL_WINDOW_SIZE, ENABLE_PUSH, in that order.
47
- http2Settings: Object.freeze([[3, 100], [4, 10485760], [2, 0]]),
56
+ //
57
+ // INITIAL_WINDOW_SIZE is 65536 and used to be 10485760, which was curl 8.7.1's value. curl changed
58
+ // it between 8.7.1 and 8.21.0, and this profile's TLS half is captured from 8.21.0 — so the
59
+ // package was presenting one curl's ClientHello above a different curl's SETTINGS. A split
60
+ // identity inside a single named client, and one only a capture could find: both halves were
61
+ // internally consistent and each was individually true of SOME curl. See test/tls/_captured-h2.js,
62
+ // which holds both recordings side by side for exactly this reason.
63
+ http2Settings: Object.freeze([[3, 100], [4, 65536], [2, 0]]),
48
64
  http2PseudoHeaderOrder: Object.freeze([':method', ':scheme', ':authority', ':path']),
49
65
  http2HpackIndexing: Object.freeze({ ':path': 'without' }),
50
66
  requires: Object.freeze([]),
@@ -95,14 +111,18 @@ export const chrome = Object.freeze({
95
111
  // both fingerprints are exactly what the browser normally sends.
96
112
  http2Settings: Object.freeze([[1, 65536], [2, 0], [4, 6291456], [6, 262144]]),
97
113
  http2ConnectionWindow: 15663105 + 65535,
98
- // NOT captured: http2HpackIndexing. So this identity currently presents curl's HPACK
99
- // representation (`:path` without indexing, everything else incremental) under a Chromium
100
- // ClientHello, which is the split identity this whole module exists to prevent narrowed to one
101
- // field, but real, and named here rather than left to be discovered. It is not in `requires`
102
- // because refusing the profile outright over one uncaptured field would take away the working
103
- // 90% of the identity, and because the honest fix is a capture rather than a guess: this package
104
- // does not invent fingerprint values. See test/http2/fingerprint.test.js, which pins the absence
105
- // so that supplying it later is a deliberate act.
114
+ // Captured in 1.6.2, and the answer was that there was nothing to change: Chromium emits `:path`
115
+ // WITHOUT indexing and everything else incrementally, byte-identical in representation to curl.
116
+ // 1.6.1 recorded this as an uncaptured gap and refused to guess at it; the guess would in fact
117
+ // have been right, which is not a reason to have guessed. It is declared explicitly now so the
118
+ // identity states its own value instead of inheriting one by accident.
119
+ http2HpackIndexing: Object.freeze({ ':path': 'without' }),
120
+ // STILL a real difference, and newly found by the same capture: Chromium sets the PRIORITY flag
121
+ // on its request HEADERS frame (flags 0x25) and carries the five deprecated priority bytes.
122
+ // This package never sets it (see frames.js headersFrame), so the frame layout differs from
123
+ // Chromium's even where every value above it matches. Not in `requires` because it is a framing
124
+ // detail this package deliberately does not emit — RFC 9113 s5.3.2 deprecates it — but it is a
125
+ // signal a frame-level fingerprinter reads, and it is written down here rather than discovered.
106
126
  //
107
127
  // The connection window immediately above was DEAD until 1.6.1 — declared here, copied by
108
128
  // nothing, passed by nothing, and read under a different name — so every connection using this
@@ -71,6 +71,9 @@ export const chrome: Readonly<{
71
71
  }>;
72
72
  http2Settings: readonly number[][];
73
73
  http2ConnectionWindow: number;
74
+ http2HpackIndexing: Readonly<{
75
+ ':path': "without";
76
+ }>;
74
77
  http2PseudoHeaderOrder: readonly string[];
75
78
  headerOrder: readonly string[];
76
79
  headers: readonly string[][];