* 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>
67 lines
1.8 KiB
JavaScript
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;
|
|
};
|