tunnelfetch 1.9.0 → 1.12.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.zh-CN.md CHANGED
@@ -517,17 +517,26 @@ CertificateError [CERT_PIN_MISMATCH]: no certificate in the chain matches any co
517
517
 
518
518
  ### 一个请求要多少
519
519
 
520
- 通过代理抓取一个尺寸可控的源站,热态,同一 isolate 7 轮以上取中位数,传输走 gzip。最后一列是同样的数字
521
- 换算成速率,那是更值得随身记住的形式:
520
+ 通过代理抓取 `sizeorigin/`,传输走 gzip,所有尺寸在**同一次扫描、同一个 isolate** 内跑完,n>=5 取中位数。
521
+ 方法写在这里,是因为上一版这张表没有把方法记清楚到能复现的程度:**一个热态页面 = `(reuse=4 - reuse=1) / 3`**,
522
+ 也就是连接已经打开之后第 2 到第 4 个页面的成本;最后一列是同一次扫描测出来的新建连接数字。
522
523
 
523
- | Body | 5 页均摊 | 复用连接 | 新建连接 |
524
+ | Body | 热态页面 | 每解压 MB | 新连接上的第一个请求 |
524
525
  | --- | --- | --- | --- |
525
- | 1 KB | 3.2 ms | 1.7 ms | 9.2 ms |
526
- | 16 KB | 4.6 ms | 3.1 ms | 10.6 ms |
527
- | 64 KB | 8.2 ms | 6.7 ms | 14.2 ms |
528
- | 256 KB | 18.2 ms | 16.7 ms | 24.2 ms |
529
- | 1 MB | 54.8 ms | 53.3 ms | 60.8 ms |
530
- | 4 MB | 119.8 ms | 118.3 ms | 125.8 ms |
526
+ | 1 KB | **1.3 ms** | | 8 ms |
527
+ | 16 KB | **2.3 ms** | 149 ms/MB | 9 ms |
528
+ | 64 KB | **6.0 ms** | 96 ms/MB | 13 ms |
529
+ | 256 KB | **14.3 ms** | 57 ms/MB | 28 ms |
530
+ | 1 MB | **36.3 ms** | 36 ms/MB | 59 ms |
531
+ | 4 MB | **102 ms** | 26 ms/MB | 135 ms |
532
+
533
+ 最后一列不是「热态页面 + 一次握手」。4 MB 那一行它比热态页面高 33 ms,而握手只值个位数——因为一条连接上的
534
+ **第一个** body 还要在 V8 把解码循环编译优化之前跑一遍。评估一个新源站要看这一列,不要看第一列。
535
+
536
+ **2026 年 8 月重测,中间几档动了。** 4 MB 那行几乎原样复现(102 对先前发布的 104);64 KB 到 1 MB 比这张表
537
+ 原先的数字低 20–30%。那**不是**下面讲的 socket 视图大小改动——在同一个 isolate 里 A/B 新旧视图大小,1 MB 热态
538
+ 页面只差约 1 ms——所以它要么是单次扫描看不到的日间波动,要么是被取代的那张表取数方式不同。旧数字已经无从复查,
539
+ 这正是把方法写在这里的理由。
531
540
 
532
541
  冷启动成本是**总数**,不是往上面某一行加的增量:
533
542
 
@@ -554,35 +563,45 @@ CertificateError [CERT_PIN_MISMATCH]: no certificate in the chain matches any co
554
563
  ### 这些折算成多少钱
555
564
 
556
565
  Workers Standard 每月 $5,含 1000 万请求和 3000 万 CPU 毫秒,超出部分每百万请求 $0.30、每百万 CPU 毫秒
557
- $0.02。把上面的实测代入,并把冷启动的影响单独列成两组列,这样任一负载「预热与否」的差别是看得见的:
558
-
559
- | 工作负载 | CPU/请求 | 1000 万,冷 | 1000 万,预热 | 10 亿,冷 | 10 亿,预热 |
560
- | --- | --- | --- | --- | --- | --- |
561
- | 平台 `fetch`,16 KB —— 参照;它用不了代理 | 0.3 ms | $5.00 | $5.00 | $307.40 | $307.40 |
562
- | 平台 `fetch`,4 MB —— 同一参照,实测 | 3.2 ms | $5.04 | $5.04 | $365.40 | $365.40 |
563
- | 连接复用,16 KB 页面 | 3.1 ms | $5.90 | $5.24 | $451.20 | $385.20 |
564
- | 每请求新建连接,16 KB | 10.6 ms | $7.41 | $6.75 | $602.20 | $536.20 |
565
- | 连接复用,1 MB 页面 | 53.3 ms | $15.94 | $15.28 | $1455.20 | $1389.20 |
566
- | 每请求新建连接,1 MB | 60.8 ms | $17.45 | $16.79 | $1606.20 | $1540.20 |
567
- | 连接复用,4 MB 页面 | 118.3 ms | $28.94 | $28.28 | $2755.20 | $2689.20 |
568
- | 每请求新建连接,4 MB | 125.8 ms | $30.45 | $29.79 | $2906.20 | $2840.20 |
566
+ $0.02。公式就是 `$5 + max(0, 请求数 - 1000万) x $0.30/百万 + max(0, CPU毫秒 - 3000万) x $0.02/百万`,
567
+ 没有别的:
568
+
569
+ | 工作负载 | CPU/请求 | 1000 万/月 | 10 亿/月 |
570
+ | --- | --- | --- | --- |
571
+ | 平台 `fetch`,16 KB —— 参照;它用不了代理 | 0.3 ms | $5.00 | $307.40 |
572
+ | 平台 `fetch`,4 MB —— 同一参照,实测 | 3.2 ms | $5.04 | $365.40 |
573
+ | 连接复用,16 KB 页面 | 2.3 ms | $5.00 | $347.40 |
574
+ | 每请求新建连接,16 KB | 9 ms | $6.20 | $481.40 |
575
+ | 连接复用,1 MB 页面 | 36.3 ms | $11.66 | $1,027.40 |
576
+ | 每请求新建连接,1 MB | 59 ms | $16.20 | $1,481.40 |
577
+ | 连接复用,4 MB 页面 | 102 ms | $24.80 | $2,341.40 |
578
+ | 每请求新建连接,4 MB | 135 ms | $31.40 | $3,001.40 |
579
+
580
+ 那两个 `max(0, ...)` 是新加的。上一版这张表把每一个请求、每一毫秒 CPU 都算了钱,忽略了它自己上面那句话
581
+ 描述的免费额度——于是 1000 万/月那一列最多高估了 74%($8.70,而实际账单是 $5.00),而在 10 亿那一列误差在
582
+ 0.2% 以内,因为那时免费额度只是个舍入误差。高估的方向是对本包不利的,大概这就是它活了这么久的原因。
569
583
 
570
584
  参照那一行给了两个尺寸,因为平台自己的 `fetch` **不是平的**——它按每解压 MB 约 0.82 ms 增长,这是在同一个 CDN 的尺寸阶梯上测的,只有大小在变。把它写成单一的 0.3 ms 再拿去和 4 MB 那一行比,是拿不同的东西相比,而且是**抬高了对手而不是抬高本包**。
571
585
 
572
- 这些美元数字跟随上面修正后的 CPU 测量,所以正文偏重的行是 1.4.0 及以前的**两到三倍**。那不是包变慢了,是移除了一个内容压缩比 220:1 的源站。
586
+ 这张表原先还按「冷 / 预热」分成两组列。那两组列现在去掉了:新的 CPU 数字全部是热态和新建连接的实测,冷 isolate
587
+ 下的同一批负载没有重测过,与其把上一版的冷启动增量套到新基线上凑出四列,不如只写实际测过的两列。冷启动的成本
588
+ 在上面那张 `warmup()` 表里,那是一个**总数**,不是往这里某一行上加的增量。
573
589
 
574
590
  #### Chrome 身份的每个选项各花多少
575
591
 
576
592
  上面那张表是默认身份:线上 gzip、AES-256-GCM、x25519。Chrome 那一行把所有变化捆在一起,对做决定没什么用。按"连接复用 + 1 MB 页面 + 十亿请求/月 + 已预热"逐项拆开:
577
593
 
578
- | 相对基线的变化 | CPU/请求 | 10 亿/月,已预热 | Δ | 何时才付 |
594
+ | 相对基线的变化 | CPU/请求 | 10 亿/月 | Δ | 何时才付 |
579
595
  | --- | --- | --- | --- | --- |
580
- | 基线 —— gzip、AES-256-GCM、x25519 | 53.3 ms | $1,389 | — | 总是 |
581
- | 源站发 `br` 而不是 gzip | 57.6 ms | $1,474 | **+$85** | 源站选择发 `br` |
582
- | 服务器选中 ChaCha20-Poly1305 | 56.3 ms | $1,448 | **+$59** | 服务器优先选它而非 AES |
583
- | 源站发 `zstd` 而不是 gzip | 56.1 ms | $1,444 | **+$55** | 源站选择发 `zstd` |
584
- | 协商 X25519MLKEM768,每连接 1 个请求 | 61.0 ms | $1,542 | **+$153** | 每次握手 |
585
- | 协商 X25519MLKEM768,每连接 20 个请求 | 53.3 ms | $1,390 | **+$0.15** | 同一次握手,摊薄后 |
596
+ | 基线 —— gzip、AES-256-GCM、x25519,热态 | 36.3 ms | $1,027 | — | 总是 |
597
+ | 源站发 `br` 而不是 gzip | 40.5 ms | $1,111 | **+$84** | 源站选择发 `br` |
598
+ | 服务器选中 ChaCha20-Poly1305 | 39.3 ms | $1,086 | **+$59** | 服务器优先选它而非 AES |
599
+ | 源站发 `zstd` 而不是 gzip | 39.1 ms | $1,083 | **+$56** | 源站选择发 `zstd` |
600
+ | 协商 X25519MLKEM768,每连接 1 个请求 | 59.2 ms | $1,484 | **+$3** | 每次握手 |
601
+ | 协商 X25519MLKEM768,每连接 20 个请求 | 36.3 ms | $1,028 | **+$0.15** | 同一次握手,摊薄后 |
602
+
603
+ ML-KEM 这两行是拿**新建连接**的 1 MB 基线 59 ms($1,481)来比的,不是拿表头那个热态基线;Δ 列反映的是这一点,
604
+ 所以是 $3 而不是旧版写的 $153。那 $153 是「不复用连接」的成本,被算到了后量子密钥交换头上。
586
605
 
587
606
  **最后两行是同样的 0.15 ms ML-KEM,差别完全来自连接复用**——这才是这张表最值得带走的一条。后量子密钥交换在你让 `Client` 活着时是这里最便宜的东西,在你不这么做时是最贵的,因为它是**每握手**,而其余全是每字节。
588
607
 
@@ -655,8 +674,40 @@ CPU 计费,所以十亿请求下「预热」两列省下的每月 $65 是净
655
674
  大 body 的"每字节成本"其实不按字节计——按的是流边界穿越次数。这个运行时的 `DecompressionStream` 以
656
675
  4096 字节为块产出输出,套接字单次交付也至多 4096 字节,而每一块在运行时与 JS 之间穿越一次都要几十微秒,
657
676
  与块大小无关——实测约 **17 µs 一次穿越**,来自同一个 1 MB 分别按 4 KiB(6.0 ms/MB)到 256 KiB
658
- (1.67 ms/MB)收集的阶梯。因此两条热路径都改为用 BYOB 读来抽干来源:它把已经缓冲的数据一次交付,且只要
659
- 有一个字节就立即以部分填充返回,流式延迟不变。
677
+ (1.67 ms/MB)收集的阶梯。因此两条热路径都改为用 BYOB 读来抽干来源:它把好几块合并成一次穿越,且只要
678
+ 有一个字节就立即以部分填充返回,流式延迟不变。合并多少是**传输层**决定的,不是视图决定的——BYOB 读从不
679
+ 等着填满——所以视图开得比传输层单次交给你的多,买不到任何东西,只会白付一次分配。4 MB body 上实测的平均
680
+ 填充:直连 37 KB,走代理 8 KB。
681
+
682
+ ### 一个在本包赖以存在的那条路径上从未接通的旋钮
683
+
684
+ `openTunnel` 做完 CONNECT(或 SOCKS5)握手时手里握着一个带缓冲的 reader——对端可能把隧道数据和应答塞在
685
+ 同一个 chunk 里——它把这个交出去时包成了 `new ReadableStream({ pull })`。逻辑正确,但那是**普通流,不是
686
+ byte stream**。TLS 记录层要 BYOB reader,要不到就静默降级成 default reader,于是*每一条走代理的连接*都丢了
687
+ BYOB,连带 `tls.pullBytes`(它唯一的职责就是决定 BYOB 视图多大)一起失效。
688
+
689
+ 同源、1 MB 过记录层、同一 isolate、n=15,修复前:
690
+
691
+ | | `pullBytes: 16 KiB` | `pullBytes: 1 MiB` |
692
+ |---|---|---|
693
+ | 直连 | min 25, p50 31 | min 73, p50 90 |
694
+ | **走代理** | min 95, p50 111 | min 101, p50 121 |
695
+
696
+ **直连 2.9×,代理 6%**——那个旋钮在代理路径上根本没被读到。连带后果:当初用来定 64 KiB 默认值、标着「对着
697
+ 真实代理 socket 扫出来」的那条 U 曲线,是同一个配置的四个采样。
698
+
699
+ `src/proxy/tunnel.js` 把隧道改成 byte stream,并且在握手剩余字节吐完之后**把调用方自己的视图直接递给
700
+ socket**。旋钮接上之后重扫(1 MB 过记录层,p50):
701
+
702
+ | | 8 KiB | **16 KiB** | 32 KiB | 64 KiB | 256 KiB |
703
+ |---|---|---|---|---|---|
704
+ | 直连 | 21 | 20 | 18 | 22 | 40 |
705
+ | 代理 A | 51 | 61 | 61 | 89 | 152 |
706
+ | 代理 B | 68 | 74 | — | 102 | — |
707
+
708
+ 两个独立代理上都单调。默认值因此从 64 KiB 改为 **16 KiB**:三条路径上都在最优列 20% 以内,而 64 KiB 最差
709
+ 偏离 45%,分配量还是四分之一,并且正好是一个 TLS 记录。端到端走真 Client + 代理、n=13:1 MB **63 对 78 ms**、
710
+ 4 MB **145 对 164 ms**(中位数),4 MB 的最小值在噪声内。
660
711
 
661
712
  读进去的那个视图是 **16 KiB,而且这个尺寸是扫出来的,不是拍的**。它比看上去重要:输入由一个 JS 任务在与
662
713
  拉取方相同的事件循环上泵送,所以读到达时解压器手上通常只压着一两块,读回来就是部分填充——实测一个 1 MB
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunnelfetch",
3
- "version": "1.9.0",
3
+ "version": "1.12.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",
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { ProxyError, LimitError, codes } from '../errors.js';
15
15
  import { ByteReader, ByteWriter, latin1, utf8 } from '../util/bytes.js';
16
+ import { tunnelReadable } from './tunnel.js';
16
17
 
17
18
  const CRLFCRLF = utf8('\r\n\r\n');
18
19
  const MAX_REPLY_HEADER = 32 * 1024;
@@ -122,9 +123,15 @@ function buildConnectRequest(proxy, target) {
122
123
  if (proxy.username) {
123
124
  lines.push(`Proxy-Authorization: Basic ${basicCredentials(proxy.username, proxy.password)}`);
124
125
  }
125
- // Some proxies still key off the pre-standard hop header; sending it is harmless and avoids a
126
- // class of proxy that closes the tunnel after one request without it.
127
- lines.push('Proxy-Connection: keep-alive');
126
+ // `Proxy-Connection` is a pre-standard hop header that never made it into a spec, and clients
127
+ // disagree about it: some send `keep-alive`, some send `close`, some omit it. The proxy sees this
128
+ // even though the origin never does, so for anyone matching a client's behaviour AT THE PROXY it
129
+ // is part of the fingerprint. `keep-alive` stays the default — it avoids a class of proxy that
130
+ // closes the tunnel after one request — but it is no longer fixed.
131
+ //
132
+ // `null` omits the header entirely, which is not the same as sending `close`.
133
+ const pc = proxy.proxyConnection === undefined ? 'keep-alive' : proxy.proxyConnection;
134
+ if (pc !== null) lines.push(`Proxy-Connection: ${pc}`);
128
135
  return `${lines.join('\r\n')}\r\n\r\n`;
129
136
  }
130
137
 
@@ -188,16 +195,7 @@ function replyError(reply, proxy, target, where) {
188
195
  */
189
196
  function tunnelFrom(socket, reader) {
190
197
  return {
191
- readable: new ReadableStream({
192
- async pull(controller) {
193
- const chunk = await reader.readSome();
194
- if (chunk === null) controller.close();
195
- else controller.enqueue(chunk);
196
- },
197
- cancel(reason) {
198
- return reader.cancel(reason);
199
- },
200
- }),
198
+ readable: tunnelReadable(socket, reader),
201
199
  writable: socket.writable,
202
200
  opened: socket.opened,
203
201
  close: () => socket.close?.(),
@@ -19,7 +19,13 @@ import { openSocks5 } from './socks5.js';
19
19
  * @typedef {(addr: {hostname: string, port: number},
20
20
  * opts?: {secureTransport?: 'off'|'on'|'starttls', allowHalfOpen?: boolean}) => Duplex} ConnectFn
21
21
  * @typedef {{ protocol: 'http'|'https'|'socks5'|'socks5h', hostname: string, port: number,
22
- * username?: string, password?: string }} ProxyConfig
22
+ * username?: string, password?: string,
23
+ * proxyConnection?: string | null }} ProxyConfig
24
+ *
25
+ * `proxyConnection` sets the pre-standard `Proxy-Connection` header on a CONNECT request, or
26
+ * omits it entirely when null. Default 'keep-alive'. The origin never sees this header; the
27
+ * proxy does, so it belongs to whatever fingerprint the proxy is reading. Clients disagree —
28
+ * some send keep-alive, some close, some nothing — and omitting is not the same as 'close'.
23
29
  */
24
30
 
25
31
  const DEFAULT_PORTS = { http: 8080, https: 443, socks5: 1080, socks5h: 1080 };
@@ -114,6 +120,10 @@ function normalise(cfg) {
114
120
  port,
115
121
  username: cfg.username || undefined,
116
122
  password: cfg.password || undefined,
123
+ // Listed explicitly because this function REBUILDS the config rather than copying it, so a
124
+ // field not named here is dropped without a word. That is how `http2ConnectionWindow` came to
125
+ // be declared in a profile and read by nothing.
126
+ proxyConnection: cfg.proxyConnection,
117
127
  });
118
128
  }
119
129
 
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { ProxyError, ConfigError, codes, hex8 } from '../errors.js';
16
16
  import { ByteReader, ByteWriter, concat, utf8 } from '../util/bytes.js';
17
+ import { tunnelReadable } from './tunnel.js';
17
18
 
18
19
  const VERSION = 0x05;
19
20
  const AUTH_VERSION = 0x01;
@@ -326,16 +327,7 @@ async function readExactly(reader, n, what, where) {
326
327
  /** Bytes that arrived alongside the reply are tunnel payload; the buffered reader carries them. */
327
328
  function tunnelFrom(socket, reader) {
328
329
  return {
329
- readable: new ReadableStream({
330
- async pull(controller) {
331
- const chunk = await reader.readSome();
332
- if (chunk === null) controller.close();
333
- else controller.enqueue(chunk);
334
- },
335
- cancel(reason) {
336
- return reader.cancel(reason);
337
- },
338
- }),
330
+ readable: tunnelReadable(socket, reader),
339
331
  writable: socket.writable,
340
332
  opened: socket.opened,
341
333
  close: () => socket.close?.(),
@@ -0,0 +1,110 @@
1
+ // The tunnel's readable half, and why it has to be a byte stream.
2
+ //
3
+ // Both dialects finish their handshake holding a buffered reader that may already carry tunnel
4
+ // payload: an HTTP CONNECT reply is read up to its blank line and a SOCKS5 reply to its fixed
5
+ // length, and in both cases the peer is free to have sent application bytes immediately behind it.
6
+ // Those bytes must be delivered first and in order, which is why the socket cannot simply be
7
+ // handed onward.
8
+ //
9
+ // What this replaces was `new ReadableStream({ pull })` — correct, and a plain stream rather than a
10
+ // byte stream. That distinction is invisible until you look at what reads it. The TLS record layer
11
+ // asks for a BYOB reader and quietly falls back to a default one when it cannot have it, so every
12
+ // connection through a proxy lost BYOB reads, and with them `tls.pullBytes`, whose entire job is to
13
+ // decide how much of the socket arrives per boundary crossing.
14
+ //
15
+ // Measured on the edge against one origin, 4 MB, differenced at a single request:
16
+ //
17
+ // direct 132 socket reads, 31.8 KB average fill
18
+ // proxied 484 socket reads, 8.7 KB average fill
19
+ //
20
+ // and the knob itself, 1 MB through the record layer, n=15 in one isolate:
21
+ //
22
+ // pullBytes 16 KiB pullBytes 1 MiB
23
+ // direct min 25 p50 31 min 73 p50 90 <- 2.9x, the knob works
24
+ // proxied min 95 p50 111 min 101 p50 121 <- 6%, the knob is not read
25
+ //
26
+ // So the U-curve recorded against "a real proxied socket" in util/bytes.js was four samples of one
27
+ // configuration. The knob was never reaching the code on that path.
28
+ //
29
+ // The fix is not to copy harder. Once the handshake's leftovers are drained this hands the caller's
30
+ // own view straight to the socket, so a read through a tunnel costs exactly what a read without one
31
+ // costs — no wrapper copy, and the same coalescing.
32
+
33
+ import { ByteReader } from '../util/bytes.js';
34
+
35
+ /**
36
+ * View size handed to a *default* reader of this stream. BYOB readers supply their own view and
37
+ * never see this; it exists so that a caller which does not do BYOB still gets socket-sized chunks
38
+ * rather than whatever the transport felt like emitting.
39
+ */
40
+ const TUNNEL_CHUNK = 65536;
41
+
42
+ /**
43
+ * Wrap a proxy handshake's buffered reader plus its socket as one byte stream.
44
+ *
45
+ * @param {{ readable: ReadableStream<Uint8Array> }} socket the raw transport
46
+ * @param {import('../util/bytes.js').ByteReader} reader the handshake's reader, possibly holding
47
+ * bytes that belong to the tunnel
48
+ * @returns {ReadableStream<Uint8Array>}
49
+ */
50
+ export function tunnelReadable(socket, reader) {
51
+ /** Taken once `reader` runs dry, after which every read goes straight to the socket. */
52
+ let direct = null;
53
+ /** Buffered stand-in for `reader` when the socket turns out not to be a byte stream. */
54
+ let buffered = reader;
55
+ let promoted = false;
56
+
57
+ // Whether a stream is BYOB-capable can only be discovered by asking it for a BYOB reader, and it
58
+ // cannot be asked while the handshake's reader holds the lock. So: release (safe only because
59
+ // the guard below proves nothing is buffered), ask, and on refusal re-take a buffered reader —
60
+ // which is exactly the shape that was here before, for transports that cannot do better. Every
61
+ // in-process stream in this package's tests is one of those, so this path is well covered.
62
+ const promote = () => {
63
+ if (promoted || buffered.buffered > 0 || buffered.atEof) return;
64
+ promoted = true;
65
+ buffered.releaseLock();
66
+ try {
67
+ direct = socket.readable.getReader({ mode: 'byob' });
68
+ } catch {
69
+ buffered = new ByteReader(socket.readable);
70
+ }
71
+ };
72
+
73
+ return new ReadableStream({
74
+ type: 'bytes',
75
+ autoAllocateChunkSize: TUNNEL_CHUNK,
76
+ async pull(controller) {
77
+ // autoAllocateChunkSize guarantees a byobRequest even for a default reader, so there is one
78
+ // path here rather than two.
79
+ const req = controller.byobRequest;
80
+ const view = req.view;
81
+ promote();
82
+ if (direct) {
83
+ const { value, done } = await direct.read(
84
+ new Uint8Array(view.buffer, view.byteOffset, view.byteLength),
85
+ );
86
+ if (done) {
87
+ // Close first: a BYOB request may only be answered with zero bytes once the stream is
88
+ // closed, and the read has detached the original view, so the answer has to be the
89
+ // zero-length view the reader handed back rather than a plain respond(0).
90
+ controller.close();
91
+ req.respondWithNewView(value ?? new Uint8Array(0));
92
+ return;
93
+ }
94
+ req.respondWithNewView(value);
95
+ return;
96
+ }
97
+ const chunk = await buffered.readSome(view.byteLength);
98
+ if (chunk === null) {
99
+ controller.close();
100
+ req.respond(0);
101
+ return;
102
+ }
103
+ new Uint8Array(view.buffer, view.byteOffset, view.byteLength).set(chunk);
104
+ req.respond(chunk.byteLength);
105
+ },
106
+ cancel(reason) {
107
+ return direct ? direct.cancel(reason) : buffered.cancel(reason);
108
+ },
109
+ });
110
+ }
@@ -137,9 +137,23 @@ function expectServerHello(msg, offers12) {
137
137
  * @property {number[]} [groups] supported_groups, in preference order.
138
138
  * @property {number[]} [offerGroups] groups to send an actual key_share for. Default the first
139
139
  * supported group; a HelloRetryRequest recovers any other choice at the cost of a round trip.
140
- * @property {number[]} [ciphers] cipher suites to offer, in preference order. Every suite must be
141
- * one this package can perform; an offer it cannot honour is a dead connection the moment a
142
- * server selects it, so an unknown suite is refused here rather than on the wire.
140
+ * @property {number[]} [ciphers] cipher suites to offer, in preference order. By default every
141
+ * suite must be one this package can perform: an offer it cannot honour is a dead connection the
142
+ * moment a server selects it, so an unknown suite is refused here rather than on the wire.
143
+ * @property {number[]} [omitExtensions] extension types to leave out of the ClientHello, the
144
+ * subtractive counterpart to `extraExtensions`. `status_request` (5) is the one extension this
145
+ * package sends that curl does not, so an identity matching a sample without it needs this.
146
+ * Dropping it gives up OCSP stapling, which is the only revocation signal this package can
147
+ * consume — pairing it with `trust.revocation: 'require-staple'` is refused rather than left to
148
+ * fail every connection.
149
+ * @property {boolean} [allowUnperformableCiphers] offer suites this package cannot complete.
150
+ * For fingerprint fidelity only. Real clients offer far more than this package implements — curl
151
+ * 8.21.0 offers thirty against seven performable here, Chromium fifteen against seven — so a
152
+ * hello restricted to what it can honour carries a cipher list shorter than any real client's,
153
+ * which is exactly what a JA3 hash reads. With this set, a server that selects an unperformable
154
+ * suite fails the handshake; the first such suite sits behind the TLS 1.3 ones in both real
155
+ * lists, so a 1.3-capable server does not reach it. Knowingly trading a rare failure for an
156
+ * accurate fingerprint is a legitimate choice; making it silently is not.
143
157
  * @property {Uint8Array[]} [extraExtensions] pre-encoded ClientHello extensions, appended before
144
158
  * ordering. `extensionOrder` can only arrange extensions that were BUILT — it filters to what
145
159
  * exists and sorts that — so ordering alone cannot produce an extension this package does not
@@ -347,7 +361,16 @@ async function drive({ record, hostname, verifyPeer, options, deps, versions })
347
361
  // package cannot perform is an offer a server may take and then find unhonoured, which fails the
348
362
  // connection rather than merely looking wrong". It applies just as much to `tls.ciphers`, and
349
363
  // now does. CIPHER_PARAMS is the set this package knows how to key and seal.
350
- if (options.ciphers) {
364
+ //
365
+ // `allowUnperformableCiphers` opts out, and it exists because refusing outright was the wrong
366
+ // default to have no escape from. Every real client offers far more suites than this package
367
+ // implements — curl 8.21.0 offers thirty and seven are performable here; Chromium offers fifteen
368
+ // and seven are — so a hello restricted to what can be honoured has a cipher list shorter than
369
+ // any real client's, and that is itself what a JA3 hash reads. A caller matching a fingerprint
370
+ // may rationally prefer the accurate list: the first unperformable suite sits at index 5 of
371
+ // curl's and 7 of Chromium's, behind the TLS 1.3 suites, so a server with 1.3 available never
372
+ // reaches it. The risk is real but narrow, and it is the caller's to take knowingly.
373
+ if (options.ciphers && !options.allowUnperformableCiphers) {
351
374
  const unperformable = ciphers.filter((c) => !CIPHER_PARAMS[c]);
352
375
  if (unperformable.length) {
353
376
  throw new TlsError(
@@ -414,6 +437,7 @@ async function drive({ record, hostname, verifyPeer, options, deps, versions })
414
437
  alpn,
415
438
  ciphers,
416
439
  extraExtensions: options.extraExtensions ?? [],
440
+ omitExtensions: options.omitExtensions ?? [],
417
441
  versions,
418
442
  extensionOrder: options.extensionOrder,
419
443
  sigSchemes: options.sigSchemes,
@@ -192,6 +192,7 @@ export async function deriveSharedSecret(group, privateKey, peerKey, deps = {})
192
192
  * @property {string[]} [alpn] default ['http/1.1']; empty array omits the extension
193
193
  * @property {number[]} [versions] default [TLS13, TLS12]
194
194
  * @property {Uint8Array[]} [extraExtensions] pre-encoded, sent verbatim (the HRR cookie)
195
+ * @property {number[]} [omitExtensions] extension types to leave out of the hello
195
196
  * @property {{ identity: Uint8Array, obfuscatedTicketAge: number, binderLen: number }} [psk]
196
197
  * offer this resumption PSK. Encoded with a zeroed binder placeholder; the caller MUST derive
197
198
  * the real binder over `message.subarray(0, truncatedLength)` and patch it in at
@@ -299,6 +300,7 @@ export function buildClientHello({
299
300
  versions = [TLS13, TLS12],
300
301
  extensionOrder = CURL_EXTENSION_ORDER,
301
302
  extraExtensions = [],
303
+ omitExtensions = [],
302
304
  psk = null,
303
305
  grease = false,
304
306
  randomBytes = defaultRandom,
@@ -341,11 +343,15 @@ export function buildClientHello({
341
343
  'a resumption PSK was supplied but TLS 1.3 is not among the offered versions');
342
344
  }
343
345
 
346
+ const omit = new Set(omitExtensions);
344
347
  const extensionParts = [
345
348
  encodeServerName(hostname),
346
- // Always offered, for either version: without it a server may not staple (RFC 6066 s8), and
347
- // a stapled OCSP response is the only revocation signal this package can consume.
348
- encodeStatusRequest(),
349
+ // Offered by default for either version: without it a server may not staple (RFC 6066 s8), and
350
+ // a stapled OCSP response is the only revocation signal this package can consume. It is also
351
+ // the one extension this package sends that curl does not, so an identity matching a sample
352
+ // without it needs a way to drop it — `omitExtensions` is the subtractive counterpart to
353
+ // `extraExtensions`, and dropping this one gives up stapling.
354
+ omit.has(EXTENSION.status_request) ? null : encodeStatusRequest(),
349
355
  encodeSupportedGroups(g ? [g.take(), ...groups] : groups),
350
356
  encodeSignatureAlgorithms(sigSchemes),
351
357
  alpn.length ? encodeAlpn(alpn) : null,
@@ -589,7 +595,10 @@ export function negotiateCipher(serverHello, { offeredCiphers, version }) {
589
595
  if (!params) {
590
596
  throw new TlsUnsupportedError(
591
597
  codes.TLS_CIPHER_UNSUPPORTED,
592
- `cipher suite ${hex16(suite)} has no parameters; this is a bug in the offer list`,
598
+ `cipher suite ${hex16(suite)} was offered but this package cannot perform it, so the ` +
599
+ 'handshake cannot continue. Either the offer list has a bug, or `allowUnperformableCiphers` ' +
600
+ 'was set to match a real client\'s cipher list and a server has selected one of the suites ' +
601
+ 'that choice knowingly put on the wire.',
593
602
  { cipherSuite: suite },
594
603
  );
595
604
  }
@@ -250,6 +250,7 @@ export async function continueTls13(ctx) {
250
250
  // not permit a second hello to change its extension SET, and a retry that quietly dropped
251
251
  // them would present one fingerprint on the first flight and a different one on the second —
252
252
  // a difference that is itself a signal.
253
+ omitExtensions: options.omitExtensions ?? [],
253
254
  extraExtensions: [
254
255
  ...(options.extraExtensions ?? []),
255
256
  ...(cookie ? [cookieExtension(cookie)] : []),
package/src/transport.js CHANGED
@@ -124,6 +124,20 @@ export async function openConnection({
124
124
  }) {
125
125
  const target = targetFromUrl(url);
126
126
  const proxyConfig = parseProxy(proxy);
127
+
128
+ // Dropping status_request gives up OCSP stapling, and stapling is the only revocation signal this
129
+ // package consumes. Combined with `require-staple` that is not a weakened check, it is a policy
130
+ // that can never be satisfied: every connection would fail with OCSP_REQUIRED, on a certificate
131
+ // that was never asked to carry a staple. Refused here, where both settings are visible.
132
+ if ((tls?.omitExtensions ?? []).includes(5) && trust?.revocation === 'require-staple') {
133
+ throw new ConfigError(
134
+ codes.CONFIG_INVALID,
135
+ "tls.omitExtensions drops status_request (5) while trust.revocation is 'require-staple'. " +
136
+ 'Without status_request a server is not asked to staple, so no staple can arrive and every ' +
137
+ 'connection would fail. Keep the extension, or relax the revocation policy — the two are ' +
138
+ 'a contradiction rather than a stricter setting.',
139
+ );
140
+ }
127
141
  const owns = !deadlines;
128
142
  const dl = deadlines ?? new DeadlineController({}, { signal });
129
143
 
package/src/util/bytes.js CHANGED
@@ -15,16 +15,29 @@ 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:
18
+ // Swept on the edge, ms of CPU for a 1 MB body at the record layer, p50 of n>=7 in one isolate,
19
+ // each column an independent path against the same origin:
19
20
  //
20
- // 16 KiB 42.0 64 KiB 38.5 256 KiB 46.5 1 MiB 57.0
21
+ // 8 KiB 16 KiB 32 KiB 64 KiB 256 KiB
22
+ // direct 21 20 18 22 40
23
+ // proxy A 51 61 61 89 152
24
+ // proxy B 68 74 - 102 -
21
25
  //
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.
27
- const BYOB_PULL_BYTES = 65536;
26
+ // Monotonic on both proxies, and shallow on the direct path: too LARGE is what costs, because a
27
+ // BYOB read resolves the instant any byte exists and never waits to fill, so a view bigger than
28
+ // what the transport hands over per read is allocation that is never used. Average fill measured
29
+ // over a 4 MB body: 37 KB direct, 8 KB through a proxy which is why the two paths want different
30
+ // numbers and why the proxied one wants a small one.
31
+ //
32
+ // 16 KiB is within ~20% of the best figure on all three paths; the 64 KiB that used to sit here is
33
+ // up to 45% off. It is also exactly one TLS record, which is the unit the caller above asks for.
34
+ //
35
+ // The previous value came from a sweep captioned "against a real proxied socket" that reported a
36
+ // clean U with its floor at 64 KiB. That sweep measured nothing: openTunnel wrapped the socket in a
37
+ // plain ReadableStream, so the record layer could not take a BYOB reader on any proxied connection
38
+ // and this constant was never read on that path. The four numbers were four samples of one
39
+ // configuration. See proxy/tunnel.js, which is where that was fixed.
40
+ const BYOB_PULL_BYTES = 16384;
28
41
 
29
42
  /** Raised when the peer stops sending in the middle of a structure we must read whole. */
30
43
  export class UnexpectedEofError extends TunnelFetchError {
@@ -50,21 +63,25 @@ export class UnexpectedEofError extends TunnelFetchError {
50
63
  * be retained beyond the caller's immediate use if memory matters.
51
64
  *
52
65
  * When the source is a byte stream — on the target runtime, a socket's readable is one — the
53
- * reader pulls with BYOB reads into large fresh views instead of taking the source's own
54
- * chunking. This is measured, not stylistic: the runtime delivers socket data in chunks of at
55
- * most 4096 bytes, ~1200 of them for a 4 MB body, and every chunk is a runtime/JS boundary
56
- * crossing; a BYOB read hands over everything the transport has buffered (up to the view size)
57
- * in one crossing, and resolves with a partial fill the instant anything at all is available,
58
- * so delivery latency is unchanged. Sources that are not byte streams (every in-process
66
+ * reader pulls with BYOB reads instead of taking the source's own chunking. This is measured, not
67
+ * stylistic: the runtime delivers socket data in chunks of at most 4096 bytes, ~1200 of them for a
68
+ * 4 MB body, and every chunk is a runtime/JS boundary crossing; a BYOB read collects several of
69
+ * them into one.
70
+ *
71
+ * How MANY it collects is the transport's decision, not the view's. A BYOB read resolves the
72
+ * instant any byte is available and never waits to fill, so the view is a ceiling that is normally
73
+ * not reached: measured over a 4 MB body, 37 KB average fill on a direct socket and 8 KB through a
74
+ * proxy, whatever the view size. Sizing the view far above that buys nothing and costs the
75
+ * allocation — see BYOB_PULL_BYTES. Sources that are not byte streams (every in-process
59
76
  * ReadableStream in this package and its tests) take the default-reader path unchanged.
60
77
  */
61
78
  export class ByteReader {
62
79
  /**
63
80
  * @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 response42 ms of a 106 ms 4 MB request is socket
67
- * reads and record decryption, of which the AEAD itself is under 2 ms.
81
+ * @param {number} [pullBytes] size of each BYOB view pulled from the source. Tunable because the
82
+ * right value depends on how much the transport hands over per read, and that differs by a
83
+ * factor of four between a direct socket and a proxied onesee BYOB_PULL_BYTES for the sweep.
84
+ * Ignored on sources that are not byte streams, which take the default-reader path.
68
85
  */
69
86
  constructor(readable, pullBytes = BYOB_PULL_BYTES) {
70
87
  this._pullBytes = pullBytes > 0 ? pullBytes : BYOB_PULL_BYTES;
@@ -49,12 +49,19 @@ export type ConnectFn = (addr: {
49
49
  secureTransport?: "off" | "on" | "starttls";
50
50
  allowHalfOpen?: boolean;
51
51
  }) => Duplex;
52
+ /**
53
+ * `proxyConnection` sets the pre-standard `Proxy-Connection` header on a CONNECT request, or
54
+ * omits it entirely when null. Default 'keep-alive'. The origin never sees this header; the
55
+ * proxy does, so it belongs to whatever fingerprint the proxy is reading. Clients disagree —
56
+ * some send keep-alive, some close, some nothing — and omitting is not the same as 'close'.
57
+ */
52
58
  export type ProxyConfig = {
53
59
  protocol: "http" | "https" | "socks5" | "socks5h";
54
60
  hostname: string;
55
61
  port: number;
56
62
  username?: string;
57
63
  password?: string;
64
+ proxyConnection?: string | null;
58
65
  };
59
66
  import { openDirect } from './direct.js';
60
67
  import { openHttpConnect } from './http-connect.js';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Wrap a proxy handshake's buffered reader plus its socket as one byte stream.
3
+ *
4
+ * @param {{ readable: ReadableStream<Uint8Array> }} socket the raw transport
5
+ * @param {import('../util/bytes.js').ByteReader} reader the handshake's reader, possibly holding
6
+ * bytes that belong to the tunnel
7
+ * @returns {ReadableStream<Uint8Array>}
8
+ */
9
+ export function tunnelReadable(socket: {
10
+ readable: ReadableStream<Uint8Array>;
11
+ }, reader: import("../util/bytes.js").ByteReader): ReadableStream<Uint8Array>;