tunnelfetch 1.4.0 → 1.6.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
@@ -217,7 +217,7 @@ const client = new Client({ connect, proxy, decoders: { br: brotli } });
217
217
 
218
218
  由此得到两条结论,在 Worker 里任何地方考虑用 WebAssembly 之前都值得知道。**同一算法下 WASM 比 JavaScript 快约 4 倍**(brotli:4.7 对 19.7)——所以某个编码若没有原生实现,WASM 是补上它的正确方式。**同一算法下原生又比 JavaScript 快约 3 倍**(inflate:2.5 对 7.5–8.2)——所以只要已经有原生路径,用户态怎么写都赢不了。这正是 `gzip` 和 `deflate` 不可覆盖的原因:替换它们只可能更慢,而静默地这么做,恰恰是这个包在别处一律拒绝的那种悄悄降级。
219
219
 
220
- brotli 本身落在原生 inflate 的 1.9 倍。这个差价就是这个编码的成本,而它省下的线上字节买不回来——见[这个包做不到什么](#这个包做不到什么为什么)。解码器名字会按 HTTP token 校验;解码器抛错时响应体 fail closed,而不是被截断;未注册的编码依然会被拒绝。
220
+ brotli 本身落在原生 inflate 的 2.5 倍。这个差价就是这个编码的成本,而它省下的线上字节买不回来——见[这个包做不到什么](#这个包做不到什么为什么)。解码器名字会按 HTTP token 校验;解码器抛错时响应体 fail closed,而不是被截断;未注册的编码依然会被拒绝。
221
221
 
222
222
  ### 一行 import 得到 Chrome 身份
223
223
 
@@ -239,6 +239,58 @@ const client = new Client({ profile: chrome, connect, proxy, decoders: { br, zst
239
239
  | `blog.cloudflare.com` | 200 | 1.3 | `0x11ec` X25519MLKEM768 | h2 |
240
240
  | `www.shopify.com` | 200 | 1.3 | `0x11ec` X25519MLKEM768 | h2 |
241
241
 
242
+ ### 自定义一个身份
243
+
244
+ 三个层次,按你大概率会用到的顺序。
245
+
246
+ **改一个字段。** `tls` 是逐项合并的,点名一项不会丢掉 profile 的其余部分:
247
+
248
+ ```js
249
+ new Client({ profile: chrome, tls: { alpn: ['http/1.1'] } });
250
+ // alpn 换了;extensionOrder、grease、ciphers、groups 仍然是 Chrome 的
251
+ ```
252
+
253
+ 顶层字段(`headerOrder`、`http2Settings`、`http2PseudoHeaderOrder`、`http2HpackIndexing`)是整体替换——一个合并了一半的顺序不成其为顺序。
254
+
255
+ **派生一个 profile。** profile 就是个普通的冻结对象,spread 它就是全部机制,没有 API 要学。想给所有请求换 User-Agent,这就是正确做法:
256
+
257
+ ```js
258
+ const mine = { ...chrome, name: 'chrome+mine',
259
+ headers: [['User-Agent', 'mybot/1.0'], ['X-Tag', 'a']] };
260
+ new Client({ profile: mine, connect, proxy });
261
+ ```
262
+
263
+ **从零写一个。** 内置的那两个没有任何特权:
264
+
265
+ ```js
266
+ const firefox = {
267
+ name: 'my-firefox/130',
268
+ tls: { alpn: ['h2', 'http/1.1'], ciphers: [0x1302, 0x1301],
269
+ extensionOrder: [0, 10, 11, 13, 16, 23, 43, 45, 51, 0xff01], grease: false },
270
+ headerOrder: ['host', 'user-agent', 'accept', 'accept-language', 'accept-encoding', '*', 'connection'],
271
+ headers: [['User-Agent', 'Mozilla/5.0 Firefox/130.0']],
272
+ http2Settings: [[1, 65536], [4, 131072], [5, 16384]],
273
+ http2PseudoHeaderOrder: [':method', ':path', ':authority', ':scheme'],
274
+ requires: [],
275
+ };
276
+ ```
277
+
278
+ `requires` 对你自己的 profile 和对内置的一视同仁:声明一项 Client 没有拿到的能力,构造就会被拒绝并指名缺什么。**自定义身份同样受"不许声明做不到的事"的约束。**
279
+
280
+ profile 的 `headers` 是默认值——单次请求的同名 header 会盖掉它;而 `Client` 的显式选项会盖掉 profile。所以优先级是:每请求 > Client 选项 > profile。
281
+
282
+ ### 四个 WASM 模块的冷启动成本
283
+
284
+ 模块作用域的实例化落在启动阶段,而这个运行时**不对启动计费**,所以只有全新 isolate 的第一个请求看得见,之后为零。对照一组除了「有没有 import 这四个模块」之外完全相同的部署:
285
+
286
+ | | 带四个 WASM 模块 | 一个都不导入 |
287
+ |---|---|---|
288
+ | 全新 isolate 的第一个请求 | 3 ms | 0 ms |
289
+ | 第 2–5 个请求 | 0 ms | 0 ms |
290
+ | 第 6 个请求以后 | 0 ms | 0 ms |
291
+
292
+ 每字节的解码成本是另一回事,而且是**有条件的**:只有源站真的发 `br` 或 `zstd` 时才付。
293
+
242
294
  ### HTTP/2 — 要的是访问,不是速度
243
295
 
244
296
  客户端默认在 ALPN 中同时报出 `h2` 与 `http/1.1`,服务器选中哪个就说哪个。没有单独的 API:
@@ -329,7 +381,7 @@ res.tunnelfetch.httpVersion; // 服务器选了 h2 就是 '2',否则是 '1.1'
329
381
  | `timeouts` | 见下文 | `connectMs`、`handshakeMs`、`headersMs`、`idleMs`、`totalMs`。 |
330
382
  | `cookies` | `false` | 启用该 Client 专属的 cookie jar。 |
331
383
  | `maxRedirects` | `20` | |
332
- | `maxBodyBytes` | `Infinity` | 在读入任何字节之前就按 `Content-Length` 强制执行。 |
384
+ | `maxBodyBytes` | **`32 MiB`** | 在读入任何字节之前就按 `Content-Length` 强制执行,并在原始流和**解码后的输出**上各执行一次——包括你自己注册的解码器。传 `Infinity` 可以退出;见[关于这个默认值](#正文上限现在有默认值了)。 |
333
385
  | `decompress` | `true` | 是否解码 `Content-Encoding`。gzip 与 deflate 内置。 |
334
386
  | `decoders` | `{}` | 额外的编码,如 `{ br: fn }`;每一个都会被加进 `Accept-Encoding`。见 [`br`、`zstd`](#brzstd-与其它编码)。 |
335
387
  | `keepAlive` | `true` | |
@@ -341,6 +393,26 @@ res.tunnelfetch.httpVersion; // 服务器选了 h2 就是 '2',否则是 '1.1'
341
393
 
342
394
  `client.close()` 释放池中全部 socket。不关闭的 `Client` 会在 isolate 的整个生命周期里泄漏 socket。
343
395
 
396
+ #### 正文上限现在有默认值了
397
+
398
+ **`maxBodyBytes` 在 1.5.0 及以前默认是 `Infinity`,从 1.6.0 起是 32 MiB,这是一个破坏性变更**——下载超过
399
+ 32 MiB 的内容现在需要显式设置 `maxBodyBytes`,压没压缩都一样,因为这个选项同时约束线上正文和解码后的正文。
400
+
401
+ 理由是:在一个有硬性内存上限的运行时里,「不设限」不是自由,是一条被对端杀死的路径。53 字节的 brotli 正文解码出
402
+ 32 MB,这是实测的,不是假想;而内置的 `br`/`zstd` 解码器自限在 256 MiB——**是 Workers isolate 那 128 MB 的
403
+ 两倍**,所以那道兜底根本不可能在 isolate 死掉之前触发。一个存在意义就是去抓你控制不了的 URL 的客户端,不该把
404
+ 「无上限」作为「没读过这张表就会拿到」的默认值。
405
+
406
+ 32 MiB 是天花板的四分之一,所以即使被 `.arrayBuffer()` 整个缓冲到上限,isolate 也还有余量活下来并报告出来;
407
+ 而它比任何页面或 API 响应都高两个数量级。如果你确实要搬大文件,就明说:
408
+
409
+ ```js
410
+ new Client({ connect, proxy, maxBodyBytes: Infinity }); // 或者任何一个你想清楚了的数字
411
+ ```
412
+
413
+ 这个取舍是有意的:一个没被要求的上限,在第一次挡住你的时候是可发现的,而且错误信息里会点名那个选项;一次没被
414
+ 要求的 OOM 两者都不是。
415
+
344
416
  ### Trust — `verify=` 旋钮
345
417
 
346
418
  ```js
@@ -408,9 +480,10 @@ CertificateError [CERT_PIN_MISMATCH]: no certificate in the chain matches any co
408
480
  - **dNSName 与 iPAddress 之外的名称约束。** 标为 *critical* 且指名不支持类型的约束扩展会被拒绝;非 critical 的则按 RFC 5280 允许的那样忽略。
409
481
  - **cookie 的 public suffix list。** 只实现了“域名里没有点”这一道防护,所以 `Domain=com` 会被拒绝,`Domain=co.uk` 不会。如实写明,而不是伪造。
410
482
  - **IDNA。** 请传 A-label (punycode);非 ASCII 主机名会被拒绝,并附上说明原因的报错。
411
- - **开箱即用的 `br` 与 `zstd`。** 运行时的 `DecompressionStream` 只接受 gzip、deflate 和 deflate-raw——这是实测的,不是假设的。但这两种编码并非够不着:用 [`decoders`](#br-zstd-与其它编码) 注册一个解码器,该编码就会被声明并被解码。包里不内置任何一个,是因为在这个运行时上拿到 Brotli 的唯一途径是 WebAssembly,而一个 208 KB 的二进制块会同时让这个包失去零依赖、以及"不经打包器即可导入"的可移植性。自带解码器,等于把那份成本和那条供应链变成你自己的、并且看得见的。
483
+ - **默认身份里的 `br` 与 `zstd`。** 运行时的 `DecompressionStream` 只接受 gzip、deflate 和 deflate-raw——这是实测的,不是假设的——所以两者都来自 WebAssembly,而默认入口一个都不带。导入 [`tunnelfetch/profile/chrome`](#一行-import-得到-chrome-身份) 就已经接好,或者用 [`decoders`](#brzstd-与其它编码) 注册你自己的。主入口不会做的事,是为一个大多数调用方永远遇不到的编码,把约 140 KB 的编译后 C 拉进每一个 bundle。
484
+
485
+ 不开它是安全的,而不是有损的:内容协商决定了服务器绝不会发送没被请求的编码,所以提供 Brotli 的源站只会返回 gzip。代价是带宽——同一个页面 gzip 是 290 KB、`br` 是 99 KB——而带宽恰恰不是这个平台计费的东西。在 CPU 上这笔交易是反着的:brotli 解码是原生 inflate 的 2.5 倍,而且压得越狠越亏不是越省,因为解压的工作量跟着**输出**字节走。开 `br` 的理由是让 `Accept-Encoding` 与浏览器一致,不是省 CPU。
412
486
 
413
- 不开它是安全的,而不是有损的:内容协商决定了服务器绝不会发送没被请求的编码,所以提供 Brotli 的源站只会返回 gzip。代价是带宽——同一个页面 gzip 是 290 KB,`br` 是 99 KB——而带宽恰恰不是这个平台计费的东西。边缘实测下来,这笔交易是反着的:WASM Brotli 的 CPU 约为运行时原生 inflate 的 **1.9 倍**;即便在 14 个站点里线上字节省得最多的那一个上,省下的 186 KB 只换回约 1.2 ms,而多出来的解压是它的好几倍。而且压得越狠越亏,不是越省——brotli quality 11(CDN 从缓存里发的就是它)比 quality 5 线上小 16%,解码却贵 **46%**,因为解压的工作量跟着**输出**字节走,而更密的编码意味着每产出一个字节要做更多活。开 `br` 的理由是让 `Accept-Encoding` 与浏览器一致,不是省 CPU。
414
487
  - **流式请求体。** 请求体会先完整读入内存再发出:一是框定长度需要一个这个客户端敢于担保的 `Content-Length`,二是遇到重定向时可能需要重放。对 SDK 发的那点 JSON 完全够用;对大文件上传就不合适,也意味着不支持 `duplex: 'half'` 的流式上传。响应体则全程流式,任何时候都不会替你缓冲。
415
488
  - **HTTP/3。** ALPN 报出的是 `h2` 与 `http/1.1`(见 [HTTP/2](#http2--要的是访问不是速度));不报 `h3`——那是跑在 UDP 上的 QUIC,从一个只暴露原始 TCP 的运行时根本够不着。服务器若选中客户端未曾报出的协议,一律 fail closed——任何一层都不存在回退重试。
416
489
  - **服务器推送、HTTP/2 优先级与 h2c。** 推送在我们的 SETTINGS 里就是关闭的,收到 `PUSH_PROMISE` 即为连接错误;RFC 9113 的优先级机制已被废弃,PRIORITY 帧一律忽略;h2 只跑在 ALPN 协商出的 TLS 之上,绝不以“prior knowledge”走明文。
@@ -430,82 +503,87 @@ CertificateError [CERT_PIN_MISMATCH]: no certificate in the chain matches any co
430
503
  通过代理抓取一个尺寸可控的源站,热态,同一 isolate 上 7 轮以上取中位数,传输走 gzip。最后一列是同样的数字
431
504
  换算成速率,那是更值得随身记住的形式:
432
505
 
433
- | | 新连接(含首个请求) | 同连接每次后续请求 |
434
- | --- | --- | --- |
435
- | 1 KB body | 6.4 ms | 1.6 ms |
436
- | 16 KB body | 6.5 ms | 1.7 ms |
437
- | 64 KB body | 6.7 ms | 1.9 ms |
438
- | 256 KB body | 7.4 ms | 2.6 ms |
439
- | 1 MB body | 10.4 ms | 5.6 ms |
440
- | 4 MB body | 22.4 ms | 17.6 ms |
441
- | **全新 isolate 的第一个请求** | 46 ms | — |
442
- | **…执行 `warmup({ iterations: 5 })` 之后** | 16 ms | — |
443
-
444
- 为 1.4.0 重新测量:经代理打一个尺寸可控的源站,每个尺寸十轮,协商 HTTP/2,线上走 gzip。这些行**不是各自独立的测量**,而是一个模型拟合出来再回代验证的:
506
+ | Body | 5 页均摊 | 复用连接 | 新建连接 |
507
+ | --- | --- | --- | --- |
508
+ | 1 KB | 3.2 ms | 1.7 ms | 9.2 ms |
509
+ | 16 KB | 4.6 ms | 3.1 ms | 10.6 ms |
510
+ | 64 KB | 8.2 ms | 6.7 ms | 14.2 ms |
511
+ | 256 KB | 18.2 ms | 16.7 ms | 24.2 ms |
512
+ | 1 MB | 54.8 ms | 53.3 ms | 60.8 ms |
513
+ | 4 MB | 119.8 ms | 118.3 ms | 125.8 ms |
445
514
 
446
- > **≈ 开一条连接 6.4 ms + 每次后续请求 1.6 ms + 每 MB body 约 4 ms**
515
+ 冷启动成本是**总数**,不是往上面某一行加的增量:
447
516
 
448
- 每次扫描请求会抓五个页面——一个走新连接、四个复用它——所以两项是靠**改变复用次数**分离出来的,不是假设的:两页对十页得出每次后续请求 1.63 ms,连接项由余数得到。用 1 KB 4 MB 拟合,模型预测 1 MB 那行 32.9 ms(实测 35)、4 MB 那行 92.9 ms(实测 94)。
517
+ | 全新 isolate 的第一个请求 | 这个请求的成本 | 整条爬坡相对热态地板的超额 |
518
+ | --- | --- | --- |
519
+ | 不用 `warmup()` | **46 ms** | 61 ms(≈ 4.4 ms/请求,摊在 isolate 早期) |
520
+ | `warmup()` 一次 | 22 ms | 40 ms(≈ 2.8 ms/请求) |
521
+ | `warmup({ iterations: 5 })` | **16 ms** | 15 ms(≈ 1.1 ms/请求) |
449
522
 
450
- 有一个模型能把每一个尺寸行都拟合到它的散布之内:
523
+ 这两个数字原先以「+46 ms」「+16 ms」的形式写在上面那张表里,**把总数变成了增量,让文档里的冷启动成本翻了一倍**。光靠这几个数就能证伪那个 `+`:如果首请求是 9.2 + 16,那它一个人就带了 16 ms 超额,而**整条**爬坡的超额总共才 15 ms。这些是在小正文上测的;冷 isolate 的第一个 4 MB 请求从未测过,而且必然更差——解码循环有多得多的字节跑在解释执行下。
451
524
 
452
- > **≈ 开一条连接 9.5 ms + 每个请求 2 ms + 每 MB body 5–8 ms**
525
+ 经代理打一个尺寸可控的源站,每尺寸八轮,HTTP/2,线上走 gzip。连接项和每请求项是靠**改变复用次数**分离出来的,不是假设的——两页对十页得出**开连接 9.8 ms**、**每次后续请求 2.25 ms**,剩下的就是正文成本。
453
526
 
454
- 从每一行独立反解出来的连接项落在 5–11 ms 之间,与用其它方法测到的「新连接 9–12 ms」一致。它的主体是 TLS
455
- 握手和证书链验证;几乎没有解析的份(见下)。
527
+ **这些数字替换掉了一批测错的,而错在哪里值得写下来。** 它们来自的那个源站重复的是一段 150 字节的 HTML 片段,gzip 把它压到 **220:1**——所以一个"1 MB 正文"在线上只有四千字节,针对它做的每一次测量都只量到了解压,而把 TLS 记录和流处理的**每线上字节**成本整个抹掉了。真实页面大约 4:1。现在源站重复的是 154 KiB 真实压缩后的 JavaScript,压缩比 2.76:1——gzip 的窗口是 32 KiB,这么大的重复周期压不掉。
456
528
 
457
- 那些区间是真实的波动,不是测不准:这个平台上 CPU 的绝对值在不同 isolate 和不同轮次之间能差到约 1.5 倍——
458
- 同一组扫描重跑会落在更快或更慢的机器上——所以表里给的是中位数,区间就是重复的同 isolate 测量真实呈现的样子。
529
+ **修正幅度很大。** 正文偏重的那几行是 1.4.0 及以前所写数字的**两到三倍**,而且再怎么讲究中位数还是最小值都发现不了——因为那些数字**自洽**,它们只是在回答错误的问题。
459
530
 
460
- **复用才是杠杆。** 从同一个站点抓 30 16 KB 页面,走一条连接约 103 ms,开 30 条约 300 ms。这个差距就是
461
- 「应该持有 `Client` 而不是每个请求调一次 `createFetch`」的全部理由,而且页面越小差距越大。
531
+ **右边两列请当作推算值来读,因为它们就是。** 只有「复用连接」那一列是逐尺寸实测的;「新建连接」是它加一个固定的 7.5 ms,「五种页面平均」是加 1.5 ms——这就是为什么在正文大小相差 4000 倍的范围内,两列的差值精确到小数点后一位都不变。而这个 7.5 ms 和紧挨着写的 9.8 ms 对不上;「每次后续请求 2.25 ms」又比 1 KB 复用请求的**全部**成本 1.7 ms 还大,照此推算 1 KB 的正文成本是负的。两者出自不同的扫描,把它们放在一起算,正是本文档反复告诫不要做的跨扫描比较。**把连接项当作 7–10 ms 之间的一个量,不要拿它做算术。**
462
532
 
463
- **HTTP/2 在每一格里都更贵,没有任何一格更便宜**——一个页面走新连接是 12 ms 8 ms,一条连接上 30 个页面是
464
- 76 ms 对 67 ms,对着同一个源站、同一个代理,只改提供的 ALPN。多出来的是 HPACK 加上帧与流的簿记,集中在连接
465
- 建立阶段:preface、`SETTINGS` 交换、以及第一个头块。多路复用——HTTP/2 在浏览器里**存在的理由**——买到的是
466
- 延迟,而一个「每个 handler 一个请求」的 Worker 花不掉它。只在站点拒绝 HTTP/1.1 时才用它,其余路径上设
467
- `http2: false`。(这两行取自 Workers GraphQL analytics API 而不是 `wrangler tail`——后者在这里的测量网络下
468
- 撑不住;两者是同一个边缘 CPU 时间指标,按分钟取分位数。)
533
+ 这里原先还引了一次「独立印证」,它其实不是印证:从 CDN 抓一个真实的 3.6 MB 文件是 142 ms,而这张表对 4 MB 的预测是约 120 ms。那是模型**低估了约 20%**,方向和它刚刚替换掉的那个错误一致。它应当作为警告写在这里,而不是佐证。
469
534
 
470
- **全新 isolate 那两行是一条爬升,不是一个台阶。** V8 按函数、按 isolate 分层编译,所以头几次执行是解释运行的,
471
- 超额在大约六个请求内衰减:不调用 `warmup()` 时相对热态地板累计超额 61 ms,调用五次迭代后是 15 ms——摊到
472
- isolate 早期分别约为每请求 4.4 ms 和 1.1 ms。预热本身的代价是启动时 10 ms(一次迭代)或 22 ms(五次),对着
473
- 1 秒的启动预算,而且它**不降低热态地板**。
535
+ 还有两点。2.76:1 的内容比典型页面**略难压**,所以这些值偏保守而不是偏乐观;以及这个平台上 CPU 在不同 isolate 之间有最多约 1.5 倍波动,所以形状比任何单个数字更要紧。
474
536
 
475
537
  ### 这些折算成多少钱
476
538
 
477
539
  Workers Standard 每月 $5,含 1000 万请求和 3000 万 CPU 毫秒,超出部分每百万请求 $0.30、每百万 CPU 毫秒
478
540
  $0.02。把上面的实测代入,并把冷启动的影响单独列成两组列,这样任一负载「预热与否」的差别是看得见的:
479
541
 
480
- | 工作负载 | 每请求 CPU | 1000 万/月,冷 | 1000 万/月,预热 | 10 亿/月,冷 | 10 亿/月,预热 |
542
+ | 工作负载 | CPU/请求 | 1000 万,冷 | 1000 万,预热 | 10 亿,冷 | 10 亿,预热 |
481
543
  | --- | --- | --- | --- | --- | --- |
482
- | 平台自带 `fetch` —— 参照;它没法走代理 | 0.3 ms | $5.00 | $5.00 | $307.40 | $307.40 |
483
- | 复用连接,16 KB 页面 | 3.3 ms | $5.93 | $5.28 | $454.60 | $389.60 |
484
- | 复用连接,1 MB 页面 | 9.2 ms | $7.11 | $6.46 | $572.60 | $507.60 |
485
- | 每请求新连接,16 KB | 11 ms | $7.47 | $6.82 | $608.60 | $543.60 |
486
- | 每请求新连接,1 MB | 14.5 ms | $8.17 | $7.52 | $678.60 | $613.60 |
487
- | 每请求新连接,4 MB | 30 ms | $11.27 | $10.62 | $988.60 | $923.60 |
544
+ | 平台 `fetch`,16 KB —— 参照;它用不了代理 | 0.3 ms | $5.00 | $5.00 | $307.40 | $307.40 |
545
+ | 平台 `fetch`,4 MB —— 同一参照,实测 | 3.2 ms | $5.04 | $5.04 | $365.40 | $365.40 |
546
+ | 连接复用,16 KB 页面 | 3.1 ms | $5.90 | $5.24 | $451.20 | $385.20 |
547
+ | 每请求新建连接,16 KB | 10.6 ms | $7.41 | $6.75 | $602.20 | $536.20 |
548
+ | 连接复用,1 MB 页面 | 53.3 ms | $15.94 | $15.28 | $1455.20 | $1389.20 |
549
+ | 每请求新建连接,1 MB | 60.8 ms | $17.45 | $16.79 | $1606.20 | $1540.20 |
550
+ | 连接复用,4 MB 页面 | 118.3 ms | $28.94 | $28.28 | $2755.20 | $2689.20 |
551
+ | 每请求新建连接,4 MB | 125.8 ms | $30.45 | $29.79 | $2906.20 | $2840.20 |
552
+
553
+ 参照那一行给了两个尺寸,因为平台自己的 `fetch` **不是平的**——它按每解压 MB 约 0.82 ms 增长,这是在同一个 CDN 的尺寸阶梯上测的,只有大小在变。把它写成单一的 0.3 ms 再拿去和 4 MB 那一行比,是拿不同的东西相比,而且是**抬高了对手而不是抬高本包**。
554
+
555
+ 这些美元数字跟随上面修正后的 CPU 测量,所以正文偏重的行是 1.4.0 及以前的**两到三倍**。那不是包变慢了,是移除了一个内容压缩比 220:1 的源站。
556
+
557
+ #### Chrome 身份的每个选项各花多少
558
+
559
+ 上面那张表是默认身份:线上 gzip、AES-256-GCM、x25519。Chrome 那一行把所有变化捆在一起,对做决定没什么用。按"连接复用 + 1 MB 页面 + 十亿请求/月 + 已预热"逐项拆开:
560
+
561
+ | 相对基线的变化 | CPU/请求 | 10 亿/月,已预热 | Δ | 何时才付 |
562
+ | --- | --- | --- | --- | --- |
563
+ | 基线 —— gzip、AES-256-GCM、x25519 | 53.3 ms | $1,389 | — | 总是 |
564
+ | 源站发 `br` 而不是 gzip | 57.6 ms | $1,474 | **+$85** | 源站选择发 `br` |
565
+ | 服务器选中 ChaCha20-Poly1305 | 56.3 ms | $1,448 | **+$59** | 服务器优先选它而非 AES |
566
+ | 源站发 `zstd` 而不是 gzip | 56.1 ms | $1,444 | **+$55** | 源站选择发 `zstd` |
567
+ | 协商 X25519MLKEM768,每连接 1 个请求 | 61.0 ms | $1,542 | **+$153** | 每次握手 |
568
+ | 协商 X25519MLKEM768,每连接 20 个请求 | 53.3 ms | $1,390 | **+$0.15** | 同一次握手,摊薄后 |
488
569
 
489
- 「冷」一栏带上了实测的全新 isolate 爬升(摊薄后每请求 +4.4 ms);「预热」是同一负载调用
490
- `warmup({ iterations: 5 })` 之后,爬升降到 +1.1 ms。省下的是每月 $0.65(1000 万请求)和 $65(十亿请求),
491
- 每一行完全相同——因为爬升是 isolate 的属性,不是请求的属性。参照行不带爬升,因为平台自己的 `fetch` 没有需要
492
- 分层编译的 JavaScript 协议栈。
570
+ **最后两行是同样的 0.15 ms ML-KEM,差别完全来自连接复用**——这才是这张表最值得带走的一条。后量子密钥交换在你让 `Client` 活着时是这里最便宜的东西,在你不这么做时是最贵的,因为它是**每握手**,而其余全是每字节。
493
571
 
494
- 从这张表里能读出四件事。
572
+ 五项里有三项还是**有条件的,而且不由你决定**。`br` 和 `zstd` 在源站选择发它们之前一分不花;ChaCha20 在服务器优先选它而非 AES-GCM 之前也是——而有 AES 硬件加速的服务器通常不会。**报出去买的是指纹,付钱只在对端真的接受时发生。**
495
573
 
496
- **在每月一千万请求这个量级上,这些都无关紧要。** 每一行都落在 $5 $11 之间,因为额度全吃下去了——在这个
497
- 量级上 included 的 CPU 折合每请求 3.0 ms,所以任何会复用连接的用法都完全包含在基础费里,冷启动也一样。
574
+ ChaCha20 那个数字是**内置的 WASM** AEAD 对它所替代的 WebCrypto AES-256-GCM 测的(4.89 对 1.95 ms/MB)。本节早先的版本写的是 +2.0 ms/MB,那是 `node:crypto` 的 ChaCha20——这个包并不走那条路,因为走它就要 `nodejs_compat`。
498
575
 
499
- **到十亿请求时,每一行里有 $297 是请求费**,它对所有行都一样,而且这个包做什么都改变不了它。剩下能优化的只有
500
- CPU,而在那里,复用连接与否在 16 KB 页面上差 $154/月。
576
+ 由此可以看出四件事。
501
577
 
502
- **复用连接并预热之后,整套用户态协议栈比平台自己的 `fetch` 贵约 27%**——$390 $307——换来的是平台的
503
- `fetch` 根本做不到的事。
578
+ **在每月一千万请求这个量级上,小页面免费,大页面不是。** 连接复用的 16 KB 负载落在基础费之内;连接复用的 1 MB 负载是每月 $15。这个量级上包含的 CPU 折合每请求 3.0 ms——16 KB 页面塞得进去,1 MB 页面塞不进去。
504
579
 
505
- 那个参照行是实测的,不是假设的,而且它**不是平的**。从同一个 Worker 抓不同大小的真实页面,复用连接上的每请求
506
- 边际成本:
580
+ **到了十亿请求,每一行里有 $297 是请求费**,所有行都一样,这个包做什么都改变不了。剩下的只有 CPU,而在那里最大的杠杆**不是连接复用,是正文大小**:复用在 1 MB 页面上省 $151/月,而把抓取目标从 1 MB 页面换成 16 KB 页面省 $1,004。
507
581
 
508
- | 页面 | 大小 | 平台 `fetch` | 本包(走代理) | 倍数 |
582
+ **正文偏重的场景才是这套用户态栈真正要花钱的地方。** 连接复用且预热后,16 KB 页面上它比平台自己的 `fetch` 25%($385 $307);1 MB 页面上是 **3.8 倍**($1,389 对 $365)——因为每一个字节都要在 JavaScript 里解密、重组、解压,而平台把这三件事都放在运行时里做,一分不计。**如果你的负载是大正文,该拿来做规划的是这个倍数,不是 16 KB 那个。**
583
+
584
+ 那一行参照值是**实测的,不是假设的**,而且它并不是平的。从同一个 Worker 抓不同大小的真实页面,连接复用下的每请求边际成本:
585
+
586
+ | 页面 | 大小 | 平台 `fetch` | 本包(经代理) | 倍数 |
509
587
  | --- | --- | --- | --- | --- |
510
588
  | `example.com` | 0.6 KB | 0.2 ms | 3.8 ms | 12.8× |
511
589
  | `news.ycombinator.com` | 35 KB | 0.3 ms | 1.8 ms | 5.5× |
@@ -528,16 +606,58 @@ CPU 计费,所以十亿请求下「预热」两列省下的每月 $65 是净
528
606
  12 倍、RSA-2048 验签的 27 倍。典型 EC 链有两个 P-384 环节,所以**全 ECDSA 链验证约 3.5 ms,RSA 链约 0.8 ms**。
529
607
  如果源站是你自己的,证书的密钥类型值得想一下。
530
608
 
531
- 小响应的解码成本由构造 `DecompressionStream` 主导,而不是字节数:一个 559 字节的 body 也要约 2 ms,所以对
532
- 小的 JSON 响应来说,`decompress: false` 可能比 gzip 更划算。
609
+ 小响应的解码成本由构造 `DecompressionStream` 主导,而不是字节数:一个 559 字节的 body 也要约 2 ms。这个固定
610
+ 成本是真的,但本文档过去从它推出的建议——小 JSON 用 `decompress: false` 更划算——**对任何不算极小的响应都是反
611
+ 的,而且当初根本没有和另一个选项对比着测过。**
612
+
613
+ 这里的成本按**线上字节**走,不按解码后的字节走:每一个线上字节都要先被解密、重新组帧、跨 JS 流边界搬运,解压器
614
+ 才看得见它。边上实测:收一个 4 MB 的**未压缩**正文,和收它 1.5 MB 的 gzip **再解压**,成本一样。关掉压缩,是
615
+ 用省下的一次解压,换整条接收管道多走 2.7 倍的字节。**把压缩开着。** 那个固定的约 2 ms 只在正文小到一次线上读
616
+ 就能读完时才划算。
617
+
618
+ ### 和平台 `fetch` 的成本平价做不到,地板在这里
619
+
620
+ 两次独立调查各自得出了这个结论,这是把它写得这么直白的主要原因。
621
+
622
+ `gz-native`——运行时自带的 `DecompressionStream` 把 1.5 MB gzip 解成 4 MB,用原生方式收集,**没有任何 JS
623
+ 抽干、没有任何接收栈**——要 **16 ms**,五轮独立扫描都复现。而平台的整个 4 MB `fetch`,含 TLS、HTTP 和解压,
624
+ 约 **3.6 ms**。
625
+
626
+ 也就是说,这个包把那段 gzip 变成字节的**最便宜的可能方式**,已经是**平台整个请求的 4.4 倍**,而这还没碰到任何
627
+ 一个 TLS 或 HTTP/2 字节。这个不对称和代码质量无关:**Cloudflare 对跑在你 isolate 里的 `DecompressionStream`
628
+ 计费,对它自己 `fetch` 内部等价的 gunzip 不计费。** 用 JavaScript 写的任何东西都下不到一个被计费的原生地板
629
+ 以下。
630
+
631
+ 剩下的约 30 倍是 JS 编排的记录层、HTTP/2 解复用和流管道——在 4 MB 时约占每请求成本的 **80%,而解码占 20%**。
632
+ 本节早先的版本把重点放在解码上,那是错的,它把优化精力引向了两者中较小的那个。
633
+
634
+ 真正能补上这个差距的是一个不存在的原语:一个校验**源站**主机名、而不是校验 `connect()` 对端的 `startTls`,
635
+ 有了它,平台自己的 `fetch` 就能在隧道里跑。那正是这整个包绕开的那块缺失,值得理解为**运行时的能力缺口,而不是
636
+ 这里的性能 bug**。
533
637
 
534
638
  大 body 的"每字节成本"其实不按字节计——按的是流边界穿越次数。这个运行时的 `DecompressionStream` 以
535
639
  4096 字节为块产出输出,套接字单次交付也至多 4096 字节,而每一块在运行时与 JS 之间穿越一次都要几十微秒,
536
- 与块大小无关。因此两条热路径都改为用 BYOB 读、以 64 KiB 视图去抽干来源(BYOB 读会把已经缓冲的数据一次
537
- 交付,且只要有一个字节就立即以部分填充返回,流式延迟不变)。这次重建把解码级从每 MB 解压输出约 28 ms 降到
538
- 约 6 ms——在同一个 isolate 里对两种实现做 A/B:同一个 4 MB body,110 ms 对 23 ms。剩下的已接近地板:
539
- inflate 本身(约 2 ms/MB)加上把 body 物化成 JS 字符串(约 1.7 ms/MB),而后者是平台自带 `fetch` 也要计费的
540
- 那一项。
640
+ 与块大小无关——实测约 **17 µs 一次穿越**,来自同一个 1 MB 分别按 4 KiB(6.0 ms/MB)到 256 KiB
641
+ (1.67 ms/MB)收集的阶梯。因此两条热路径都改为用 BYOB 读来抽干来源:它把已经缓冲的数据一次交付,且只要
642
+ 有一个字节就立即以部分填充返回,流式延迟不变。
643
+
644
+ 读进去的那个视图是 **16 KiB,而且这个尺寸是扫出来的,不是拍的**。它比看上去重要:输入由一个 JS 任务在与
645
+ 拉取方相同的事件循环上泵送,所以读到达时解压器手上通常只压着一两块,读回来就是部分填充——实测一个 1 MB
646
+ body:**93 次读,93 次全是部分填充**,平均每次 11.3 KiB。于是 64 KiB 的视图为了搬 1 MB 数据要分配
647
+ **5.8 MB** 的一次性缓冲区。在边上扫,每 MB 解压输出的 CPU,五档在同一个 isolate 里交错跑:
648
+
649
+ | BYOB 视图 | 4 KiB | 8 KiB | **16 KiB** | 32 KiB | 64 KiB |
650
+ |---|---|---|---|---|---|
651
+ | 解码级 ms/MB | 19.33 | 16.00 | **13.00** | 15.33 | 17.67 |
652
+
653
+ 干净的 U 形:太小付每次读的开销,太大付永远填不满的分配。原先那个 64 KiB 是从一个用原生 `pipeTo` 喂输入的
654
+ 探针上选的——`pipeTo` 会跑在前面,**确实**能填满 64 KiB 视图(16 次读,没有一次部分填充)。而那是发布出去的
655
+ 接线永远不会进入的工况。探针和产品说法不一致,当时信了探针。改过来之后解码级降了 **31%**,18.0 → 12.3
656
+ ms/MB,在同一个 isolate 里 A/B。
657
+
658
+ 剩下的**并不**接近地板,本节早先的版本说它接近,那是错的。同样内容的原生 inflate 是 4.3 ms/MB,而这一级是
659
+ 12.3,也就是说还有约 **8 ms/MB 是本包自己的管道**——JS 输入泵和输出包装。要压下去得重新设计,而不是改一个
660
+ 常数;这是 body 路径上剩下的最大一项。
541
661
 
542
662
  导入这个包是免费的。121 个内置锚是以主题 DN 哈希为索引的 base64 字符串,只有链实际落到的那一个会被解码,所以
543
663
  380 KB 打包(gzip 后 133 KB)的启动时间保持在约 2 ms,而一个导入了但没使用本包的请求是 0 ms。
@@ -552,7 +672,7 @@ inflate 本身(约 2 ms/MB)加上把 body 物化成 JS 字符串(约 1.7 m
552
672
  | `tls.extensionOrder: 'shuffle'` | 测不出来 | 每次握手洗牌约 11 个元素 |
553
673
  | `headerOrder` | 测不出来——有序列表比平台的 `Headers` **更快**(1.6 µs 对 3.8 µs) | 每请求 |
554
674
  | `groups: { x25519mlkem768 }` | 用内置 WASM **+0.15 ms**,用纯 JS 的 ML-KEM **+1.35 ms** | **每连接**,不是每请求——复用该连接的所有请求共同摊薄 |
555
- | `ciphers: { chacha20 }` | **+2.0 ms/MB**,而且只有服务器**选中**它时才付 | 每字节。有 AES 硬件加速的服务器通常偏好 AES-GCM,所以实际成本往往是零,真正起作用的是"出现在 offer 里" |
675
+ | `ciphers: { chacha20 }` | **+2.95 ms/MB**(内置 WASM AEAD 4.89 对 AES-GCM 1.95),而且只有服务器**选中**它时才付 | 每字节。有 AES 硬件加速的服务器通常偏好 AES-GCM,所以实际成本往往是零,真正起作用的是"出现在 offer 里" |
556
676
  | `decoders: { br }` | **+4.4 ms/MB** | 每字节,只要源站发 brotli。压得越狠越亏:quality 11 线上小 16%,解码贵 46% |
557
677
  | `profile: chrome` | 上面三项之和 | |
558
678
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunnelfetch",
3
- "version": "1.4.0",
3
+ "version": "1.6.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",
@@ -3,7 +3,7 @@
3
3
  // the request side (what we advertise) and the response side (what we decode) live in one
4
4
  // file so they cannot drift apart.
5
5
 
6
- import { HttpError, codes } from '../errors.js';
6
+ import { HttpError, LimitError, codes } from '../errors.js';
7
7
 
8
8
  /**
9
9
  * What the request layer advertises with no extra decoders registered. The target runtime's
@@ -80,7 +80,26 @@ const KNOWN_UNSUPPORTED = new Set(['br', 'zstd', 'compress', 'x-compress']);
80
80
  * least one byte is available — it never waits for the view to fill — so a large view cannot
81
81
  * add latency; it only lets a fast decompressor hand over more per boundary crossing.
82
82
  */
83
- const DECOMPRESS_READ_BYTES = 65536;
83
+ // Sized to the fill that actually arrives, not to the one a 64 KiB view hopes for.
84
+ //
85
+ // A BYOB read resolves the moment ANY output exists — that is the property that keeps an SSE body
86
+ // from stalling, and it is why this drain is safe on a streaming path. But it also means the view
87
+ // size is an upper bound that is essentially never reached here: the input is pumped by a JS task
88
+ // on the same event loop as the puller, so the decompressor typically holds one or two of its
89
+ // 4 KiB output chunks when the read arrives. Measured on the edge over a 1 MB body of real content:
90
+ // 93 reads, 93 of them partial, average fill 11.3 KiB — and with a 64 KiB view that is 5.8 MB of
91
+ // throwaway buffer allocated to carry 1 MB of data.
92
+ //
93
+ // Swept on the edge, ms of CPU per MB of decompressed output, all five interleaved in one isolate:
94
+ //
95
+ // 4 KiB 19.33 16 KiB 13.00 64 KiB 17.67
96
+ // 8 KiB 16.00 32 KiB 15.33
97
+ //
98
+ // A clean U: too small pays per-read overhead, too large pays for allocation it never fills.
99
+ // 64 KiB was chosen when this drain was first measured against an input fed by a native pipeTo,
100
+ // which runs ahead and DOES fill a 64 KiB view (16 reads, none partial) — a regime the shipped
101
+ // wiring never enters. The probe and the product disagreed, and the probe was believed.
102
+ const DECOMPRESS_READ_BYTES = 16384;
84
103
 
85
104
  /**
86
105
  * One decompression stage. `sniffDeflate` handles the deflate ambiguity:
@@ -93,20 +112,28 @@ const DECOMPRESS_READ_BYTES = 65536;
93
112
  * both, so the check is reliable in practice.
94
113
  *
95
114
  * The output side is pull-driven and drains the decompressor with a BYOB reader when the
96
- * runtime supports one (a large view per read), falling back to a default reader elsewhere.
97
- * This shape is measured, not aesthetic: the target runtime's DecompressionStream emits
98
- * 4096-byte chunks, and the previous wiring (pipeTo → WritableStream → TransformStream)
99
- * crossed the JS/runtime boundary several times per chunk measured on the edge at
100
- * ~28 ms of CPU per MB of decompressed output for this stage alone, against ~2 ms/MB for
101
- * the inflate itself. Draining with one 64 KiB read per crossing brings the stage to
102
- * ~6 ms/MB (A/B-ed old-vs-new inside one isolate: 110 ms vs 23 ms for a 4 MB body).
103
- * A BYOB read resolves with a partial fill the moment any output exists — verified on the
104
- * edge with a stalled input, 58 KB arrived into a 1 MB view — so streaming latency is
105
- * unchanged. Input is still pumped by an independent task: a decompressor legitimately
106
- * consumes many input chunks before producing output, so tying input progress to output
107
- * pulls would deadlock.
115
+ * runtime supports one, falling back to a default reader elsewhere. This shape is measured, not
116
+ * aesthetic: the target runtime's DecompressionStream emits 4096-byte chunks, and the original
117
+ * wiring (pipeTo → WritableStream → TransformStream) crossed the JS/runtime boundary several
118
+ * times per chunk. Draining it directly, one read per crossing, is a large win.
119
+ *
120
+ * A BYOB read resolves with a partial fill the moment any output exists — verified on the edge
121
+ * with a stalled input so this cannot add streaming latency. Input is still pumped by an
122
+ * independent task: a decompressor legitimately consumes many input chunks before producing
123
+ * output, so tying input progress to output pulls would deadlock.
124
+ *
125
+ * The per-MB numbers this comment used to carry were all measured against a fixture that tiled a
126
+ * 63-byte phrase. gzip crushes that ~200:1 into a handful of long matches, so inflating it is
127
+ * nearly free and every figure taken on it was a floor no real body reaches. Against content that
128
+ * compresses like content (2.76:1 minified JS) the same stage costs 4.7x more. See
129
+ * DECOMPRESS_READ_BYTES for the current, honestly-sourced figures.
108
130
  */
109
- function decompressionStage(source, coding) {
131
+ function decompressionStage(source, coding, maxBytes = Infinity) {
132
+ // Bytes this stage has produced. `maxBodyBytes` bounded only the COMPRESSED wire body, so a
133
+ // caller asking for at most 1 MB received 20 MB from a 20 KB gzip bomb — the cap was applied to
134
+ // the wrong side of the decompressor. gzip reaches roughly 1000:1, so the gap was not bounded in
135
+ // any useful sense. Counted here, per stage, so a chain like `br, gzip` cannot exceed it either.
136
+ let produced = 0;
110
137
  const srcReader = source.getReader();
111
138
  /** Rejections here surface through the output stream; pre-observed like chunked.js does. */
112
139
  let pumpDone = null;
@@ -207,6 +234,18 @@ function decompressionStage(source, coding) {
207
234
  return;
208
235
  }
209
236
  if (value.byteLength === 0) continue; // legal, carries nothing; keep reading
237
+ produced += value.byteLength;
238
+ if (produced > maxBytes) {
239
+ // Refused BEFORE the over-long chunk is handed on, so the caller never holds more than
240
+ // it asked for. Fail closed: a truncated body delivered as if complete would be worse.
241
+ throw new HttpError(
242
+ codes.LIMIT_BODY,
243
+ `decoded body exceeded maxBodyBytes: ${produced} bytes of "${coding}" output past a ` +
244
+ `${maxBytes} byte cap. The compressed body was within the cap; the decompressed ` +
245
+ 'one is what a gzip bomb inflates.',
246
+ { coding, produced, maxBytes },
247
+ );
248
+ }
210
249
  c.enqueue(value);
211
250
  return;
212
251
  }
@@ -239,6 +278,62 @@ function decompressionStage(source, coding) {
239
278
  });
240
279
  }
241
280
 
281
+ /**
282
+ * Bound a caller-supplied decoder's OUTPUT at `maxBytes`, the same fail-closed way the built-in
283
+ * decompression stage bounds its own. A registered decoder does its own decompression, so a small
284
+ * coded body can still inflate far past `maxBodyBytes` in the decoder — and the package ships two
285
+ * such decoders itself (`br`/`zstd` in `tunnelfetch/profile/chrome`), so leaving their output
286
+ * unbounded made a documented guarantee ("enforced again on the DECODED output", SECURITY.md's
287
+ * "a peer cannot make this client allocate without bound") false for its own blessed identity: a
288
+ * ~50-byte brotli bomb decoded to hundreds of MB under a 1 MB cap. This is a bound, not a
289
+ * truncation — the over-long chunk is refused before it is handed on, exactly like the gzip path,
290
+ * so nothing partial is ever presented as complete. Only applied when a finite cap is set; an
291
+ * Infinity cap (the default) leaves the bound entirely to the caller as before.
292
+ *
293
+ * @param {ReadableStream<Uint8Array>} source the decoder's output
294
+ * @param {string} coding for the error message
295
+ * @param {number} maxBytes finite cap on decoded output
296
+ * @returns {ReadableStream<Uint8Array>}
297
+ */
298
+ function capDecodedOutput(source, coding, maxBytes) {
299
+ let produced = 0;
300
+ const reader = source.getReader();
301
+ return new ReadableStream({
302
+ async pull(c) {
303
+ try {
304
+ for (;;) {
305
+ const { value, done } = await reader.read();
306
+ if (done) {
307
+ c.close();
308
+ return;
309
+ }
310
+ if (!value || value.byteLength === 0) continue;
311
+ produced += value.byteLength;
312
+ if (produced > maxBytes) {
313
+ // Refused BEFORE the over-long chunk is enqueued, so the caller never holds more than
314
+ // it asked for. The decoder's own error, if any, still propagates untouched below.
315
+ throw new LimitError(
316
+ codes.LIMIT_BODY,
317
+ `decoded body exceeded maxBodyBytes: ${produced} bytes of "${coding}" output past a ` +
318
+ `${maxBytes} byte cap. The coded body was within the cap; the decoded one is what a ` +
319
+ 'decompression bomb inflates — the registered decoder for this coding does not stop it.',
320
+ { coding, produced, maxBytes },
321
+ );
322
+ }
323
+ c.enqueue(value);
324
+ return;
325
+ }
326
+ } catch (e) {
327
+ await reader.cancel(e).catch(() => {});
328
+ c.error(e);
329
+ }
330
+ },
331
+ async cancel(reason) {
332
+ await reader.cancel(reason).catch(() => {});
333
+ },
334
+ });
335
+ }
336
+
242
337
  /** Byte at logical offset `i` across the buffered head chunks. */
243
338
  function firstBytes(chunks, i) {
244
339
  for (const c of chunks) {
@@ -263,9 +358,12 @@ function firstBytes(chunks, i) {
263
358
  * comma-separated list names codings in the order the SERVER applied them, so decoding
264
359
  * applies them in reverse.
265
360
  * @param {Record<string, BodyDecoder> | null} [decoders] caller-supplied codings
361
+ * @param {number} [maxBytes] cap on DECODED output, per stage, for built-in AND registered
362
+ * decoders alike. `maxBodyBytes` alone bounded the compressed body, which a decompression bomb
363
+ * walks straight past. A non-finite cap leaves a registered decoder's output to the caller.
266
364
  * @returns {ReadableStream<Uint8Array>} decoded bytes
267
365
  */
268
- export function decodeBody(stream, contentEncoding, decoders = null) {
366
+ export function decodeBody(stream, contentEncoding, decoders = null, maxBytes = Infinity) {
269
367
  /** Look a coding up among the caller's decoders, case-insensitively as the header is. */
270
368
  const custom = (coding) => {
271
369
  if (!decoders) return null;
@@ -321,10 +419,13 @@ export function decodeBody(stream, contentEncoding, decoders = null) {
321
419
  { coding },
322
420
  );
323
421
  }
324
- out = staged;
422
+ // The DECODED output is bounded at maxBytes just like the built-in path — a registered
423
+ // decoder decompresses too, so a small coded body can inflate far past the cap inside it.
424
+ // Fail-closed, not truncation (see capDecodedOutput). An Infinity cap is a no-op.
425
+ out = Number.isFinite(maxBytes) ? capDecodedOutput(staged, coding, maxBytes) : staged;
325
426
  continue;
326
427
  }
327
- out = decompressionStage(out, coding === 'x-gzip' ? 'gzip' : coding);
428
+ out = decompressionStage(out, coding === 'x-gzip' ? 'gzip' : coding, maxBytes);
328
429
  }
329
430
  return out;
330
431
  }
package/src/client.js CHANGED
@@ -30,6 +30,27 @@ import { ALPN_H2, ALPN_HTTP11 } from './http2/constants.js';
30
30
  /** Status codes whose Response may not carry a body, per the Response constructor. */
31
31
  const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
32
32
 
33
+ /**
34
+ * The default cap on a response body, raw and decoded alike. **This has a default because a
35
+ * runtime with a hard memory ceiling makes "no limit" a way to be killed by a peer, not a freedom.**
36
+ *
37
+ * It used to be `Infinity`. That left every caller who had not thought about it open to a
38
+ * decompression bomb: 53 coded bytes reaching 32 MB is real and measured, and the bundled `br`/`zstd`
39
+ * decoders self-limit at 256 MiB, which is TWICE the 128 MB a Workers isolate gets. A cap above the
40
+ * ceiling cannot fire before the isolate dies, so the practical protection was none. A client whose
41
+ * entire purpose is fetching URLs it does not control should not ship that as its default.
42
+ *
43
+ * 32 MiB is a quarter of the ceiling, so a body buffered to the cap by `.arrayBuffer()`/`.json()`
44
+ * still leaves the isolate room to survive and report it. It is also two orders of magnitude above
45
+ * any HTML page or API response, so the callers it interrupts are the ones deliberately moving large
46
+ * files — who get a message naming the option and can raise it or set `Infinity` to opt out.
47
+ *
48
+ * This bounds the WIRE body too, not only the decoded one, so it is a real behaviour change and not
49
+ * only a bomb guard: a 50 MB download now needs an explicit `maxBodyBytes`. That is the intended
50
+ * trade — an unasked-for limit is discoverable the first time it bites, an unasked-for OOM is not.
51
+ */
52
+ const DEFAULT_MAX_BODY_BYTES = 32 * 1024 * 1024;
53
+
33
54
  /**
34
55
  * A `fetch`-shaped function. Deliberately the platform's own signature: being assignable to
35
56
  * `typeof fetch` is what lets an SDK accept this in place of the global without adapting.
@@ -72,7 +93,11 @@ const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
72
93
  * @property {import('./client/cookies.js').CookieJar} [jar] supply a jar directly, e.g. to share
73
94
  * one across Clients or to persist it.
74
95
  * @property {number} [maxRedirects] default 20.
75
- * @property {number} [maxBodyBytes] enforced from Content-Length before a byte is read.
96
+ * @property {number} [maxBodyBytes] the most body this client will produce, **default 32 MiB**.
97
+ * Checked against Content-Length before a byte is read, enforced on the raw stream, and enforced
98
+ * again on the DECODED output — a compressed body within the cap can decompress far past it, and
99
+ * a registered decoder's output is bounded by it too. Pass `Infinity` to opt out, which is the
100
+ * right choice for streaming large files and the wrong one for fetching URLs you do not control.
76
101
  * @property {boolean} [decompress] gzip/deflate. Default true.
77
102
  * @property {Record<string, import('./client/decode.js').BodyDecoder>} [decoders] extra
78
103
  * content-codings this client can read, e.g. `{ br: (s) => ... }`. Registering one is what
@@ -684,7 +709,7 @@ async function sendAndReceive(client, conn, current, { key, deadlines, reused })
684
709
  client.jar.setFromResponse(current.url, headInfo.setCookie);
685
710
  }
686
711
 
687
- const raw = readResponseBody(reader, framing, { maxBytes: o.maxBodyBytes ?? Infinity });
712
+ const raw = readResponseBody(reader, framing, { maxBytes: o.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES });
688
713
 
689
714
  // The connection goes back to the pool only when the body reaches the end its framing declared.
690
715
  // `completed` resolving false means the caller cancelled and the stream position is unknown.
@@ -825,7 +850,7 @@ function decodeResponseBody(body, headers, options) {
825
850
  if (options.decompress === false) return body;
826
851
  const encoding = headers.get('content-encoding');
827
852
  if (!encoding) return body;
828
- return decodeBody(body, encoding, options.decoders ?? null);
853
+ return decodeBody(body, encoding, options.decoders ?? null, options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
829
854
  }
830
855
 
831
856
  function buildResponse(headInfo, body, framing, conn) {