feat(terminal): add persistent websocket transport for low-latency input (#348)

* fix(server): resolve terminal WebSocket proxy conflict and implement server-side transport

- Disables proxy websocket handling conflict in server proxy config to fix 1006 abnormal closes.
- Implements server-side WebSocket upgrade and connection handling for terminal input.
- Adds server-side debug instrumentation for WS lifecycle events.
- Includes terminal input WS protocol definition and unit tests.

* feat(ui): implement hardened terminal WebSocket transport with idempotency and diagnostics

- Adds client-side WebSocket transport manager with automatic reconnection and jitter.
- Implements idempotency and debug instrumentation for terminal input WS.
- Adds HMR dispose cleanup for terminal WS transport manager.
- Primes terminal input transport when terminal view becomes active.
- Updates terminal session types to include input capabilities.

* refactor(terminal): remove temporary websocket debug instrumentation
This commit is contained in:
shekohex
2026-02-08 15:39:22 +02:00
committed by GitHub
parent 148b55f66b
commit a630b8860e
9 changed files with 1105 additions and 14 deletions
@@ -0,0 +1,66 @@
export const TERMINAL_INPUT_WS_PATH = '/api/terminal/input-ws';
export const TERMINAL_INPUT_WS_CONTROL_TAG_JSON = 0x01;
export const TERMINAL_INPUT_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 normalizeTerminalInputWsMessageToBuffer = (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 normalizeTerminalInputWsMessageToText = (rawData) => {
if (typeof rawData === 'string') {
return rawData;
}
return normalizeTerminalInputWsMessageToBuffer(rawData).toString('utf8');
};
export const readTerminalInputWsControlFrame = (rawData) => {
if (!rawData) {
return null;
}
const buffer = normalizeTerminalInputWsMessageToBuffer(rawData);
if (buffer.length < 2 || buffer[0] !== TERMINAL_INPUT_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 createTerminalInputWsControlFrame = (payload) => {
const jsonBytes = Buffer.from(JSON.stringify(payload), 'utf8');
return Buffer.concat([Buffer.from([TERMINAL_INPUT_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;
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'bun:test';
import {
TERMINAL_INPUT_WS_CONTROL_TAG_JSON,
TERMINAL_INPUT_WS_PATH,
createTerminalInputWsControlFrame,
isRebindRateLimited,
normalizeTerminalInputWsMessageToBuffer,
normalizeTerminalInputWsMessageToText,
parseRequestPathname,
pruneRebindTimestamps,
readTerminalInputWsControlFrame,
} from './terminal-input-ws-protocol.js';
describe('terminal input websocket protocol', () => {
it('uses fixed websocket path', () => {
expect(TERMINAL_INPUT_WS_PATH).toBe('/api/terminal/input-ws');
});
it('encodes control frames with control tag prefix', () => {
const frame = createTerminalInputWsControlFrame({ t: 'ok', v: 1 });
expect(frame[0]).toBe(TERMINAL_INPUT_WS_CONTROL_TAG_JSON);
});
it('roundtrips control frame payload', () => {
const payload = { t: 'b', s: 'abc123', v: 1 };
const frame = createTerminalInputWsControlFrame(payload);
expect(readTerminalInputWsControlFrame(frame)).toEqual(payload);
});
it('rejects control frame without protocol tag', () => {
const frame = Buffer.from(JSON.stringify({ t: 'b', s: 'abc123' }), 'utf8');
expect(readTerminalInputWsControlFrame(frame)).toBeNull();
});
it('rejects malformed control json', () => {
const frame = Buffer.concat([
Buffer.from([TERMINAL_INPUT_WS_CONTROL_TAG_JSON]),
Buffer.from('{not json', 'utf8'),
]);
expect(readTerminalInputWsControlFrame(frame)).toBeNull();
});
it('rejects empty control payloads', () => {
expect(readTerminalInputWsControlFrame(null)).toBeNull();
expect(readTerminalInputWsControlFrame(undefined)).toBeNull();
expect(readTerminalInputWsControlFrame(Buffer.alloc(0))).toBeNull();
});
it('rejects control json that is not object', () => {
const frame = Buffer.concat([
Buffer.from([TERMINAL_INPUT_WS_CONTROL_TAG_JSON]),
Buffer.from('"str"', 'utf8'),
]);
expect(readTerminalInputWsControlFrame(frame)).toBeNull();
});
it('parses control frame from chunk arrays', () => {
const frame = createTerminalInputWsControlFrame({ t: 'bok', v: 1 });
const chunks = [frame.subarray(0, 2), frame.subarray(2)];
expect(readTerminalInputWsControlFrame(chunks)).toEqual({ t: 'bok', v: 1 });
});
it('normalizes buffer passthrough', () => {
const raw = Buffer.from('abc', 'utf8');
const normalized = normalizeTerminalInputWsMessageToBuffer(raw);
expect(normalized).toBe(raw);
expect(normalized.toString('utf8')).toBe('abc');
});
it('normalizes uint8 arrays', () => {
const normalized = normalizeTerminalInputWsMessageToBuffer(new Uint8Array([97, 98, 99]));
expect(normalized.toString('utf8')).toBe('abc');
});
it('normalizes array buffer payloads', () => {
const source = new Uint8Array([97, 98, 99]).buffer;
const normalized = normalizeTerminalInputWsMessageToBuffer(source);
expect(normalized.toString('utf8')).toBe('abc');
});
it('normalizes chunk array payloads', () => {
const normalized = normalizeTerminalInputWsMessageToBuffer([
Buffer.from('ab', 'utf8'),
Buffer.from('c', 'utf8'),
]);
expect(normalized.toString('utf8')).toBe('abc');
});
it('normalizes text payload from string', () => {
expect(normalizeTerminalInputWsMessageToText('\u001b[A')).toBe('\u001b[A');
});
it('normalizes text payload from binary data', () => {
expect(normalizeTerminalInputWsMessageToText(Buffer.from('\r', 'utf8'))).toBe('\r');
});
it('parses relative request pathname', () => {
expect(parseRequestPathname('/api/terminal/input-ws?x=1')).toBe('/api/terminal/input-ws');
});
it('parses absolute request pathname', () => {
expect(parseRequestPathname('http://localhost:3000/api/terminal/input-ws')).toBe('/api/terminal/input-ws');
});
it('returns empty pathname for non-string request url', () => {
expect(parseRequestPathname(null)).toBe('');
});
it('returns empty pathname for invalid request url', () => {
expect(parseRequestPathname('http://')).toBe('');
expect(parseRequestPathname('')).toBe('');
});
it('prunes stale rebind timestamps', () => {
const now = 1_000;
const pruned = pruneRebindTimestamps([100, 200, 950, 999], now, 100);
expect(pruned).toEqual([950, 999]);
});
it('keeps rebind timestamps within active window', () => {
const now = 1_000;
const pruned = pruneRebindTimestamps([920, 950, 999], now, 100);
expect(pruned).toEqual([920, 950, 999]);
});
it('does not rate limit below threshold', () => {
expect(isRebindRateLimited([1, 2, 3], 4)).toBe(false);
});
it('does not rate limit empty window', () => {
expect(isRebindRateLimited([], 1)).toBe(false);
});
it('rate limits at threshold', () => {
expect(isRebindRateLimited([1, 2, 3, 4], 4)).toBe(true);
});
});