import { ClipboardAddon } from "@xterm/addon-clipboard"; import { FitAddon } from "@xterm/addon-fit"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebLinksAddon } from "@xterm/addon-web-links"; import * as xterm from "@xterm/xterm"; import { Loader2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import cn from "~/utils/cn"; import { useLiveData } from "~/utils/live-data"; import toast from "~/utils/toast"; import "@xterm/xterm/css/xterm.css"; interface XTermProps { ipn: TsWasmNet; username: string; hostname: string; } // Go's WASM -> JS crosses realms so we might have to normalize the data under // certain conditions. This also enforces bytes instead of strings being sent. function normU8(data: unknown) { if (data instanceof Uint8Array) { return data; } if (data && typeof data === "object") { const any = data as { buffer?: ArrayBufferLike; byteOffset?: number; byteLength?: number; }; if (any.buffer instanceof ArrayBuffer && typeof any.byteLength === "number") { return new Uint8Array( any.buffer.slice(any.byteOffset ?? 0, (any.byteOffset ?? 0) + any.byteLength), ); } } throw new Error("Data is not a Uint8Array or ArrayBuffer-like object"); } export default function XTerm({ ipn, username, hostname }: XTermProps) { const { pause } = useLiveData(); const genRef = useRef(0); const termRef = useRef(null); const roRef = useRef(null); const inputRef = useRef<(input: Uint8Array) => void>(null); const sshRef = useRef(null); const divRef = useRef(null); const [isResizing, setIsResizing] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { pause(); }); useEffect(() => { if (!divRef.current) { return; } const currentGen = ++genRef.current; const term = new xterm.Terminal({ allowProposedApi: true, cursorBlink: true, convertEol: true, fontSize: 14, }); const fit = new FitAddon(); term.loadAddon(fit); term.loadAddon(new Unicode11Addon()); term.loadAddon(new ClipboardAddon()); term.loadAddon( new WebLinksAddon((event, uri) => { event.view?.open(uri, "_blank", "noopener noreferrer"); }), ); term.unicode.activeVersion = "11"; termRef.current = term; term.open(divRef.current!); fit.fit(); term.focus(); const session = ipn.OpenSSH(hostname, username, { rows: term.rows, cols: term.cols, onStdout: (data) => { if (currentGen !== genRef.current || term !== termRef.current) { console.warn("Stale terminal instance, ignoring stdout"); return; } const text = normU8(data); term.write(text); }, onStderr: (data) => { if (currentGen !== genRef.current || term !== termRef.current) { console.warn("Stale terminal instance, ignoring stderr"); return; } const text = normU8(data); term.write(text); const str = new TextDecoder().decode(text); setError(str); }, onStdin: (func) => { inputRef.current = func; }, onConnect: () => { if (currentGen !== genRef.current) { console.warn("Stale terminal instance, ignoring onConnect"); return; } setIsLoading(false); }, onDisconnect: () => { if (currentGen !== genRef.current) { console.warn("Stale terminal instance, ignoring onDisconnect"); return; } roRef.current?.disconnect(); term.dispose(); termRef.current = null; inputRef.current = null; sshRef.current = null; setIsLoading(false); }, }); sshRef.current = session; const enc = new TextEncoder(); term.onData((data) => { if (currentGen !== genRef.current) { console.warn("Stale terminal instance, ignoring onData"); return; } const bytes = enc.encode(data); inputRef.current?.(bytes); }); const ro = new ResizeObserver(() => { if (currentGen !== genRef.current || term !== termRef.current) { console.warn("Stale terminal instance, ignoring resize"); return; } setIsResizing(true); fit.fit(); sshRef.current?.Resize(term.cols, term.rows); setTimeout(() => setIsResizing(false), 100); }); roRef.current = ro; ro.observe(divRef.current!); return () => { ++genRef.current; roRef.current?.disconnect(); roRef.current = null; sshRef.current?.Close(); sshRef.current = null; term.dispose(); if (termRef.current === term) { termRef.current = null; } inputRef.current = null; }; }, [ipn, username, hostname]); return ( <> {isLoading ? (
) : undefined}
{termRef.current && isResizing ? (
{termRef.current.cols}x{termRef.current.rows}
) : undefined} {error !== null ? (
Failed to connect to SSH session {error}
) : undefined} ); }