Use a browser to open a web server that runs in a sandbox. The server has no public port, so your Worker forwards requests to it. In this example, the Worker forwards each request under /previews/<NAME>/ to the sandbox named <NAME>.
- A Worker with a Durable Object that starts a container with the Durable Object scheduling policy. To create one, refer to Run a Linux command.
-
Choose the command that starts your server. In this example, the command runs a small Node.js server that returns the request path and the server uptime. Replace it with your own command, such as
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"); `;The server must listen on
0.0.0.0, becausegetTcpPort()cannot reach a server that listens only on127.0.0.1. Many development servers listen onlocalhostby default and provide an option to change the address. The server runs as the main process. When it exits, for example after a crash, the instance stops, and its files end with it. If commands in the sandbox change files that you need, start the server as described in Run a server in the background instead.After you deploy, servers built on the Python
http.servermodule, such aspython3 -m http.server, exit on startup, because they look up the container hostname. For a Python command that serves files, refer to Differences after you deploy. -
Add an inactivity timeout to your Durable Object. The constructor sets the timeout again when a restarted Durable Object finds the container running:
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), ); } } }The five-minute inactivity timeout keeps the server running between visits. For more information, refer to Sandbox lifetime.
Then start the server and wait until it responds:
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("The container binding is not 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; } } -
Forward requests from the
fetch()handler of your Durable Object: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 }); } } } -
Route preview paths from your Worker to your 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>;The server receives each path without the
/previews/<NAME>prefix. Links that start with/leave the preview, so use relative links or serve each preview on its own hostname.In this example, the route reads the sandbox name from the URL. The pattern accepts only names that are valid DNS labels: 1 to 63 lowercase letters, digits, and hyphens, with no hyphen at either end. A name that this route accepts can also be a preview hostname. An encoded
/does not match, so this route cannot reach a sandbox that another route names with a/, such as<OWNER>/<SESSION>.Authenticate callers before they reach a preview. Serve previews from a hostname that does not host your application. For more information, refer to Sandbox security.
-
Deploy your Worker:
npx wrangler deployyarn wrangler deploypnpm wrangler deploy -
Request a page from the preview named
ada. Replace the example hostname with theworkers.devURL that Wrangler prints:curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/previews/ada/hello{ "path": "/hello", "uptime": 0 }Wait a few seconds and send the same request again. The uptime increases, because the same server handles both requests:
{ "path": "/hello", "uptime": 5 }
The fetch() handler in the previous section passes WebSocket upgrade requests to the server, so WebSocket connections work. A connection that only passes through the Durable Object does not keep the sandbox running. When the inactivity timeout ends, Cloudflare stops the instance and the connection closes.
To keep the sandbox running while a page has a connection open, accept both ends of the connection in the Durable Object:
-
Add a function that relays messages between the visitor and the server:
src/index.jsjs function bridge(upstream) { 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); return new Response(null, { status: 101, webSocket: client }); }src/index.tsts function bridge(upstream: WebSocket): Response { 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); return new Response(null, { status: 101, webSocket: client }); } -
In the
fetch()handler of your Durable Object, bridge responses that open a WebSocket:src/index.tsts try { const response = await container.getTcpPort(port).fetch(request); return response.webSocket ? bridge(response.webSocket) : response; } catch {
With the bridge, the sandbox keeps running while any page has a connection open, even when no messages arrive. After the last connection closes, the inactivity timeout applies again.
Development servers such as Vite ↗︎ reload the page over a WebSocket. With the bridge from the previous section, the reload connection reaches the server. Two server settings decide whether hot reloading works:
- The server checks the
Hostheader of each request. Vite rejects hostnames that it does not know, so add the preview hostname toserver.allowedHosts↗︎. Under/previews/<NAME>/, requests arrive with theHostheadercontainer. - The server builds absolute URLs for its assets and for the reload connection from its base path. Under
/previews/<NAME>/, those URLs leave the preview. Serve each preview on its own hostname, where paths reach the server unchanged.
The following Vite configuration accepts requests for previews on their own hostnames under example-previews.com:
import { defineConfig } from "vite";
export default defineConfig({
server: {
host: "0.0.0.0",
port: 8080,
allowedHosts: [".example-previews.com"],
},
});Give someone access to one preview for a limited time. In this example, the Durable Object stores random tokens with an expiry time. The Worker forwards requests under /shared/<NAME>/<TOKEN>/ only while the token is valid.
-
Add methods that create and check tokens to your Durable Object:
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; } } -
In the
fetch()handler of your Worker, add routes that create a link and serve shared previews, before the404response: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]); }Authenticate the
POSTrequest, so that only the owner of a preview can create links to it. Code in a preview runs on this hostname and can send requests to this route with the visitor's cookies. To prevent that, serve the route from another hostname, or authenticate it with a credential that pages in the browser cannot send, such as a secret that your application backend adds.The shared route needs no authentication, because the token is the permission.
-
Deploy your Worker, then create a link to the preview named
ada:curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/shares/ada --request POST{ "url": "https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/shared/ada/<TOKEN>/" }Open the URL in a browser. The preview loads for one hour. After that, and for any other token or preview name, the Worker responds with
404.
Anyone with the link can open the preview until it expires. Each token belongs to one preview name. To revoke a link early, delete its key from Durable Object storage.
Stop the server when the preview is no longer needed, instead of waiting for the inactivity timeout:
-
Add a method that stops the container to your Durable Object:
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; } } -
In your Worker, call it for
DELETE /previews/<NAME>, before forwarding:src/index.tsts if (!preview[2] && request.method === "DELETE") { await sandbox.stop(); return new Response(null, { status: 204 }); } -
Deploy your Worker, and stop the preview named
ada:curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/previews/ada --request DELETEThe Worker responds with
204. The next request to the preview starts a new server, with an uptime of0.