Files
openchamber/packages/web/server/lib/terminal/terminal-ws-protocol.js
T
YifanandBohdan Triapitsyn 2b70ec6f3a feat(terminal): switch terminal transport to pure websocket with fallback (#762)
* feat(terminal): add resumable websocket transport

Unify terminal input and stream traffic on `/api/terminal/ws` with a v2 control-frame protocol and advertised transport capabilities.

Buffer recent PTY output on the server so rebinding clients can replay missed chunks after reconnects or startup races, while keeping SSE as a fallback stream path.

Update the web terminal client and store to negotiate the new transport, track tab lifecycle, and avoid reopening exited sessions when restoring tabs.

* fix(terminal): retry rehydrated websocket reconnects

* fix(terminal): keep reconnect retries silent

* fix(terminal): wire ws stream output and replay in runtime

* fix(web): enable ws proxy for /api in dev

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-04-01 19:31:21 +03:00

69 lines
1.8 KiB
JavaScript

export const TERMINAL_WS_PATH = '/api/terminal/ws';
export const TERMINAL_WS_CONTROL_TAG_JSON = 0x01;
export const TERMINAL_WS_MAX_PAYLOAD_BYTES = 64 * 1024;
export const parseRequestPathname = (requestUrl) => {
if (typeof requestUrl !== 'string' || requestUrl.length === 0) {
return '';
}
try {
return new URL(requestUrl, 'http://localhost').pathname;
} catch {
return '';
}
};
export const isTerminalWsPathname = (pathname) => pathname === TERMINAL_WS_PATH;
export const normalizeTerminalWsMessageToBuffer = (rawData) => {
if (Buffer.isBuffer(rawData)) {
return rawData;
}
if (Array.isArray(rawData)) {
return Buffer.concat(rawData.map((chunk) => (Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))));
}
return Buffer.from(rawData);
};
export const normalizeTerminalWsMessageToText = (rawData) => {
if (typeof rawData === 'string') {
return rawData;
}
return normalizeTerminalWsMessageToBuffer(rawData).toString('utf8');
};
export const readTerminalWsControlFrame = (rawData) => {
if (!rawData) {
return null;
}
const buffer = normalizeTerminalWsMessageToBuffer(rawData);
if (buffer.length < 2 || buffer[0] !== TERMINAL_WS_CONTROL_TAG_JSON) {
return null;
}
try {
const parsed = JSON.parse(buffer.subarray(1).toString('utf8'));
if (!parsed || typeof parsed !== 'object') {
return null;
}
return parsed;
} catch {
return null;
}
};
export const createTerminalWsControlFrame = (payload) => {
const jsonBytes = Buffer.from(JSON.stringify(payload), 'utf8');
return Buffer.concat([Buffer.from([TERMINAL_WS_CONTROL_TAG_JSON]), jsonBytes]);
};
export const pruneRebindTimestamps = (timestamps, now, windowMs) =>
timestamps.filter((timestamp) => now - timestamp < windowMs);
export const isRebindRateLimited = (timestamps, maxPerWindow) => timestamps.length >= maxPerWindow;