npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

tunnelfetch

v1.13.0

Published

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.

Readme

tunnelfetch

English · 简体中文

CI npm license

一个 fetch 形态的 HTTP 客户端,能在只暴露原始 TCP 的运行时上——主要是 Cloudflare Workers (workerd)——通过 HTTP CONNECT、HTTPS 或 SOCKS5 代理发出请求。

零依赖。ESM。无构建步骤。src/ 中没有任何 node: 导入。

成熟度。 这是一个新实现,不是久经沙场的实现。它在用户态实现了 TLS 1.2/1.3 与证书校验——在这个领域,好的测试是必要条件,但不是充分条件。它有一千一百多个离线测试、RFC 向量、逐字节分片、真实边缘互操作测试、对每一个面向对端的解析器做种子化 fuzzing,行覆盖率 95%;但它没有经过外部安全审计。请把它当作一个值得一试的高质量实现,而不是一个已被生产验证的东西。发现任何问题都欢迎报告——见 SECURITY.md。

import { Client } from 'tunnelfetch';
import { connect } from 'cloudflare:sockets';

const client = new Client({ connect, proxy: 'http://user:[email protected]:8080' });
const res = await client.fetch('https://api.example.com/v1/things');
const data = await res.json();
await client.close();

为什么会有这个包

在 Cloudflare Workers 上,没有任何受支持的方式能让 HTTPS 请求经第三方代理发出。原因是结构性的,而且每一条都在边缘实测过,不是推断出来的:

  1. fetch() 没有代理选项。 没有 proxy,没有 agent,也没有 dispatcher。运行时的出站路由控制 (fetcher、globalOutbound) 指向的是其他 Worker,不是代理。
  2. node:net / node:tls 帮不上忙。 它们是真实存在的,但都实现在同一套 cloudflare:sockets API 之上,继承了它的每一条限制。
  3. cloudflare:sockets 的 connect() 给的是原始 TCP,所以 CONNECT 或 SOCKS5 握手完全做得到——做不到的是隧道里面的那层 TLS。

第三点就是问题的全部。startTls() 拿传给 connect() 的主机名去校验对端证书。而在隧道里,那个主机名是代理,不是源站,于是运行时校验的是一个错误的身份。expectedServerHostname 选项看起来像解法,其实不是:workerd 自己的源码称它 "not currently supported",每次使用都会记日志,还带着一个 autogate,准备将来干脆拒绝它。2026-07-31 于边缘实测:

| 实验 | 结果 | | --- | --- | | connect(A) → startTls() | 握手完成,数据正常流动 | | connect(A) → startTls({expectedServerHostname: B}) | 握手仍然完成——这个选项挪动的是 SNI,不是身份校验那道门 | | connect(A) → startTls({expectedServerHostname: "probe.invalid"}) | 仍然完成 | | 到源站的 CONNECT 隧道 → startTls({expectedServerHostname: origin}) | TLS Handshake Failed |

隧道这一例 fail closed,这是正确的失败方式——但它也没留下任何一条路。而且 getPeerCertificate() 抛出 not implemented,SocketInfo 只带地址,rejectUnauthorized: false 一用就抛错,所以证书既没法查看,也没法事后重新校验。

所以,在 Worker 里发出经代理的 HTTPS 请求的唯一办法——同时也是提供 httpx 风格 verify= 的唯一办法——就是在用户态实现 TLS。这个包做的就是这件事。

已在 Cloudflare 边缘端到端验证,经由五个不同的第三方代理:TLS 1.3 (0x0304)、TLS_AES_128_GCM_SHA256、X25519、ALPN 协商出 h2 或 http/1.1、HTTP/2、chunked、content-length 三种 framing、gzip 解码、证书链按 121 个内置 CCADB 根证书完成校验。

安装

npm install tunnelfetch

包在 src/ 下直接发布纯 ESM。没有构建产物,也没有 nodejs_compat 要求——线上实测环境部署时不带任何兼容性标志。

TypeScript 声明放在 types/,由源码里的 JSDoc 生成并签入仓库,因此安装时不需要构建任何东西。这些声明不是摆设:trust 是一个判别联合,这让好几种把安全配置写错的方式变成编译期错误,而不是运行期错误。

new Client({ trust: { mode: 'pinned' } });
//                   ^ Property 'pins' is missing but required in type 'PinnedTrust'

new Client({ trust: { mode: 'none' } });
//                   ^ Property 'insecureAcceptAnyCertificate' is missing but required

用法

作为自定义 fetch

OpenAI 与 Anthropic 的 SDK,以及大多数值得走代理的库,都接受一个 fetch 函数。这个形态就是本包的首要交付物。

import { Client } from 'tunnelfetch';
import { connect } from 'cloudflare:sockets';
import Anthropic from '@anthropic-ai/sdk';

const transport = new Client({ connect, proxy: env.PROXY_URL });

const client = new Anthropic({
  apiKey: env.ANTHROPIC_API_KEY,
  fetch: transport.fetch,          // 已在构造时绑定;连接池跨调用存活
});

client.fetch 在构造函数里就绑定好了,正是为了能直接按引用交给 SDK。这里请优先用它而不是 createFetch:后者每次调用都开一条连接、用完即关,在按 CPU 计费的运行时上意味着每个请求都重付一次完整 TLS 握手——实测约 10 ms,而复用已有连接的请求是 0.9 ms。createFetch 是给一次性调用准备的,对应 httpx 的模块级快捷函数。

连接复用与 cookie jar

const client = new Client({
  connect,
  proxy: 'socks5://user:[email protected]:1080',
  cookies: true,
});

for (const url of urls) {
  const res = await client.fetch(url);
  await handle(await res.text());
}
await client.close();          // 必须调用:释放池中的 socket

边缘实测:第一个请求 678 ms,发往同一源站的第二个 135 ms。

这个 jar 是刻意最小化的——它做 RFC 6265 的域名与路径匹配、Secure、host-only cookie、过期与 Max-Age, 别的都不做。但它强制执行 __Host- 与 __Secure- 名字前缀,因为那不是便利功能:名字本身就是服务端 「这个 cookie 是带着特定属性设置的」这一声明,而一个忽略该声明的客户端,等于悄悄拿掉了服务端正在依赖的一层 保护。一个违背自己前缀的 Set-Cookie 会被整条拒绝,绝不「修好再存」——修好它,恰恰就制造出了服务端最 不该拿到的那份证明。

前缀匹配是大小写不敏感的,这是 RFC 6265bis §5.4 的 MUST,而且并不是个显然的选择:服务端普遍按大小写不敏感 比较 cookie 名字,所以一个按大小写敏感匹配的客户端会把 __SeCuRe-SID 原样存下、一条规则都不施加,而服务端 分辨不出它和真的那个有什么区别。按大小写敏感匹配就是 CVE-2024-5699。

§5.7 里有两条相关规则没有实现,如果你要靠这个 jar 提供安全性,值得知道:「Leave Secure Cookies Alone」 (第 16 步),所以一个普通名字的 Secure cookie 即便是在 https 上设置的,仍可能被 http 覆盖;以及名字加值 4096 字节的上限(第 4 步)。

获取 socket 工厂

connect 是参数而不是 import,这是刻意的:它是这个包唯一无法自带的部分,由调用方提供也正是上面每一层都能在 内存管道上做字节级测试的原因。稳妥地获取一个:

import { resolveConnect } from 'tunnelfetch';

const connect = await resolveConnect({
  specifiers: ['cloudflare:sockets'],
});
const client = new Client({ connect, proxy: env.PROXY_URL });

第一个能导出可调用函数的 specifier 胜出;全部失败时报错会列出试过的每一个 specifier 及其失败原因——错误在 启动时就现身,而不是以 "connect is not a function" 的样子从 TLS 层深处冒出来。过程中关掉了两个坑:

  1. 导入是动态的,失败会被捕获。某个只在一个运行时上能解析的 specifier,一旦被静态导入,就会让整个包在其它 所有平台上无法加载;列着多个运行时的可移植列表则是安全的。

  2. 打包器看不穿变量形式的 specifier。打包部署时直接把 connect 传进来,或者给 resolveConnect 传一个闭包了 字面量的 importModule,让打包器看得到:

    const connect = await resolveConnect({
      importModule: () => import('cloudflare:sockets'),
    });

normaliseSocket 把一个运行时 socket 拍平成普通双工对象,normalisingConnect 包一层工厂,让每个返回的 socket 都被拍平。它们的存在是因为 edge 运行时上 socket 的 readable 和 writable 是原型上的访问器,{ ...socket } 两个都拷不到,第一次读取就会在 TLS 层深处报 getReader 的错误。

替换全局 fetch

有的库只会直接调用全局 fetch,为它们准备的是:

import { install } from 'tunnelfetch';
const uninstall = install({ connect, proxy: env.PROXY_URL });
try { await thirdPartyLibrary(); } finally { uninstall(); }

这件事绝不会在 import 时自动发生。悄悄替换全局对象,会让进程里每一个不相干的故障看起来都像这个包的 bug。

预热一个全新 isolate

V8 按函数、按 isolate 编译与优化,所以经过一个全新 isolate 的第一个请求,TLS 和 HTTP 路径是解释运行的—— 46 ms,而热态地板约 10 ms,超额部分在大约六个请求内衰减。warmup() 在模块作用域用真实的驱动重放一次录制好的 握手,这样第一个真实请求碰到的就是引擎已经分层优化过的代码。

import { warmup } from 'tunnelfetch';

await warmup();                       // 模块作用域,每个 isolate 一次
export default { async fetch(req, env) { /* ... */ } };

它是 opt-in 的,本包永远不会自己调用它,因为这笔账不是对每个人都一样。标准 Workers 不对启动 CPU 计费,所以这是 把计费的请求毫秒换成不计费的,纯赚。在会对启动计费的形态上——比如 Cloudflare 的动态 Worker 加载——它不免费, 但通常仍然划算:启动成本每个 isolate 只付一次,会被这个 isolate 服务的所有请求摊掉,所以超过约每 isolate 7 个 请求它就回本,低于这个数则亏。它同时也在 isolate 启动时消耗真实墙钟时间,如果你的启动预算本就紧张,这一点要考虑。 一个库不该替使用者做这个决定。

它不缓存任何东西、不持有任何状态:重放用一个显式的 anchors 模式配置,拿自己合成的链去验自己烘焙的根,从不触碰 内置的根库;不调用 warmup() 时行为逐字节相同,只是一开始慢一些。一个导入了但从不调用它的 Worker 实测不受影响。 每种迭代次数分别买到什么,见开销那一节的表。

Server-sent events

SSE 在这里没有任何专属代码:它就是一个 text/event-stream 的 body,与其它 body 无异。真正要紧的是 body 确实在流式传输——这一点在边缘经代理实测过,一个 592 KB 的响应分成 442 个 chunk 到达,第一个 chunk 与响应头同一时刻。

const client = new Client({
  connect,
  proxy: env.PROXY_URL,
  timeouts: { idleMs: 60_000, totalMs: 0 },
});
const res = await client.fetch(url, { headers: { accept: 'text/event-stream' } });
for await (const chunk of res.body) {
  // 事件写出即到达,而不是等整个响应结束
}

有两个设置值得刻意选择。idleMs 量的是 chunk 之间的间隔,不是总时长——把它调到高于你的上游心跳间隔,否则一条安静但活着的流会被切断。totalMs 默认关闭,这正是长连接想要的;只在需要兜底时才打开。

中途放弃一条流,连接绝不会回到池里:它的位置是未知的,复用会把上一个响应的残余拼接到下一个请求上。

br、zstd 与其它编码

gzip 和 deflate 是内置的,因为运行时原生就能解。其余都是可插拔的:给 decoders 传一个"每种编码一个函数"的表,该编码就会被追加进 Accept-Encoding,并被用于解码匹配的响应。注册这个动作本身,正是"声明"变得诚实的前提——请求一个自己读不了的编码,会把每一个这样的响应变成乱码,所以两者绑在一起、不可能漂移。

import { Client } from 'tunnelfetch';
import { connect } from 'cloudflare:sockets';
import { BrotliDecStream, BrotliStreamResultCode, initSync } from 'brotli-dec-wasm/web';
import wasm from 'brotli-dec-wasm/web/bg.wasm';

// 放在模块作用域,实例化就落进 isolate 启动阶段,而这个运行时不对启动计费。
// 边缘实测:这一步的成本测不出来(带它启动 12 ms,不带也是 12 ms)。
initSync({ module: wasm });

const brotli = (stream) => {
  const dec = new BrotliDecStream();
  return stream.pipeThrough(new TransformStream({
    transform(chunk, c) {
      let r = dec.dec(chunk, 1 << 20);
      if (r.buf.length) c.enqueue(r.buf);
      while (r.code === BrotliStreamResultCode.NeedsMoreOutput) {
        r = dec.dec(new Uint8Array(0), 1 << 20);
        if (r.buf.length) c.enqueue(r.buf);
      }
    },
  }));
};

const client = new Client({ connect, proxy, decoders: { br: brotli } });
// 此后会发送 `Accept-Encoding: gzip, deflate, br`,并解码 `Content-Encoding: br`。

顺序是内置项之后按注册顺序排,所以 { br, zstd } 得到的正是 Chrome 发的 gzip, deflate, br, zstd——而这才是做这件事的真正理由。这个客户端默认呈现 curl 的 TLS 与 HTTP/2 指纹,而 gzip, deflate 正是 curl 发的,所以默认状态本就是一致的。它不再一致,恰恰是从你把握手打扮成浏览器、却把这个头留在原地的那一刻开始。

它不是一项省钱优化。边缘实测,把同一个 256 KB 页面解成同样的字节:

| 实现 | 算法 | ms/MB | |---|---|---| | DecompressionStream——运行时自己的 C++ | inflate | 2.5 | | WASM brotli(brotli-dec-wasm) | brotli | 4.7 | | JS inflate(fflate / pako) | inflate | 7.5 / 8.2 | | JS brotli(brotli) | brotli | 19.7 |

由此得到两条结论,在 Worker 里任何地方考虑用 WebAssembly 之前都值得知道。同一算法下 WASM 比 JavaScript 快约 4 倍(brotli:4.7 对 19.7)——所以某个编码若没有原生实现,WASM 是补上它的正确方式。同一算法下原生又比 JavaScript 快约 3 倍(inflate:2.5 对 7.5–8.2)——所以只要已经有原生路径,用户态怎么写都赢不了。这正是 gzip 和 deflate 不可覆盖的原因:替换它们只可能更慢,而静默地这么做,恰恰是这个包在别处一律拒绝的那种悄悄降级。

brotli 本身落在原生 inflate 的 2.5 倍。这个差价就是这个编码的成本,而它省下的线上字节买不回来——见这个包做不到什么。解码器名字会按 HTTP token 校验;解码器抛错时响应体 fail closed,而不是被截断;未注册的编码依然会被拒绝。

一行 import 得到 Chrome 身份

import { Client } from 'tunnelfetch';
import { chrome } from 'tunnelfetch/profile/chrome';

const client = new Client({ profile: chrome, connect, proxy, decoders: { br, zstd } });

这个子路径带着运行时没有原生实现的两个原语——X25519MLKEM768 密钥交换要的 ML-KEM-768,以及记录层要的 ChaCha20-Poly1305,都编译成无依赖的 WASM,并且都在本仓库里有已知答案测试。导入即 opt-in:打包器只为走这条路径的代码拉入它们,默认身份一个字节都不背。

br 和 zstd 仍然要你自己带。它们不是密码学、没有唯一正解,所以 profile 会一直拒绝到你提供为止——一个声称支持 br 却读不了的 Chrome,比一个老实说不支持的更糟。

不是「能构造出来」,是端到端验证过:

| 源站 | | TLS | 群 | HTTP | |---|---|---|---|---| | blog.cloudflare.com | 200 | 1.3 | 0x11ec X25519MLKEM768 | h2 | | www.shopify.com | 200 | 1.3 | 0x11ec X25519MLKEM768 | h2 |

自定义一个身份

三个层次,按你大概率会用到的顺序。

改一个字段。 tls 是逐项合并的,点名一项不会丢掉 profile 的其余部分:

new Client({ profile: chrome, tls: { alpn: ['http/1.1'] } });
// alpn 换了;extensionOrder、grease、ciphers、groups 仍然是 Chrome 的

顶层字段(headerOrder、http2Settings、http2PseudoHeaderOrder、http2HpackIndexing)是整体替换——一个合并了一半的顺序不成其为顺序。

派生一个 profile。 profile 就是个普通的冻结对象,spread 它就是全部机制,没有 API 要学。想给所有请求换 User-Agent,这就是正确做法:

const mine = { ...chrome, name: 'chrome+mine',
               headers: [['User-Agent', 'mybot/1.0'], ['X-Tag', 'a']] };
new Client({ profile: mine, connect, proxy });

从零写一个。 内置的那两个没有任何特权:

const firefox = {
  name: 'my-firefox/130',
  tls: { alpn: ['h2', 'http/1.1'], ciphers: [0x1302, 0x1301],
         extensionOrder: [0, 10, 11, 13, 16, 23, 43, 45, 51, 0xff01], grease: false },
  headerOrder: ['host', 'user-agent', 'accept', 'accept-language', 'accept-encoding', '*', 'connection'],
  headers: [['User-Agent', 'Mozilla/5.0 Firefox/130.0']],
  http2Settings: [[1, 65536], [4, 131072], [5, 16384]],
  http2PseudoHeaderOrder: [':method', ':path', ':authority', ':scheme'],
  requires: [],
};

requires 对你自己的 profile 和对内置的一视同仁:声明一项 Client 没有拿到的能力,构造就会被拒绝并指名缺什么。自定义身份同样受"不许声明做不到的事"的约束。

profile 的 headers 是默认值——单次请求的同名 header 会盖掉它;而 Client 的显式选项会盖掉 profile。所以优先级是:每请求 > Client 选项 > profile。

四个 WASM 模块的冷启动成本

模块作用域的实例化落在启动阶段,而这个运行时不对启动计费,所以只有全新 isolate 的第一个请求看得见,之后为零。对照一组除了「有没有 import 这四个模块」之外完全相同的部署:

| | 带四个 WASM 模块 | 一个都不导入 | |---|---|---| | 全新 isolate 的第一个请求 | 3 ms | 0 ms | | 第 2–5 个请求 | 0 ms | 0 ms | | 第 6 个请求以后 | 0 ms | 0 ms |

每字节的解码成本是另一回事,而且是有条件的:只有源站真的发 br 或 zstd 时才付。

HTTP/2 — 要的是访问,不是速度

客户端默认在 ALPN 中同时报出 h2 与 http/1.1,服务器选中哪个就说哪个。没有单独的 API: 落在 h2 连接上的请求,只是在自己的 tunnelfetch 详情里报 httpVersion: '2' 而已。设置 http2: false 则只报 http/1.1。

在这里实现 HTTP/2,为的是访问,不是性能——而且在这个运行时上,它消耗的 CPU 比 HTTP/1.1 更多,不是更少。 这由两件事决定:HPACK 是 HTTP/1.1 根本不做的头部压缩工作;多路复用买到的 是延迟,而一个通常只发一个请求、然后等它返回的 Worker handler 花不出去这笔延迟。所以,如果你 是在按 CPU 计费的平台上指望靠 HTTP/2 提速,那是拉错了杠杆。它真正买到的,是够得着那些把 HTTP/1.1 当作机器人信号的站点。这一点是测出来的,不是想当然——同一个代理、同一个浏览器 User-Agent、同一组请求头,只改协议:

stackoverflow.com  --http1.1 -> 403 "Just a moment..."  (Cloudflare challenge, cf-mitigated: challenge)
                   --http2   -> 200, 291 KB of real content

在十个站点的样本里,HTTP/2 恰好只改变了一个站点的结果:四个站点在两种协议下被一视同仁地拦住, 五个两种协议都畅通。所以诚实的预期是“十个站点大约解锁一个”,而不是“解决了反爬”。

而这个预期本身是有保质期的。 就在这个功能落地的当天,用同样的代理重测,那个站点已经连 HTTP/2 一起挑战了——curl --http2 在那里被拒,和本包被拒得一模一样,而同样的代理抓其它站点 仍然正常。所以这项能力是真实且正确的(ALPN 确实协商出 h2,这个客户端受到的对待与 curl 无异),但它当初要赢下的那个具体访问,没撑过一天。反爬是对抗性的,一直在动;协议是一时的窗口, 不是恒定的属性。不要因为某一个站点的表现就在这里启用 HTTP/2——去测你自己的目标,并且预期答案 会变。在那个样本里,我们的 TLS 指纹和 curl 的在每一个可达主机上结果完全一致,所以 JA3 一类的 TLS 指纹塑形不是这里的门槛——但 curl 的 HTTP/2 指纹能过 HTTP/1.1 被挑战的地方。因此 SETTINGS 帧的取值、初始窗口大小、连接级的 WINDOW_UPDATE、伪头的顺序,全部逐字节对齐 curl(8.7.1 / nghttp2),照线上抓包原样复刻。这么做是实证需要,不是讲究:一个想当然的 h2 指纹,可能恰好败在 curl 能过的地方——那这整件事就白做了。

指纹

两侧都对齐 curl 8.21.0 / OpenSSL 3.6.3,且全部可自定义。TLS 后端比 curl 版本更要紧——同一个 curl 用 SecureTransport 编译出来的 ClientHello 完全不同——所以参照物是从线上抓下来的,不是凭记忆写的,并且钉死在 test/tls/fingerprint.test.js 与 test/http2/fingerprint.test.js 里。

| 层 | 默认 | 配置项 | |---|---|---| | ClientHello 扩展顺序 | 与 curl 完全一致(就双方都发的那些而言) | tls.extensionOrder | | 密码套件 | 本包实现的 AEAD 套件,按 curl 的相对顺序 | tls.ciphers | | supported_groups | x25519, secp256r1, secp384r1, secp521r1 | tls.groups | | 签名算法 | SHA-256/384/512 上的 ECDSA 与 RSA-PSS/PKCS#1 | tls.sigSchemes | | ALPN | h2, http/1.1 | tls.alpn | | HTTP/2 SETTINGS 的 id 与顺序 | curl 的:MAX_CONCURRENT_STREAMS, INITIAL_WINDOW_SIZE, ENABLE_PUSH。取值默认不同——见下 | http2Settings | | h2 前导、WINDOW_UPDATE、伪头顺序 | curl 的,逐字节一致 | http2ConnectionWindow、http2PseudoHeaderOrder | | HPACK 表示 | curl 的(:path 不索引,其余 incremental) | http2HpackIndexing | | Accept-Encoding | gzip, deflate——curl 的 | decoders 会追加 |

默认的 SETTINGS 取值是本包自己的,不是 curl 的,这是一个有意的取舍。 profiles.curl 带的是 curl 8.21.0 实测的 INITIAL_WINDOW_SIZE = 64 KiB,抓包固化在 test/tls/_captured-h2.js。而不加 profile 时连接的默认值是 10 MiB——那是 curl 8.7.1 发的值,本包保留它:64 KiB 的流窗口意味着每消费 32 KiB 就发一次 WINDOW_UPDATE,而不是每 5 MiB 一次。

实测:8.7 MB 的 body、走 h2、打真实源站、两档窗口在同一 isolate 里交错——64 KiB 窗口每解压 MB 多花约 1.4 ms CPU,相对本包搬大 body 的约 21.7 ms/MB 是多 6–7%。这就是准确指纹的价钱,对多数调用方值得付; 如果你要搬大 body 又不需要长得像 curl,自己设 http2Settings。

这个数字有两点要说明。本文档在别处推荐的"取最小值"规则在这里失效了:两档样本数不等、源站又有丢失,最小值 在两轮扫描之间从 +2 ms 跳到 +13 ms——样本多的那一档白得一个更低的最小值。上面的数取自 p25 和中位数,两者一致, 并且和算术对得上:8.7 MB 在 32 KiB 的补充阈值下约是 276 个额外帧,276 × 约 47 µs 约等于 13 ms。

1.6.2 之前,这个 profile 带着 curl 8.7.1 的窗口,却呈现 curl 8.21.0 的 ClientHello:同一个具名 客户端,两个来源版本——一个只有抓包才能发现的身份分裂,因为两半各自都真的属于某个 curl。

扩展顺序之所以要紧,是因为 JA3 和 JA4 哈希的正是线上顺序的扩展列表,那是指纹识别读到的主要内容。pre_shared_key 无论你怎么配都强制排最后:RFC 8446 §4.2.11 把 binder 的转录定义为"截到 binder 之前的那段 hello",只有后面不跟东西时这个范围才成立。

默认值刻意与 curl 不同的地方,以及为什么不能照抄:ClientHello 是一份要约,服务器可以接受其中任何一项。声明你做不到的事,等于拿指纹不一致换一次握手失败——后者更糟,而且是静默的。

| curl 发送 | 本包 | 原因 | |---|---|---| | 30 个套件,含 RSA 密钥交换与 CBC | 6 个 AEAD 套件 | 有意不实现。服务器选中 TLS_RSA_WITH_AES_256_CBC_SHA 会得到一条死连接 | | X25519MLKEM768 群及 1216 字节 key share | 不提供 | 未实现 ML-KEM | | SHA-1 签名方案 | 不提供 | 明确拒绝 | | encrypt_then_mac | 不发 | 只对 CBC 套件有意义,而 CBC 不提供 | | post_handshake_auth | 不发 | 会招来握手后的 CertificateRequest,未实现 | | — | status_request | curl 不索要 OCSP staple;本包必须要,因为 staple 是它唯一的吊销信号 |

要抹平这个差距,得先实现 ML-KEM、RSA 密钥交换和 CBC 套件——那是另一个项目,而且后两者是本包故意不做的。tls.ciphers 和 tls.groups 允许你照样把它们报出去;服务器一旦选中握手就会失败,后果由你自己承担。

有一个测试断言这张差异表恰好就是上面这些,所以哪天获得了其中某项能力却没更新这张表,构建会直接失败。

HTTP/1.1 的 body 有的,HTTP/2 的 body 全都保留:流式(SSE 原样可用)、trailer、gzip 解码、 以及在任何解码之前先包住原始 body 的 idle 截止线。结构上唯一不同的在水面之下——一条 h2 连接把 发往同一源站的所有并发请求复用在一起,而不是一次签出、一次只服务一个请求。install()、重定向、 cookie 与 verify= 的行为全部一致。

const client = new Client({ connect, proxy: env.PROXY_URL });
const res = await client.fetch('https://example.org/', {
  headers: { 'user-agent': 'Mozilla/5.0 (…) Chrome/140.0.0.0 Safari/537.36' },
});
res.tunnelfetch.httpVersion; // 服务器选了 h2 就是 '2',否则是 '1.1'

API

new Client(options)

| 选项 | 默认值 | 含义 | | --- | --- | --- | | connect | — | socket 工厂。在 Workers 上就是 cloudflare:sockets 导出的 connect。凡平台 fetch 无法承接的请求都需要它。 | | proxy | null | URL 字符串或对象。http:、https:、socks5:、socks5h:。 | | trust | {mode:'system'} | 证书策略;见下文。 | | tls | {} | 握手选项 (alpn、groups、ciphers、offerGroups)。 | | timeouts | 见下文 | connectMs、handshakeMs、headersMs、idleMs、totalMs。 | | cookies | false | 启用该 Client 专属的 cookie jar。 | | maxRedirects | 20 | | | maxBodyBytes | 32 MiB | 在读入任何字节之前就按 Content-Length 强制执行,并在原始流和解码后的输出上各执行一次——包括你自己注册的解码器。传 Infinity 可以退出;见关于这个默认值。 | | decompress | true | 是否解码 Content-Encoding。gzip 与 deflate 内置。 | | decoders | {} | 额外的编码,如 { br: fn };每一个都会被加进 Accept-Encoding。见 br、zstd。 | | keepAlive | true | | | http2 | true | 在 ALPN 中报出 h2,服务器选中即使用。见 HTTP/2。 | | forceTunnel | false | 永不委托给平台的 fetch。 | | nativeFetch | globalThis.fetch | 委托目标。 |

client.fetch(input, init) 接受并返回平台的 Request/Response。响应上带有一个非标准的 tunnelfetch 属性,内容为 {proxied, proxy, tls, httpVersion, framing}。

client.close() 释放池中全部 socket。不关闭的 Client 会在 isolate 的整个生命周期里泄漏 socket。

正文上限现在有默认值了

maxBodyBytes 在 1.5.0 及以前默认是 Infinity,从 1.6.0 起是 32 MiB,这是一个破坏性变更——下载超过 32 MiB 的内容现在需要显式设置 maxBodyBytes,压没压缩都一样,因为这个选项同时约束线上正文和解码后的正文。

理由是:在一个有硬性内存上限的运行时里,「不设限」不是自由,是一条被对端杀死的路径。53 字节的 brotli 正文解码出 32 MB,这是实测的,不是假想;而内置的 br/zstd 解码器自限在 256 MiB——是 Workers isolate 那 128 MB 的 两倍,所以那道兜底根本不可能在 isolate 死掉之前触发。一个存在意义就是去抓你控制不了的 URL 的客户端,不该把 「无上限」作为「没读过这张表就会拿到」的默认值。

32 MiB 是天花板的四分之一,所以即使被 .arrayBuffer() 整个缓冲到上限,isolate 也还有余量活下来并报告出来; 而它比任何页面或 API 响应都高两个数量级。如果你确实要搬大文件,就明说:

new Client({ connect, proxy, maxBodyBytes: Infinity });   // 或者任何一个你想清楚了的数字

这个取舍是有意的:一个没被要求的上限,在第一次挡住你的时候是可发现的,而且错误信息里会点名那个选项;一次没被 要求的 OOM 两者都不是。

Trust — verify= 旋钮

{ mode: 'system' }                                  // bundled CCADB roots (default)
{ mode: 'anchors', anchors: [pemOrDer, ...] }       // exactly these, nothing else
{ mode: 'pinned', pins: ['sha256/BASE64...'] }      // full validation plus an SPKI pin set
{ mode: 'custom', verify: async (chain, host) => {} } // your policy; throw to reject
{ mode: 'none', insecureAcceptAnyCertificate: true } // no verification at all

吊销状态通过 OCSP stapling(RFC 6960,经 TLS status_request 扩展)检查:每个 hello 都请求服务器附上装订响应;收到的装订响应必须严格解析、与已验证证书的签发者和序列号匹配、签名经签发 CA 或其授权应答者验证通过、且处于新鲜度窗口之内——已验证的 revoked(以及 unknown)一律使连接失败。服务器没有装订时默认放行,因为大多数服务器不装订,硬性失败会弄断大半个网络;已知对端会装订的调用方可以强制要求:

{ mode: 'system', revocation: 'require-staple' }    // 缺失即 OCSP_REQUIRED

刻意不存在任何能忽略 revoked 结论的配置值。

mode: 'none' 必须同时带上第二个标志;靠打错字是到不了这一档的。pin 不匹配时会把实际看到的 pin 报出来,正确的值可以直接从日志里复制:

CertificateError [CERT_PIN_MISMATCH]: no certificate in the chain matches any configured pin
(observed: sha256/uOmwqBIvMM6bY2khsu8Tmp+ltdXst3nxA6Z3ZuKeAWA=, sha256/ZSagvDzj…)

什么情况下会改用平台的 fetch

只有当一个请求能被完全等价地满足时,才会委托给平台自己的 fetch:没有代理、默认信任策略、没有 TLS 选项、没有 forceTunnel。其余一律走本包的协议栈。把一个要求 pin 证书的请求,交给一个用着另一套信任库的实现,等于回答了一个调用方从未问过的问题。

在委托适用的时候,它通常正是你想要的:更快、不消耗计费 CPU、会说 HTTP/2 和 /3,还能到达原始 socket 被禁止拨号的那些源站。

超时

| 截止时间 | 默认值 | | | --- | --- | --- | | connectMs | 10 000 | TCP 连接与代理握手 | | handshakeMs | 15 000 | TLS 握手 | | headersMs | 30 000 | 状态行与响应头 | | idleMs | 60 000 | 响应体分块之间的间隔 | | totalMs | 0 (关闭) | 整个请求的上限 |

真正起控制作用的是 idle 截止线,不是总时长:对流式响应来说,“距上一个字节过了多久”才是出问题的信号,“总共花了多久”不是。所有计时器都由流事件驱动,而不是去读时钟——因为在这个运行时上,Date.now() 在一整段同步代码执行期间是冻结的,只有跨过 I/O 才会前进;靠轮询时钟实现的截止时间,要么永远不触发,要么在一个毫不相干的时刻触发。

这些是活性控制,不是成本控制。运行时按 CPU 计费而不是按墙钟计费,所以一条等着慢速对端的连接是免费的;而且响应头一旦到达,它就不再占用「一次调用最多同时等待响应头的六个槽位」之一。正是这种不对称决定了 idleMs 的默认值应当偏长:设得过长,只不过多挂着一条不计费的连接;设得过短,则会掐死一个本来会成功的请求。不要试图把它对齐到对端的心跳间隔——会发送保活事件的流式 API 通常并不承诺间隔,而一个正在算长答案、还没吐出第一个字节的对端,本就该安静那么久。

headersMs 是唯一真正占用那个响应头等待槽位的阶段,所以更紧。如果对端会把整个慢响应缓冲完才发响应头,就调高它;流式对端会立刻发响应头,永远用不到。

它做不到什么,以及为什么

这一节才是重点。每一条限制都是有意为之。

直连(不走代理)能到达的网络少得可怜。 connect() 拒绝 Cloudflare 自家的地址段,而互联网相当大的一部分正坐在这些地址段后面——今天连 example.com 也在其中。拒绝在 0–6 ms 内发生,报 proxy request failed, cannot connect to the specified address。现实后果是:证书 pinning 与自定义 anchors 只有走代理才用得上,因为直连模式根本到不了大多数源站。

只支持 TLS 1.3 与 1.2,只支持 AEAD。 可协商的范围:TLS 1.3 配 AES-128/256-GCM,TLS 1.2 配 ECDHE + AES-GCM。密钥交换 X25519、P-256、P-384、P-521。签名 ECDSA、RSA-PSS、RSA-PKCS#1 (SHA-256 及以上)、Ed25519。

未实现,也不打算实现:

  • 任何 TLS 版本下的 CBC 密码套件。 CBC 是 MAC-then-encrypt,要扛住 Lucky13,padding 校验就必须是常数时间的。JavaScript 承诺不了常数时间——JIT 分层和 GC 保证了这一点——所以发布 CBC,就是以兼容性之名发布一个 padding oracle。这一条对 TLS 1.2 的 CBC 套件与对 TLS 1.0/1.1 同等成立。

  • TLS 1.0 / 1.1。 RC4 已被攻破,那里剩下的全是 CBC。浏览器自 2020 年起就拒绝这两个版本。注意其中的区别:同时也支持 1.0/1.1 的服务器没有问题,我们会与它协商出 1.2 或 1.3;够不着的只有除此之外什么都不支持的服务器。

  • RSA 密钥传输。 没有前向保密。

  • ChaCha20-Poly1305。 加上它换不来任何东西:服务器只能从我们报出的套件里选,TLS 1.3 强制要求 AES-128-GCM,而 AES-GCM 在 TLS 1.2 的实际部署中无处不在。WebCrypto 没有 ChaCha20,要提供它就得引入 node:crypto 依赖,兼容性收益却是零。

  • 客户端证书(mTLS)、0-RTT、重协商。 HelloRequest 会被拒绝而不是照做。0-RTT 是决定而非遗漏:early data 可被重放,提供它等于让捕获了 POST 的攻击者可以重放它。(会话恢复本身已经实现——ticket 按池键保存,用 psk_dhe_ke 提供。)

  • 吊销信息的主动获取(下载 CRL、查询 OCSP 应答者)。 两者都要在握手中途经代理多跑网络往返,而且 OCSP 查询会把你访问的源站告诉 CA。吊销状态会从服务器装订(staple)的 OCSP 响应中校验(见上文 Trust 一节);没有实现、也不打算实现的,是替服务器去取它没有装订的东西。

  • 证书策略处理 (policyConstraints、inhibitAnyPolicy)。它们永远是 critical 的,所以一旦出现就直接拒绝,而不是被错误地校验过去。

  • dNSName 与 iPAddress 之外的名称约束。 标为 critical 且指名不支持类型的约束扩展会被拒绝;非 critical 的则按 RFC 5280 允许的那样忽略。

  • cookie 的 public suffix list。 只实现了“域名里没有点”这一道防护,所以 Domain=com 会被拒绝,Domain=co.uk 不会。如实写明,而不是伪造。

  • IDNA。 请传 A-label (punycode);非 ASCII 主机名会被拒绝,并附上说明原因的报错。

  • 默认身份里的 br 与 zstd。 运行时的 DecompressionStream 只接受 gzip、deflate 和 deflate-raw——这是实测的,不是假设的——所以两者都来自 WebAssembly,而默认入口一个都不带。导入 tunnelfetch/profile/chrome 就已经接好,或者用 decoders 注册你自己的。主入口不会做的事,是为一个大多数调用方永远遇不到的编码,把约 140 KB 的编译后 C 拉进每一个 bundle。

    不开它是安全的,而不是有损的:内容协商决定了服务器绝不会发送没被请求的编码,所以提供 Brotli 的源站只会返回 gzip。代价是带宽——同一个页面 gzip 是 290 KB、br 是 99 KB——而带宽恰恰不是这个平台计费的东西。在 CPU 上这笔交易是反着的:brotli 解码是原生 inflate 的 2.5 倍,而且压得越狠越亏不是越省,因为解压的工作量跟着输出字节走。开 br 的理由是让 Accept-Encoding 与浏览器一致,不是省 CPU。

  • 流式请求体。 请求体会先完整读入内存再发出:一是框定长度需要一个这个客户端敢于担保的 Content-Length,二是遇到重定向时可能需要重放。对 SDK 发的那点 JSON 完全够用;对大文件上传就不合适,也意味着不支持 duplex: 'half' 的流式上传。响应体则全程流式,任何时候都不会替你缓冲。

  • HTTP/3。 ALPN 报出的是 h2 与 http/1.1(见 HTTP/2);不报 h3——那是跑在 UDP 上的 QUIC,从一个只暴露原始 TCP 的运行时根本够不着。服务器若选中客户端未曾报出的协议,一律 fail closed——任何一层都不存在回退重试。

  • 服务器推送、HTTP/2 优先级与 h2c。 推送在我们的 SETTINGS 里就是关闭的,收到 PUSH_PROMISE 即为连接错误;RFC 9113 的优先级机制已被废弃,PRIORITY 帧一律忽略;h2 只跑在 ALPN 协商出的 TLS 之上,绝不以“prior knowledge”走明文。

socket 不能跨请求上下文。 连接池按设计随 Client、随单次调用存在;不存在跨请求的连接缓存,因为运行时不允许有。

并发。 限制是每次 Worker 调用最多六条连接同时处于等待响应头的状态——不是每个 Worker,也不是每个账号,所以打到你 Worker 的不同请求各有各的六条。连接在响应头到达的那一刻就腾出槽位,所以它约束的是「同时能有多少次握手在飞」,而不是「同时能下载多少个 body」。想在一次调用内跑更高并行度的爬虫,必须在这个限额之内做流水线。

线上 Worker 的开销

下面每一个数字都是在 Cloudflare 边缘上用 wrangler tail 实测的,经过真实代理,并且按 isolate 分组统计—— 所以首次执行绝不会和热态平均在一起。Workers 按 CPU 时间计费而不是墙钟时间,而这里一个请求的绝大部分时间 花在等网络上,那部分不计费。

一个请求要多少

通过代理抓取 sizeorigin/,传输走 gzip,所有尺寸在同一次扫描、同一个 isolate 内跑完,n>=5 取中位数。 方法写在这里,是因为上一版这张表没有把方法记清楚到能复现的程度:一个热态页面 = (reuse=4 - reuse=1) / 3, 也就是连接已经打开之后第 2 到第 4 个页面的成本;最后一列是同一次扫描测出来的新建连接数字。

| Body | 热态页面 | 每解压 MB | 新连接上的第一个请求 | | --- | --- | --- | --- | | 1 KB | 1.3 ms | — | 8 ms | | 16 KB | 2.3 ms | 149 ms/MB | 9 ms | | 64 KB | 6.0 ms | 96 ms/MB | 13 ms | | 256 KB | 14.3 ms | 57 ms/MB | 28 ms | | 1 MB | 36.3 ms | 36 ms/MB | 59 ms | | 4 MB | 102 ms | 26 ms/MB | 135 ms |

最后一列不是「热态页面 + 一次握手」。4 MB 那一行它比热态页面高 33 ms,而握手只值个位数——因为一条连接上的 第一个 body 还要在 V8 把解码循环编译优化之前跑一遍。评估一个新源站要看这一列,不要看第一列。

2026 年 8 月重测,中间几档动了。 4 MB 那行几乎原样复现(102 对先前发布的 104);64 KB 到 1 MB 比这张表 原先的数字低 20–30%。那不是下面讲的 socket 视图大小改动——在同一个 isolate 里 A/B 新旧视图大小,1 MB 热态 页面只差约 1 ms——所以它要么是单次扫描看不到的日间波动,要么是被取代的那张表取数方式不同。旧数字已经无从复查, 这正是把方法写在这里的理由。

冷启动成本是总数,不是往上面某一行加的增量:

| 全新 isolate 的第一个请求 | 这个请求的成本 | 整条爬坡相对热态地板的超额 | | --- | --- | --- | | 不用 warmup() | 46 ms | 61 ms(≈ 4.4 ms/请求,摊在 isolate 早期) | | warmup() 一次 | 22 ms | 40 ms(≈ 2.8 ms/请求) | | warmup({ iterations: 5 }) | 16 ms | 15 ms(≈ 1.1 ms/请求) |

这两个数字原先以「+46 ms」「+16 ms」的形式写在上面那张表里,把总数变成了增量,让文档里的冷启动成本翻了一倍。光靠这几个数就能证伪那个 +:如果首请求是 9.2 + 16,那它一个人就带了 16 ms 超额,而整条爬坡的超额总共才 15 ms。这些是在小正文上测的;冷 isolate 的第一个 4 MB 请求从未测过,而且必然更差——解码循环有多得多的字节跑在解释执行下。

经代理打一个尺寸可控的源站,每尺寸八轮,HTTP/2,线上走 gzip。连接项和每请求项是靠改变复用次数分离出来的,不是假设的——两页对十页得出开连接 9.8 ms、每次后续请求 2.25 ms,剩下的就是正文成本。

这些数字替换掉了一批测错的,而错在哪里值得写下来。 它们来自的那个源站重复的是一段 150 字节的 HTML 片段,gzip 把它压到 220:1——所以一个"1 MB 正文"在线上只有四千字节,针对它做的每一次测量都只量到了解压,而把 TLS 记录和流处理的每线上字节成本整个抹掉了。真实页面大约 4:1。现在源站重复的是 154 KiB 真实压缩后的 JavaScript,压缩比 2.76:1——gzip 的窗口是 32 KiB,这么大的重复周期压不掉。

修正幅度很大。 正文偏重的那几行是 1.4.0 及以前所写数字的两到三倍,而且再怎么讲究中位数还是最小值都发现不了——因为那些数字自洽,它们只是在回答错误的问题。

右边两列请当作推算值来读,因为它们就是。 只有「复用连接」那一列是逐尺寸实测的;「新建连接」是它加一个固定的 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 之间的一个量,不要拿它做算术。

这里原先还引了一次「独立印证」,它其实不是印证:从 CDN 抓一个真实的 3.6 MB 文件是 142 ms,而这张表对 4 MB 的预测是约 120 ms。那是模型低估了约 20%,方向和它刚刚替换掉的那个错误一致。它应当作为警告写在这里,而不是佐证。

还有两点。2.76:1 的内容比典型页面略难压,所以这些值偏保守而不是偏乐观;以及这个平台上 CPU 在不同 isolate 之间有最多约 1.5 倍波动,所以形状比任何单个数字更要紧。

这些折算成多少钱

Workers Standard 每月 $5,含 1000 万请求和 3000 万 CPU 毫秒,超出部分每百万请求 $0.30、每百万 CPU 毫秒 $0.02。公式就是 $5 + max(0, 请求数 - 1000万) x $0.30/百万 + max(0, CPU毫秒 - 3000万) x $0.02/百万, 没有别的:

| 工作负载 | CPU/请求 | 1000 万/月 | 10 亿/月 | | --- | --- | --- | --- | | 平台 fetch,16 KB —— 参照;它用不了代理 | 0.3 ms | $5.00 | $307.40 | | 平台 fetch,4 MB —— 同一参照,实测 | 3.2 ms | $5.04 | $365.40 | | 连接复用,16 KB 页面 | 2.3 ms | $5.00 | $347.40 | | 每请求新建连接,16 KB | 9 ms | $6.20 | $481.40 | | 连接复用,1 MB 页面 | 36.3 ms | $11.66 | $1,027.40 | | 每请求新建连接,1 MB | 59 ms | $16.20 | $1,481.40 | | 连接复用,4 MB 页面 | 102 ms | $24.80 | $2,341.40 | | 每请求新建连接,4 MB | 135 ms | $31.40 | $3,001.40 |

那两个 max(0, ...) 是新加的。上一版这张表把每一个请求、每一毫秒 CPU 都算了钱,忽略了它自己上面那句话 描述的免费额度——于是 1000 万/月那一列最多高估了 74%($8.70,而实际账单是 $5.00),而在 10 亿那一列误差在 0.2% 以内,因为那时免费额度只是个舍入误差。高估的方向是对本包不利的,大概这就是它活了这么久的原因。

参照那一行给了两个尺寸,因为平台自己的 fetch 不是平的——它按每解压 MB 约 0.82 ms 增长,这是在同一个 CDN 的尺寸阶梯上测的,只有大小在变。把它写成单一的 0.3 ms 再拿去和 4 MB 那一行比,是拿不同的东西相比,而且是抬高了对手而不是抬高本包。

这张表原先还按「冷 / 预热」分成两组列。那两组列现在去掉了:新的 CPU 数字全部是热态和新建连接的实测,冷 isolate 下的同一批负载没有重测过,与其把上一版的冷启动增量套到新基线上凑出四列,不如只写实际测过的两列。冷启动的成本 在上面那张 warmup() 表里,那是一个总数,不是往这里某一行上加的增量。

Chrome 身份的每个选项各花多少

上面那张表是默认身份:线上 gzip、AES-256-GCM、x25519。Chrome 那一行把所有变化捆在一起,对做决定没什么用。按"连接复用 + 1 MB 页面 + 十亿请求/月 + 已预热"逐项拆开:

| 相对基线的变化 | CPU/请求 | 10 亿/月 | Δ | 何时才付 | | --- | --- | --- | --- | --- | | 基线 —— gzip、AES-256-GCM、x25519,热态 | 36.3 ms | $1,027 | — | 总是 | | 源站发 br 而不是 gzip | 40.5 ms | $1,111 | +$84 | 源站选择发 br | | 服务器选中 ChaCha20-Poly1305 | 39.3 ms | $1,086 | +$59 | 服务器优先选它而非 AES | | 源站发 zstd 而不是 gzip | 39.1 ms | $1,083 | +$56 | 源站选择发 zstd | | 协商 X25519MLKEM768,每连接 1 个请求 | 59.2 ms | $1,484 | +$3 | 每次握手 | | 协商 X25519MLKEM768,每连接 20 个请求 | 36.3 ms | $1,028 | +$0.15 | 同一次握手,摊薄后 |

ML-KEM 这两行是拿新建连接的 1 MB 基线 59 ms($1,481)来比的,不是拿表头那个热态基线;Δ 列反映的是这一点, 所以是 $3 而不是旧版写的 $153。那 $153 是「不复用连接」的成本,被算到了后量子密钥交换头上。

最后两行是同样的 0.15 ms ML-KEM,差别完全来自连接复用——这才是这张表最值得带走的一条。后量子密钥交换在你让 Client 活着时是这里最便宜的东西,在你不这么做时是最贵的,因为它是每握手,而其余全是每字节。

五项里有三项还是有条件的,而且不由你决定。br 和 zstd 在源站选择发它们之前一分不花;ChaCha20 在服务器优先选它而非 AES-GCM 之前也是——而有 AES 硬件加速的服务器通常不会。报出去买的是指纹,付钱只在对端真的接受时发生。

ChaCha20 那个数字是内置的 WASM AEAD 对它所替代的 WebCrypto AES-256-GCM 测的(4.89 对 1.95 ms/MB)。本节早先的版本写的是 +2.0 ms/MB,那是 node:crypto 的 ChaCha20——这个包并不走那条路,因为走它就要 nodejs_compat。

由此可以看出四件事。

在每月一千万请求这个量级上,小页面免费,大页面不是。 连接复用的 16 KB 负载落在基础费之内;连接复用的 1 MB 负载是每月 $15。这个量级上包含的 CPU 折合每请求 3.0 ms——16 KB 页面塞得进去,1 MB 页面塞不进去。

到了十亿请求,每一行里有 $297 是请求费,所有行都一样,这个包做什么都改变不了。剩下的只有 CPU,而在那里最大的杠杆不是连接复用,是正文大小:复用在 1 MB 页面上省 $151/月,而把抓取目标从 1 MB 页面换成 16 KB 页面省 $1,004。

正文偏重的场景才是这套用户态栈真正要花钱的地方。 连接复用且预热后,16 KB 页面上它比平台自己的 fetch 贵 25%($385 对 $307);1 MB 页面上是 3.8 倍($1,389 对 $365)——因为每一个字节都要在 JavaScript 里解密、重组、解压,而平台把这三件事都放在运行时里做,一分不计。如果你的负载是大正文,该拿来做规划的是这个倍数,不是 16 KB 那个。

那一行参照值是实测的,不是假设的,而且它并不是平的。从同一个 Worker 抓不同大小的真实页面,连接复用下的每请求边际成本:

| 页面 | 大小 | 平台 fetch | 本包(经代理) | 倍数 | | --- | --- | --- | --- | --- | | example.com | 0.6 KB | 0.2 ms | 3.8 ms | 12.8× | | news.ycombinator.com | 35 KB | 0.3 ms | 1.8 ms | 5.5× | | www.wikipedia.org | 118 KB | 0.5 ms | 1.5 ms | 3.0× | | github.com | 591 KB | 2.0 ms | 14.0 ms | 7.0× |

平台的 fetch 同样随 body 大小增长——它不是恒定的一毫秒——因为它仍然要把 body 物化成 JS 值,那是两边都要付的 唯一一项每字节成本。它不付的是 TLS、HTTP 成帧和解压,这些都在运行时里完成,从不计入调用方的账单。这四行有 噪声:CPU 时间按 1 ms 粒度上报,而这些又都是小数值,所以倍数在 3× 和 13× 之间跳,并且不随大小单调(118 KB 那个页面测出来比 35 KB 那个还便宜)。方向是可信的;具体倍数不值得精确到小数点后一位。

warmup() 在 Standard 上免费,在别处通常也划算。 它自身的代价是启动 CPU,而 Workers Standard 不对启动 CPU 计费,所以十亿请求下「预热」两列省下的每月 $65 是净赚。在会对启动计费的形态上——比如动态 Worker 加载——那 22 ms 每个 isolate 只收一次,并摊给该 isolate 服务的请求:十亿请求、按这里实测的每 isolate 约 17.8 个请求算是每月 $25,对着省下的 $65。低于约每 isolate 7 个请求,它就不再回本。

成本在哪,不在哪

链验证的成本是验签,不是解析。解析整条链约 158 µs;而单次 ECDSA P-384 验签是 665–816 µs,约为 P-256 验签的 12 倍、RSA-2048 验签的 27 倍。典型 EC 链有两个 P-384 环节,所以全 ECDSA 链验证约 3.5 ms,RSA 链约 0.8 ms。 如果源站是你自己的,证书的密钥类型值得想一下。

小响应的解码成本由构造 DecompressionStream 主导,而不是字节数:一个 559 字节的 body 也要约 2 ms。这个固定 成本是真的,但本文档过去从它推出的建议——小 JSON 用 decompress: false 更划算——对任何不算极小的响应都是反 的,而且当初根本没有和另一个选项对比着测过。

这里的成本按线上字节走,不按解码后的字节走:每一个线上字节都要先被解密、重新组帧、跨 JS 流边界搬运,解压器 才看得见它。边上实测:收一个 4 MB 的未压缩正文,和收它 1.5 MB 的 gzip 再解压,成本一样。关掉压缩,是 用省下的一次解压,换整条接收管道多走 2.7 倍的字节。把压缩开着。 那个固定的约 2 ms 只在正文小到一次线上读 就能读完时才划算。

和平台 fetch 的成本平价做不到,地板在这里

两次独立调查各自得出了这个结论,这是把它写得这么直白的主要原因。

gz-native——运行时自带的 DecompressionStream 把 1.5 MB gzip 解成 4 MB,用原生方式收集,没有任何 JS 抽干、没有任何接收栈——要 16 ms,五轮独立扫描都复现。而平台的整个 4 MB fetch,含 TLS、HTTP 和解压, 约 3.6 ms。

也就是说,这个包把那段 gzip 变成字节的最便宜的可能方式,已经是平台整个请求的 4.4 倍,而这还没碰到任何 一个 TLS 或 HTTP/2 字节。这个不对称和代码质量无关:Cloudflare 对跑在你 isolate 里的 DecompressionStream 计费,对它自己 fetch 内部等价的 gunzip 不计费。 用 JavaScript 写的任何东西都下不到一个被计费的原生地板 以下。

剩下的约 30 倍是 JS 编排的记录层、HTTP/2 解复用和流管道——在 4 MB 时约占每请求成本的 80%,而解码占 20%。 本节早先的版本把重点放在解码上,那是错的,它把优化精力引向了两者中较小的那个。

真正能补上这个差距的是一个不存在的原语:一个校验源站主机名、而不是校验 connect() 对端的 startTls, 有了它,平台自己的 fetch 就能在隧道里跑。那正是这整个包绕开的那块缺失,值得理解为运行时的能力缺口,而不是 这里的性能 bug。

大 body 的"每字节成本"其实不按字节计——按的是流边界穿越次数。这个运行时的 DecompressionStream 以 4096 字节为块产出输出,套接字单次交付也至多 4096 字节,而每一块在运行时与 JS 之间穿越一次都要几十微秒, 与块大小无关——实测约 17 µs 一次穿越,来自同一个 1 MB 分别按 4 KiB(6.0 ms/MB)到 256 KiB (1.67 ms/MB)收集的阶梯。因此两条热路径都改为用 BYOB 读来抽干来源:它把好几块合并成一次穿越,且只要 有一个字节就立即以部分填充返回,流式延迟不变。合并多少是传输层决定的,不是视图决定的——BYOB 读从不 等着填满——所以视图开得比传输层单次交给你的多,买不到任何东西,只会白付一次分配。4 MB body 上实测的平均 填充:直连 37 KB,走代理 8 KB。

一个在本包赖以存在的那条路径上从未接通的旋钮

openTunnel 做完 CONNECT(或 SOCKS5)握手时手里握着一个带缓冲的 reader——对端可能把隧道数据和应答塞在 同一个 chunk 里——它把这个交出去时包成了 new ReadableStream({ pull })。逻辑正确,但那是普通流,不是 byte stream。TLS 记录层要 BYOB reader,要不到就静默降级成 default reader,于是每一条走代理的连接都丢了 BYOB,连带 tls.pullBytes(它唯一的职责就是决定 BYOB 视图多大)一起失效。

同源、1 MB 过记录层、同一 isolate、n=15,修复前:

| | pullBytes: 16 KiB | pullBytes: 1 MiB | |---|---|---| | 直连 | min 25, p50 31 | min 73, p50 90 | | 走代理 | min 95, p50 111 | min 101, p50 121 |

直连 2.9×,代理 6%——那个旋钮在代理路径上根本没被读到。连带后果:当初用来定 64 KiB 默认值、标着「对着 真实代理 socket 扫出来」的那条 U 曲线,是同一个配置的四个采样。

src/proxy/tunnel.js 把隧道改成 byte stream,并且在握手剩余字节吐完之后把调用方自己的视图直接递给 socket。旋钮接上之后重扫(1 MB 过记录层,p50):

| | 8 KiB | 16 KiB | 32 KiB | 64 KiB | 256 KiB | |---|---|---|---|---|---| | 直连 | 21 | 20 | 18 | 22 | 40 | | 代理 A | 51 | 61 | 61 | 89 | 152 | | 代理 B | 68 | 74 | — | 102 | — |

两个独立代理上都单调。默认值因此从 64 KiB 改为 16 KiB:三条路径上都在最优列 20% 以内,而 64 KiB 最差 偏离 45%,分配量还是四分之一,并且正好是一个 TLS 记录。端到端走真 Client + 代理、n=13:1 MB 63 对 78 ms、 4 MB 145 对 164 ms(中位数),4 MB 的最小值在噪声内。

读进去的那个视图是 16 KiB,而且这个尺寸是扫出来的,不是拍的。它比看上去重要:输入由一个 JS 任务在与 拉取方相同的事件循环上泵送,所以读到达时解压器手上通常只压着一两块,读回来就是部分填充——实测一个 1 MB body:93 次读,93 次全是部分填充,平均每次 11.3 KiB。于是 64 KiB 的视图为了搬 1 MB 数据要分配 5.8 MB 的一次性缓冲区。在边上扫,每 MB 解压输出的 CPU,五档在同一个 isolate 里交错跑:

| BYOB 视图 | 4 KiB | 8 KiB | 16 KiB | 32 KiB | 64 KiB | |---|---|---|---|---|---| | 解码级 ms/MB | 19.33 | 16.00 | 13.00 | 15.33 | 17.67 |

干净的 U 形:太小付每次读的开销,太大付永远填不满的分配。原先那个 64 KiB 是从一个用原生 pipeTo 喂输入的 探针上选的——pipeTo 会跑在前面,确实能填满 64 KiB 视图(16 次读,没有一次部分填充)。而那是发布出去的 接线永远不会进入的工况。探针和产品说法不一致,当时信了探针。改过来之后解码级降了 31%,18.0 → 12.3 ms/MB,在同一个 isolate 里 A/B。

剩下的并不接近地板,本节早先的版本说它接近,那是错的。同样内容的原生 inflate 是 4.3 ms/MB,而这一级是 12.3,也就是说还有约 8 ms/MB 是本包自己的管道——JS 输入泵和输出包装。要压下去得重新设计,而不是改一个 常数;这是 body 路径上剩下的最大一项。

导入这个包是免费的。121 个内置锚是以主题 DN 哈希为索引的 base64 字符串,只有链实际落到的那一个会被解码,所以 380 KB 打包(gzip 后 133 KB)的启动时间保持在约 2 ms,而一个导入了但没使用本包的请求是 0 ms。

可选开关各自的成本

上面所有数字都是默认身份:curl 指纹、gzip 与 deflate、无后量子、无 GREASE。下面每个开关默认都关着,这张表是「打开它要付什么」。测法与其余部分一致——差分两个工作量、取样本最小值。

| 开关 | 成本 | 何时付 | |---|---|---| | grease: true | 测不出来 | 一次 hello 里多几个字节 | | tls.extensionOrder: 'shuffle' | 测不出来 | 每次握手洗牌约 11 个元素 | | headerOrder | 测不出来——有序列表比平台的 Headers 更快(1.6 µs 对 3.8 µs) | 每请求 | | groups: { x25519mlkem768 } | 用内置 WASM +0.15 ms,用纯 JS 的 ML-KEM +1.35 ms | 每连接,不是每请求——复用该连接的所有请求共同摊薄 | | ciphers: { chacha20 } | +2.95 ms/MB(内置 WASM AEAD 4.89 对 AES-GCM 1.95),而且只有服务器选中它时才付 | 每字节。有 AES 硬件加速的服务器通常偏好 AES-GCM,所以实际成本往往是零,真正起作用的是"出现在 offer 里" | | decoders: { br } | +4.4 ms/MB | 每字节,只要源站发 brotli。压得越狠越亏:quality 11 线上小 16%,解码贵 46% | | profile: chrome | 上面三项之和 | |

1.4.0 里有两个默认值变了,而它们都没有出现在上面那张表里:套件顺序对齐 curl 之后,原本协商 AES-128-GCM 的地方现在协商 AES-256-GCM,实测贵 4%(1.50 对 1.45 ms/MB——硬件 AES 让多出来的轮次很便宜);有序 header 列表取代了平台 Headers,反而略微更便宜。两者都落在上面数字本来就带的 ±1.5× 波动之内。

体积:import 'tunnelfetch/profile/chrome' 会为两个 WASM 原语增加约 22 KB(gzip)。没有其它地方引用它们,所以走默认身份的使用者一个字节都不背。

计划限制

付费计划上 30 秒的默认 CPU 上限不是约束点——那大约是一次调用里 3000 条连接或 1 GB 的 body,而"同时等待响应头 的连接最多六条"这个限制会远早于 CPU 触顶。

免费计划文档写的是每次调用 10 ms CPU,一条新连接(10–15 ms)正好压在这条线上或略高,而池化请求(2–4 ms)则 宽裕地装得下。实际上运行时容许偶发超限并会把没用完的预算结转,所以远超 10 ms 的单个请求确实能完成——实测有 几百毫秒的请求正常返回,运行时在约 2 秒处以 exceededCpu 终止。但这并不等于证明了持续高于 10 ms 是可行的, 因为那些探测是低速率、突发形状的,恰恰是这种宽容机制会放过的形状。如果你打算在免费计划上跑,请复用连接,并且 测量你自己的持续平均值,而不要相信文档上那个数字或突发行为中的任何一个。

运行时要求

WebCrypto (X25519、ECDH P-256/384/521、ECDSA、RSA-PSS、RSASSA-PKCS1、HKDF、HMAC、AES-GCM)、含 BYOB reader 的 WHATWG Streams、TextEncoder/TextDecoder、DecompressionStream、URL、Headers、Request、Response、AbortSignal、btoa。这些在 workerd 与 Node ≥ 22 上全部具备。唯一与具体运行时绑定的部分,是你自己提供的 connect 函数。

| 运行时 | 离线测试 | 说明 | |---|---|---| | Node 22、24 | 全部通过 | CI 把关的组合 | | Node 20 | 不支持 | TextDecoder 把 iso-8859-1 当作真正的 ISO-8859-1,而没有按 WHATWG 要求别名到 windows-1252,该字符集的响应体解码结果不同。已于 2026 年 4 月结束维护 | | workerd | live 边缘测试全过 | 目标运行时;由定时边缘任务端到端验证,不在离线套件里 | | Deno 2.9 | 2 个失败 | 两个失败都落在 TLS 1.2 测试服务器的 secp521r1 路径上,不在包内;Deno 的 WebCrypto ECDSA 与 ECDH 在 P-521 上单独测试都正常。原因未定位,因此不宣称支持 | | Bun 1.3 | 3 个失败 | 一个 repo-hygiene 测试的模块解析差异、一个对时序敏感的 deadline 测试,以及同一个 TLS 1.2 套件测试。原因未定位,因此不宣称支持 |

受支持的组合是 Node 22 与 workerd。Deno 和 Bun 已经非常接近可用,但没有进 CI——把这套测试在那两个运行时上跑通,是一个很好的首次贡献。

CI 会跑 engines 里声明的每一个版本,并且有一个 repo-hygiene 测试在两者不一致时直接失败——一个没人跑过的支持下限,是声称,不是事实。

测试

npm test          # offline, hermetic, no network
npm run test:live # explicit; needs TUNNELFETCH_PROXY in the environment

离线套件从不接触网络,而且这一点是被强制执行的:一套仓库卫生套件会让构建失败,只要任何测试文件写了可路由的主机名,src/ 从 node: 导入,src/ 里出现 URL、厂商名、Math.random 或 console.* 调用,或者 src/ 下有任何模块没有测试。

每一个按字节消费输入的解析器都会过一遍 underAllChunkings:同一串字节分别以整块、逐字节、以及若干伪随机切分点的方式喂入,并断言所有跑法结果一致。在不同分块形状下表现不同的解析器,带着一个只在真实网络分片下才现身的 bug——而那正是那种无法凭报告复现的 bug。

TLS 密钥调度与记录层逐字节钉死在 RFC 8448 "Example Handshake Traces for TLS 1.3" 上:AEAD 复现出 RFC 里一模一样的密文记录,记录层把 RFC 的完整线上字节在两个方向上原样重放。两个 TLS 驱动都对着独立编写的测试服务器测试——1.2 的服务器建在 node:crypto 上,而不是本包自己的原语上,这样客户端的 bug 就不可能被服务器端的同一个 bug 抵消掉。

每一个消费对端字节的解析器——TLS 记录层与握手消息、X.509、OCSP、HTTP/1.1 响应头、分块体、HTTP/2 帧、HPACK——都会被 fuzz,断言的属性只有一条:任意输入,要么解析成功,要么抛出 TunnelFetchError。 抛出未类型化的错误(少了空值检查的 TypeError、越界的 RangeError)就是一个发现:它意味着某处检查缺失,而所有依赖类型化契约来 fail closed 的调用方都接不住它。

fuzzer 自带种子、零依赖,失败时会打印种子、迭代序号和 base64 的输入——可以精确复现,而无法复现失败的 fuzzer 算不上 fuzzer。目标从 test/fuzz/targets/ 自动发现,新增一个就是放一个文件进去。这套测试还会 fuzz 它自己:两个合成目标分别证明引擎能报出未类型化的抛出、且不会误报类型化的抛出——否则一次全绿的 fuzz 只能证明 fuzzer 从来没睁眼。

FUZZ_ITERATIONS=1000000 node --test test/fuzz/fuzz.test.js   # 长跑
FUZZ_SEED=12345 node --test test/fuzz/fuzz.test.js           # 换一个角落

CI 每次提交跑固定种子,作为门禁;定时任务跑三百万次迭代、用 run id 作种子,那才是真正在搜索新地面的那一半。

probe/ 存放一个可复现的能力探测,输出机器可读的 JSON;probe/results/ 存放本设计所依据的测量结果。live/ 是边缘互操作实测环境。

凭据只从环境读取。live 套件在未配置时会大声失败而不是跳过:一个意思是“我们没检查”的绿勾,比红叉更糟。

致谢

信任锚点派生自 Mozilla / Common CA Database (CCADB),按 CDLA-Permissive-2.0 使用。用 npm run roots:refresh 重新生成;生成的模块会记录来源、获取日期、上游 SHA-256 与锚点数量——因为过期的根证书库是一个无声的可用性 bug,几个月后才以“TLS 随机失败”的面目浮出水面。

jawj/subtls (MIT) 是一个很有价值的概念验证,证明了在这类运行时上用 WebCrypto 跑 TLS 1.3 是可行的,编写本包时曾将其作为参考阅读。

许可证

MIT。