在 Sandbox SDK 0.12 中,exposePort() 在 Durable Object 存储中为每个端口存储令牌。proxyToSandbox() 将 8080-ada-<TOKEN>.example.com 这类主机名路由到该端口上的服务器。在 1.0 中,你的 Worker 路由这些主机名,Durable Object 检查令牌并用 getTcpPort() 转发请求。本页上的代码读取 0.12 存储令牌的位置,因此用户已有的预览 URL 会继续有效。
你的 1.0 类已替换 0.12 Sandbox 类,并保留其名称及其 Sandbox 绑定。有关该类,请参阅 替换 Sandbox 类。
本页替换 exposePort()、unexposePort()、getExposedPorts()、isPortExposed()、validatePortToken()、proxyToSandbox() 和 wsConnect()。保留服务预览主机名的通配符 DNS 记录、证书和 Worker 路由。1.0 会原样使用它们。
本页不涵盖预览到达的服务器。要启动它,请参阅 迁移后台进程。
-
将这些声明添加到
src/index.ts的顶层:src/index.tsts type PortToken = { token: string; name?: string }; type Stored = Record<string, string | PortToken>; // The key where 0.12 stored preview tokens. const PORT_TOKENS = "portTokens"; function previewUrl( port: number, name: string, token: string, hostname: string, ): string { return `https://${port}-${name}-${token}.${hostname}/`; } // 16 characters from [a-z0-9_], as 0.12 generated them. function generateToken(): string { const bytes = crypto.getRandomValues(new Uint8Array(12)); return btoa(String.fromCharCode(...bytes)) .replace(/[+/]/g, "_") .toLowerCase(); } -
向 Durable Object 类添加读取令牌的方法以及
exposePort():src/index.tsts export class MySandbox extends DurableObject<Env> { // ... private async readPortTokens(): Promise<Record<string, PortToken>> { const stored = await this.ctx.storage.get<Stored>(PORT_TOKENS); const tokens: Record<string, PortToken> = {}; // Early 0.12 versions stored the token as a string. for (const [port, value] of Object.entries(stored ?? {})) { tokens[port] = typeof value === "string" ? { token: value } : value; } return tokens; } async exposePort( sandboxName: string, port: number, options: { hostname: string; name?: string; token?: string }, ) { if (!Number.isInteger(port) || port < 1024 || port > 65535) { throw new Error(`Invalid port number: ${port}`); } if (options.token && !/^[a-z0-9_]{1,16}$/.test(options.token)) { throw new Error(`Invalid token: ${options.token}`); } const tokens = await this.readPortTokens(); const token = options.token ?? tokens[port]?.token ?? generateToken(); tokens[port] = { token, name: options.name }; await this.ctx.storage.put(PORT_TOKENS, tokens); return { url: previewUrl(port, sandboxName, token, options.hostname), port, name: options.name, }; } }该方法以 0.12 使用的格式存储令牌,并返回与 0.12 相同的 URL。与 0.12 不同,它不会启动容器。0.12 从自己的存储读取 sandbox 名称,因此请从 Worker 传入名称:
src/index.tsts const sandbox = env.Sandbox.getByName(name); const { url } = await sandbox.exposePort(name, 8080, { hostname: "example.com", }); -
将其他端口方法添加到类:
src/index.tsts export class MySandbox extends DurableObject<Env> { // ... async unexposePort(port: number): Promise<void> { const tokens = await this.readPortTokens(); delete tokens[port]; await this.ctx.storage.put(PORT_TOKENS, tokens); } async getExposedPorts(sandboxName: string, hostname: string) { const tokens = await this.readPortTokens(); return Object.entries(tokens).map(([port, { token }]) => ({ url: previewUrl(Number(port), sandboxName, token, hostname), port: Number(port), status: "active" as const, })); } async isPortExposed(port: number): Promise<boolean> { const tokens = await this.readPortTokens(); return port in tokens; } async validatePortToken( port: number, token: string, ): Promise<boolean> { const tokens = await this.readPortTokens(); const expected = tokens[port]?.token; if (expected === undefined) { return false; } // Compare in constant time, so timing does not reveal the token. const encoder = new TextEncoder(); const a = encoder.encode(expected); const b = encoder.encode(token); if (a.byteLength !== b.byteLength) { return false; } return crypto.subtle.timingSafeEqual(a, b); } }0.12 的
getExposedPorts()和isPortExposed()仅在当前容器中运行过exposePort()后才报告端口。这些方法报告每个已存储的端口,因为 Durable Object 会将请求转发到任何已存储的端口。0.12
destroy()会删除令牌。在代码调用destroy()以结束 sandbox 的地方,也请调用this.ctx.storage.delete(PORT_TOKENS)。 -
在 Worker 中路由预览主机名。在 0.12 中,
proxyToSandbox()在其他路由之前运行:src/index.ts (0.12)ts export default { async fetch(request: Request, env: Env): Promise<Response> { const proxied = await proxyToSandbox(request, env); if (proxied) { return proxied; } // Your other routes. }, };在 1.0 中,Worker 从主机名读取端口、sandbox 名称和令牌,并检查令牌:
src/index.ts (1.0)ts // <port>-<sandbox name>-<token>, as 0.12 issued preview hostnames. const PREVIEW = /^(\d{4,5})-([a-z0-9-]{1,63})-([a-z0-9_]{1,63})\./; // The Durable Object forwards to the port in this header. function toPort(request: Request, port: number): Request { const headers = new Headers(request.headers); headers.set("X-Sandbox-Port", String(port)); return new Request(request, { headers }); } export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url); const preview = PREVIEW.exec(url.hostname); if (preview) { const [, portText, name, token] = preview; const port = Number(portText); const sandbox = env.Sandbox.getByName(name); if (!(await sandbox.validatePortToken(port, token))) { return new Response("Not found", { status: 404 }); } return sandbox.fetch(toPort(request, port)); } // Your other routes. }, } satisfies ExportedHandler<Env>;该表达式像 0.12 一样拆分第一个标签。端口在第一个连字符处结束,令牌在最后一个连字符之后开始。错误的令牌或没有令牌的端口会得到
404,与 0.12 一样。Durable Object 信任
X-Sandbox-Port标头,toPort()会替换访问者发送的任何值。只通过toPort()向 Durable Object 的fetch()handler 发送请求,或先移除该标头,如 迁移浏览器终端 所述。 -
从 Durable Object 的
fetch()handler 转发请求:src/index.tsts export class MySandbox extends DurableObject<Env> { // ... async fetch(request: Request): Promise<Response> { const container = this.ctx.container; const port = Number(request.headers.get("X-Sandbox-Port")); // Preview requests do not start the container. if (!container?.running) { return new Response("The sandbox is not running", { status: 503, }); } const url = new URL(request.url); url.protocol = "http:"; const forwarded = new Request(url, request); forwarded.headers.delete("X-Sandbox-Port"); try { const tcpPort = container.getTcpPort(port); const response = await tcpPort.fetch(forwarded); if (response.webSocket) { return bridge(response); } return response; } catch { return new Response("Nothing is listening on this port", { status: 503, }); } } }从 保持 WebSocket 连接打开 复制
bridge()函数。仅经过 Durable Object 的 WebSocket 不会保持容器运行,因此该桥接会接受两端。服务器在
Host标头中收到预览主机名。0.12 发送localhost:<PORT>,并将预览主机名放在X-Forwarded-Host中。如果你的服务器检查任一标头(例如 Vite 使用server.allowedHosts↗),请更新其配置。 -
替换 Worker 中的每个
wsConnect()调用。在 0.12 中,它将 WebSocket 连接到端口:src/index.ts (0.12)ts if (request.headers.get("Upgrade") === "websocket") { return getSandbox(env.Sandbox, name).wsConnect(request, 8080); }在 1.0 中,通过
toPort()将请求发送到同一个fetch()handler:src/index.ts (1.0)ts if (request.headers.get("Upgrade") === "websocket") { return env.Sandbox.getByName(name).fetch(toPort(request, 8080)); }该请求不会启动容器,因此请在页面连接之前启动服务器。因为此路由没有令牌,请保留 Worker 在调用前运行的检查。
就地部署切换时,Durable Objects 会保留 portTokens 键。切换后,预览 URL 的行为如下:
| 请求 | 结果 |
|---|---|
| 0.12 签发的 URL | 在新容器中该端口上有服务器监听之前返回 503,然后到达该服务器。部署后的短时间内,它仍可能到达 0.12 服务器。 |
| 容器再次启动后的任何 URL | 无需再次调用 exposePort() 即可到达服务器。0.12 会返回 410,直到你再次暴露该端口。 |
| 错误的令牌,或没有令牌的端口 | 返回 404。 |
unexposePort() 之后的 URL |
返回 404。 |
如果你 并排迁移 sandbox,copyFrom0x() 会将令牌复制到 1.0 类。在 Worker 的预览路由中,用 await sandboxFor(env, name) 获取 stub。
部署到预发布 Worker,并在 0.12 中有预览 URL 的 sandbox 中启动服务器。请求该 URL:
curl https://8080-ada-<TOKEN>.example.com/响应来自你的服务器。用错误令牌请求同一端口:
curl https://8080-ada-wrong.example.com/ --write-out " %{http_code}\n"Not found 404