跳到主要内容

预览 URL 与端口暴露

本章讲一个常见需求:容器里跑了个 web 服务(比如 :8080),我想从公网访问它。 SDK 的做法是把「哪个沙箱、哪个端口、什么 token」全编进一个子域名,再用一个 Worker 函数把 进来的请求路由回对应容器。

1. 要解决的小问题

你在沙箱里 startProcess('python -m http.server 8080'),服务只监听容器内 localhost。 外界够不着。你需要一个公网 URL 指向这个内部端口,而且得安全——不能让别人猜到 URL 就 访问你的服务,也不能让一个沙箱的 URL 命中另一个沙箱。

2. 思路:把路由信息编进子域名

预览 URL 的格式(request-handler.ts:70 注释)是:

{port}-{sandboxId}-{token}.yourdomain.com
│ │ │
│ │ └─ 鉴权 token([a-z0-9_]+,≤16 字符发放)
│ └─ 沙箱 id(可含连字符,如 UUID)
└─ 容器内端口(4-5 位数字)

所有信息都在主机名里。这要求 DNS 是通配的(*.yourdomain.com),这也是为什么 生产必须用自定义域名:.workers.dev 不支持这种子域名形态。exposePort 检测到 .workers.dev 直接抛 CustomDomainRequiredError(sandbox.ts:4994)。

3. 两个端:发放 URL 与路由请求

3.1 发放:exposePort

sandbox.exposePort(8080, { hostname })(sandbox.ts:4979)做的事:

  1. 校验端口合法(1024–65535、非 3000,validatePort)。
  2. 拒绝 .workers.dev
  3. 确保有默认会话、读到当前运行时身份(currentRuntime.get() / markStarted(),sandbox.ts:5021)。
  4. 在一个 DO 存储事务里:为端口生成/复用 token,写入 portTokens,并把 activePreviewPorts[port] = runtime.scope({ token })——即把这次激活绑定到当前运行时身份 (sandbox.ts:5025-5051)。
  5. 事务后再 assertActive(runtime) 一次:若期间有并发生命周期钩子记录了更新的运行时身份, 就失败,而不是返回一个一到就过期的 URL(sandbox.ts:5057,注释明说)。
  6. 拼出 URL 返回。

关键设计:token(鉴权)由 DO 存储拥有,跨容器重启存活;但转发只在端口被「当前运行时」 激活后才生效(架构 skill「Preview URL authorization is Durable Object-owned, while forwarding is active only after exposePort() activates the port for the current runtime」)。 这把「谁有权」和「现在能不能转」解耦了。

3.2 路由:proxyToSandbox

请求进 Worker 后,你必须先proxyToSandbox(request, env)(request-handler.ts:32):

// packages/sandbox/src/request-handler.ts:38 —— 真实源码(节选)
const routeInfo = extractSandboxRoute(url); // 解析 {port}-{id}-{token}.domain
if (!routeInfo) return null; // 不是预览请求 → 交还给你的业务逻辑

const { sandboxId, port, token } = routeInfo;
const sandbox = getSandbox(env.Sandbox, sandboxId, { normalizeId: true });
// 把 port/token/sandboxId 放进内部 header,转发给该沙箱 DO
return await sandbox.fetch(previewRequest);

解析子域名的 extractSandboxRoute(request-handler.ts:69)很讲究——因为 token 里没有连字符,而 sandboxId 可能有(UUID),所以它在最后一个连字符处切分, 并对 port(/^\d{4,5}$/)、token(/^[a-z0-9_]+$/,≤63)、sandboxId(≤63、可 sanitize) 各做正则校验(request-handler.ts:85-122)。任何不合格都返回 null(交还业务逻辑), 不会误吞普通请求。

为什么预览 URL 总用 normalizeId: proxyToSandbox 里硬编码 { normalizeId: true } (request-handler.ts:46)——主机名大小写不敏感,沙箱 id 必须统一小写才不会路由错。 这也呼应了 §1 文档里 getSandbox 对大写 id 的 warn。

4. 鉴权:运行时身份 + token 双闸

请求转发到 DO 后还要过验证。两道闸:

  1. token 匹配:validatePortToken(port, token)(sandbox.ts:5299)对存储里的 token 比对。
  2. 运行时激活有效:仅当该端口的激活记录属于当前运行时身份、且容器健康/运行中, 转发才放行。PreviewURLRuntimeValidation(sandbox.ts:177)把失败原因细分成 runtime-not-healthy / runtime-not-running / missing-activation / runtime-mismatch / token-mismatch 等——便于诊断「URL 看着对但就是连不上」。
公网请求 {8080}-{my-sandbox}-{tok}.example.com

▼ proxyToSandbox: 解析子域名 → getSandbox(id, normalizeId) → sandbox.fetch
▼ DO 内:① token 匹配? ② 该端口激活属于当前运行时且容器在跑?
│ 两者都过 ──▶ 转发到容器内 :8080(forwardPreviewRequest)
│ 任一不过 ──▶ 拒绝(带细分原因)

5. 巧妙之处 / 边界

  • 撤销很便宜且幂等。 unexposePort(sandbox.ts:5096)只清 DO 存储里的 token + 激活, 不碰、不唤醒容器——因为存储才是鉴权的真相源,清掉就等于撤销转发。
  • getExposedPorts 只列「现在可转发」的。 有持久授权但当前运行时没激活的端口被省略 (sandbox.ts:5143),避免给你一个其实连不上的 URL。
  • 边界: SSE(text/event-stream)在某些预览/隧道路径会被缓冲(README 提到 trycloudflare 对 SSE 缓冲);WebSocket 正常。
  • 边界: 端口固定排除 3000(控制面),且必须 ≥1024(容器非 root,绑不了特权端口, security.ts:validatePort)。

6. 代码地图

主题文件符号名
暴露端口packages/sandbox/src/sandbox.tsexposePortunexposePortgetExposedPorts
token 校验packages/sandbox/src/sandbox.tsvalidatePortTokenisPortExposed
运行时激活校验packages/sandbox/src/sandbox.tsPreviewURLRuntimeValidationcurrentRuntime(assertActive)
预览请求转发packages/sandbox/src/preview-forwarding.tsforwardPreviewRequest
入口路由packages/sandbox/src/request-handler.tsproxyToSandboxextractSandboxRoute
子域名协议头packages/sandbox/src/preview-proxy-protocol.tsPREVIEW_PROXY_* 常量
端口/id 校验packages/sandbox/src/security.tsvalidatePortsanitizeSandboxId