// ttyd 1.7.x WebSocket protocol (see ttyd src/server.h and src/protocol.c). // // Frames are binary. The first message is a bare JSON object; every later // message starts with a one-character command. export const Client = Object.freeze({ INPUT: '0', RESIZE: '1', PAUSE: '2', RESUME: '3' }); export const Server = Object.freeze({ OUTPUT: '0', SET_WINDOW_TITLE: '1', SET_PREFERENCES: '2' }); export const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; export const LIST_ARG = '--list'; const encoder = new TextEncoder(); export function encodeInit(token, columns, rows) { return encoder.encode(JSON.stringify({ AuthToken: token, columns, rows })); } export function encodeInput(text) { const body = encoder.encode(text); const frame = new Uint8Array(body.length + 1); frame[0] = Client.INPUT.charCodeAt(0); frame.set(body, 1); return frame; } // xterm.js onBinary delivers a "binary string" (one char per byte). export function encodeBinaryInput(binary) { const frame = new Uint8Array(binary.length + 1); frame[0] = Client.INPUT.charCodeAt(0); for (let i = 0; i < binary.length; i++) frame[i + 1] = binary.charCodeAt(i) & 0xff; return frame; } export function encodeResize(columns, rows) { return encoder.encode(Client.RESIZE + JSON.stringify({ columns, rows })); } export function encodeCommand(command) { return encoder.encode(command); } export function decodeServer(data) { const bytes = new Uint8Array(data); return { type: String.fromCharCode(bytes[0]), payload: bytes.subarray(1) }; } // URLs are relative to the page so DevDesk also works under a path prefix. function basePath(location) { return location.pathname.replace(/[^/]*$/, ''); } export function tokenUrl(location) { return `${location.protocol}//${location.host}${basePath(location)}token`; } // ttyd --url-arg appends each ?arg= to the command line: `devdesk attach `. export function wsUrl(location, args) { const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:'; const list = Array.isArray(args) ? args : args ? [args] : []; const query = list.length ? `?${list.map((a) => `arg=${encodeURIComponent(a)}`).join('&')}` : ''; return `${scheme}//${location.host}${basePath(location)}ws${query}`; } // Session names are [A-Za-z0-9_-]; turn free text ("PBS upgrade!") into one ("PBS-upgrade"). export function slugify(text) { return String(text || '').trim().replace(/[^A-Za-z0-9_-]+/g, '-') .replace(/^[-_]+/, '').replace(/-+$/, '').slice(0, 64); } // Untrusted text (e.g. Grafana alert annotations) typed into a terminal: drop // every control character so it can't run a command (newline in a shell), // submit early (CR) or inject escape sequences (devdesk#2). export function pasteSafe(text) { // eslint-disable-next-line no-control-regex return String(text ?? '').replace(/[\x00-\x1f\x7f]+/g, ' '); } export function stripAnsi(text) { // eslint-disable-next-line no-control-regex return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, ''); } export function sessionFromSearch(search) { const value = new URLSearchParams(search).get('arg'); return value && SESSION_RE.test(value) ? value : null; } // tmux set-titles-string is "devdesk:#S|#T"; the picker prints "devdesk:picker|…". export function parseTitle(title) { const match = /^devdesk:([^|]*)\|([\s\S]*)$/.exec(title || ''); if (!match) return null; return { session: match[1], pane: match[2] }; } // Output of `devdesk attach --list` arrives through a pty: CRLF line endings and // possibly split across frames. Pull the JSON object out of the joined text. export function parseListOutput(raw) { const text = stripAnsi(raw); // tools may colorize on a tty const start = text.indexOf('{'); const end = text.lastIndexOf('}'); if (start < 0 || end < start) throw new Error('no session list in output'); return JSON.parse(text.slice(start, end + 1)); } // Same back-pressure scheme as ttyd's own client: pause the pty when xterm.js // falls behind on rendering, resume once it catches up. export class FlowControl { constructor({ limit = 100000, highWater = 10, lowWater = 4 } = {}) { Object.assign(this, { limit, highWater, lowWater, written: 0, pending: 0 }); } // Returns { command, track }: command is PAUSE when the writer should stop; // track says whether to pass a callback to term.write (and call release()). onData(length) { this.written += length; if (this.written <= this.limit) return { command: null, track: false }; this.written = 0; this.pending += 1; return { command: this.pending > this.highWater ? Client.PAUSE : null, track: true }; } release() { this.pending = Math.max(this.pending - 1, 0); return this.pending < this.lowWater ? Client.RESUME : null; } }