In Sandbox SDK 0.12, exposePort() stores a token for each port in Durable Object storage. proxyToSandbox() routes hostnames such as 8080-ada-<TOKEN>.example.com to the server on that port. In 1.0, your Worker routes those hostnames, and your Durable Object checks the token and forwards the request with getTcpPort(). The code on this page reads the tokens where 0.12 stored them, so the preview URLs that your users already have keep working.
Your 1.0 class replaces the 0.12 Sandbox class and keeps its name and its Sandbox binding. For the class, refer to Replace the Sandbox class.
This page replaces exposePort(), unexposePort(), getExposedPorts(), isPortExposed(), validatePortToken(), proxyToSandbox(), and wsConnect(). Keep the wildcard DNS record, the certificate, and the Worker route that serve your preview hostnames. 1.0 uses them unchanged.
This page does not cover the server that a preview reaches. To start it, refer to Move background processes.
-
Add these declarations to the top level of
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(); } -
Add a method that reads the tokens, and
exposePort(), to your Durable Object class: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, }; } }The method stores tokens in the format that 0.12 used, and returns the same URL as 0.12. Unlike 0.12, it does not start the container. 0.12 read the sandbox name from its own storage, so pass the name from your Worker:
src/index.tsts const sandbox = env.Sandbox.getByName(name); const { url } = await sandbox.exposePort(name, 8080, { hostname: "example.com", }); -
Add the other port methods to the class:
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()andisPortExposed()reported a port only afterexposePort()ran in the current container. These methods report every stored port, because the Durable Object forwards requests to any stored port.0.12
destroy()deleted the tokens. Where your code callsdestroy()to end a sandbox, also callthis.ctx.storage.delete(PORT_TOKENS). -
Route preview hostnames in your Worker. In 0.12,
proxyToSandbox()runs before your other routes: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. }, };In 1.0, your Worker reads the port, the sandbox name, and the token from the hostname, and checks the token:
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>;The expression splits the first label as 0.12 did. The port ends at the first hyphen, and the token starts after the last hyphen. A wrong token, or a port without a token, gets
404, as in 0.12.The Durable Object trusts the
X-Sandbox-Portheader, andtoPort()replaces any value that the visitor sent. Send requests to thefetch()handler of your Durable Object only throughtoPort(), or remove the header first, as Move browser terminals does. -
Forward the request from the
fetch()handler of your Durable Object: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.webSocket); } return response; } catch { return new Response("Nothing is listening on this port", { status: 503, }); } } }Copy the
bridge()function from Keep WebSocket connections open. A WebSocket that only passes through the Durable Object does not keep the container running, so the bridge accepts both ends.The server receives the preview hostname in the
Hostheader. 0.12 sentlocalhost:<PORT>and put the preview hostname inX-Forwarded-Host. If your server checks either header, as Vite does withserver.allowedHosts↗︎, update its configuration. -
Replace each
wsConnect()call in your Worker. In 0.12, it connects a WebSocket to a port:src/index.ts (0.12)ts if (request.headers.get("Upgrade") === "websocket") { return getSandbox(env.Sandbox, name).wsConnect(request, 8080); }In 1.0, send the request through
toPort()to the samefetch()handler:src/index.ts (1.0)ts if (request.headers.get("Upgrade") === "websocket") { return env.Sandbox.getByName(name).fetch(toPort(request, 8080)); }The request does not start the container, so start the server before a page connects. Because this route has no token, keep the checks that your Worker runs before the call.
Your Durable Objects keep the portTokens key when you deploy the switch in place. After the switch, preview URLs behave as follows:
| Request | Result |
|---|---|
| A URL that 0.12 issued | Returns 503 until a server listens on its port in the new container, and then reaches that server. For a short time after the deploy, it can still reach the 0.12 server. |
| Any URL after a container starts again | Reaches the server without another exposePort() call. 0.12 returned 410 until you exposed the port again. |
| A wrong token, or a port without a token | Returns 404. |
A URL after unexposePort() |
Returns 404. |
If you move sandboxes side by side, copyFrom0x() copies the tokens into the 1.0 class. In the preview route of your Worker, get the stub with await sandboxFor(env, name).
Deploy to your staging Worker, and start a server in a sandbox that had a preview URL in 0.12. Request that URL:
curl https://8080-ada-<TOKEN>.example.com/The response comes from your server. Request the same port with a wrong token:
curl https://8080-ada-wrong.example.com/ --write-out " %{http_code}\n"Not found 404