tunnelfetch 1.11.0 → 1.13.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
@@ -117,6 +117,38 @@ await client.close(); // 必须调用:释放池中的 socket
117
117
  (第 16 步),所以一个普通名字的 `Secure` cookie 即便是在 https 上设置的,仍可能被 http 覆盖;以及名字加值
118
118
  4096 字节的上限(第 4 步)。
119
119
 
120
+ ### 获取 socket 工厂
121
+
122
+ `connect` 是参数而不是 import,这是刻意的:它是这个包唯一无法自带的部分,由调用方提供也正是上面每一层都能在
123
+ 内存管道上做字节级测试的原因。稳妥地获取一个:
124
+
125
+ ```js
126
+ import { resolveConnect } from 'tunnelfetch';
127
+
128
+ const connect = await resolveConnect({
129
+ specifiers: ['cloudflare:sockets'],
130
+ });
131
+ const client = new Client({ connect, proxy: env.PROXY_URL });
132
+ ```
133
+
134
+ 第一个能导出可调用函数的 specifier 胜出;全部失败时报错会列出试过的每一个 specifier 及其失败原因——错误在
135
+ 启动时就现身,而不是以 "connect is not a function" 的样子从 TLS 层深处冒出来。过程中关掉了两个坑:
136
+
137
+ 1. 导入是动态的,失败会被捕获。某个只在一个运行时上能解析的 specifier,一旦被静态导入,就会让整个包在其它
138
+ 所有平台上无法加载;列着多个运行时的可移植列表则是安全的。
139
+ 2. 打包器看不穿变量形式的 specifier。打包部署时直接把 `connect` 传进来,或者给 `resolveConnect` 传一个闭包了
140
+ 字面量的 `importModule`,让打包器看得到:
141
+
142
+ ```js
143
+ const connect = await resolveConnect({
144
+ importModule: () => import('cloudflare:sockets'),
145
+ });
146
+ ```
147
+
148
+ `normaliseSocket` 把一个运行时 socket 拍平成普通双工对象,`normalisingConnect` 包一层工厂,让每个返回的 socket
149
+ 都被拍平。它们的存在是因为 edge 运行时上 socket 的 `readable` 和 `writable` 是原型上的访问器,`{ ...socket }`
150
+ 两个都拷不到,第一次读取就会在 TLS 层深处报 `getReader` 的错误。
151
+
120
152
  ### 替换全局 fetch
121
153
 
122
154
  有的库只会直接调用全局 `fetch`,为它们准备的是:
@@ -517,17 +549,26 @@ CertificateError [CERT_PIN_MISMATCH]: no certificate in the chain matches any co
517
549
 
518
550
  ### 一个请求要多少
519
551
 
520
- 通过代理抓取一个尺寸可控的源站,热态,同一 isolate 7 轮以上取中位数,传输走 gzip。最后一列是同样的数字
521
- 换算成速率,那是更值得随身记住的形式:
552
+ 通过代理抓取 `sizeorigin/`,传输走 gzip,所有尺寸在**同一次扫描、同一个 isolate** 内跑完,n>=5 取中位数。
553
+ 方法写在这里,是因为上一版这张表没有把方法记清楚到能复现的程度:**一个热态页面 = `(reuse=4 - reuse=1) / 3`**,
554
+ 也就是连接已经打开之后第 2 到第 4 个页面的成本;最后一列是同一次扫描测出来的新建连接数字。
522
555
 
523
- | Body | 5 页均摊 | 复用连接 | 新建连接 |
556
+ | Body | 热态页面 | 每解压 MB | 新连接上的第一个请求 |
524
557
  | --- | --- | --- | --- |
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 |
558
+ | 1 KB | **1.3 ms** | | 8 ms |
559
+ | 16 KB | **2.3 ms** | 149 ms/MB | 9 ms |
560
+ | 64 KB | **6.0 ms** | 96 ms/MB | 13 ms |
561
+ | 256 KB | **14.3 ms** | 57 ms/MB | 28 ms |
562
+ | 1 MB | **36.3 ms** | 36 ms/MB | 59 ms |
563
+ | 4 MB | **102 ms** | 26 ms/MB | 135 ms |
564
+
565
+ 最后一列不是「热态页面 + 一次握手」。4 MB 那一行它比热态页面高 33 ms,而握手只值个位数——因为一条连接上的
566
+ **第一个** body 还要在 V8 把解码循环编译优化之前跑一遍。评估一个新源站要看这一列,不要看第一列。
567
+
568
+ **2026 年 8 月重测,中间几档动了。** 4 MB 那行几乎原样复现(102 对先前发布的 104);64 KB 到 1 MB 比这张表
569
+ 原先的数字低 20–30%。那**不是**下面讲的 socket 视图大小改动——在同一个 isolate 里 A/B 新旧视图大小,1 MB 热态
570
+ 页面只差约 1 ms——所以它要么是单次扫描看不到的日间波动,要么是被取代的那张表取数方式不同。旧数字已经无从复查,
571
+ 这正是把方法写在这里的理由。
531
572
 
532
573
  冷启动成本是**总数**,不是往上面某一行加的增量:
533
574
 
@@ -554,35 +595,45 @@ CertificateError [CERT_PIN_MISMATCH]: no certificate in the chain matches any co
554
595
  ### 这些折算成多少钱
555
596
 
556
597
  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 |
598
+ $0.02。公式就是 `$5 + max(0, 请求数 - 1000万) x $0.30/百万 + max(0, CPU毫秒 - 3000万) x $0.02/百万`,
599
+ 没有别的:
600
+
601
+ | 工作负载 | CPU/请求 | 1000 万/月 | 10 亿/月 |
602
+ | --- | --- | --- | --- |
603
+ | 平台 `fetch`,16 KB —— 参照;它用不了代理 | 0.3 ms | $5.00 | $307.40 |
604
+ | 平台 `fetch`,4 MB —— 同一参照,实测 | 3.2 ms | $5.04 | $365.40 |
605
+ | 连接复用,16 KB 页面 | 2.3 ms | $5.00 | $347.40 |
606
+ | 每请求新建连接,16 KB | 9 ms | $6.20 | $481.40 |
607
+ | 连接复用,1 MB 页面 | 36.3 ms | $11.66 | $1,027.40 |
608
+ | 每请求新建连接,1 MB | 59 ms | $16.20 | $1,481.40 |
609
+ | 连接复用,4 MB 页面 | 102 ms | $24.80 | $2,341.40 |
610
+ | 每请求新建连接,4 MB | 135 ms | $31.40 | $3,001.40 |
611
+
612
+ 那两个 `max(0, ...)` 是新加的。上一版这张表把每一个请求、每一毫秒 CPU 都算了钱,忽略了它自己上面那句话
613
+ 描述的免费额度——于是 1000 万/月那一列最多高估了 74%($8.70,而实际账单是 $5.00),而在 10 亿那一列误差在
614
+ 0.2% 以内,因为那时免费额度只是个舍入误差。高估的方向是对本包不利的,大概这就是它活了这么久的原因。
569
615
 
570
616
  参照那一行给了两个尺寸,因为平台自己的 `fetch` **不是平的**——它按每解压 MB 约 0.82 ms 增长,这是在同一个 CDN 的尺寸阶梯上测的,只有大小在变。把它写成单一的 0.3 ms 再拿去和 4 MB 那一行比,是拿不同的东西相比,而且是**抬高了对手而不是抬高本包**。
571
617
 
572
- 这些美元数字跟随上面修正后的 CPU 测量,所以正文偏重的行是 1.4.0 及以前的**两到三倍**。那不是包变慢了,是移除了一个内容压缩比 220:1 的源站。
618
+ 这张表原先还按「冷 / 预热」分成两组列。那两组列现在去掉了:新的 CPU 数字全部是热态和新建连接的实测,冷 isolate
619
+ 下的同一批负载没有重测过,与其把上一版的冷启动增量套到新基线上凑出四列,不如只写实际测过的两列。冷启动的成本
620
+ 在上面那张 `warmup()` 表里,那是一个**总数**,不是往这里某一行上加的增量。
573
621
 
574
622
  #### Chrome 身份的每个选项各花多少
575
623
 
576
624
  上面那张表是默认身份:线上 gzip、AES-256-GCM、x25519。Chrome 那一行把所有变化捆在一起,对做决定没什么用。按"连接复用 + 1 MB 页面 + 十亿请求/月 + 已预热"逐项拆开:
577
625
 
578
- | 相对基线的变化 | CPU/请求 | 10 亿/月,已预热 | Δ | 何时才付 |
626
+ | 相对基线的变化 | CPU/请求 | 10 亿/月 | Δ | 何时才付 |
579
627
  | --- | --- | --- | --- | --- |
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** | 同一次握手,摊薄后 |
628
+ | 基线 —— gzip、AES-256-GCM、x25519,热态 | 36.3 ms | $1,027 | — | 总是 |
629
+ | 源站发 `br` 而不是 gzip | 40.5 ms | $1,111 | **+$84** | 源站选择发 `br` |
630
+ | 服务器选中 ChaCha20-Poly1305 | 39.3 ms | $1,086 | **+$59** | 服务器优先选它而非 AES |
631
+ | 源站发 `zstd` 而不是 gzip | 39.1 ms | $1,083 | **+$56** | 源站选择发 `zstd` |
632
+ | 协商 X25519MLKEM768,每连接 1 个请求 | 59.2 ms | $1,484 | **+$3** | 每次握手 |
633
+ | 协商 X25519MLKEM768,每连接 20 个请求 | 36.3 ms | $1,028 | **+$0.15** | 同一次握手,摊薄后 |
634
+
635
+ ML-KEM 这两行是拿**新建连接**的 1 MB 基线 59 ms($1,481)来比的,不是拿表头那个热态基线;Δ 列反映的是这一点,
636
+ 所以是 $3 而不是旧版写的 $153。那 $153 是「不复用连接」的成本,被算到了后量子密钥交换头上。
586
637
 
587
638
  **最后两行是同样的 0.15 ms ML-KEM,差别完全来自连接复用**——这才是这张表最值得带走的一条。后量子密钥交换在你让 `Client` 活着时是这里最便宜的东西,在你不这么做时是最贵的,因为它是**每握手**,而其余全是每字节。
588
639
 
@@ -655,8 +706,40 @@ CPU 计费,所以十亿请求下「预热」两列省下的每月 $65 是净
655
706
  大 body 的"每字节成本"其实不按字节计——按的是流边界穿越次数。这个运行时的 `DecompressionStream` 以
656
707
  4096 字节为块产出输出,套接字单次交付也至多 4096 字节,而每一块在运行时与 JS 之间穿越一次都要几十微秒,
657
708
  与块大小无关——实测约 **17 µs 一次穿越**,来自同一个 1 MB 分别按 4 KiB(6.0 ms/MB)到 256 KiB
658
- (1.67 ms/MB)收集的阶梯。因此两条热路径都改为用 BYOB 读来抽干来源:它把已经缓冲的数据一次交付,且只要
659
- 有一个字节就立即以部分填充返回,流式延迟不变。
709
+ (1.67 ms/MB)收集的阶梯。因此两条热路径都改为用 BYOB 读来抽干来源:它把好几块合并成一次穿越,且只要
710
+ 有一个字节就立即以部分填充返回,流式延迟不变。合并多少是**传输层**决定的,不是视图决定的——BYOB 读从不
711
+ 等着填满——所以视图开得比传输层单次交给你的多,买不到任何东西,只会白付一次分配。4 MB body 上实测的平均
712
+ 填充:直连 37 KB,走代理 8 KB。
713
+
714
+ ### 一个在本包赖以存在的那条路径上从未接通的旋钮
715
+
716
+ `openTunnel` 做完 CONNECT(或 SOCKS5)握手时手里握着一个带缓冲的 reader——对端可能把隧道数据和应答塞在
717
+ 同一个 chunk 里——它把这个交出去时包成了 `new ReadableStream({ pull })`。逻辑正确,但那是**普通流,不是
718
+ byte stream**。TLS 记录层要 BYOB reader,要不到就静默降级成 default reader,于是*每一条走代理的连接*都丢了
719
+ BYOB,连带 `tls.pullBytes`(它唯一的职责就是决定 BYOB 视图多大)一起失效。
720
+
721
+ 同源、1 MB 过记录层、同一 isolate、n=15,修复前:
722
+
723
+ | | `pullBytes: 16 KiB` | `pullBytes: 1 MiB` |
724
+ |---|---|---|
725
+ | 直连 | min 25, p50 31 | min 73, p50 90 |
726
+ | **走代理** | min 95, p50 111 | min 101, p50 121 |
727
+
728
+ **直连 2.9×,代理 6%**——那个旋钮在代理路径上根本没被读到。连带后果:当初用来定 64 KiB 默认值、标着「对着
729
+ 真实代理 socket 扫出来」的那条 U 曲线,是同一个配置的四个采样。
730
+
731
+ `src/proxy/tunnel.js` 把隧道改成 byte stream,并且在握手剩余字节吐完之后**把调用方自己的视图直接递给
732
+ socket**。旋钮接上之后重扫(1 MB 过记录层,p50):
733
+
734
+ | | 8 KiB | **16 KiB** | 32 KiB | 64 KiB | 256 KiB |
735
+ |---|---|---|---|---|---|
736
+ | 直连 | 21 | 20 | 18 | 22 | 40 |
737
+ | 代理 A | 51 | 61 | 61 | 89 | 152 |
738
+ | 代理 B | 68 | 74 | — | 102 | — |
739
+
740
+ 两个独立代理上都单调。默认值因此从 64 KiB 改为 **16 KiB**:三条路径上都在最优列 20% 以内,而 64 KiB 最差
741
+ 偏离 45%,分配量还是四分之一,并且正好是一个 TLS 记录。端到端走真 Client + 代理、n=13:1 MB **63 对 78 ms**、
742
+ 4 MB **145 对 164 ms**(中位数),4 MB 的最小值在噪声内。
660
743
 
661
744
  读进去的那个视图是 **16 KiB,而且这个尺寸是扫出来的,不是拍的**。它比看上去重要:输入由一个 JS 任务在与
662
745
  拉取方相同的事件循环上泵送,所以读到达时解压器手上通常只压着一两块,读回来就是部分填充——实测一个 1 MB
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunnelfetch",
3
- "version": "1.11.0",
3
+ "version": "1.13.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",
package/src/connect.js ADDED
@@ -0,0 +1,187 @@
1
+ // Obtaining the host runtime's raw-socket factory.
2
+ //
3
+ // Every layer above this one takes `connect` as an argument, and that is deliberate: it is what
4
+ // keeps src/ free of runtime-specific imports and what makes every protocol path testable over an
5
+ // in-memory pipe. This module does not weaken that. It answers a narrower question that callers
6
+ // kept answering badly by hand:
7
+ //
8
+ // given a runtime whose socket factory lives behind a module specifier, produce a validated
9
+ // ConnectFn, or fail with an error that says what was tried.
10
+ //
11
+ // Two rules shape the design.
12
+ //
13
+ // 1. The specifier is DATA, supplied by the caller. No module name is baked in here, because a
14
+ // name baked in is behaviour conditioned on one vendor's runtime, and this package is not
15
+ // allowed to have any. The host application knows which runtime it is running on; this module
16
+ // does not need to guess, and a wrong guess is worse than an argument.
17
+ //
18
+ // 2. The import is dynamic and its failure is caught. A static import of a specifier that only
19
+ // resolves on one runtime makes this module unloadable everywhere else, which would take the
20
+ // whole package down with it on every other platform.
21
+ //
22
+ // A BUNDLER CAVEAT that matters more than it looks. The default importer calls `import(spec)` with
23
+ // a variable. Bundlers cannot see through a variable specifier, so they will not include the
24
+ // target module in the output, and on a bundled deployment the import fails at runtime even though
25
+ // the module exists. Where a bundler is in play — which on edge runtimes is nearly always — pass
26
+ // the socket factory straight in as `connect`, or pass an `importModule` that closes over a
27
+ // LITERAL specifier the bundler can see:
28
+ //
29
+ // resolveConnect({ importModule: () => import('some:sockets') })
30
+ //
31
+ // That is why `connect` is checked first and short-circuits without importing anything: direct
32
+ // injection is the recommended path, and the specifier list is the convenience, not the contract.
33
+
34
+ import { ConfigError, codes } from './errors.js';
35
+
36
+ /** @typedef {import('./proxy/index.js').ConnectFn} ConnectFn */
37
+ /** @typedef {import('./proxy/index.js').Duplex} Duplex */
38
+
39
+ /**
40
+ * @typedef {object} ResolveConnectOptions
41
+ * @property {ConnectFn} [connect] an already-obtained socket factory. Checked first and returned
42
+ * as-is, so the recommended path costs no import and cannot be defeated by a bundler.
43
+ * @property {string[]} [specifiers] module specifiers to try, in order. The first module whose
44
+ * `exportName` is callable wins.
45
+ * @property {(specifier: string) => Promise<object>} [importModule] the importer. Defaults to a
46
+ * dynamic `import()`; override it to hand the bundler a literal specifier.
47
+ * @property {string} [exportName] the export to read off each module. Default 'connect'.
48
+ */
49
+
50
+ /**
51
+ * Is `value` shaped like a socket factory? Only callability can be checked without dialling, so
52
+ * that is what this checks — the duplex contract is enforced when a socket is actually opened.
53
+ * @param {unknown} value
54
+ * @returns {value is ConnectFn}
55
+ */
56
+ export function isConnectFn(value) {
57
+ return typeof value === 'function';
58
+ }
59
+
60
+ /**
61
+ * Resolve a ConnectFn from an injected factory or from the first module specifier that yields one.
62
+ *
63
+ * Resolution order, and nothing else is consulted:
64
+ * 1. `options.connect`, when callable.
65
+ * 2. each entry of `options.specifiers`, in order.
66
+ *
67
+ * Throws ConfigError (CONFIG_UNSATISFIABLE) when nothing resolves, listing every specifier tried
68
+ * with the reason it failed. A socket factory that cannot be found is a deployment mistake, and a
69
+ * deployment mistake deserves to name the thing that was missing rather than surface later as
70
+ * "connect is not a function" from inside the TLS layer.
71
+ *
72
+ * @param {ResolveConnectOptions} [options]
73
+ * @returns {Promise<ConnectFn>}
74
+ */
75
+ export async function resolveConnect(options = {}) {
76
+ const { connect, specifiers = [], importModule = defaultImport, exportName = 'connect' } = options;
77
+
78
+ if (connect !== undefined && connect !== null) {
79
+ if (!isConnectFn(connect)) {
80
+ throw new ConfigError(
81
+ codes.CONFIG_INVALID,
82
+ `connect must be a function returning { readable, writable, opened?, close? }, got ` +
83
+ `${describe(connect)}`,
84
+ );
85
+ }
86
+ return connect;
87
+ }
88
+
89
+ if (!Array.isArray(specifiers)) {
90
+ throw new ConfigError(codes.CONFIG_INVALID, 'specifiers must be an array of module specifiers');
91
+ }
92
+ if (typeof importModule !== 'function') {
93
+ throw new ConfigError(codes.CONFIG_INVALID, 'importModule must be a function');
94
+ }
95
+
96
+ /** @type {string[]} */
97
+ const tried = [];
98
+ for (const specifier of specifiers) {
99
+ if (typeof specifier !== 'string' || specifier === '') {
100
+ throw new ConfigError(
101
+ codes.CONFIG_INVALID,
102
+ `specifiers must be non-empty strings, got ${describe(specifier)}`,
103
+ );
104
+ }
105
+ let mod;
106
+ try {
107
+ mod = await importModule(specifier);
108
+ } catch (cause) {
109
+ // Not resolvable on this runtime, which for a portable specifier list is the expected
110
+ // outcome for every entry but one. Recorded, not thrown.
111
+ tried.push(`${specifier}: not importable (${cause?.message ?? cause})`);
112
+ continue;
113
+ }
114
+ const candidate = mod?.[exportName] ?? mod?.default?.[exportName];
115
+ if (isConnectFn(candidate)) return candidate;
116
+ tried.push(`${specifier}: imported, but has no callable "${exportName}" export`);
117
+ }
118
+
119
+ throw new ConfigError(
120
+ codes.CONFIG_UNSATISFIABLE,
121
+ 'no socket factory could be resolved. Supply `connect` directly — the raw-TCP socket factory ' +
122
+ 'of the host runtime, returning { readable, writable, opened?, close? } — or name a module ' +
123
+ 'that exports one. ' +
124
+ (tried.length ? `Tried:\n ${tried.join('\n ')}` : 'No specifiers were given.'),
125
+ { tried },
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Flatten a runtime socket into a plain duplex object.
131
+ *
132
+ * Worth its own function because of a trap that has already cost this package a bug: on the edge
133
+ * runtime a socket's `readable` and `writable` are ACCESSORS ON THE PROTOTYPE, so `{ ...socket }`
134
+ * copies neither and the first read fails far away, inside the TLS layer, with "Cannot read
135
+ * properties of undefined (reading 'getReader')". Reading the properties explicitly — which is
136
+ * what this does — is the only spread-safe way to hand a host socket to code that may copy it.
137
+ *
138
+ * @param {Duplex} socket
139
+ * @returns {Duplex}
140
+ */
141
+ export function normaliseSocket(socket) {
142
+ if (!socket || typeof socket !== 'object') {
143
+ throw new ConfigError(
144
+ codes.CONFIG_INVALID,
145
+ `connect must return a socket object, got ${describe(socket)}`,
146
+ );
147
+ }
148
+ const { readable, writable } = socket;
149
+ if (!readable || typeof readable.getReader !== 'function') {
150
+ throw new ConfigError(codes.CONFIG_INVALID, 'socket has no ReadableStream `readable`');
151
+ }
152
+ if (!writable || typeof writable.getWriter !== 'function') {
153
+ throw new ConfigError(codes.CONFIG_INVALID, 'socket has no WritableStream `writable`');
154
+ }
155
+ return {
156
+ readable,
157
+ writable,
158
+ opened: socket.opened,
159
+ close: () => socket.close?.(),
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Wrap a ConnectFn so every socket it returns is flattened by normaliseSocket. Useful when the
165
+ * factory comes from a runtime whose sockets are host objects rather than plain records.
166
+ * @param {ConnectFn} connect
167
+ * @returns {ConnectFn}
168
+ */
169
+ export function normalisingConnect(connect) {
170
+ if (!isConnectFn(connect)) {
171
+ throw new ConfigError(codes.CONFIG_INVALID, `connect must be a function, got ${describe(connect)}`);
172
+ }
173
+ return (addr, opts) => normaliseSocket(connect(addr, opts));
174
+ }
175
+
176
+ /** The default importer: a dynamic import a bundler cannot see through. See the header caveat. */
177
+ function defaultImport(specifier) {
178
+ return import(specifier);
179
+ }
180
+
181
+ /** A short, safe rendering of a bad argument for an error message. */
182
+ function describe(value) {
183
+ if (value === null) return 'null';
184
+ if (Array.isArray(value)) return 'an array';
185
+ const t = typeof value;
186
+ return t === 'object' ? 'an object' : t === 'string' ? JSON.stringify(value) : t;
187
+ }
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ export { warmup } from './warmup.js';
16
16
  export { ConnectionPool, poolKey } from './pool.js';
17
17
  export { openConnection, targetFromUrl, nativeFetchCanServe } from './transport.js';
18
18
  export { openTunnel, parseProxy } from './proxy/index.js';
19
+ export { resolveConnect, isConnectFn, normaliseSocket, normalisingConnect } from './connect.js';
19
20
  export { CookieJar } from './client/cookies.js';
20
21
  export { verifyChain, rootStoreProvenance } from './trust/index.js';
21
22
 
@@ -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;
@@ -194,16 +195,7 @@ function replyError(reply, proxy, target, where) {
194
195
  */
195
196
  function tunnelFrom(socket, reader) {
196
197
  return {
197
- readable: new ReadableStream({
198
- async pull(controller) {
199
- const chunk = await reader.readSome();
200
- if (chunk === null) controller.close();
201
- else controller.enqueue(chunk);
202
- },
203
- cancel(reason) {
204
- return reader.cancel(reason);
205
- },
206
- }),
198
+ readable: tunnelReadable(socket, reader),
207
199
  writable: socket.writable,
208
200
  opened: socket.opened,
209
201
  close: () => socket.close?.(),
@@ -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
+ }
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;