跳到主要内容

架构与请求流程

本章回答一个问题:你写下的那行 await sandbox.exec('...'),到底经过了哪些组件? 先看三层骨架,再看 getSandbox 怎么把一个本地对象「变成」远程容器的句柄,最后端到端走一遍。

1. 三层骨架

SDK 是一个 npm workspaces + Turbo 的 monorepo(package.json:36 workspaces)。 核心是三个包,职责泾渭分明:

packages/
├── sandbox/ ← @cloudflare/sandbox (发布到 npm,你 import 的就是它)
│ · Sandbox 类:一个 Durable Object,管容器生命周期
│ · clients/ + container-control/ :把方法调用送到容器的两套通道
│ · interpreter / preview / tunnels / backup …
├── shared/ ← @repo/shared (内部,不发布)
│ · 双方共用的类型、错误类(errors/)、日志(logger/)
└── sandbox-container/ ← @repo/sandbox-container (内部,打进 Docker 镜像)
· Bun HTTP 服务器(端口 3000)
· core/container.ts:DI 容器;services/:真正干活的服务

为什么这么分? 因为这两端跑在完全不同的运行时:

  • packages/sandbox 跑在 Cloudflare Worker / Durable Object 运行时(V8 isolate)。
  • packages/sandbox-container 跑在 容器里的 Bun(完整 Linux 进程)。
  • packages/shared 是两边都 import 的「契约」——同一套类型和错误码,保证跨进程传过来的 错误两边认得(架构 skill:「Errors flow from container → Sandbox DO → Worker」)。

2. getSandbox:把名字变成容器句柄

你拿沙箱的唯一入口是 getSandbox(packages/sandbox/src/sandbox.ts:696)。它做三件事:

2.1 按 id 取到 Durable Object 桩

// packages/sandbox/src/sandbox.ts:716 —— 真实源码(节选)
const stub = getContainer(
ns as unknown as DurableObjectNamespace<Container<Cloudflare.Env>>,
effectiveId
) as unknown as T & SandboxProxyStub;

getContainer 来自 @cloudflare/containers:同一个 effectiveId 永远映射到同一个 Durable Object 实例——这就是「同名沙箱状态保留」的根。Sandbox 类本身 extends Container<Env>(sandbox.ts:947),即它就是一个容器型 DO。

小坑: 大写字母会污染预览 URL(主机名大小写不敏感)。getSandbox 检测到大写且未开 normalizeId 时会 logger.warn(sandbox.ts:706),并预告未来 normalizeId 会默认为 true

2.2 把配置「悄悄」推给 DO(带回滚)

getSandbox 可以带 options(sleepAfterkeepAlivetransport…)。它不阻塞你的调用, 而是 fire-and-forget 地把变化推给 DO,并用一个进程内缓存避免重复推送;若推送失败就把缓存回滚:

// packages/sandbox/src/sandbox.ts:736 —— 真实源码(节选)
void applySandboxConfiguration(stub, configuration).catch(() => {
if (cachedConfiguration) {
namespaceCache.set(effectiveId, cachedConfiguration); // 失败:回滚到旧值
return;
}
namespaceCache.delete(effectiveId);
});

这意味着配置是最终一致的:getSandbox(...).exec(...) 不会因为「先等配置落库」而变慢。

2.3 用 Proxy 增强部分方法

这是 getSandbox 最微妙的部分。它返回的不是裸桩,而是一个 Proxy:

// packages/sandbox/src/sandbox.ts:904 —— 真实源码(节选)
return new Proxy(stub, {
get(target, prop) {
if (typeof prop === 'string' && prop in enhancedMethods) {
// 命中增强表:包一层 withSandboxOperationContext(翻译平台中断错误)
return withSandboxOperationContext(`sandbox.${prop}`, method as ...);
}
// 其余方法:原样透传给 RPC 桩(保留它内部的 Proxy 行为)
return target[prop];
}
});

为什么要增强? 注释点明(sandbox.ts:749):

Any method that returns ExecutionSession must be listed here to ensure the returned session uses proxyTerminal instead of RPC's terminal.

两类增强:

  • 会话默认值: 若用户没开 default session,SDK 在客户端就把 DISABLE_SESSION_TOKEN 填进去(execwriteFile… 各 case,sandbox.ts:753+),省得每个 DO 方法重复判断。
  • tunnels 代理: sandbox.tunnels.get(...) 被代理成 stub.callTunnels('get', args) (sandbox.ts:890),绕开 RPC 在某些打包器下「按属性 getter 做 pipelining」会失效的问题 (sandbox.ts:1078 callTunnels 注释)。

3. 端到端:一次 exec 的旅程

把上面拼起来,跟一条命令从你的 Worker 到 bash 再回来:

① Worker: await sandbox.exec("ls")
│ getSandbox 返回的 Proxy → 命中 enhancedMethods.exec
│ (没开 default session 时填 DISABLE_SESSION_TOKEN)

② Sandbox DO: Sandbox.exec() (sandbox.ts:3827)
│ resolveExecution() 决定用哪个 session
│ → execWithSession() → this.client.commands.execute(cmd, sessionId, opts)

③ client(默认 ContainerControlClient): commands getter
│ 通过 capnweb RPC 把调用送上 /rpc WebSocket

④ 容器 Bun 服务器: server.ts 收到 capnweb 帧
│ → SandboxControlAPI.commands → CommandsRPCAPI → ProcessService

⑤ SessionManager: 在「常驻 bash」里跑 ls,拿回 stdout/stderr/exitCode

▼ 原路封成 ExecResult 返回给 ②,再返回给你

关键代码锚点:

  • DO 侧入口:Sandbox.exec(sandbox.ts:3827)→ execWithSession(sandbox.ts:3855), 核心一行是 this.client.commands.execute(command, sessionId, commandOptions)(sandbox.ts:3891)。
  • this.client 在构造时按传输选定:createClientForTransport(sandbox.ts:1131)。 默认 rpcContainerControlClient;http/websocketSandboxClient(详见 §2 文档)。
  • 容器侧入口:SandboxControlAPI(packages/sandbox-container/src/control-plane/api.ts:79), 它 extends RpcTarget,把每个域(commands/files/…)暴露成嵌套 RpcTarget(api.ts:89+)。

4. 容器侧:DI 容器 → 路由 → 服务

容器运行时启动于 startServer(packages/sandbox-container/src/server.ts:172)。它:

  1. new Container()initialize()——这是个依赖注入容器(core/container.ts:65), 把所有 service / handler / middleware 注册进一个 Dependencies map(container.ts:30)。
  2. Router 装上 CORS 中间件、setupRoutes(路由式兼容 API)。
  3. SandboxControlAPI(主控面),把各 service 注入进去(server.ts:77)。
  4. serve<WSData>({ port: 3000, ... }) 起 Bun 服务器,在 WebSocket open 里按 ws.data.type 分流:pty / control / capnweb(server.ts:188)。

两条路并存(这是刻意的):

通道容器侧入口用途
/rpc capnweb(主)SandboxControlAPI(control-plane/)新能力默认走这里
HTTP :3000 + /ws(兼容)Router + handlers/兼容、调试、本地开发、回退

架构 skill 明确:新的控制面能力默认加在 container-control/control-plane/, 不要加在路由式 clients/handlers/。后者只为兼容/调试/回退保留。

5. 代码地图

主题文件符号名
拿沙箱句柄packages/sandbox/src/sandbox.tsgetSandbox
Sandbox DO 类packages/sandbox/src/sandbox.tsSandbox
Proxy 增强表packages/sandbox/src/sandbox.tsenhancedMethods
平台中断错误翻译packages/sandbox/src/sandbox.tswithSandboxOperationContext
选传输建客户端packages/sandbox/src/sandbox.tscreateClientForTransport
DO 侧 execpackages/sandbox/src/sandbox.tsexecWithSession
公开 API 汇出packages/sandbox/src/index.ts(整文件)
容器启动packages/sandbox-container/src/server.tsstartServercreateApplication
DI 容器packages/sandbox-container/src/core/container.tsContainerDependencies
容器主控 APIpackages/sandbox-container/src/control-plane/api.tsSandboxControlAPI