使用浏览器打开 sandbox 中运行的 Web server。该 server 没有公网端口,因此由 Worker 将请求转发给它。本例中,Worker 会将 /previews/<NAME>/ 下的每个请求转发到名为 <NAME> 的 sandbox。
- 带有 Durable Object 的 Worker,该 Durable Object 使用 Durable Object 调度策略 启动 container。要创建一个,请参阅 运行 Linux 命令。
-
选择启动 server 的命令。本例中,该命令运行一个小型 Node.js server,返回请求路径和 server 运行时间。请将其替换为你自己的命令,例如
npm run dev:src/index.jsjs const port = 8080; const server = ` const { createServer } = require("node:http"); process.on("SIGTERM", () => process.exit(0)); createServer((request, response) => { response.setHeader("Content-Type", "application/json"); response.end( JSON.stringify({ path: request.url, uptime: Math.round(process.uptime()) }), ); }).listen(8080, "0.0.0.0"); `;src/index.tsts const port = 8080; const server = ` const { createServer } = require("node:http"); process.on("SIGTERM", () => process.exit(0)); createServer((request, response) => { response.setHeader("Content-Type", "application/json"); response.end( JSON.stringify({ path: request.url, uptime: Math.round(process.uptime()) }), ); }).listen(8080, "0.0.0.0"); `;server 必须监听
0.0.0.0,因为getTcpPort()无法到达只监听127.0.0.1的 server。许多开发 server 默认监听localhost,并提供更改地址的选项。该 server 作为主进程运行。当它退出时(例如崩溃后),实例会停止,其文件也会随之结束。如果 sandbox 中的命令会更改你需要的文件,请改为按 在后台运行 server 中的说明启动 server。部署后,基于 Python
http.server模块的 server(例如python3 -m http.server)会在启动时退出,因为它们会查找 container hostname。对于提供文件的 Python 命令,请参阅 部署后的差异。 -
为 Durable Object 添加不活动超时。当重启后的 Durable Object 发现 container 仍在运行时,构造函数会再次设置超时:
src/index.tsts const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; export class MyContainer extends DurableObject<Env> { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); const container = ctx.container; if (container?.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS), ); } } }五分钟不活动超时会在访问之间让 server 保持运行。更多信息请参阅 Sandbox 生命周期。
然后启动 server 并等到它响应:
src/index.tsts export class MyContainer extends DurableObject<Env> { // ... // Skip the check after the first response private ready = false; private async startServer(): Promise<Container> { const container = this.ctx.container; if (!container) { throw new Error("No container is configured"); } if (!container.running) { container.start({ image: "cloudflare/debian-trixie", entrypoint: ["node", "--eval", server], enableInternet: false, }); await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); this.ready = false; } // `start()` returns before the server listens. Send `GET /` until any // HTTP response arrives, for up to 30 seconds const deadline = Date.now() + 30_000; while (!this.ready && Date.now() < deadline) { try { const response = await container .getTcpPort(port) .fetch("http://container/", { signal: AbortSignal.timeout(1_000) }); await response.body?.cancel(); this.ready = true; } catch { await scheduler.wait(500); } } return container; } } -
从 Durable Object 的
fetch()handler 转发请求:src/index.tsts export class MyContainer extends DurableObject<Env> { // ... async fetch(request: Request): Promise<Response> { const container = await this.startServer(); if (!this.ready) { return new Response("The preview did not start", { status: 503 }); } // Forward each request once, because a retry could send a `POST` twice try { return await container.getTcpPort(port).fetch(request); } catch { // Check the server again on the next request this.ready = false; return new Response("The preview is not responding", { status: 502 }); } } } -
将预览路径从 Worker 路由到 Durable Object:
src/index.jsjs function forward(sandbox, request, path) { const url = new URL(request.url); if (!path) { url.pathname += "/"; return Response.redirect(url.toString(), 308); } // Prefix the host, so that a path such as //example.com/ stays a path. const target = new URL(`http://container${path}${url.search}`); return sandbox.fetch(new Request(target, request)); } export default { async fetch(request, env) { const url = new URL(request.url); const preview = /^\/previews\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\/.*)?$/.exec( url.pathname, ); if (!preview) { return new Response("Not found", { status: 404 }); } const sandbox = env.MY_CONTAINER.getByName(preview[1]); return forward(sandbox, request, preview[2]); }, };src/index.tsts function forward( sandbox: DurableObjectStub<MyContainer>, request: Request, path: string | undefined, ): Promise<Response> | Response { const url = new URL(request.url); if (!path) { url.pathname += "/"; return Response.redirect(url.toString(), 308); } // Prefix the host, so that a path such as //example.com/ stays a path. const target = new URL(`http://container${path}${url.search}`); return sandbox.fetch(new Request(target, request)); } export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url); const preview = /^\/previews\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\/.*)?$/.exec(url.pathname); if (!preview) { return new Response("Not found", { status: 404 }); } const sandbox = env.MY_CONTAINER.getByName(preview[1]); return forward(sandbox, request, preview[2]); }, } satisfies ExportedHandler<Env>;server 会收到没有
/previews/<NAME>前缀的每条路径。以/开头的链接会离开预览,因此请使用相对链接,或 在独立 hostname 上提供每个预览。本例中,路由从 URL 读取 sandbox 名称。该模式只接受有效 DNS 标签的名称:1 到 63 个小写字母、数字和连字符,两端不能是连字符。此路由接受的名称也可以是 预览 hostname。编码后的
/不会匹配,因此此路由无法到达另一条路由用/命名的 sandbox,例如<OWNER>/<SESSION>。在调用方到达预览之前请先认证。从不以你的应用为宿主的 hostname 提供预览。更多信息请参阅 Sandbox 安全。
-
部署你的 Worker:
npx wrangler deployyarn wrangler deploypnpm wrangler deploy -
从名为
ada的预览请求页面。将示例 hostname 替换为 Wrangler 打印的workers.devURL:curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/previews/ada/hello{ "path": "/hello", "uptime": 0 }等待几秒后再次发送相同请求。运行时间会增加,因为同一个 server 处理这两个请求:
{ "path": "/hello", "uptime": 5 }
上一节中的 fetch() handler 会将 WebSocket 升级请求传给 server,因此 WebSocket 连接可以工作。只经过 Durable Object 的连接不会让 sandbox 保持运行。不活动超时结束时,Cloudflare 会停止实例,连接会关闭。
要在页面打开连接时让 sandbox 保持运行,请在 Durable Object 中接受连接的两端:
-
添加在访问者和 server 之间中继消息的函数:
src/index.jsjs function bridge(response) { const upstream = response.webSocket; const [client, server] = Object.values(new WebSocketPair()); // Receive binary messages as `ArrayBuffer` instead of `Blob` upstream.binaryType = "arraybuffer"; server.binaryType = "arraybuffer"; upstream.accept(); server.accept(); // A listener that throws drops the connection, so these never throw. const relay = (from, to) => { from.addEventListener("message", (event) => { try { to.send(event.data); } catch { from.close(1011, "The other side closed"); } }); from.addEventListener("close", (event) => { // 1005 and 1006 report a missing or abnormal close and cannot be sent. const code = event.code === 1005 || event.code === 1006 ? 1000 : event.code; try { to.close(code, event.reason); } catch {} }); }; relay(server, upstream); relay(upstream, server); // Pass on the subprotocol that the server chose. A browser closes a // connection that requested one and receives none. const headers = new Headers(); const protocol = response.headers.get("Sec-WebSocket-Protocol"); if (protocol) { headers.set("Sec-WebSocket-Protocol", protocol); } return new Response(null, { status: 101, webSocket: client, headers }); }src/index.tsts function bridge(response: Response): Response { const upstream = response.webSocket!; const [client, server] = Object.values(new WebSocketPair()); // Receive binary messages as `ArrayBuffer` instead of `Blob` upstream.binaryType = "arraybuffer"; server.binaryType = "arraybuffer"; upstream.accept(); server.accept(); // A listener that throws drops the connection, so these never throw. const relay = (from: WebSocket, to: WebSocket) => { from.addEventListener("message", (event) => { try { to.send(event.data); } catch { from.close(1011, "The other side closed"); } }); from.addEventListener("close", (event) => { // 1005 and 1006 report a missing or abnormal close and cannot be sent. const code = event.code === 1005 || event.code === 1006 ? 1000 : event.code; try { to.close(code, event.reason); } catch {} }); }; relay(server, upstream); relay(upstream, server); // Pass on the subprotocol that the server chose. A browser closes a // connection that requested one and receives none. const headers = new Headers(); const protocol = response.headers.get("Sec-WebSocket-Protocol"); if (protocol) { headers.set("Sec-WebSocket-Protocol", protocol); } return new Response(null, { status: 101, webSocket: client, headers }); } -
在 Durable Object 的
fetch()handler 中,桥接打开 WebSocket 的响应:src/index.tsts try { const response = await container.getTcpPort(port).fetch(request); return response.webSocket ? bridge(response) : response; } catch {
有了桥接,只要任何页面有打开的连接,sandbox 就会保持运行,即使没有消息到达。最后一个连接关闭后,不活动超时会再次生效。
Vite ↗ 等开发 server 会通过 WebSocket 重载页面。使用上一节的桥接时,重载连接会到达 server。两个 server 设置决定热重载是否工作:
- server 会检查每个请求的
Hostheader。Vite 会拒绝它不认识的 hostname,因此请将预览 hostname 添加到server.allowedHosts↗。在/previews/<NAME>/下,请求带着Hostheadercontainer到达。 - server 会从其 base path 构建资源绝对 URL 和重载连接。在
/previews/<NAME>/下,这些 URL 会离开预览。请 在独立 hostname 上提供每个预览,让路径原样到达 server。
以下 Vite 配置接受 example-previews.com 下独立 hostname 上的预览请求:
import { defineConfig } from "vite";
export default defineConfig({
server: {
host: "0.0.0.0",
port: 8080,
allowedHosts: [".example-previews.com"],
},
});让某人在有限时间内访问一个预览。本例中,Durable Object 存储带过期时间的随机 token。Worker 仅在 token 有效时转发 /shared/<NAME>/<TOKEN>/ 下的请求。
-
向 Durable Object 添加创建和检查 token 的方法:
src/index.tsts export class MyContainer extends DurableObject<Env> { // ... async share(ttl: number): Promise<string> { const token = crypto.randomUUID(); await this.ctx.storage.put(`share:${token}`, Date.now() + ttl); return token; } async isShared(token: string): Promise<boolean> { const key = `share:${token}`; const expires = await this.ctx.storage.get<number>(key); if (expires !== undefined && expires <= Date.now()) { await this.ctx.storage.delete(key); return false; } return expires !== undefined; } } -
在 Worker 的
fetch()handler 中,在404响应之前添加创建链接和提供共享预览的路由:src/index.tsts const share = /^\/shares\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/.exec(url.pathname); if (share && request.method === "POST") { const sandbox = env.MY_CONTAINER.getByName(share[1]); const token = await sandbox.share(60 * 60 * 1000); return Response.json({ url: `${url.origin}/shared/${share[1]}/${token}/` }); } const shared = /^\/shared\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/([^/]+)(\/.*)?$/.exec(url.pathname); if (shared) { const sandbox = env.MY_CONTAINER.getByName(shared[1]); if (!(await sandbox.isShared(shared[2]))) { return new Response("Not found", { status: 404 }); } return forward(sandbox, request, shared[3]); }请认证
POST请求,以便只有预览所有者可以创建指向它的链接。预览中的代码运行在此 hostname 上,可以用访问者的 cookie 向此路由发送请求。为防止这种情况,请从另一个 hostname 提供该路由,或使用浏览器中的页面无法发送的凭据进行认证,例如应用后端添加的 secret。共享路由不需要认证,因为 token 就是权限。
-
部署 Worker,然后创建指向名为
ada的预览的链接:curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/shares/ada --request POST{ "url": "https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/shared/ada/<TOKEN>/" }在浏览器中打开该 URL。预览会加载一小时。之后,以及对于任何其他 token 或预览名称,Worker 会以
404响应。
任何拥有该链接的人都可以打开预览,直到它过期。每个 token 属于一个预览名称。要提前撤销链接,请从其 Durable Object 存储中删除其键。
当不再需要预览时停止 server,而不是等待不活动超时:
-
向 Durable Object 添加停止 container 的方法:
src/index.tsts export class MyContainer extends DurableObject<Env> { // ... async stop(): Promise<void> { if (this.ctx.container?.running) { await this.ctx.container.destroy(); } this.ready = false; } } -
在 Worker 中,对
DELETE /previews/<NAME>调用它,在转发之前:src/index.tsts if (!preview[2] && request.method === "DELETE") { await sandbox.stop(); return new Response(null, { status: 204 }); } -
部署 Worker,并停止名为
ada的预览:curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/previews/ada --request DELETEWorker 会以
204响应。对预览的下一个请求会启动新 server,运行时间为0。