mirror of
https://github.com/tale/headplane.git
synced 2026-08-30 08:49:30 +00:00
feat: cleanup and streamline webssh capability
This commit is contained in:
@@ -4,36 +4,30 @@ import { Unicode11Addon } from '@xterm/addon-unicode11';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import * as xterm from '@xterm/xterm';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { decode, encode } from 'cborg';
|
||||
import type {
|
||||
SSHDataCommand,
|
||||
SSHFrameData,
|
||||
SSHResizeCommand,
|
||||
} from '~/server/agent/dispatcher';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import cn from '~/utils/cn';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
|
||||
interface XTermProps {
|
||||
ws: WebSocket;
|
||||
sessionId: string;
|
||||
queue: Array<Uint8Array>;
|
||||
ipn: TsWasmNet;
|
||||
username: string;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
const RED = new TextEncoder().encode('\x1b[31m');
|
||||
const RESET = new TextEncoder().encode('\x1b[0m');
|
||||
|
||||
export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
||||
export default function XTerm({ ipn, username, hostname }: XTermProps) {
|
||||
const { pause } = useLiveData();
|
||||
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const term = useRef<xterm.Terminal>(null);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const inputRef = useRef<((input: string) => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
pause();
|
||||
|
||||
const terminal = new xterm.Terminal({
|
||||
allowProposedApi: true,
|
||||
cursorBlink: true,
|
||||
@@ -49,9 +43,13 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
||||
|
||||
terminal.loadAddon(new Unicode11Addon());
|
||||
terminal.loadAddon(new ClipboardAddon());
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
terminal.unicode.activeVersion = '11';
|
||||
terminal.loadAddon(
|
||||
new WebLinksAddon((event, uri) => {
|
||||
event.view?.open(uri, '_blank', 'noopener noreferrer');
|
||||
}),
|
||||
);
|
||||
|
||||
terminal.unicode.activeVersion = '11';
|
||||
const gl = new WebglAddon();
|
||||
terminal.loadAddon(gl);
|
||||
|
||||
@@ -67,97 +65,61 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
||||
terminal.focus();
|
||||
term.current = terminal;
|
||||
|
||||
const handleFrame = (data: Uint8Array) => {
|
||||
try {
|
||||
const frame: SSHFrameData = decode(data);
|
||||
if (frame.op !== 'ssh_frame') {
|
||||
console.warn('Received unexpected frame type:', frame.op);
|
||||
return;
|
||||
let ro: ResizeObserver | null = null;
|
||||
let onUnload: ((e: Event) => void) | null = null;
|
||||
|
||||
const session = ipn.OpenSSH(hostname, username, {
|
||||
Rows: terminal.rows,
|
||||
Cols: terminal.cols,
|
||||
|
||||
OnStdout: (data) => terminal.write(data),
|
||||
OnStderr: (data) => {
|
||||
terminal.write(data);
|
||||
console.log('SSH stderr:', data);
|
||||
},
|
||||
OnStdin: (func) => {
|
||||
inputRef.current = func;
|
||||
},
|
||||
OnConnect: () => {
|
||||
console.log('SSH session connected');
|
||||
},
|
||||
|
||||
OnDisconnect: () => {
|
||||
ro?.disconnect();
|
||||
terminal.dispose();
|
||||
if (onUnload) {
|
||||
parent.removeEventListener('unload', onUnload);
|
||||
}
|
||||
|
||||
// If this is stderr, color it red
|
||||
if (frame.payload.channel === 2) {
|
||||
terminal.write(
|
||||
new Uint8Array([...RED, ...frame.payload.frame, ...RESET]),
|
||||
);
|
||||
} else {
|
||||
terminal.write(frame.payload.frame);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decode CBOR frame:', err);
|
||||
console.log('SSH session disconnected');
|
||||
term.current = null;
|
||||
},
|
||||
});
|
||||
|
||||
const parent = container.current?.ownerDocument.defaultView ?? window;
|
||||
ro = new parent.ResizeObserver(() => {
|
||||
if (term.current) {
|
||||
setIsResizing(true);
|
||||
fit.fit();
|
||||
setTimeout(() => setIsResizing(false), 100);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
for (const buffer of queue) {
|
||||
handleFrame(buffer);
|
||||
if (container.current) {
|
||||
ro.observe(container.current);
|
||||
}
|
||||
|
||||
terminal.onData((input) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
encode({
|
||||
op: 'ssh_data',
|
||||
payload: {
|
||||
sessionId,
|
||||
data: new TextEncoder().encode(input),
|
||||
},
|
||||
} satisfies SSHDataCommand),
|
||||
);
|
||||
} else {
|
||||
console.warn('WebSocket is not open, cannot send data');
|
||||
}
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
session.Resize(rows, cols);
|
||||
});
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (!(event.data instanceof ArrayBuffer)) {
|
||||
console.warn('Received non-binary message from WebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
const size = event.data.byteLength;
|
||||
console.log(`[WS] Received ${size} bytes at ${Date.now()}`);
|
||||
|
||||
const data = new Uint8Array(event.data);
|
||||
handleFrame(data);
|
||||
};
|
||||
|
||||
ws.addEventListener('message', onMessage);
|
||||
const ro = new ResizeObserver(() => {
|
||||
const before = {
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
};
|
||||
|
||||
fit.fit();
|
||||
if (before.cols !== terminal.cols || before.rows !== terminal.rows) {
|
||||
console.log(
|
||||
`Resized terminal to ${terminal.cols} cols and ${terminal.rows} rows`,
|
||||
);
|
||||
ws.send(
|
||||
encode({
|
||||
op: 'ssh_resize',
|
||||
payload: {
|
||||
sessionId,
|
||||
width: terminal.cols,
|
||||
height: terminal.rows,
|
||||
},
|
||||
} satisfies SSHResizeCommand),
|
||||
);
|
||||
|
||||
setIsResizing(true);
|
||||
setTimeout(() => {
|
||||
setIsResizing(false);
|
||||
}, 1000);
|
||||
}
|
||||
terminal.onData((data) => {
|
||||
inputRef.current?.(data);
|
||||
});
|
||||
|
||||
ro.observe(container.current!);
|
||||
return () => {
|
||||
ws.removeEventListener('message', onMessage);
|
||||
term.current?.dispose();
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [ws, queue]);
|
||||
onUnload = (_) => session.Close();
|
||||
parent.addEventListener('unload', onUnload);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full group">
|
||||
|
||||
Reference in New Issue
Block a user