feat: support resizing and other xterm.js addons

This commit is contained in:
Aarnav Tale
2025-05-31 16:41:07 -04:00
parent a0a80852eb
commit ccde3513dc
8 changed files with 282 additions and 30 deletions
+11
View File
@@ -70,6 +70,17 @@ func FollowMaster(agent *tsnet.TSAgent) {
sshutil.CloseWebSSH(agent, sshPayload)
continue
case "ssh_resize":
var sshPayload sshutil.SSHResizePayload
err = cbor.Unmarshal(msg.Payload, &sshPayload)
if err != nil {
log.Error("Unable to unmarshal SSH resize payload: %s", err)
continue
}
sshutil.ResizeWebSSH(agent, sshPayload)
continue
}
}
+36 -1
View File
@@ -22,6 +22,12 @@ type SSHClosePayload struct {
SessionId string `cbor:"sessionId"`
}
type SSHResizePayload struct {
SessionId string `cbor:"sessionId"`
Width int `cbor:"width"`
Height int `cbor:"height"`
}
func connectToTailscaleSSH(agent *tsnet.TSAgent, params SSHConnectPayload) (*ssh.Client, error) {
log := util.GetLogger()
addr := strings.Join([]string{params.Hostname, ":", strconv.Itoa(params.Port)}, "")
@@ -96,7 +102,7 @@ func StartWebSSH(agent *tsnet.TSAgent, params SSHConnectPayload) {
}
// Resize event is possible via the control channel later
err = sess.RequestPty("xterm-256color", 80, 40, modes)
err = sess.RequestPty("xterm-256color", 24, 80, modes)
if err != nil {
log.Error("Failed to request PTY for (%s): %s", params.SessionId, err)
return
@@ -164,3 +170,32 @@ func CloseWebSSH(agent *tsnet.TSAgent, params SSHClosePayload) {
RemoveSession(ctx.ID)
log.Info("SSH session for %s closed", params.SessionId)
}
func ResizeWebSSH(agent *tsnet.TSAgent, params SSHResizePayload) {
log := util.GetLogger()
if agent == nil {
log.Error("tsnet.TSAgent is not initialized correctly")
return
}
if params.SessionId == "" || params.Width <= 0 || params.Height <= 0 {
log.Error("Invalid SSH resize parameters: %v", params)
return
}
log.Debug("Resizing SSH session for session ID: %s to %dx%d", params.SessionId, params.Width, params.Height)
ctx, ok := lookupSession(params.SessionId)
if !ok {
log.Info("No active SSH session found for session ID: %s", params.SessionId)
return
}
err := ctx.Session.WindowChange(params.Height, params.Width)
if err != nil {
log.Error("Failed to resize SSH session for (%s): %s", params.SessionId, err)
return
}
log.Info("Resized SSH session for %s to %dx%d", params.SessionId, params.Width, params.Height)
}
+89 -5
View File
@@ -1,8 +1,18 @@
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 { WebglAddon } from '@xterm/addon-webgl';
import * as xterm from '@xterm/xterm';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import '@xterm/xterm/css/xterm.css';
import { decode } from 'cborg';
import type { SSHFrameData } from '~/server/agent/dispatcher';
import { decode, encode } from 'cborg';
import type {
SSHDataCommand,
SSHFrameData,
SSHResizeCommand,
} from '~/server/agent/dispatcher';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
interface XTermProps {
@@ -19,19 +29,40 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
const container = useRef<HTMLDivElement>(null);
const term = useRef<xterm.Terminal>(null);
const [isResizing, setIsResizing] = useState(false);
useEffect(() => {
pause();
const terminal = new xterm.Terminal({
allowProposedApi: true,
cursorBlink: true,
convertEol: true,
fontSize: 14,
cols: 80,
rows: 24,
theme: {
background: '#1e1e1e',
foreground: '#ffffff',
},
});
terminal.loadAddon(new Unicode11Addon());
terminal.loadAddon(new ClipboardAddon());
terminal.loadAddon(new WebLinksAddon());
terminal.unicode.activeVersion = '11';
const gl = new WebglAddon();
terminal.loadAddon(gl);
const fit = new FitAddon();
terminal.loadAddon(fit);
gl.onContextLoss(() => {
console.warn('WebGL context lost, falling back to canvas rendering');
gl.dispose();
});
terminal.open(container.current!);
terminal.focus();
term.current = terminal;
@@ -63,7 +94,15 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
terminal.onData((input) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(input);
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');
}
@@ -80,12 +119,57 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
};
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);
}
});
ro.observe(container.current!);
return () => {
ws.removeEventListener('message', onMessage);
term.current?.dispose();
ro.disconnect();
};
}, [ws, queue]);
return <div ref={container} style={{ height: '100%', width: '100%' }} />;
return (
<div className="relative w-full h-full group">
<div ref={container} className="w-full h-full" />
{term.current && isResizing ? (
<div
className={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
'px-4 py-2 bg-headplane-800 text-white rounded-full shadow z-50',
)}
>
{term.current.cols}x{term.current.rows}
</div>
) : undefined}
</div>
);
}
+20 -3
View File
@@ -8,7 +8,7 @@ export interface Command {
payload: unknown;
}
interface SSHConnectCommand extends Command {
export interface SSHConnectCommand extends Command {
op: 'ssh_conn';
payload: {
sessionId: string;
@@ -18,14 +18,31 @@ interface SSHConnectCommand extends Command {
};
}
interface SSHCloseCommand extends Command {
export interface SSHCloseCommand extends Command {
op: 'ssh_term';
payload: {
sessionId: string;
};
}
type AgentCommand = SSHConnectCommand | SSHCloseCommand;
export interface SSHResizeCommand extends Command {
op: 'ssh_resize';
payload: {
sessionId: string;
width: number;
height: number;
};
}
export interface SSHDataCommand extends Command {
op: 'ssh_data';
payload: {
sessionId: string;
data: Uint8Array;
};
}
type AgentCommand = SSHConnectCommand | SSHCloseCommand | SSHResizeCommand;
export async function dispatchCommand(
dispatcher: Writable,
+6 -14
View File
@@ -13,7 +13,7 @@ export type ChannelType = 0 | 1 | 2;
interface SSHFrame {
sessionId: string;
channel: ChannelType;
payload: Blob | ArrayBufferLike | string;
payload: Buffer;
}
export async function encodeSSHFrame(frame: SSHFrame) {
@@ -23,17 +23,9 @@ export async function encodeSSHFrame(frame: SSHFrame) {
return;
}
const payload = Buffer.isBuffer(frame.payload)
? frame.payload
: typeof frame.payload === 'string'
? Buffer.from(frame.payload, 'utf8')
: frame.payload instanceof Blob
? Buffer.from(await frame.payload.arrayBuffer())
: Buffer.from(frame.payload);
// Size can only hold 4 bytes
if (payload.length > 0xffffffff) {
log.error('agent', 'SSH payload too large: %d bytes', payload.length);
if (frame.payload.length > 0xffffffff) {
log.error('agent', 'SSH payload too large: %d bytes', frame.payload.length);
return;
}
@@ -42,7 +34,7 @@ export async function encodeSSHFrame(frame: SSHFrame) {
1 + // Version
1 + // Channel Type
(1 + sid.length) + // Session ID length + SID
(4 + payload.length); // Payload length + Payload
(4 + frame.payload.length); // Payload length + Payload
const buf = Buffer.alloc(frameSize);
buf.write(MAGIC, 0, 'utf8');
@@ -53,8 +45,8 @@ export async function encodeSSHFrame(frame: SSHFrame) {
const offset = 7 + sid.length;
sid.copy(buf, 7);
buf.writeUInt32BE(payload.length, offset);
payload.copy(buf, offset + 4);
buf.writeUInt32BE(frame.payload.length, offset);
frame.payload.copy(buf, offset + 4);
return buf;
}
+49 -7
View File
@@ -1,10 +1,17 @@
import { ChildProcess } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import type { Readable, Writable } from 'node:stream';
import { decode } from 'cborg';
import { Context } from 'hono';
import { WSContext, WSEvents } from 'hono/ws';
import log from '~/utils/log';
import { dispatchCommand, dispatchWeb } from './dispatcher';
import {
Command,
SSHDataCommand,
SSHResizeCommand,
dispatchCommand,
dispatchWeb,
} from './dispatcher';
import { decodeSSHFrame, encodeSSHFrame } from './encoder';
interface SSHConnection {
@@ -134,13 +141,48 @@ export class SSHMultiplexer {
return;
}
const encodedFrame = await encodeSSHFrame({
sessionId,
channel: 0, // stdin
payload: event.data,
});
const wsData = Buffer.isBuffer(event.data)
? event.data
: typeof event.data === 'string'
? Buffer.from(event.data, 'utf8')
: event.data instanceof Blob
? Buffer.from(await event.data.arrayBuffer())
: Buffer.from(event.data);
this.sshInput.write(encodedFrame);
const obj = decode(wsData) as Command;
if (obj.op === 'ssh_data') {
const data = obj as SSHDataCommand;
if (data.payload.sessionId !== sessionId) {
log.warn(
'agent',
'Received data for mismatched SSH session %s',
data.payload.sessionId,
);
return;
}
const encodedFrame = await encodeSSHFrame({
sessionId,
channel: 0, // stdin
payload: Buffer.from(data.payload.data),
});
this.sshInput.write(encodedFrame);
}
if (obj.op === 'ssh_resize') {
const resize = obj as SSHResizeCommand;
if (resize.payload.sessionId !== sessionId) {
log.warn(
'agent',
'Received resize for mismatched SSH session %s',
resize.payload.sessionId,
);
return;
}
await dispatchCommand(this.control, resize);
}
},
onClose: async (_, ws) => {
+5
View File
@@ -28,6 +28,11 @@
"@uiw/codemirror-theme-github": "^4.23.12",
"@uiw/codemirror-theme-xcode": "^4.23.12",
"@uiw/react-codemirror": "^4.23.12",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"arktype": "^2.1.20",
"cborg": "^4.2.11",
+66
View File
@@ -61,6 +61,21 @@ importers:
'@uiw/react-codemirror':
specifier: ^4.23.12
version: 4.23.12(@babel/runtime@7.27.3)(@codemirror/autocomplete@6.18.2(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.36.8)(@lezer/common@1.2.3))(@codemirror/language@6.11.0)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.36.8)(codemirror@6.0.1(@lezer/common@1.2.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@xterm/addon-clipboard':
specifier: ^0.1.0
version: 0.1.0(@xterm/xterm@5.5.0)
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
'@xterm/addon-unicode11':
specifier: ^0.8.0
version: 0.8.0(@xterm/xterm@5.5.0)
'@xterm/addon-web-links':
specifier: ^0.11.0
version: 0.11.0(@xterm/xterm@5.5.0)
'@xterm/addon-webgl':
specifier: ^0.18.0
version: 0.18.0(@xterm/xterm@5.5.0)
'@xterm/xterm':
specifier: ^5.5.0
version: 5.5.0
@@ -1594,6 +1609,31 @@ packages:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@xterm/addon-clipboard@0.1.0':
resolution: {integrity: sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==}
peerDependencies:
'@xterm/xterm': ^5.4.0
'@xterm/addon-fit@0.10.0':
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-unicode11@0.8.0':
resolution: {integrity: sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-web-links@0.11.0':
resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-webgl@0.18.0':
resolution: {integrity: sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/xterm@5.5.0':
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
@@ -1989,6 +2029,9 @@ packages:
jose@6.0.11:
resolution: {integrity: sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==}
js-base64@3.7.7:
resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -4719,6 +4762,27 @@ snapshots:
- '@codemirror/lint'
- '@codemirror/search'
'@xterm/addon-clipboard@0.1.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
js-base64: 3.7.7
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-unicode11@0.8.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-webgl@0.18.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/xterm@5.5.0': {}
acorn@8.14.1:
@@ -5093,6 +5157,8 @@ snapshots:
jose@6.0.11: {}
js-base64@3.7.7: {}
js-tokens@4.0.0: {}
js-yaml@4.1.0: