Buckets:
| 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 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; | |
| } | |
| private register<T extends IDisposable>(d: T): T { | |
| this.disposables.push(d); | |
| return d; | |
| } | |
| public sendFile(files: FileList) { | |
| this.zmodemAddon?.sendFile(files); | |
| } | |
| 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); | |
| } | |
| } | |
| 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; | |
| } | |
| 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(); | |
| } | |
| 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)); | |
| } | |
| 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); | |
| } | |
| } | |
| 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); | |
| } | |
| } | |
| 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))); | |
| } | |
| 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(); | |
| } | |
| 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'); | |
| } | |
| } | |
| 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; | |
| } | |
| 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)), | |
| ...this.parseOptsFromUrlQuery(window.location.search), | |
| } as Preferences); | |
| break; | |
| default: | |
| console.warn(`[ttyd] unknown command: ${cmd}`); | |
| break; | |
| } | |
| } | |
| 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; | |
| } | |
| } | |
| } | |
| 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; | |
| } | |
| } | |
| } | |
Xet Storage Details
- Size:
- 18.4 kB
- Xet hash:
- 9414378c74c6de42332af762b90afc1320b1ce6713c1830e81a2b17d9e511990
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.