import { bind } from 'decko'; import type { IDisposable, ITerminalOptions } from '@xterm/xterm'; import { Terminal } from '@xterm/xterm'; import { CanvasAddon } from '@xterm/addon-canvas'; import { ClipboardAddon } from '@xterm/addon-clipboard'; 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 { Unicode11Addon } from '@xterm/addon-unicode11'; import { OverlayAddon } from './addons/overlay'; import { ZmodemAddon } from './addons/zmodem'; import '@xterm/xterm/css/xterm.css'; interface TtydTerminal extends Terminal { fit(): void; } declare global { interface Window { term: TtydTerminal; } } 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; trzszDragInitTimeout: number; unicodeVersion: string; closeOnDisconnect: 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 clipboardAddon = new ClipboardAddon(); private webLinksAddon = new WebLinksAddon(); 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 closeOnDisconnect = false; private intervalID: NodeJS.Timeout; private writeFunc = (data: ArrayBuffer) => this.writeData(new Uint8Array(data)); private status = false; private titleStatus = false; private checkStatus = false; private connectStatus = false; private beforeCommand?: string; 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, clipboardAddon, webLinksAddon } = this; window.term = terminal as TtydTerminal; window.term.fit = () => { this.fitAddon.fit(); }; terminal.loadAddon(fitAddon); terminal.loadAddon(overlayAddon); terminal.loadAddon(clipboardAddon); terminal.loadAddon(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 => { if (this.status) { sendData(data); } else { this.writeData('\b \b'); } }) ); 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 changeUrl(ip: string, port: string) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; this.options.wsUrl = [protocol, '//' + ip + ':' + port +'/ws', window.location.search].join(''); this.options.tokenUrl = [window.location.protocol, '//' + ip + ':' + port +'/token'].join(''); } @bind public changeStatus(v: boolean){ this.status = v; } @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))); const options = new URLSearchParams(decodeURIComponent(window.location.search)); if (options.get('type') === 'docker') { this.intervalID = setInterval(this.loadCommand, 3000); } } @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 if (this.closeOnDisconnect) { window.close(); } 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 loadCommand() { const options = new URLSearchParams(decodeURIComponent(window.location.search)); const params = new URLSearchParams({ repo: options.get('repoid') as string, user: options.get('userid') as string, }); fetch( 'http://' + options.get('domain') + ':'+ options.get('port') +'/' + options.get('user') + '/' + options.get('repo') + '/devcontainer/command?' + params ) .then(response => response.json()) .then(data => { if (data.status !== '4' && data.status !== '0') { this.sendData(data.command); } else { clearInterval(this.intervalID); if (data.status === '4') { fetch( 'http://' + options.get('domain') + ':'+ options.get('port') +'/' + options.get('user') + '/' + options.get('repo') + '/devcontainer/command?' + params ) .then(response => response.json()) .then(data => { this.sendData(data.command); }) .catch(error => { console.error('Error:', error); }); } } }) .catch(error => { console.error('Error:', error); }); } @bind private parseOptsFromUrlQuery(query: string): Preferences { const { terminal } = this; const { clientOptions } = this.options; const prefs = {} as Preferences; const queryObj = Array.from(new URLSearchParams(query) as unknown as Iterable<[string, string]>); for (const [k, queryVal] of queryObj) { let v = clientOptions[k]; if (v === undefined) v = terminal.options[k]; switch (typeof v) { case 'boolean': prefs[k] = queryVal === 'true' || queryVal === '1'; break; case 'number': case 'bigint': prefs[k] = Number.parseInt(queryVal, 10); break; case 'string': prefs[k] = queryVal; break; case 'object': prefs[k] = JSON.parse(queryVal); break; default: console.warn(`[ttyd] maybe unknown option: ${k}=${queryVal}, treating as string`); prefs[k] = queryVal; break; } } return prefs; } @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: console.log(this.status + ': ' + textDecoder.decode(data) + this.connectStatus + ' ' + this.checkStatus + ' ' + this.titleStatus ); const options = new URLSearchParams(decodeURIComponent(window.location.search)); if (options.get('type') === 'docker') { if ( this.status === false && textDecoder.decode(data).replace(/\s/g, '').includes('Successfully connected to the container'.replace(/\s/g, '')) ) { if(this.connectStatus == true){ this.status = true; } this.connectStatus = true; } if (this.checkStatus) { if (textDecoder.decode(data).replace(/\s/g, '').includes('You have out the container. Please refresh the terminal to reconnect.'.replace(/\s/g, ''))) { this.checkStatus = false; this.status = false; this.connectStatus = false; } if(textDecoder.decode(data).includes('\x1b')){ this.checkStatus = false; } } if (this.titleStatus && textDecoder.decode(data).includes('\x1b')) { this.titleStatus = false; this.checkStatus = true; this.sendData( `echo $WEB_TERMINAL\n` ); } if ( !(this.status === false && (textDecoder.decode(data).includes('\x1b') || textDecoder.decode(data).replace(/\s/g, '').includes('docker'))) && this.titleStatus !== true && this.checkStatus !== true ){ this.writeFunc(data); } if (textDecoder.decode(data).replace(/\s/g, '').includes('exit')) { this.titleStatus = true; } } else { this.writeFunc(data); } console.log(this.status + ': ' + textDecoder.decode(data) + this.connectStatus + ' ' + this.checkStatus + ' ' + this.titleStatus ); break; case Command.SET_WINDOW_TITLE: console.log('SET_WINDOW_TITLESET_WINDOW_TITLE'); this.title = textDecoder.decode(data); document.title = this.title; break; case Command.SET_PREFERENCES: console.log('SET_PREFERENCESSET_PREFERENCESSET_PREFERENCES'); this.applyPreferences({ ...this.options.clientOptions, ...JSON.parse(textDecoder.decode(data)), ...this.parseOptsFromUrlQuery(window.location.search), } 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, trzszDragInitTimeout: prefs.trzszDragInitTimeout, onSend: this.sendCb, sender: this.sendData, writer: this.writeData, }); this.writeFunc = data => this.zmodemAddon?.consume(data); terminal.loadAddon(register(this.zmodemAddon)); } for (const [key, value] of Object.entries(prefs)) { 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 'trzszDragInitTimeout': if (value) console.log(`[ttyd] trzsz drag init timeout: ${value}`); break; case 'enableSixel': if (value) { terminal.loadAddon(register(new ImageAddon())); console.log('[ttyd] Sixel enabled'); } break; case 'closeOnDisconnect': if (value) { console.log('[ttyd] close on disconnect enabled (Reconnect disabled)'); this.closeOnDisconnect = true; this.reconnect = false; this.doReconnect = false; } 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; case 'unicodeVersion': switch (value) { case 6: case '6': console.log('[ttyd] setting Unicode version: 6'); break; case 11: case '11': default: console.log('[ttyd] setting Unicode version: 11'); terminal.loadAddon(new Unicode11Addon()); terminal.unicode.activeVersion = '11'; break; } 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; } } }