Skip to content

Move browser terminals

Last updated View as MarkdownAgent setup

In Sandbox SDK 0.12, terminal() attaches a WebSocket to a shell that the container keeps for each session, and SandboxAddon reconnects the browser to it. In 1.0, your Durable Object attaches each WebSocket to a tmux ↗︎ session with exec(argv, { pty }). tmux keeps the shell running between connections.

Before you start

Your 1.0 class replaces the 0.12 Sandbox class, and has the container getter, the ensureRunning() method, and the ENV constant from Replace the Sandbox class.

This page replaces terminal(), session.terminal(), proxyTerminal(), and SandboxAddon from @cloudflare/sandbox/xterm. It does not cover sessions for commands. For those, refer to Replace sessions.

Attach terminals to tmux

  1. Add tmux to your Dockerfile:

    Dockerfiledockerfile
    RUN apt-get update \
    	&& apt-get install -y --no-install-recommends \
    		ca-certificates git python3 tmux \
    	&& rm -rf /var/lib/apt/lists/*
    
    # Scroll back with the mouse wheel, and keep more history.
    RUN printf 'set -g mouse on\nset -g history-limit 50000\n' \
    	> /etc/tmux.conf
  2. Add the fetch() handler from Open a terminal in the browser to your class. Replace its container constant, start() call, and setInactivityTimeout() call with ensureRunning(), and start the shell with the directory and variables that 0.12 used:

    src/index.tsts
    await this.ensureRunning();
    
    const abort = new AbortController();
    const tmux = await this.container.exec(
    	["tmux", "new-session", "-A", "-s", session],
    	{
    		pty: {
    			cols: Number(url.searchParams.get("cols")) || 80,
    			rows: Number(url.searchParams.get("rows")) || 24,
    		},
    		env: { ...ENV, TERM: "xterm-256color" },
    		cwd: "/workspace",
    		signal: abort.signal,
    	},
    );

    0.12 started each shell in /workspace, with the variables from envVars and from the ENV lines of the image. exec() inherits only PATH from either, so add any other image variables that your shells need to ENV.

  3. Send the message that 0.12 clients wait for, right after accept():

    src/index.tsts
    server.binaryType = "arraybuffer";
    server.accept();
    
    // 0.12 clients wait for this message before they send their size.
    server.send(JSON.stringify({ type: "ready" }));

    SandboxAddon and other clients written for the 0.12 messages stay in the connecting state until {"type":"ready"} arrives. Then they send the window size. The handler already reads cols and rows from the 0.12 resize message, which also carries "type": "resize". With this message, a page loaded before the switch keeps working when it reconnects.

  4. If createSession() gave a session its own working directory and variables, pass them to tmux when a page opens that session:

    src/index.tsts
    // The working directory and variables of each 0.12 session
    // that browsers open by name.
    type SessionOptions = { cwd: string; env: Record<string, string> };
    
    const SESSIONS: Record<string, SessionOptions> = {
    	build: { cwd: "/workspace/app", env: { CI: "1" } },
    };

    In the handler, build the tmux command from the session name, and pass argv to exec() in place of the array:

    src/index.tsts
    const argv = ["tmux", "new-session", "-A", "-s", session];
    const options = SESSIONS[session];
    
    if (options) {
    	argv.push("-c", options.cwd);
    
    	for (const [name, value] of Object.entries(options.env)) {
    		argv.push("-e", `${name}=${value}`);
    	}
    }

    tmux applies -c and -e when it creates the session. A request without ?session= opens the session main, which replaces the 0.12 default session.

    The handler accepts session names of lowercase letters, digits, and hyphens, and tmux does not accept . or :. Rename 0.12 sessions that use other characters.

    To run another command instead of bash, as the 0.12 shell option did, refer to Run another command in the terminal.

  5. If your class forwards preview URLs from Move preview URLs, it already has a fetch() handler. Rename that handler to forwardToPort(), rename the terminal handler to openTerminal(), make both private, and add a fetch() handler that chooses one:

    src/index.tsts
    export class MySandbox extends DurableObject<Env> {
    	// ...
    
    	async fetch(request: Request): Promise<Response> {
    		// Only the preview route of the Worker sets this header.
    		if (request.headers.has("X-Sandbox-Port")) {
    			return this.forwardToPort(request);
    		}
    
    		return this.openTerminal(request);
    	}
    }

Forward the WebSocket

In 0.12, the Worker passes the WebSocket request to terminal():

src/index.ts (0.12)ts
const sandbox = getSandbox(env.MY_SANDBOX, id);
const sessionId = url.searchParams.get("session");

if (sessionId) {
	const session = await sandbox.getSession(sessionId);
	return session.terminal(request);
}

return sandbox.terminal(request, { cols: 80, rows: 24 });

In 1.0, the Worker forwards the request to the Durable Object, and the session query parameter goes with it:

src/index.ts (1.0)ts
return env.MY_SANDBOX.getByName(id.toLowerCase()).fetch(request);

Replace proxyTerminal(stub, sessionId, request) with the same fetch() call, and put the session in the session query parameter. The handler reads the size from the cols and rows query parameters, and uses 80 by 24 without them. A request without a WebSocket upgrade gets HTTP 426, where 0.12 threw an error.

If your class also forwards preview URLs, remove the X-Sandbox-Port header before you forward the request. Otherwise a visitor who sends the header reaches a port without a preview token:

src/index.ts (1.0)ts
const headers = new Headers(request.headers);
headers.delete("X-Sandbox-Port");

const sandbox = env.MY_SANDBOX.getByName(id.toLowerCase());
return sandbox.fetch(new Request(request, { headers }));

Replace SandboxAddon

1.0 has no @cloudflare/sandbox/xterm module. In 0.12, the page loads SandboxAddon into xterm.js:

terminal.ts (0.12)ts
import { SandboxAddon } from "@cloudflare/sandbox/xterm";

const addon = new SandboxAddon({
	getWebSocketUrl: ({ sandboxId, sessionId, origin }) => {
		const params = new URLSearchParams({ id: sandboxId });
		if (sessionId) params.set("session", sessionId);
		return `${origin}/ws/terminal?${params}`;
	},
	onStateChange: (state) => console.log(state),
});

terminal.loadAddon(addon);
addon.connect({ sandboxId: "my-sandbox", sessionId: "build" });

In 1.0, the page opens the WebSocket itself, and reconnects when it closes:

terminal.ts (1.0)ts
const encoder = new TextEncoder();
let socket: WebSocket;
let attempts = 0;

const send = (data: string | Uint8Array) => {
	if (socket.readyState === WebSocket.OPEN) socket.send(data);
};

const connect = () => {
	const url = new URL("/ws/terminal", location.href);
	url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
	url.searchParams.set("id", "my-sandbox");
	url.searchParams.set("session", "build");
	url.searchParams.set("cols", String(terminal.cols));
	url.searchParams.set("rows", String(terminal.rows));

	socket = new WebSocket(url);
	socket.binaryType = "arraybuffer";

	socket.addEventListener("open", () => {
		attempts = 0;
		// tmux redraws the whole screen when a client attaches.
		terminal.reset();
	});
	socket.addEventListener("message", (event) => {
		// Skip the ready message.
		if (typeof event.data === "string") return;
		terminal.write(new Uint8Array(event.data));
	});
	socket.addEventListener("close", (event) => {
		// Code 1000 means the shell exited or the person detached.
		if (event.code === 1000 || attempts >= 10) return;
		const delay = Math.min(1000 * 2 ** attempts++, 30_000);
		setTimeout(connect, delay);
	});
};

connect();
terminal.onData((data) => send(encoder.encode(data)));
terminal.onResize(({ cols, rows }) => {
	send(JSON.stringify({ cols, rows }));
});

The states of the addon map to socket events. connecting lasts until the open event, and connected starts with it. disconnected follows a close event that the page does not retry.

Like the addon, the page retries after 1 second, doubles the delay, and stops after 10 attempts. An HTTP error in place of the upgrade arrives as a close event with code 1006.

What changes at the terminal

A reconnected page shows the screen that tmux redraws, where 0.12 replayed up to 256 KiB of earlier output. Earlier output stays in the tmux history, so people scroll back with the mouse wheel and press q to leave the history.

When the shell exits, the socket closes with code 1000, and the reason carries the exit code, such as Terminal closed with code 0. The next connection starts a new shell. 0.12 kept the ended terminal, so later connections showed its old output and ran nothing until the container restarted.

Detaching with CTRL + B then D also closes the socket with code 1000, and the session keeps running.

Browsers that open the same session share one screen, as they did in 0.12.

Terminals open at the switch

The switch ends the 0.12 shells and their output. Open terminals stop receiving data without a close event, so SandboxAddon does not reconnect. Ask people to reload the page after the deploy, or add the read timeout from Plan the move. After a reload, the Durable Object creates a new tmux session.

Check the terminal

After you deploy the switch, open a terminal for a sandbox and make the window 100 columns by 30 rows. In the browser developer tools, the first message on the WebSocket is {"type":"ready"}. Run this command in the terminal:

echo pwd=$(pwd) NODE_ENV=$NODE_ENV; stty size

The shell starts in /workspace with the variables from ENV, and tmux takes one row for its status line:

pwd=/workspace NODE_ENV=test
29 100

Start a loop that prints a line every second, and reload the page. The count has kept going. Run exit. The WebSocket closes with code 1000 and the reason Terminal closed with code 0, and the page does not reconnect.

Was this helpful?