Files
openchamber/packages/web/server/lib/terminal/output-replay-buffer.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

67 lines
1.8 KiB
JavaScript

export const TERMINAL_OUTPUT_REPLAY_MAX_BYTES = 64 * 1024;
const trimTerminalOutputChunkToMaxBytes = (data, maxBytes) => {
if (typeof data !== 'string' || data.length === 0) {
return '';
}
const bytes = Buffer.byteLength(data, 'utf8');
if (bytes <= maxBytes) {
return data;
}
const trimmedBuffer = Buffer.from(data, 'utf8').subarray(-maxBytes);
return trimmedBuffer.toString('utf8');
};
export const createTerminalOutputReplayBuffer = () => ({
chunks: [],
totalBytes: 0,
nextId: 1,
});
export const appendTerminalOutputReplayChunk = (bufferState, data, maxBytes = TERMINAL_OUTPUT_REPLAY_MAX_BYTES) => {
if (!bufferState || typeof bufferState !== 'object') {
return null;
}
const normalizedData = trimTerminalOutputChunkToMaxBytes(data, maxBytes);
if (!normalizedData) {
return null;
}
const bytes = Buffer.byteLength(normalizedData, 'utf8');
const chunk = {
id: bufferState.nextId,
data: normalizedData,
bytes,
};
bufferState.nextId += 1;
bufferState.chunks.push(chunk);
bufferState.totalBytes += bytes;
while (bufferState.totalBytes > maxBytes && bufferState.chunks.length > 1) {
const removedChunk = bufferState.chunks.shift();
bufferState.totalBytes -= removedChunk?.bytes ?? 0;
}
return chunk;
};
export const listTerminalOutputReplayChunksSince = (bufferState, lastSeenId = 0) => {
if (!bufferState || typeof bufferState !== 'object' || !Array.isArray(bufferState.chunks)) {
return [];
}
return bufferState.chunks.filter((chunk) => chunk.id > lastSeenId);
};
export const getLatestTerminalOutputReplayChunkId = (bufferState) => {
if (!bufferState || typeof bufferState !== 'object' || !Array.isArray(bufferState.chunks) || bufferState.chunks.length === 0) {
return 0;
}
return bufferState.chunks[bufferState.chunks.length - 1]?.id ?? 0;
};