| 1 | // The web terminal (phase 4): xterm.js on a WebSocket to the shell in the person's sandbox. |
| 2 | // Loaded only on the terminal page, which renders <div id="dfy-terminal" data-socket="/o/r/terminals/<id>/socket">. |
| 3 | // Terminal bytes travel as binary frames both ways; resizes and pings are JSON text frames. |
| 4 | import { FitAddon } from "@xterm/addon-fit"; |
| 5 | import { Terminal } from "@xterm/xterm"; |
| 6 | |
| 7 | const el = document.getElementById("dfy-terminal"); |
| 8 | const statusEl = document.getElementById("dfy-terminal-status"); |
| 9 | if (el?.dataset.socket) { |
| 10 | const css = getComputedStyle(document.body); |
| 11 | const term = new Terminal({ |
| 12 | cursorBlink: true, |
| 13 | fontFamily: css.getPropertyValue("--mono").trim() || "ui-monospace, Menlo, monospace", |
| 14 | fontSize: 13, |
| 15 | scrollback: 5000, |
| 16 | allowProposedApi: false, |
| 17 | theme: { background: "#111113", foreground: "#ededed", cursor: "#ededed", selectionBackground: "#3b4b6b" }, |
| 18 | }); |
| 19 | const fit = new FitAddon(); |
| 20 | term.loadAddon(fit); |
| 21 | term.open(el); |
| 22 | fit.fit(); |
| 23 | |
| 24 | const encoder = new TextEncoder(); |
| 25 | let ws: WebSocket | null = null; |
| 26 | let tries = 0; |
| 27 | let stopped = false; |
| 28 | const say = (text: string, tone: "" | "ok" | "warn" = "") => { |
| 29 | if (!statusEl) return; |
| 30 | statusEl.textContent = text; |
| 31 | statusEl.dataset.tone = tone; |
| 32 | }; |
| 33 | const send = (data: string) => { |
| 34 | if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data)); |
| 35 | }; |
| 36 | const resize = () => { |
| 37 | fit.fit(); |
| 38 | if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); |
| 39 | }; |
| 40 | |
| 41 | const connect = () => { |
| 42 | const url = `${location.protocol === "https:" ? "wss://" : "ws://"}${location.host}${el.dataset.socket}?cols=${term.cols}&rows=${term.rows}`; |
| 43 | const socket = new WebSocket(url); |
| 44 | socket.binaryType = "arraybuffer"; |
| 45 | ws = socket; |
| 46 | say("Connecting…"); |
| 47 | socket.onopen = () => { |
| 48 | tries = 0; |
| 49 | say("Connected", "ok"); |
| 50 | term.reset(); |
| 51 | term.focus(); |
| 52 | }; |
| 53 | socket.onmessage = (e) => { |
| 54 | if (e.data instanceof ArrayBuffer) term.write(new Uint8Array(e.data)); |
| 55 | }; |
| 56 | socket.onclose = (e) => { |
| 57 | if (ws !== socket) return; |
| 58 | ws = null; |
| 59 | if (e.code === 4000) { |
| 60 | stopped = true; |
| 61 | say("This terminal is open in another tab. Reload to use it here.", "warn"); |
| 62 | return; |
| 63 | } |
| 64 | if (e.reason === "terminal ended" || e.code === 4404) { |
| 65 | stopped = true; |
| 66 | say("The terminal ended.", "warn"); |
| 67 | setTimeout(() => location.reload(), 1500); |
| 68 | return; |
| 69 | } |
| 70 | if (e.reason === "the shell exited") { |
| 71 | say("The shell exited. Starting a new one…"); |
| 72 | setTimeout(connect, 500); |
| 73 | return; |
| 74 | } |
| 75 | if (stopped || tries >= 8) { |
| 76 | say("Disconnected. Reload the page to try again.", "warn"); |
| 77 | return; |
| 78 | } |
| 79 | const wait = Math.min(15_000, 500 * 2 ** tries++); |
| 80 | say(`Disconnected. Reconnecting in ${Math.round(wait / 1000)} s…`, "warn"); |
| 81 | setTimeout(connect, wait); |
| 82 | }; |
| 83 | }; |
| 84 | |
| 85 | term.onData(send); |
| 86 | term.onBinary((data) => { |
| 87 | if (ws?.readyState === WebSocket.OPEN) ws.send(Uint8Array.from(data, (c) => c.charCodeAt(0))); |
| 88 | }); |
| 89 | new ResizeObserver(() => resize()).observe(el); |
| 90 | setInterval(() => { |
| 91 | if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" })); |
| 92 | }, 25_000); |
| 93 | // Toolbar buttons type a command into the shell, as if the person typed it. |
| 94 | for (const b of document.querySelectorAll<HTMLButtonElement>("[data-terminal-type]")) { |
| 95 | b.addEventListener("click", () => { |
| 96 | send(`${b.dataset.terminalType}\r`); |
| 97 | term.focus(); |
| 98 | }); |
| 99 | } |
| 100 | connect(); |
| 101 | } |