Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

101 lines · 3.5 KBRaw History
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.
4import { FitAddon } from "@xterm/addon-fit";
5import { Terminal } from "@xterm/xterm";
6
7const el = document.getElementById("dfy-terminal");
8const statusEl = document.getElementById("dfy-terminal-status");
9if (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}