107 lines
3.8 KiB
JavaScript
107 lines
3.8 KiB
JavaScript
// 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`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function wsUrl(location, arg) {
|
||
|
|
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
|
|
const query = arg ? `?arg=${encodeURIComponent(arg)}` : '';
|
||
|
|
return `${scheme}//${location.host}${basePath(location)}ws${query}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
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) {
|
||
|
|
// eslint-disable-next-line no-control-regex
|
||
|
|
const text = raw.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, ''); // 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;
|
||
|
|
}
|
||
|
|
}
|