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.
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.
-
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 -
Add the
fetch()handler from Open a terminal in the browser to your class. Replace itscontainerconstant,start()call, andsetInactivityTimeout()call withensureRunning(), 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 fromenvVarsand from theENVlines of the image.exec()inherits onlyPATHfrom either, so add any other image variables that your shells need toENV. -
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" }));SandboxAddonand other clients written for the 0.12 messages stay in theconnectingstate until{"type":"ready"}arrives. Then they send the window size. The handler already readscolsandrowsfrom the 0.12 resize message, which also carries"type": "resize". With this message, a page loaded before the switch keeps working when it reconnects. -
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
argvtoexec()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
-cand-ewhen it creates the session. A request without?session=opens the sessionmain, 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.12shelloption did, refer to Run another command in the terminal. -
If your class forwards preview URLs from Move preview URLs, it already has a
fetch()handler. Rename that handler toforwardToPort(), rename the terminal handler toopenTerminal(), make bothprivate, and add afetch()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); } }
In 0.12, the Worker passes the WebSocket request to terminal():
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:
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:
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 }));1.0 has no @cloudflare/sandbox/xterm module. In 0.12, the page loads SandboxAddon into xterm.js:
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:
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.
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.
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.
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 sizeThe shell starts in /workspace with the variables from ENV, and tmux takes one row for its status line:
pwd=/workspace NODE_ENV=test
29 100Start 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.