Skip to content

Preview a web application

Last updated View as MarkdownAgent setup

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>.

Prerequisites

Forward requests to the server

  1. 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, because getTcpPort() cannot reach a server that listens only on 127.0.0.1. Many development servers listen on localhost by 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.server module, such as python3 -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.

  2. 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;
    	}
    }
  3. 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 });
    		}
    	}
    }
  4. 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.

  5. Deploy your Worker:

    npx wrangler deploy
  6. Request a page from the preview named ada. Replace the example hostname with the workers.dev URL 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 }

Keep WebSocket connections open

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:

  1. 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 });
    }
  2. 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.

Use hot reloading with a development server

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 Host header of each request. Vite rejects hostnames that it does not know, so add the preview hostname to server.allowedHosts ↗︎. Under /previews/<NAME>/, requests arrive with the Host header container.
  • 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:

vite.config.jsjs
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.

  1. 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;
    	}
    }
  2. In the fetch() handler of your Worker, add routes that create a link and serve shared previews, before the 404 response:

    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 POST request, 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.

  3. 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 a preview

Stop the server when the preview is no longer needed, instead of waiting for the inactivity timeout:

  1. 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;
    	}
    }
  2. 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 });
    }
  3. Deploy your Worker, and stop the preview named ada:

    curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/previews/ada --request DELETE

    The Worker responds with 204. The next request to the preview starts a new server, with an uptime of 0.

Was this helpful?