跳至正文
Workers — Cap'n Web RPC:远程对象、Promise pipelining 与使用方式

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;示例使用 npm capnweb@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 pipeliningPromise 流水线先描述依赖调用,让远端使用中间结果继续执行
HTTP batchHTTP 批处理把一组 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() 取值。示例不涉及认证或持久化。

在一个空的实验目录安装依赖:

sh
npm init -y
npm install --save-exact capnweb@0.12.0

保存为 server.mjs:

js
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:

js
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:

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.tssession、引用表、消息收发与错误传播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 服务:

js
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 部分替换为:

js
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 更直接表达对象方法及依赖操作
tRPCTypeScript 类型约束的过程调用同样无需独立 IDL;Cap’n Web 的重点是远程对象、函数引用与依赖调用的 Promise pipelining
JSON-RPC方法名与数据参数通常不包含对象引用及其生命周期、对尚未返回结果的引用机制
GraphQL查询语言描述的数据图Cap’n Web 用对象调用构造依赖关系,但没有自动数据库批量加载或查询成本分析
Cap’n Protoschema、代码生成与二进制协议适合多语言场景;Cap’n Web 更贴近 JavaScript,两者线协议不兼容

Batching 合并的是参数已经确定的独立调用;Promise pipelining 还能表示后一次调用依赖前一次结果。这是比较 tRPC 与 Cap’n Web 时更有用的区分,不能仅根据“都支持批处理”判断能力相同。官方方案比较

如果应用本来就有会话对象、文档编辑对象、客户端回调,而且前后端主要使用 JS/TS,Cap’n Web 值得做小规模接入验证。如果主要目标是公开多语言 API、标准 HTTP 缓存或声明式数据查询,应先评估对应方案的客户端生态和运维方式。

对外服务还需要按业务操作限制开销。一个 HTTP 请求可以包含很多调用,嵌套 .map() 也可能放大工作量;仅统计请求数的网关限制不等于限制了昂贵操作。应在应用层限制批量规模、操作次数或业务配额,并按 transport 设置载荷上限。应用限流与消息大小

关联阅读

一手资料

博客助手

正在打开博客助手…