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.md +420 -64
- package/README.zh-CN.md +114 -31
- package/package.json +1 -1
- package/src/connect.js +187 -0
- package/src/index.js +1 -0
- package/src/proxy/http-connect.js +2 -10
- package/src/proxy/socks5.js +2 -10
- package/src/proxy/tunnel.js +110 -0
- package/src/util/bytes.js +35 -18
- package/types/connect.d.ts +78 -0
- package/types/index.d.ts +1 -0
- package/types/proxy/tunnel.d.ts +11 -0
- package/types/util/bytes.d.ts +14 -10
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
|
-
|
|
521
|
-
|
|
552
|
+
通过代理抓取 `sizeorigin/`,传输走 gzip,所有尺寸在**同一次扫描、同一个 isolate** 内跑完,n>=5 取中位数。
|
|
553
|
+
方法写在这里,是因为上一版这张表没有把方法记清楚到能复现的程度:**一个热态页面 = `(reuse=4 - reuse=1) / 3`**,
|
|
554
|
+
也就是连接已经打开之后第 2 到第 4 个页面的成本;最后一列是同一次扫描测出来的新建连接数字。
|
|
522
555
|
|
|
523
|
-
| Body |
|
|
556
|
+
| Body | 热态页面 | 每解压 MB | 新连接上的第一个请求 |
|
|
524
557
|
| --- | --- | --- | --- |
|
|
525
|
-
| 1 KB | 3
|
|
526
|
-
| 16 KB |
|
|
527
|
-
| 64 KB |
|
|
528
|
-
| 256 KB |
|
|
529
|
-
| 1 MB |
|
|
530
|
-
| 4 MB |
|
|
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
|
-
|
|
560
|
-
|
|
|
561
|
-
|
|
|
562
|
-
| 平台 `fetch`,
|
|
563
|
-
|
|
|
564
|
-
|
|
|
565
|
-
|
|
|
566
|
-
|
|
|
567
|
-
|
|
|
568
|
-
|
|
|
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
|
-
|
|
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 |
|
|
581
|
-
| 源站发 `br` 而不是 gzip |
|
|
582
|
-
| 服务器选中 ChaCha20-Poly1305 |
|
|
583
|
-
| 源站发 `zstd` 而不是 gzip |
|
|
584
|
-
| 协商 X25519MLKEM768,每连接 1 个请求 |
|
|
585
|
-
| 协商 X25519MLKEM768,每连接 20 个请求 |
|
|
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.
|
|
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:
|
|
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?.(),
|
package/src/proxy/socks5.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
23
|
-
// the
|
|
24
|
-
// the
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
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
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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 one — see 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;
|