Cap'n Web RPC:远程对象、Promise pipelining 与使用方式
Cloudflare 原文:Cap’n Web — a new RPC system for browsers and web servers
AI 参与说明(Agent:Codex):本文由 Codex 基于 Cloudflare 发布文章、官方文档与固定版本源码辅助整理,并运行最小示例。整理日期:2026-10-21。源码分析固定为
766655329fe4fe2dada67c8b4e82e3a81e38b194;示例使用 npmcapnweb@0.12.0,两者是不同快照。执行入口为 Codex Desktop;完整模型标识与 reasoning effort 未取得任务级运行记录。
Cap’n Web 让浏览器与服务器通过 JavaScript 对象交互:方法可以返回另一个带方法的远程对象,客户端也可以把回调传给服务器。它最值得关注的能力是 Promise pipelining,让后一次调用直接引用前一次尚未返回的结果,减少连续操作中的网络等待。
这套模型适合前后端均使用 JavaScript / TypeScript、需要持续交互或细粒度授权的应用。它是 MIT 开源的协议和库,可以运行在浏览器、Cloudflare Workers、Node.js、Bun、Deno 等环境中,不要求部署在 Cloudflare。原文发布于 2025-09-22;以下接口与机制同时对照当前固定源码,不沿用发布时的包体积或实验状态描述。官方 README
| 英文术语 | 中文名称 | 简要解释 |
|---|---|---|
| RPC | 远程过程调用 | 通过调用方法来请求另一个进程执行操作 |
| object-capability | 对象能力 | 持有某个对象的引用,就获得调用该对象所公开能力的权限 |
| RpcTarget | 保留 API 名称 | 标记可以按引用传给远端、供对方调用的类 |
| RpcStub | 远程代理 | 客户端持有的代理对象,调用会被发送给真正持有对象的一方 |
| RpcPromise | 远程结果承诺 | 可以等待,也可以继续调用其未来结果的特殊代理 |
| Promise pipelining | Promise 流水线 | 先描述依赖调用,让远端使用中间结果继续执行 |
| HTTP batch | HTTP 批处理 | 把一组 RPC 消息放在一次 HTTP 请求与响应中完成 |
从返回数据到返回远程对象
A Cap’n Web call can return a live object reference, and later calls execute where that object lives. RpcTarget 与公共 API
假设服务器提供 getCounter(10),返回一个初始值为 10 的 Counter。客户端拿到的不是这个对象的完整副本,而是能调用它的 RpcStub。调用 counter.add(5) 时,服务器上的对象更新为 15;调用 counter.read() 时,服务器返回这个数值。
普通对象和数组主要按值传输。需要保留方法与身份的类继承 RpcTarget,函数则可以按引用传递。函数也不需要传输源码:服务器收到客户端回调的 RpcStub,调用时再通过连接回到客户端执行。因此,在 WebSocket 或 MessagePort 上,双方都可以发起调用。值与引用的编码
一个可以运行的例子
下面使用 Node.js 24 和 capnweb@0.12.0,只监听本机。getCounter() 返回远程对象,add() 返回同一个对象,再由 read() 取值。示例不涉及认证或持久化。
在一个空的实验目录安装依赖:
npm init -y
npm install --save-exact capnweb@0.12.0
保存为 server.mjs:
import { createServer } from 'node:http';
import { RpcTarget, nodeHttpBatchRpcResponse } from 'capnweb';
class Counter extends RpcTarget {
#value;
constructor(start) {
super();
this.#value = start;
}
add(delta) {
if (typeof delta !== 'number' || !Number.isFinite(delta)) {
throw new TypeError('delta must be a finite number');
}
this.#value += delta;
return this;
}
read() {
return this.#value;
}
}
class Api extends RpcTarget {
getCounter(start) {
if (typeof start !== 'number' || !Number.isFinite(start)) {
throw new TypeError('start must be a finite number');
}
return new Counter(start);
}
}
let posts = 0;
const server = createServer(async (request, response) => {
if (request.url !== '/api') {
response.writeHead(404).end();
return;
}
if (request.method === 'POST') console.log(`POST /api #${++posts}`);
try {
await nodeHttpBatchRpcResponse(request, response, new Api());
} catch (error) {
console.error(error);
response.writeHead(500).end();
}
});
server.listen(8787, '127.0.0.1', () => {
console.log('RPC: http://127.0.0.1:8787/api');
});
保存为 client.mjs:
import { newHttpBatchRpcSession } from 'capnweb';
const api = newHttpBatchRpcSession('http://127.0.0.1:8787/api');
const counter = api.getCounter(10);
const updated = counter.add(5);
const result = await updated.read();
console.log(result);
先在一个终端执行 node server.mjs,再在另一个终端执行 node client.mjs。客户端输出 15,服务器新增一行 POST /api #1。三个有依赖的 RPC 只使用一次 HTTP 请求与响应。本文在 Node.js 24.19.0 上运行得到这一结果。Node HTTP 适配入口
counter 和 updated 都是 RpcPromise。客户端还没有收到真实对象,就已经能描述后续操作;服务器会先获得 Counter,再执行 add(5),最后执行 read()。
Promise pipelining 怎样节省往返
Promise pipelining removes intermediate network waits while preserving dependencies between calls. 官方 Pipelining Tour
如果通过持续连接逐次 await getCounter()、await add()、await read(),客户端需要三次等待回复。上面的写法提前描述完整依赖关系,HTTP batch 将这些消息一起发送。连接建立和服务器计算仍需要时间,减少的是中途把结果带回客户端、再发下一次请求的等待。
sequenceDiagram
participant C as Client
participant S as Server
Note over C,S: 下列四条请求消息放在同一个 HTTP POST 中
C->>S: push 1:getCounter(10)
C->>S: push 2:结果 1 的 add(5)
C->>S: push 3:结果 2 的 read()
C->>S: pull 3:请求取回最终结果
Note over S: 按依赖取得 Counter、加 5、读取数值
S-->>C: resolve 3:15
在示例的实际 HTTP 请求体中,可以看到四行 JSON:
["push",["pipeline",0,["getCounter"],[10]]]
["push",["pipeline",1,["add"],[5]]]
["push",["pipeline",2,["read"],[]]]
["pull",3]
这是按行传输的四条 JSON 消息,不是一个 JSON 数组。ID 0 代表初始 API,随后三次调用的结果分别取得 ID 1、2、3。后续消息引用这些 ID,就不必先收到对象;最终响应为 ["resolve",3,15]。
需要区分“发起调用”和“取回结果”。Calling a method sends or buffers a push; awaiting its RpcPromise requests the result with pull. WebSocket 的 push 可以立即发送;HTTP batch 的 transport 先收集消息,再在下一宏任务发出 POST。不能把两种模式都理解为“只有 await 才联网”。sendCall 与 sendPull、BatchClientTransport
如果客户端必须先读出某个结果、用本地 if 判断,再决定调用哪个方法,这一步仍然要等待。Promise pipelining 也不提供数据库事务的原子性:链中某个后续操作失败,不表示此前的业务写入会回滚。
架构与源码入口
各 transport 共用同一套对象、结果和消息模型。可以从下表按问题定位,而不必先通读整个仓库。
| 模块 | 职责 | 对应能力与入口 |
|---|---|---|
src/index.ts | 对外 API 与运行时适配 | RpcTarget、RpcStub、RpcPromise 与 Workers 入口 |
src/core.ts | 代理属性路径、方法调用与应用值交付 | PROXY_HANDLERS、RpcPromise |
src/rpc.ts | session、引用表、消息收发与错误传播 | RpcSessionImpl |
src/serialize.ts | 值编码与调用表达式求值 | Devaluator、Evaluator |
src/batch.ts、src/websocket.ts、src/messageport.ts | 通过不同连接传输消息 | HTTP batch、WebSocket、MessagePort |
src/map.ts | 记录并重放逐元素 RPC 操作 | sendMap、applyMap |
支撑这些模块的核心数据包括:
| 数据 | 保存什么 | 在例子中的作用 |
|---|---|---|
| RpcStub 与 RpcPromise | 指向调用目标的 hook,以及属性路径或未来结果 | 让 counter.add 表示一次远程方法调用 |
| RpcPayload | 参数或返回值,以及其中引用的所有权 | 传递 10、5、Counter 引用与最终 15 |
| import table | 本地持有的远端引用、等待结果与引用计数 | 记录客户端等待的结果 ID 3 |
| export table | 向对端暴露的本地目标、结果与引用计数 | 保存初始 Api、Counter 和后续结果 |
沿 getCounter(10) 到 15 的真实路径看:PROXY_HANDLERS.apply() 收到方法调用,封装 RpcPayload;RpcImportHook.call() 进入 sendCall(),编码 pipeline 并发出 push。服务端 readLoop() 收到消息后,用 Evaluator 求值并把结果放入 export table。下一条消息中的 ID 引用由此找到前一结果,最终通过 RpcPayload.deliverCall() 在参数准备好后调用应用方法。代理与调用、readLoop、deliverCall
最后一次 await 进入 RpcPromise 的 then() / pullPromise() 路径,要求远端传回结果。服务端收到 pull 后等待对应值,发送 resolve 或 reject;客户端按 import ID 找回等待项,交给 JavaScript 调用方。因此,中间对象可以始终留在服务器,客户端只接收最后的数值。pullPromise、结果解析与错误传播
object-capability 怎样表达权限
Counter 示例展示了对象引用,但没有权限设计。实际应用可以只公开 authenticate(token),认证成功后返回绑定某个用户的 AuthenticatedApi,再由它返回该用户可编辑的文档对象。
Holding an object reference grants access to the operations that object exposes. 客户端不能仅凭自己构造一段对象数据就取得某个会话的有效引用;服务端必须在允许访问时把 capability 交给它。不同用户取得不同对象,也就可以具有不同的操作范围。官方安全说明
这不代表所有授权检查都能取消。对象内部仍需维护业务约束;权限撤销、过期、资源归属与是否允许转授,都要按应用场景设计。官方建议通过 RPC 方法在连接内提交 token 并取得授权对象,不把整个连接的权限隐含在调用顺序里。
TypeScript 类型在运行时会被擦除。示例中的 typeof 和 Number.isFinite() 才真正拒绝错误参数;实际 API 可以使用既有 schema 校验库,或选择官方配套的 capnweb-validate。后者需要单独接入,不能把“无需独立 schema 编译”理解成“输入自动可信”。另外,TypeScript 的 private 不会在运行时隐藏方法;确需私有成员时使用 JavaScript # 语法。Types are not validation
列表的 map 做了什么
假设另一个 API 提供 listUserIds() 和 getUserProfile(id),可以这样描述逐元素调用;这是接口示意片段,不属于前面的 Counter 服务:
const profiles = await api.listUserIds().map(id => api.getUserProfile(id));
The map callback runs once on the calling side to record RPC operations; the peer replays those operations for each element. 客户端先用占位值运行同步回调,记录“取得 ID 后调用 getUserProfile”的操作,再把记录交给远端逐项执行。map 的机制与限制
因此,不能把它当成任意 JavaScript 的远程执行环境。回调不能 await,也不能依靠尚未取得的 ID 做本地条件判断或字符串拼接;普通本地副作用只会在记录时发生一次。回调捕获的 RpcStub 会交给对端,也必须在相应信任边界内使用。
减少客户端网络往返也不会自动合并数据库查询。如果每个 getUserProfile() 都单独查数据库,服务器仍然可能执行 N+1 次查询;需要合并读取时,应提供接受一组 ID 的方法并在业务层批量查询。官方 N+1 讨论
HTTP batch、WebSocket 与 Workers 接入
| 方式 | session 持续时间 | 适合的交互 |
|---|---|---|
| HTTP batch | 一次 HTTP 请求与响应 | 一次性读取、提交及其中的依赖调用 |
| WebSocket | 对应连接的生命周期 | 持续交互、服务器调用客户端回调 |
| MessagePort | 对应 port 的生命周期 | 页面与 iframe、Web Worker 之间的对象调用 |
HTTP batch 收到结果后就结束,不能继续使用从该 batch 获得的对象引用。WebSocket 断开后,旧 RpcStub 也会失效;应用需要建立新 session、重新认证并取得新的引用。session 的引用表不是持久化数据库;长任务进度、断线续传位置与重复提交的处理需要单独设计。持续 session 中不再使用的引用也应按 disposal 规则释放。Sessions & Reconnection
本文同时实测了两个边界:Counter batch 完成后再次调用旧引用会被拒绝;新 batch 传入字符串 getCounter('10'),最终等待操作会收到服务端的 start must be a finite number 错误。
Cloudflare Workers 可以复用同样的业务类。下面是适配片段,需要把前面的 Counter、Api 类放在同一模块中,并将 Node HTTP server 部分替换为:
import { newWorkersRpcResponse } from 'capnweb';
export default {
fetch(request) {
if (new URL(request.url).pathname !== '/api') {
return new Response('Not found', { status: 404 });
}
return newWorkersRpcResponse(request, new Api());
},
};
newWorkersRpcResponse() 可以处理 HTTP batch 和 WebSocket upgrade;浏览器长期连接对应 newWebSocketRpcSession('wss://example.com/api')。这部分对照官方适配入口,实际运行结果来自前面的 Node HTTP 示例。Workers 入口
Cap’n Web 与原生 Workers RPC 属于不同接入方式:前者提供浏览器等环境可用的库与线协议,后者由 Workers 平台提供 Service bindings 等运行时接口。两者可以配合使用,但安装 capnweb 不会自动获得某个 Worker binding 的权限。
与其他方案怎样取舍
| 方案 | 主要表达方式 | 选型时值得比较的差异 |
|---|---|---|
| REST | 资源、URL 与 HTTP 方法 | HTTP 缓存与通用客户端工具有成熟支持;Cap’n Web 更直接表达对象方法及依赖操作 |
| tRPC | TypeScript 类型约束的过程调用 | 同样无需独立 IDL;Cap’n Web 的重点是远程对象、函数引用与依赖调用的 Promise pipelining |
| JSON-RPC | 方法名与数据参数 | 通常不包含对象引用及其生命周期、对尚未返回结果的引用机制 |
| GraphQL | 查询语言描述的数据图 | Cap’n Web 用对象调用构造依赖关系,但没有自动数据库批量加载或查询成本分析 |
| Cap’n Proto | schema、代码生成与二进制协议 | 适合多语言场景;Cap’n Web 更贴近 JavaScript,两者线协议不兼容 |
Batching 合并的是参数已经确定的独立调用;Promise pipelining 还能表示后一次调用依赖前一次结果。这是比较 tRPC 与 Cap’n Web 时更有用的区分,不能仅根据“都支持批处理”判断能力相同。官方方案比较
如果应用本来就有会话对象、文档编辑对象、客户端回调,而且前后端主要使用 JS/TS,Cap’n Web 值得做小规模接入验证。如果主要目标是公开多语言 API、标准 HTTP 缓存或声明式数据查询,应先评估对应方案的客户端生态和运维方式。
对外服务还需要按业务操作限制开销。一个 HTTP 请求可以包含很多调用,嵌套 .map() 也可能放大工作量;仅统计请求数的网关限制不等于限制了昂贵操作。应在应用层限制批量规模、操作次数或业务配额,并按 transport 设置载荷上限。应用限流与消息大小
关联阅读
- Cloudflare 文档导航:按 Workers、Durable Objects 等产品继续阅读。
- Workers:运行时 API、Bindings 与执行模型:区分运行时接口、资源授权与控制面 API。
- Cloudflare OS:产品形态、Gadgets、Gatekeepers 与 Workers 架构:看 Cap’n Web 在真实应用中怎样逐步授予对象能力。
一手资料
- Cloudflare 发布文章:设计动机、基本用法与 2025 年发布时的说明。
- 固定源码与文档:本文机制分析的依据,包含
packages/docs/。 - capnweb@0.12.0 release:本文 npm 示例使用的发行版本。
- Wire protocol:消息、表达式、引用与生命周期约定。