Files
devdesk/web/src/protocol.js
T

119 lines
4.3 KiB
JavaScript
Raw Normal View History

// 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 <args…>`.
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);
}
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;
}
}