import { bind } from 'decko'; import { IDisposable, ITerminalOptions, Terminal } from 'xterm'; import { CanvasAddon } from 'xterm-addon-canvas'; import { WebglAddon } from 'xterm-addon-webgl'; import { FitAddon } from 'xterm-addon-fit'; import { WebLinksAddon } from 'xterm-addon-web-links'; import { ImageAddon } from 'xterm-addon-image'; import { OverlayAddon } from './addons/overlay'; import { ZmodemAddon } from './addons/zmodem'; import 'xterm/css/xterm.css'; interface TtydTerminal extends Terminal { fit(): void; } declare global { interface Window { term: TtydTerminal; } } const enum Command { // server side OUTPUT = '0', SET_WINDOW_TITLE = '1', SET_PREFERENCES = '2', // client side INPUT = '0', RESIZE_TERMINAL = '1', PAUSE = '2', RESUME = '3', } type Preferences = ITerminalOptions & ClientOptions; export type RendererType = 'dom' | 'canvas' | 'webgl'; export interface ClientOptions { rendererType: RendererType; disableLeaveAlert: boolean; disableResizeOverlay: boolean; enableZmodem: boolean; enableTrzsz: boolean; enableSixel: boolean; titleFixed?: string; isWindows: boolean; } export interface FlowControl { limit: number; highWater: number; lowWater: number; } export interface XtermOptions { wsUrl: string; tokenUrl: string; flowControl: FlowControl; clientOptions: ClientOptions; termOptions: ITerminalOptions; } function toDisposable(f: () => void): IDisposable { return { dispose: f }; } function addEventListener(target: EventTarget, type: string, listener: EventListener): IDisposable { target.addEventListener(type, listener); return toDisposable(() => target.removeEventListener(type, listener)); } export class Xterm { private disposables: IDisposable[] = []; private textEncoder = new TextEncoder(); private textDecoder = new TextDecoder(); private written = 0; private pending = 0; private terminal: Terminal; private fitAddon = new FitAddon(); private overlayAddon = new OverlayAddon(); private webglAddon?: WebglAddon; private canvasAddon?: CanvasAddon; private zmodemAddon?: ZmodemAddon; private socket?: WebSocket; private token: string; private opened = false; private title?: string; private titleFixed?: string; private resizeOverlay = true; private reconnect = true; private doReconnect = true; private writeFunc = (data: ArrayBuffer) => this.writeData(new Uint8Array(data)); constructor( private options: XtermOptions, private sendCb: () => void ) {} dispose() { for (const d of this.disposables) { d.dispose(); } this.disposables.length = 0; } @bind private register(d: T): T { this.disposables.push(d); return d; } @bind public sendFile(files: FileList) { this.zmodemAddon?.sendFile(files); } @bind public async refreshToken() { try { const resp = await fetch(this.options.tokenUrl); if (resp.ok) { const json = await resp.json(); this.token = json.token; } } catch (e) { console.error(`[ttyd] fetch ${this.options.tokenUrl}: `, e); } } @bind private onWindowUnload(event: BeforeUnloadEvent) { event.preventDefault(); if (this.socket?.readyState === WebSocket.OPEN) { const message = 'Close terminal? this will also terminate the command.'; event.returnValue = message; return message; } return undefined; } @bind public open(parent: HTMLElement) { this.terminal = new Terminal(this.options.termOptions); const { terminal, fitAddon, overlayAddon } = this; window.term = terminal as TtydTerminal; window.term.fit = () => { this.fitAddon.fit(); }; terminal.loadAddon(fitAddon); terminal.loadAddon(overlayAddon); terminal.loadAddon(new WebLinksAddon()); terminal.open(parent); fitAddon.fit(); } @bind private initListeners() { const { terminal, fitAddon, overlayAddon, register, sendData } = this; register( terminal.onTitleChange(data => { if (data && data !== '' && !this.titleFixed) { document.title = data + ' | ' + this.title; } }) ); register(terminal.onData(data => sendData(data))); register(terminal.onBinary(data => sendData(Uint8Array.from(data, v => v.charCodeAt(0))))); register( terminal.onResize(({ cols, rows }) => { const msg = JSON.stringify({ columns: cols, rows: rows }); this.socket?.send(this.textEncoder.encode(Command.RESIZE_TERMINAL + msg)); if (this.resizeOverlay) overlayAddon.showOverlay(`${cols}x${rows}`, 300); }) ); register( terminal.onSelectionChange(() => { if (this.terminal.getSelection() === '') return; try { document.execCommand('copy'); } catch (e) { return; } this.overlayAddon?.showOverlay('\u2702', 200); }) ); register(addEventListener(window, 'resize', () => fitAddon.fit())); register(addEventListener(window, 'beforeunload', this.onWindowUnload)); } @bind public writeData(data: string | Uint8Array) { const { terminal, textEncoder } = this; const { limit, highWater, lowWater } = this.options.flowControl; this.written += data.length; if (this.written > limit) { terminal.write(data, () => { this.pending = Math.max(this.pending - 1, 0); if (this.pending < lowWater) { this.socket?.send(textEncoder.encode(Command.RESUME)); } }); this.pending++; this.written = 0; if (this.pending > highWater) { this.socket?.send(textEncoder.encode(Command.PAUSE)); } } else { terminal.write(data); } } @bind public sendData(data: string | Uint8Array) { const { socket, textEncoder } = this; if (socket?.readyState !== WebSocket.OPEN) return; if (typeof data === 'string') { const payload = new Uint8Array(data.length * 3 + 1); payload[0] = Command.INPUT.charCodeAt(0); const stats = textEncoder.encodeInto(data, payload.subarray(1)); socket.send(payload.subarray(0, (stats.written as number) + 1)); } else { const payload = new Uint8Array(data.length + 1); payload[0] = Command.INPUT.charCodeAt(0); payload.set(data, 1); socket.send(payload); } } @bind public connect() { this.socket = new WebSocket(this.options.wsUrl, ['tty']); const { socket, register } = this; socket.binaryType = 'arraybuffer'; register(addEventListener(socket, 'open', this.onSocketOpen)); register(addEventListener(socket, 'message', this.onSocketData as EventListener)); register(addEventListener(socket, 'close', this.onSocketClose as EventListener)); register(addEventListener(socket, 'error', () => (this.doReconnect = false))); } @bind private onSocketOpen() { console.log('[ttyd] websocket connection opened'); const { textEncoder, terminal, overlayAddon } = this; const msg = JSON.stringify({ AuthToken: this.token, columns: terminal.cols, rows: terminal.rows }); this.socket?.send(textEncoder.encode(msg)); if (this.opened) { terminal.reset(); terminal.options.disableStdin = false; overlayAddon.showOverlay('Reconnected', 300); } else { this.opened = true; } this.doReconnect = this.reconnect; this.initListeners(); terminal.focus(); } @bind private onSocketClose(event: CloseEvent) { console.log(`[ttyd] websocket connection closed with code: ${event.code}`); const { refreshToken, connect, doReconnect, overlayAddon } = this; overlayAddon.showOverlay('Connection Closed'); this.dispose(); // 1000: CLOSE_NORMAL if (event.code !== 1000 && doReconnect) { overlayAddon.showOverlay('Reconnecting...'); refreshToken().then(connect); } else { const { terminal } = this; const keyDispose = terminal.onKey(e => { const event = e.domEvent; if (event.key === 'Enter') { keyDispose.dispose(); overlayAddon.showOverlay('Reconnecting...'); refreshToken().then(connect); } }); overlayAddon.showOverlay('Press ⏎ to Reconnect'); } } @bind private onSocketData(event: MessageEvent) { const { textDecoder } = this; const rawData = event.data as ArrayBuffer; const cmd = String.fromCharCode(new Uint8Array(rawData)[0]); const data = rawData.slice(1); switch (cmd) { case Command.OUTPUT: this.writeFunc(data); break; case Command.SET_WINDOW_TITLE: this.title = textDecoder.decode(data); document.title = this.title; break; case Command.SET_PREFERENCES: this.applyPreferences({ ...this.options.clientOptions, ...JSON.parse(textDecoder.decode(data)), } as Preferences); break; default: console.warn(`[ttyd] unknown command: ${cmd}`); break; } } @bind private applyPreferences(prefs: Preferences) { const { terminal, fitAddon, register } = this; if (prefs.enableZmodem || prefs.enableTrzsz) { this.zmodemAddon = new ZmodemAddon({ zmodem: prefs.enableZmodem, trzsz: prefs.enableTrzsz, windows: prefs.isWindows, onSend: this.sendCb, sender: this.sendData, writer: this.writeData, }); this.writeFunc = data => this.zmodemAddon?.consume(data); terminal.loadAddon(register(this.zmodemAddon)); } Object.keys(prefs).forEach(key => { const value = prefs[key]; switch (key) { case 'rendererType': this.setRendererType(value); break; case 'disableLeaveAlert': if (value) { window.removeEventListener('beforeunload', this.onWindowUnload); console.log('[ttyd] Leave site alert disabled'); } break; case 'disableResizeOverlay': if (value) { console.log('[ttyd] Resize overlay disabled'); this.resizeOverlay = false; } break; case 'disableReconnect': if (value) { console.log('[ttyd] Reconnect disabled'); this.reconnect = false; this.doReconnect = false; } break; case 'enableZmodem': if (value) console.log('[ttyd] Zmodem enabled'); break; case 'enableTrzsz': if (value) console.log('[ttyd] trzsz enabled'); break; case 'enableSixel': if (value) { terminal.loadAddon(register(new ImageAddon())); console.log('[ttyd] Sixel enabled'); } break; case 'titleFixed': if (!value || value === '') return; console.log(`[ttyd] setting fixed title: ${value}`); this.titleFixed = value; document.title = value; break; case 'isWindows': if (value) console.log('[ttyd] is windows'); break; default: console.log(`[ttyd] option: ${key}=${JSON.stringify(value)}`); if (terminal.options[key] instanceof Object) { terminal.options[key] = Object.assign({}, terminal.options[key], value); } else { terminal.options[key] = value; } if (key.indexOf('font') === 0) fitAddon.fit(); break; } }); } @bind private setRendererType(value: RendererType) { const { terminal } = this; const disposeCanvasRenderer = () => { try { this.canvasAddon?.dispose(); } catch { // ignore } this.canvasAddon = undefined; }; const disposeWebglRenderer = () => { try { this.webglAddon?.dispose(); } catch { // ignore } this.webglAddon = undefined; }; const enableCanvasRenderer = () => { if (this.canvasAddon) return; this.canvasAddon = new CanvasAddon(); disposeWebglRenderer(); try { this.terminal.loadAddon(this.canvasAddon); console.log('[ttyd] canvas renderer loaded'); } catch (e) { console.log('[ttyd] canvas renderer could not be loaded, falling back to dom renderer', e); disposeCanvasRenderer(); } }; const enableWebglRenderer = () => { if (this.webglAddon) return; this.webglAddon = new WebglAddon(); disposeCanvasRenderer(); try { this.webglAddon.onContextLoss(() => { this.webglAddon?.dispose(); }); terminal.loadAddon(this.webglAddon); console.log('[ttyd] WebGL renderer loaded'); } catch (e) { console.log('[ttyd] WebGL renderer could not be loaded, falling back to canvas renderer', e); disposeWebglRenderer(); enableCanvasRenderer(); } }; switch (value) { case 'canvas': enableCanvasRenderer(); break; case 'webgl': enableWebglRenderer(); break; case 'dom': disposeWebglRenderer(); disposeCanvasRenderer(); console.log('[ttyd] dom renderer loaded'); break; default: break; } } }