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>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
d68bec491c
commit
2b70ec6f3a
@@ -54,7 +54,7 @@ const STREAM_OPTIONS = {
|
||||
|
||||
const REHYDRATED_STREAM_OPTIONS = {
|
||||
retry: {
|
||||
maxRetries: 0,
|
||||
...STREAM_OPTIONS.retry,
|
||||
initialDelayMs: 200,
|
||||
maxDelayMs: 500,
|
||||
},
|
||||
@@ -106,6 +106,7 @@ export const TerminalView: React.FC = () => {
|
||||
const setActiveTab = terminalStore.setActiveTab;
|
||||
const closeTab = terminalStore.closeTab;
|
||||
const setTabSessionId = terminalStore.setTabSessionId;
|
||||
const setTabLifecycle = terminalStore.setTabLifecycle;
|
||||
const setConnecting = terminalStore.setConnecting;
|
||||
const appendToBuffer = terminalStore.appendToBuffer;
|
||||
|
||||
@@ -132,11 +133,13 @@ export const TerminalView: React.FC = () => {
|
||||
}, [directoryTerminalState, activeTabId]);
|
||||
|
||||
const terminalSessionId = activeTab?.terminalSessionId ?? null;
|
||||
const terminalLifecycle = activeTab?.lifecycle ?? 'idle';
|
||||
const bufferChunks = activeTab?.bufferChunks ?? [];
|
||||
const isConnecting = activeTab?.isConnecting ?? false;
|
||||
|
||||
const [connectionError, setConnectionError] = React.useState<string | null>(null);
|
||||
const [isFatalError, setIsFatalError] = React.useState(false);
|
||||
const [isReconnectPending, setIsReconnectPending] = React.useState(false);
|
||||
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
|
||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||
const [viewportLayoutVersion, setViewportLayoutVersion] = React.useState(0);
|
||||
@@ -234,6 +237,7 @@ export const TerminalView: React.FC = () => {
|
||||
streamCleanupRef.current?.();
|
||||
streamCleanupRef.current = null;
|
||||
activeTerminalIdRef.current = null;
|
||||
setIsReconnectPending(false);
|
||||
}, []);
|
||||
|
||||
React.useEffect(
|
||||
@@ -273,6 +277,7 @@ export const TerminalView: React.FC = () => {
|
||||
setConnecting(directory, tabId, false);
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
focusTerminalWhenWindowActive();
|
||||
|
||||
// After a reload, buffer is empty and a reused PTY can look "stuck"
|
||||
@@ -286,10 +291,10 @@ export const TerminalView: React.FC = () => {
|
||||
break;
|
||||
}
|
||||
case 'reconnecting': {
|
||||
const attempt = event.attempt ?? 0;
|
||||
const maxAttempts = event.maxAttempts ?? 3;
|
||||
setConnectionError(`Reconnecting (${attempt}/${maxAttempts})...`);
|
||||
void event;
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(true);
|
||||
break;
|
||||
}
|
||||
case 'data': {
|
||||
@@ -313,10 +318,12 @@ export const TerminalView: React.FC = () => {
|
||||
exitCode !== null ? ` with code ${exitCode}` : ''
|
||||
}${signal !== null ? ` (signal ${signal})` : ''}]\r\n`
|
||||
);
|
||||
setTabLifecycle(directory, tabId, 'exited');
|
||||
setTabSessionId(directory, tabId, null);
|
||||
setConnecting(directory, tabId, false);
|
||||
setConnectionError(isActionTab ? null : 'Terminal session ended');
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
disconnectStream();
|
||||
break;
|
||||
}
|
||||
@@ -327,18 +334,19 @@ export const TerminalView: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMsg = fatal
|
||||
? `Connection failed: ${error.message}`
|
||||
: error.message || 'Terminal stream connection error';
|
||||
|
||||
setConnectionError(errorMsg);
|
||||
setIsFatalError(!!fatal);
|
||||
|
||||
if (fatal) {
|
||||
setConnecting(directory, tabId, false);
|
||||
setTabSessionId(directory, tabId, null);
|
||||
disconnectStream();
|
||||
if (!fatal) {
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsReconnectPending(false);
|
||||
setConnectionError(`Connection failed: ${error.message}`);
|
||||
setIsFatalError(true);
|
||||
setConnecting(directory, tabId, false);
|
||||
setTabLifecycle(directory, tabId, 'exited');
|
||||
setTabSessionId(directory, tabId, null);
|
||||
disconnectStream();
|
||||
},
|
||||
},
|
||||
streamOptions
|
||||
@@ -349,7 +357,7 @@ export const TerminalView: React.FC = () => {
|
||||
activeTerminalIdRef.current = null;
|
||||
};
|
||||
},
|
||||
[appendToBuffer, disconnectStream, focusTerminalWhenWindowActive, setConnecting, setTabSessionId, terminal]
|
||||
[appendToBuffer, disconnectStream, focusTerminalWhenWindowActive, setConnecting, setTabLifecycle, setTabSessionId, terminal]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -389,6 +397,7 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
const tab = state.tabs.find((t) => t.id === tabId) ?? state.tabs[0];
|
||||
let terminalId = tab?.terminalSessionId ?? null;
|
||||
const terminalLifecycle = tab?.lifecycle ?? 'idle';
|
||||
const isActionTab = Boolean(tab?.label?.startsWith('Action:'));
|
||||
const hasBufferedOutput = (tab?.bufferLength ?? 0) > 0 || (tab?.bufferChunks?.length ?? 0) > 0;
|
||||
|
||||
@@ -402,6 +411,11 @@ export const TerminalView: React.FC = () => {
|
||||
Boolean(terminalId) && rehydratedTerminalIdsRef.current.has(terminalId as string);
|
||||
|
||||
if (!terminalId) {
|
||||
if (terminalLifecycle === 'exited') {
|
||||
setConnecting(directory, tabId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActionTab && hasBufferedOutput) {
|
||||
setConnecting(directory, tabId, false);
|
||||
return;
|
||||
@@ -409,6 +423,7 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
setConnecting(directory, tabId, true);
|
||||
try {
|
||||
const size = lastViewportSizeRef.current;
|
||||
@@ -440,6 +455,7 @@ export const TerminalView: React.FC = () => {
|
||||
: 'Failed to start terminal session'
|
||||
);
|
||||
setIsFatalError(true);
|
||||
setIsReconnectPending(false);
|
||||
setConnecting(directory, tabId, false);
|
||||
}
|
||||
return;
|
||||
@@ -476,12 +492,14 @@ export const TerminalView: React.FC = () => {
|
||||
hasActiveContext,
|
||||
effectiveDirectory,
|
||||
terminalSessionId,
|
||||
terminalLifecycle,
|
||||
activeTabId,
|
||||
hasOpenedTerminalViewport,
|
||||
enableTabs,
|
||||
terminalHydrated,
|
||||
ensureDirectory,
|
||||
setConnecting,
|
||||
setTabLifecycle,
|
||||
setTabSessionId,
|
||||
startStream,
|
||||
disconnectStream,
|
||||
@@ -520,6 +538,7 @@ export const TerminalView: React.FC = () => {
|
||||
setIsRestarting(true);
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
|
||||
disconnectStream();
|
||||
|
||||
@@ -528,6 +547,7 @@ export const TerminalView: React.FC = () => {
|
||||
} catch (error) {
|
||||
setConnectionError(error instanceof Error ? error.message : 'Failed to restart terminal');
|
||||
setIsFatalError(true);
|
||||
setIsReconnectPending(false);
|
||||
} finally {
|
||||
setIsRestarting(false);
|
||||
}
|
||||
@@ -544,6 +564,7 @@ export const TerminalView: React.FC = () => {
|
||||
setActiveTab(effectiveDirectory, tabId);
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
disconnectStream();
|
||||
}, [createTab, disconnectStream, effectiveDirectory, setActiveTab]);
|
||||
|
||||
@@ -553,6 +574,7 @@ export const TerminalView: React.FC = () => {
|
||||
setActiveTab(effectiveDirectory, tabId);
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
disconnectStream();
|
||||
},
|
||||
[disconnectStream, effectiveDirectory, setActiveTab]
|
||||
@@ -568,6 +590,7 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
void closeTab(effectiveDirectory, tabId);
|
||||
},
|
||||
[activeTabId, closeTab, disconnectStream, effectiveDirectory]
|
||||
@@ -576,7 +599,7 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
const handleViewportInput = React.useCallback(
|
||||
(data: string) => {
|
||||
if (!data) {
|
||||
if (!data || isReconnectPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -602,7 +625,9 @@ export const TerminalView: React.FC = () => {
|
||||
if (!terminalId) return;
|
||||
|
||||
void terminal.sendInput(terminalId, payload).catch((error) => {
|
||||
setConnectionError(error instanceof Error ? error.message : 'Failed to send input');
|
||||
if (!isReconnectPending) {
|
||||
setConnectionError(error instanceof Error ? error.message : 'Failed to send input');
|
||||
}
|
||||
});
|
||||
|
||||
if (modifierConsumed) {
|
||||
@@ -610,7 +635,7 @@ export const TerminalView: React.FC = () => {
|
||||
terminalControllerRef.current?.focus();
|
||||
}
|
||||
},
|
||||
[activeModifier, setActiveModifier, terminal]
|
||||
[activeModifier, isReconnectPending, setActiveModifier, terminal]
|
||||
);
|
||||
|
||||
const handleViewportResize = React.useCallback(
|
||||
@@ -855,7 +880,7 @@ export const TerminalView: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting;
|
||||
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting || isReconnectPending;
|
||||
const shouldRenderViewport = isMobile ? isTerminalVisible : hasOpenedTerminalViewport;
|
||||
const quickKeysControls = (
|
||||
<>
|
||||
@@ -1075,7 +1100,7 @@ export const TerminalView: React.FC = () => {
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{connectionError && (
|
||||
{!isReconnectPending && connectionError && (
|
||||
<div className="absolute inset-x-0 bottom-0 bg-[var(--status-error-background)] px-3 py-2 text-xs text-[var(--status-error-foreground)] flex items-center justify-between gap-2">
|
||||
<span>{connectionError}</span>
|
||||
{isFatalError && isMobile && (
|
||||
|
||||
@@ -29,20 +29,23 @@ export interface RetryPolicy {
|
||||
maxDelayMs: number;
|
||||
}
|
||||
|
||||
export interface TerminalTransportCapability {
|
||||
preferred?: 'ws' | 'http' | 'sse';
|
||||
transports?: Array<'ws' | 'http' | 'sse'>;
|
||||
ws?: {
|
||||
path: string;
|
||||
v?: number;
|
||||
enc?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
sessionId: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
capabilities?: {
|
||||
input?: {
|
||||
preferred?: 'ws' | 'http';
|
||||
transports?: Array<'ws' | 'http'>;
|
||||
ws?: {
|
||||
path: string;
|
||||
v?: number;
|
||||
enc?: string;
|
||||
};
|
||||
};
|
||||
input?: TerminalTransportCapability;
|
||||
stream?: TerminalTransportCapability;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+501
-232
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,12 @@ export interface TerminalChunk {
|
||||
data: string;
|
||||
}
|
||||
|
||||
export type TerminalTabLifecycle = 'idle' | 'running' | 'exited';
|
||||
|
||||
export type TerminalTab = {
|
||||
id: string;
|
||||
terminalSessionId: string | null;
|
||||
lifecycle: TerminalTabLifecycle;
|
||||
label: string;
|
||||
bufferChunks: TerminalChunk[];
|
||||
bufferLength: number;
|
||||
@@ -40,6 +43,7 @@ interface TerminalStore {
|
||||
closeTab: (directory: string, tabId: string) => Promise<void>;
|
||||
|
||||
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => void;
|
||||
setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle) => void;
|
||||
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => void;
|
||||
appendToBuffer: (directory: string, tabId: string, chunk: string) => void;
|
||||
clearBuffer: (directory: string, tabId: string) => void;
|
||||
@@ -52,7 +56,7 @@ const TERMINAL_BUFFER_LIMIT = 1_000_000;
|
||||
const TERMINAL_STORE_NAME = 'terminal-store';
|
||||
let hydrationListenerAttached = false;
|
||||
|
||||
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'terminalSessionId' | 'createdAt'>;
|
||||
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'terminalSessionId' | 'lifecycle' | 'createdAt'>;
|
||||
|
||||
type PersistedDirectoryTerminalState = {
|
||||
tabs: PersistedTerminalTab[];
|
||||
@@ -85,6 +89,7 @@ function normalizeDirectory(dir: string): string {
|
||||
const createEmptyTab = (id: string, label: string): TerminalTab => ({
|
||||
id,
|
||||
terminalSessionId: null,
|
||||
lifecycle: 'idle',
|
||||
label,
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
@@ -298,9 +303,14 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
const tab = existing.tabs[idx];
|
||||
const shouldResetBuffer = sessionId !== null && tab.terminalSessionId !== sessionId;
|
||||
|
||||
const nextLifecycle = sessionId
|
||||
? 'running'
|
||||
: (tab.terminalSessionId ? 'exited' : tab.lifecycle);
|
||||
|
||||
const nextTab: TerminalTab = {
|
||||
...tab,
|
||||
terminalSessionId: sessionId,
|
||||
lifecycle: nextLifecycle,
|
||||
isConnecting: false,
|
||||
...(shouldResetBuffer ? { bufferChunks: [], bufferLength: 0 } : {}),
|
||||
};
|
||||
@@ -312,6 +322,27 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = { ...nextTabs[idx], lifecycle, isConnecting: false };
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
@@ -428,6 +459,7 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
terminalSessionId: tab.terminalSessionId,
|
||||
lifecycle: tab.lifecycle,
|
||||
createdAt: tab.createdAt,
|
||||
})),
|
||||
},
|
||||
@@ -474,13 +506,21 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
maxTabNum = Math.max(maxTabNum, num);
|
||||
}
|
||||
|
||||
const terminalSessionId =
|
||||
typeof rawTab.terminalSessionId === 'string' || rawTab.terminalSessionId === null
|
||||
? (rawTab.terminalSessionId as string | null)
|
||||
: null;
|
||||
const lifecycleRaw = rawTab.lifecycle;
|
||||
const lifecycle =
|
||||
lifecycleRaw === 'idle' || lifecycleRaw === 'running' || lifecycleRaw === 'exited'
|
||||
? lifecycleRaw
|
||||
: (terminalSessionId ? 'running' : 'idle');
|
||||
|
||||
tabs.push({
|
||||
id,
|
||||
label: typeof rawTab.label === 'string' ? rawTab.label : 'Terminal',
|
||||
terminalSessionId:
|
||||
typeof rawTab.terminalSessionId === 'string' || rawTab.terminalSessionId === null
|
||||
? (rawTab.terminalSessionId as string | null)
|
||||
: null,
|
||||
terminalSessionId,
|
||||
lifecycle,
|
||||
createdAt: typeof rawTab.createdAt === 'number' ? rawTab.createdAt : Date.now(),
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# Terminal Input WS Protocol
|
||||
|
||||
## Goal
|
||||
Reduce terminal input latency by replacing per-keystroke HTTP requests with a persistent WebSocket input channel, while keeping SSE output and HTTP endpoints as compatibility fallback.
|
||||
|
||||
## Scope
|
||||
- Input path: WebSocket (`/api/terminal/input-ws`)
|
||||
- Output path: SSE (`/api/terminal/:sessionId/stream`)
|
||||
- HTTP input fallback remains: `POST /api/terminal/:sessionId/input`
|
||||
|
||||
## Framing
|
||||
- Text frame: terminal keystroke payload (hot path)
|
||||
- Examples: `"\r"`, `"\u001b[A"`, `"\u0003"`
|
||||
- Binary frame: control envelope
|
||||
- Byte 0: tag (`0x01` = JSON control)
|
||||
- Bytes 1..N: UTF-8 JSON payload
|
||||
|
||||
## Control Messages
|
||||
- Bind active socket to terminal session:
|
||||
- client -> server: `{"t":"b","s":"<sessionId>","v":1}`
|
||||
- Keepalive ping:
|
||||
- client -> server: `{"t":"p","v":1}`
|
||||
- server -> client: `{"t":"po","v":1}`
|
||||
- Server control responses:
|
||||
- ready: `{"t":"ok","v":1}`
|
||||
- bind ok: `{"t":"bok","v":1}`
|
||||
- error: `{"t":"e","c":"<code>","f":true|false}`
|
||||
|
||||
## Multiplexing Model
|
||||
- Single shared socket per client runtime.
|
||||
- Socket has one mutable `boundSessionId`.
|
||||
- Client sends bind control when active terminal changes.
|
||||
- Keystroke frames apply to currently bound session.
|
||||
- Client keeps socket open and sends periodic keepalive pings so the channel stays ready for next input.
|
||||
- Client primes/opens this socket when the Terminal tab is opened (not per keystroke).
|
||||
|
||||
## Security
|
||||
- UI auth session required when UI password is enabled.
|
||||
- Origin validation enforced for cookie-authenticated browser upgrades.
|
||||
- Invalid/malformed frames are rate-limited and may close socket.
|
||||
|
||||
## Fallback Behavior
|
||||
- On WS unavailable/error/close, client falls back to HTTP input immediately.
|
||||
- Existing terminal behavior remains functional during mixed-version rollout.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Terminal WebSocket Transport Protocol
|
||||
|
||||
## Goal
|
||||
Use a single persistent WebSocket for terminal input and output, while keeping the legacy SSE output route and HTTP input route as compatibility fallbacks.
|
||||
|
||||
## Scope
|
||||
- Primary full-duplex path: WebSocket (`/api/terminal/ws`)
|
||||
- Legacy output fallback: SSE (`/api/terminal/:sessionId/stream`)
|
||||
- HTTP input fallback remains: `POST /api/terminal/:sessionId/input`
|
||||
|
||||
## Framing
|
||||
- Text frame:
|
||||
- client -> server: terminal keystroke payload
|
||||
- server -> client: raw PTY output chunk
|
||||
- Binary frame: control envelope
|
||||
- Byte 0: tag (`0x01` = JSON control)
|
||||
- Bytes 1..N: UTF-8 JSON payload
|
||||
|
||||
## Control Messages
|
||||
- Bind active socket to terminal session:
|
||||
- client -> server: `{"t":"b","s":"<sessionId>","v":2}`
|
||||
- Keepalive ping:
|
||||
- client -> server: `{"t":"p","v":2}`
|
||||
- server -> client: `{"t":"po","v":2}`
|
||||
- Server control responses:
|
||||
- ready: `{"t":"ok","v":2}`
|
||||
- bind ok: `{"t":"bok","s":"<sessionId>","runtime":"node|bun","ptyBackend":"...","v":2}`
|
||||
- exit: `{"t":"x","s":"<sessionId>","exitCode":0,"signal":null}`
|
||||
- error: `{"t":"e","c":"<code>","f":true|false}`
|
||||
|
||||
## Multiplexing Model
|
||||
- Single shared socket per client runtime.
|
||||
- Socket has one mutable bound session.
|
||||
- Client sends a bind control when the active terminal changes.
|
||||
- Text frames always apply to the currently bound session.
|
||||
- PTY output is pushed back over the same socket as text frames.
|
||||
- Client keeps the socket primed so both stream subscription and input reuse the same transport.
|
||||
|
||||
## Security
|
||||
- UI auth session required when UI password is enabled.
|
||||
- Origin validation enforced for cookie-authenticated browser upgrades.
|
||||
- Invalid or malformed frames are rate-limited and may close the socket.
|
||||
|
||||
## Fallback Behavior
|
||||
- New clients prefer `capabilities.stream.ws` and reuse the same socket for input.
|
||||
- If stream WebSocket capability is unavailable, clients fall back to SSE output.
|
||||
- If terminal input cannot be sent over WebSocket, clients fall back to HTTP input.
|
||||
- The removed `/api/terminal/input-ws` path should fail with `404 Not Found`.
|
||||
@@ -1,115 +1,76 @@
|
||||
# Terminal Module Documentation
|
||||
|
||||
## Purpose
|
||||
This module provides WebSocket protocol utilities for terminal input handling in the web server runtime, including message normalization, control frame parsing, rate limiting, and pathname resolution for terminal WebSocket connections.
|
||||
This module provides WebSocket transport utilities for terminal input and output in the web server runtime, including message normalization, control frame parsing, rate limiting, pathname resolution, and short-lived output replay buffering for terminal WebSocket connections.
|
||||
|
||||
## Entrypoints and structure
|
||||
- `packages/web/server/lib/terminal/`: Terminal module directory.
|
||||
- `index.js`: Stable module entrypoint that re-exports protocol helpers/constants.
|
||||
- `index.js`: Stable module entrypoint that re-exports protocol helpers and replay-buffer helpers.
|
||||
- `runtime.js`: Runtime module that owns terminal session state, WS server setup, and `/api/terminal/*` route registration.
|
||||
- `input-ws-protocol.js`: Single-file module containing all terminal input WebSocket protocol utilities.
|
||||
- `packages/web/server/lib/terminal/input-ws-protocol.test.js`: Test file for protocol utilities.
|
||||
- `terminal-ws-protocol.js`: Single-file module containing terminal WebSocket protocol utilities.
|
||||
- `output-replay-buffer.js`: Helper module for buffering recent terminal output so late subscribers can receive startup prompt data.
|
||||
- `packages/web/server/lib/terminal/terminal-ws-protocol.test.js`: Test file for protocol utilities.
|
||||
- `packages/web/server/lib/terminal/output-replay-buffer.test.js`: Test file for replay buffer helpers.
|
||||
|
||||
Public API entry point: imported by `packages/web/server/index.js` from `./lib/terminal/index.js`.
|
||||
|
||||
## Public exports
|
||||
|
||||
### Constants
|
||||
- `TERMINAL_INPUT_WS_PATH`: WebSocket endpoint path (`/api/terminal/input-ws`).
|
||||
- `TERMINAL_INPUT_WS_CONTROL_TAG_JSON`: Control frame tag byte (0x01) indicating JSON payload.
|
||||
- `TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES`: Maximum payload size (64KB).
|
||||
- `TERMINAL_WS_PATH`: Primary WebSocket endpoint path (`/api/terminal/ws`).
|
||||
- `TERMINAL_WS_CONTROL_TAG_JSON`: Control frame tag byte (`0x01`) indicating JSON payload.
|
||||
- `TERMINAL_WS_MAX_PAYLOAD_BYTES`: Maximum inbound WebSocket payload size (64KB).
|
||||
- `TERMINAL_OUTPUT_REPLAY_MAX_BYTES`: Maximum buffered terminal output retained for replay (64KB).
|
||||
|
||||
### Request Parsing
|
||||
- `parseRequestPathname(requestUrl)`: Extracts pathname from request URL string. Returns empty string for invalid inputs.
|
||||
- `isTerminalWsPathname(pathname)`: Returns whether a pathname matches a supported terminal WebSocket route.
|
||||
|
||||
### Message Normalization
|
||||
- `normalizeTerminalInputWsMessageToBuffer(rawData)`: Normalizes various data types (Buffer, Uint8Array, ArrayBuffer, string, chunk arrays) to a single Buffer.
|
||||
- `normalizeTerminalInputWsMessageToText(rawData)`: Normalizes data to UTF-8 text string. Passes through strings directly, converts binary data to text.
|
||||
- `normalizeTerminalWsMessageToBuffer(rawData)`: Normalizes various data types (Buffer, Uint8Array, ArrayBuffer, string, chunk arrays) to a single Buffer.
|
||||
- `normalizeTerminalWsMessageToText(rawData)`: Normalizes data to UTF-8 text string.
|
||||
|
||||
### Control Frame Handling
|
||||
- `readTerminalInputWsControlFrame(rawData)`: Parses WebSocket message as control frame. Returns parsed JSON object or null if invalid/malformed. Validates control tag prefix and JSON structure.
|
||||
- `createTerminalInputWsControlFrame(payload)`: Creates a control frame with JSON payload. Prepends control tag byte.
|
||||
- `readTerminalWsControlFrame(rawData)`: Parses WebSocket message as control frame. Returns parsed JSON object or null if invalid or malformed.
|
||||
- `createTerminalWsControlFrame(payload)`: Creates a control frame with JSON payload and prepends the control tag byte.
|
||||
|
||||
### Replay Buffer Helpers
|
||||
- `createTerminalOutputReplayBuffer()`: Creates mutable state for recent terminal output replay.
|
||||
- `appendTerminalOutputReplayChunk(bufferState, data, maxBytes?)`: Appends a chunk, trimming older buffered data to stay within the configured byte budget.
|
||||
- `listTerminalOutputReplayChunksSince(bufferState, lastSeenId)`: Returns buffered chunks newer than the provided replay cursor.
|
||||
- `getLatestTerminalOutputReplayChunkId(bufferState)`: Returns the latest chunk id in the replay buffer, or `0` when empty.
|
||||
|
||||
### Rate Limiting
|
||||
- `pruneRebindTimestamps(timestamps, now, windowMs)`: Filters timestamps to keep only those within the active time window.
|
||||
- `isRebindRateLimited(timestamps, maxPerWindow)`: Checks if rebind operations have exceeded rate limit threshold.
|
||||
|
||||
## Response contracts
|
||||
|
||||
### Control Frame
|
||||
Control frames use binary encoding:
|
||||
- First byte: `TERMINAL_INPUT_WS_CONTROL_TAG_JSON` (0x01)
|
||||
- Remaining bytes: UTF-8 encoded JSON object
|
||||
- Parsed result: Object or null on parse failure
|
||||
|
||||
### Normalized Buffer
|
||||
Input types are normalized to Buffer:
|
||||
- `Buffer`: Returned as-is
|
||||
- `Uint8Array`/`ArrayBuffer`: Converted to Buffer
|
||||
- `String`: Converted to UTF-8 Buffer
|
||||
- `Array<Buffer|string|Uint8Array>`: Concatenated to single Buffer
|
||||
|
||||
### Rate Limiting
|
||||
Rate limiting uses timestamp arrays:
|
||||
- `pruneRebindTimestamps`: Returns filtered array of active timestamps
|
||||
- `isRebindRateLimited`: Returns boolean indicating if limit is reached
|
||||
- `isRebindRateLimited(timestamps, maxPerWindow)`: Checks if rebind operations have exceeded the configured threshold.
|
||||
|
||||
## Usage in web server
|
||||
|
||||
The terminal protocol utilities are used by `packages/web/server/index.js` for:
|
||||
- WebSocket endpoint path definition (`TERMINAL_INPUT_WS_PATH`)
|
||||
- Message normalization for input handling
|
||||
- Control frame parsing for session binding
|
||||
The terminal helpers are used by `packages/web/server/index.js` for:
|
||||
- WebSocket endpoint path definition and matching
|
||||
- Message normalization for terminal input payloads
|
||||
- Control frame parsing for session binding, keepalive, and exit signaling
|
||||
- Rate limiting for session rebind operations
|
||||
- Request pathname parsing for WebSocket routing
|
||||
- Replaying startup output such as shell prompts when the client binds after the PTY already emitted data
|
||||
|
||||
The web server uses these utilities in combination with `bun-pty` or `node-pty` for PTY session management.
|
||||
The web server combines these utilities with `bun-pty` or `node-pty` to drive full-duplex PTY sessions.
|
||||
|
||||
## Notes for contributors
|
||||
|
||||
### Adding New Control Frame Types
|
||||
1. Define new control tag constants (e.g., `TERMINAL_INPUT_WS_CONTROL_TAG_CUSTOM = 0x02`)
|
||||
2. Update `readTerminalInputWsControlFrame` to handle new tag type
|
||||
3. Update `createTerminalInputWsControlFrame` or create new frame creation function
|
||||
4. Add corresponding tests in `terminal-input-ws-protocol.test.js`
|
||||
|
||||
### Message Normalization
|
||||
- Always normalize incoming WebSocket messages before processing
|
||||
- Use `normalizeTerminalInputWsMessageToBuffer` for binary data
|
||||
- Use `normalizeTerminalInputWsMessageToText` for text data (terminal escape sequences)
|
||||
- Normalize chunked messages from WebSocket fragmentation handling
|
||||
|
||||
### Rate Limiting
|
||||
- Rate limiting is time-window based: tracks timestamps within a rolling window
|
||||
- Use `pruneRebindTimestamps` to clean up stale timestamps before rate limit checks
|
||||
- Configure `maxPerWindow` based on operational requirements (prevent abuse)
|
||||
|
||||
### Error Handling
|
||||
- `readTerminalInputWsControlFrame` returns null for invalid/malformed frames
|
||||
- `parseRequestPathname` returns empty string for invalid URLs
|
||||
- Callers should handle null/empty returns gracefully
|
||||
|
||||
### Testing
|
||||
- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes
|
||||
- Test edge cases: empty payloads, malformed JSON, chunked messages, rate limit boundaries
|
||||
- Verify control frame roundtrip: create → read → validate payload equality
|
||||
- Test pathname parsing with relative URLs, absolute URLs, and invalid inputs
|
||||
- Keep control frames backward-compatible when possible; use explicit `v` values for protocol changes.
|
||||
- Always normalize incoming WebSocket messages before processing them.
|
||||
- Keep replay buffering small and memory-only; it exists to cover startup races, not to implement persistent scrollback.
|
||||
- Add tests for new control frame types, websocket path changes, malformed payload handling, and replay trimming semantics.
|
||||
- Keep HTTP input and SSE output fallbacks functional unless the rollout explicitly removes them.
|
||||
|
||||
## Verification notes
|
||||
|
||||
### Manual verification
|
||||
1. Start web server and create terminal session via `/api/terminal/create`
|
||||
2. Connect to `/api/terminal/input-ws` WebSocket
|
||||
3. Send control frames with valid/invalid payloads to verify parsing
|
||||
4. Test message normalization with various data types
|
||||
5. Verify rate limiting by issuing rapid rebind requests
|
||||
1. Start the web server and create a terminal session via `/api/terminal/create`.
|
||||
2. Wait briefly before binding the client to ensure the shell emits its prompt first.
|
||||
3. Connect to `/api/terminal/ws` WebSocket and bind to the session.
|
||||
4. Verify the startup prompt and early shell output are replayed before interactive input begins.
|
||||
5. Verify `/api/terminal/input-ws` is rejected with `404 Not Found` and `/api/terminal/:sessionId/stream` still works as a fallback path.
|
||||
|
||||
### Automated verification
|
||||
- Run test file: `bun test packages/web/server/lib/terminal/input-ws-protocol.test.js`
|
||||
- Protocol tests should pass covering:
|
||||
- WebSocket path constant
|
||||
- Control frame encoding/decoding
|
||||
- Payload validation
|
||||
- Message normalization (all data types)
|
||||
- Pathname parsing
|
||||
- Rate limiting logic
|
||||
- Run `bun test packages/web/server/lib/terminal/terminal-ws-protocol.test.js`
|
||||
- Run `bun test packages/web/server/lib/terminal/output-replay-buffer.test.js`
|
||||
- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes.
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
export {
|
||||
TERMINAL_INPUT_WS_PATH,
|
||||
TERMINAL_INPUT_WS_CONTROL_TAG_JSON,
|
||||
TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
|
||||
TERMINAL_WS_PATH,
|
||||
TERMINAL_WS_CONTROL_TAG_JSON,
|
||||
TERMINAL_WS_MAX_PAYLOAD_BYTES,
|
||||
isTerminalWsPathname,
|
||||
parseRequestPathname,
|
||||
normalizeTerminalInputWsMessageToBuffer,
|
||||
normalizeTerminalInputWsMessageToText,
|
||||
readTerminalInputWsControlFrame,
|
||||
createTerminalInputWsControlFrame,
|
||||
normalizeTerminalWsMessageToBuffer,
|
||||
normalizeTerminalWsMessageToText,
|
||||
readTerminalWsControlFrame,
|
||||
createTerminalWsControlFrame,
|
||||
pruneRebindTimestamps,
|
||||
isRebindRateLimited,
|
||||
} from './input-ws-protocol.js';
|
||||
} from './terminal-ws-protocol.js';
|
||||
|
||||
export {
|
||||
TERMINAL_WS_PATH as TERMINAL_INPUT_WS_PATH,
|
||||
TERMINAL_WS_CONTROL_TAG_JSON as TERMINAL_INPUT_WS_CONTROL_TAG_JSON,
|
||||
TERMINAL_WS_MAX_PAYLOAD_BYTES as TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
|
||||
normalizeTerminalWsMessageToBuffer as normalizeTerminalInputWsMessageToBuffer,
|
||||
normalizeTerminalWsMessageToText as normalizeTerminalInputWsMessageToText,
|
||||
readTerminalWsControlFrame as readTerminalInputWsControlFrame,
|
||||
createTerminalWsControlFrame as createTerminalInputWsControlFrame,
|
||||
} from './terminal-ws-protocol.js';
|
||||
|
||||
export {
|
||||
TERMINAL_OUTPUT_REPLAY_MAX_BYTES,
|
||||
createTerminalOutputReplayBuffer,
|
||||
appendTerminalOutputReplayChunk,
|
||||
listTerminalOutputReplayChunksSince,
|
||||
getLatestTerminalOutputReplayChunkId,
|
||||
} from './output-replay-buffer.js';
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import {
|
||||
TERMINAL_OUTPUT_REPLAY_MAX_BYTES,
|
||||
appendTerminalOutputReplayChunk,
|
||||
createTerminalOutputReplayBuffer,
|
||||
getLatestTerminalOutputReplayChunkId,
|
||||
listTerminalOutputReplayChunksSince,
|
||||
} from './output-replay-buffer.js';
|
||||
|
||||
describe('terminal output replay buffer', () => {
|
||||
it('starts empty', () => {
|
||||
const bufferState = createTerminalOutputReplayBuffer();
|
||||
expect(bufferState).toEqual({ chunks: [], totalBytes: 0, nextId: 1 });
|
||||
expect(getLatestTerminalOutputReplayChunkId(bufferState)).toBe(0);
|
||||
});
|
||||
|
||||
it('appends chunks with incrementing ids', () => {
|
||||
const bufferState = createTerminalOutputReplayBuffer();
|
||||
const first = appendTerminalOutputReplayChunk(bufferState, 'prompt> ');
|
||||
const second = appendTerminalOutputReplayChunk(bufferState, 'ls\r\n');
|
||||
|
||||
expect(first).toEqual({ id: 1, data: 'prompt> ', bytes: 8 });
|
||||
expect(second).toEqual({ id: 2, data: 'ls\r\n', bytes: 4 });
|
||||
expect(getLatestTerminalOutputReplayChunkId(bufferState)).toBe(2);
|
||||
});
|
||||
|
||||
it('lists chunks after a replay cursor', () => {
|
||||
const bufferState = createTerminalOutputReplayBuffer();
|
||||
appendTerminalOutputReplayChunk(bufferState, 'prompt> ');
|
||||
appendTerminalOutputReplayChunk(bufferState, 'ls\r\n');
|
||||
appendTerminalOutputReplayChunk(bufferState, 'file.txt\r\n');
|
||||
|
||||
expect(listTerminalOutputReplayChunksSince(bufferState, 1).map((chunk) => chunk.data)).toEqual([
|
||||
'ls\r\n',
|
||||
'file.txt\r\n',
|
||||
]);
|
||||
});
|
||||
|
||||
it('trims old chunks beyond max bytes', () => {
|
||||
const bufferState = createTerminalOutputReplayBuffer();
|
||||
appendTerminalOutputReplayChunk(bufferState, '1234', 8);
|
||||
appendTerminalOutputReplayChunk(bufferState, '5678', 8);
|
||||
appendTerminalOutputReplayChunk(bufferState, '90', 8);
|
||||
|
||||
expect(bufferState.chunks.map((chunk) => chunk.data)).toEqual(['5678', '90']);
|
||||
expect(bufferState.totalBytes).toBe(6);
|
||||
});
|
||||
|
||||
it('trims oversized single chunks to the configured max bytes', () => {
|
||||
const bufferState = createTerminalOutputReplayBuffer();
|
||||
const chunk = appendTerminalOutputReplayChunk(bufferState, 'abcdefghij', 4);
|
||||
|
||||
expect(chunk?.data).toBe('ghij');
|
||||
expect(chunk?.bytes).toBe(4);
|
||||
expect(bufferState.totalBytes).toBe(4);
|
||||
});
|
||||
|
||||
it('uses the default max bytes when not provided', () => {
|
||||
const bufferState = createTerminalOutputReplayBuffer();
|
||||
const chunk = appendTerminalOutputReplayChunk(bufferState, 'ok');
|
||||
|
||||
expect(chunk?.bytes).toBe(2);
|
||||
expect(TERMINAL_OUTPUT_REPLAY_MAX_BYTES).toBe(64 * 1024);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,12 @@ import { WebSocketServer } from 'ws';
|
||||
import {
|
||||
TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
|
||||
TERMINAL_INPUT_WS_PATH,
|
||||
TERMINAL_OUTPUT_REPLAY_MAX_BYTES,
|
||||
appendTerminalOutputReplayChunk,
|
||||
createTerminalOutputReplayBuffer,
|
||||
createTerminalInputWsControlFrame,
|
||||
isRebindRateLimited,
|
||||
listTerminalOutputReplayChunksSince,
|
||||
normalizeTerminalInputWsMessageToText,
|
||||
parseRequestPathname,
|
||||
pruneRebindTimestamps,
|
||||
@@ -150,8 +154,10 @@ export function createTerminalRuntime({
|
||||
};
|
||||
|
||||
const terminalSessions = new Map();
|
||||
const terminalWsConnections = new Set();
|
||||
const MAX_TERMINAL_SESSIONS = 20;
|
||||
const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000;
|
||||
const terminalRuntimeName = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun';
|
||||
const sanitizeTerminalEnv = (env) => {
|
||||
const next = { ...env };
|
||||
delete next.BASH_XTRACEFD;
|
||||
@@ -159,13 +165,22 @@ export function createTerminalRuntime({
|
||||
delete next.ENV;
|
||||
return next;
|
||||
};
|
||||
const terminalInputCapabilities = {
|
||||
const terminalTransportCapabilities = {
|
||||
input: {
|
||||
preferred: 'ws',
|
||||
transports: ['http', 'ws'],
|
||||
ws: {
|
||||
path: TERMINAL_INPUT_WS_PATH,
|
||||
v: 1,
|
||||
v: 2,
|
||||
enc: 'text+json-bin-control',
|
||||
},
|
||||
},
|
||||
stream: {
|
||||
preferred: 'ws',
|
||||
transports: ['sse', 'ws'],
|
||||
ws: {
|
||||
path: TERMINAL_INPUT_WS_PATH,
|
||||
v: 2,
|
||||
enc: 'text+json-bin-control',
|
||||
},
|
||||
},
|
||||
@@ -189,13 +204,17 @@ export function createTerminalRuntime({
|
||||
|
||||
terminalInputWsServer.on('connection', (socket) => {
|
||||
const connectionState = {
|
||||
socket,
|
||||
boundSessionId: null,
|
||||
invalidFrames: 0,
|
||||
rebindTimestamps: [],
|
||||
replayCursorBySession: new Map(),
|
||||
lastActivityAt: Date.now(),
|
||||
};
|
||||
|
||||
sendTerminalInputWsControl(socket, { t: 'ok', v: 1 });
|
||||
terminalWsConnections.add(connectionState);
|
||||
|
||||
sendTerminalInputWsControl(socket, { t: 'ok', v: 2 });
|
||||
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
if (socket.readyState !== 1) {
|
||||
@@ -231,7 +250,7 @@ export function createTerminalRuntime({
|
||||
}
|
||||
|
||||
if (controlMessage.t === 'p') {
|
||||
sendTerminalInputWsControl(socket, { t: 'po', v: 1 });
|
||||
sendTerminalInputWsControl(socket, { t: 'po', v: 2 });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -268,9 +287,32 @@ export function createTerminalRuntime({
|
||||
return;
|
||||
}
|
||||
|
||||
const replaySinceRaw =
|
||||
typeof controlMessage.r === 'number' && Number.isFinite(controlMessage.r)
|
||||
? Math.max(0, Math.trunc(controlMessage.r))
|
||||
: 0;
|
||||
const rememberedReplayCursor = connectionState.replayCursorBySession.get(nextSessionId) ?? 0;
|
||||
const replaySince = Math.max(replaySinceRaw, rememberedReplayCursor);
|
||||
|
||||
connectionState.rebindTimestamps.push(now);
|
||||
connectionState.boundSessionId = nextSessionId;
|
||||
sendTerminalInputWsControl(socket, { t: 'bok', v: 1 });
|
||||
sendTerminalInputWsControl(socket, {
|
||||
t: 'bok',
|
||||
v: 2,
|
||||
s: nextSessionId,
|
||||
runtime: terminalRuntimeName,
|
||||
ptyBackend: targetSession.ptyBackend || 'unknown',
|
||||
});
|
||||
|
||||
const replayChunks = listTerminalOutputReplayChunksSince(targetSession.outputReplayBuffer, replaySince);
|
||||
for (const replayChunk of replayChunks) {
|
||||
try {
|
||||
socket.send(replayChunk.data);
|
||||
connectionState.replayCursorBySession.set(nextSessionId, replayChunk.id);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -301,6 +343,8 @@ export function createTerminalRuntime({
|
||||
|
||||
socket.on('close', () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
connectionState.boundSessionId = null;
|
||||
terminalWsConnections.delete(connectionState);
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
@@ -347,6 +391,56 @@ export function createTerminalRuntime({
|
||||
void handleUpgrade();
|
||||
});
|
||||
|
||||
const wireTerminalSession = (sessionId, session) => {
|
||||
session.ptyProcess.onData((data) => {
|
||||
session.lastActivity = Date.now();
|
||||
const replayChunk = appendTerminalOutputReplayChunk(
|
||||
session.outputReplayBuffer,
|
||||
data,
|
||||
TERMINAL_OUTPUT_REPLAY_MAX_BYTES
|
||||
);
|
||||
|
||||
for (const wsConnection of terminalWsConnections) {
|
||||
if (wsConnection.boundSessionId !== sessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!wsConnection.socket || wsConnection.socket.readyState !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
wsConnection.socket.send(data);
|
||||
if (replayChunk) {
|
||||
wsConnection.replayCursorBySession.set(sessionId, replayChunk.id);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
session.ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
|
||||
for (const wsConnection of terminalWsConnections) {
|
||||
if (wsConnection.boundSessionId !== sessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
wsConnection.boundSessionId = null;
|
||||
wsConnection.replayCursorBySession.delete(sessionId);
|
||||
sendTerminalInputWsControl(wsConnection.socket, {
|
||||
t: 'x',
|
||||
v: 2,
|
||||
s: sessionId,
|
||||
exitCode,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
terminalSessions.delete(sessionId);
|
||||
});
|
||||
};
|
||||
|
||||
const idleSweepInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [sessionId, session] of terminalSessions.entries()) {
|
||||
@@ -399,17 +493,14 @@ export function createTerminalRuntime({
|
||||
cwd,
|
||||
lastActivity: Date.now(),
|
||||
clients: new Set(),
|
||||
outputReplayBuffer: createTerminalOutputReplayBuffer(),
|
||||
};
|
||||
|
||||
terminalSessions.set(sessionId, session);
|
||||
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
|
||||
terminalSessions.delete(sessionId);
|
||||
});
|
||||
wireTerminalSession(sessionId, session);
|
||||
|
||||
console.log(`Created terminal session: ${sessionId} in ${cwd} using shell ${shell}`);
|
||||
res.json({ sessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities });
|
||||
res.json({ sessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalTransportCapabilities });
|
||||
} catch (error) {
|
||||
console.error('Failed to create terminal session:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to create terminal session' });
|
||||
@@ -433,9 +524,8 @@ export function createTerminalRuntime({
|
||||
session.clients.add(clientId);
|
||||
session.lastActivity = Date.now();
|
||||
|
||||
const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun';
|
||||
const ptyBackend = session.ptyBackend || 'unknown';
|
||||
res.write(`data: ${JSON.stringify({ type: 'connected', runtime, ptyBackend })}\n\n`);
|
||||
res.write(`data: ${JSON.stringify({ type: 'connected', runtime: terminalRuntimeName, ptyBackend })}\n\n`);
|
||||
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
try {
|
||||
@@ -501,7 +591,7 @@ export function createTerminalRuntime({
|
||||
req.on('close', cleanup);
|
||||
req.on('error', cleanup);
|
||||
|
||||
console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${runtime} pty=${ptyBackend}`);
|
||||
console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${terminalRuntimeName} pty=${ptyBackend}`);
|
||||
});
|
||||
|
||||
app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
|
||||
@@ -613,17 +703,14 @@ export function createTerminalRuntime({
|
||||
cwd,
|
||||
lastActivity: Date.now(),
|
||||
clients: new Set(),
|
||||
outputReplayBuffer: createTerminalOutputReplayBuffer(),
|
||||
};
|
||||
|
||||
terminalSessions.set(newSessionId, session);
|
||||
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
console.log(`Terminal session ${newSessionId} exited with code ${exitCode}, signal ${signal}`);
|
||||
terminalSessions.delete(newSessionId);
|
||||
});
|
||||
wireTerminalSession(newSessionId, session);
|
||||
|
||||
console.log(`Restarted terminal session: ${sessionId} -> ${newSessionId} in ${cwd} using shell ${shell}`);
|
||||
res.json({ sessionId: newSessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities });
|
||||
res.json({ sessionId: newSessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalTransportCapabilities });
|
||||
} catch (error) {
|
||||
console.error('Failed to restart terminal session:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to restart terminal session' });
|
||||
|
||||
+13
-11
@@ -1,6 +1,6 @@
|
||||
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 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) {
|
||||
@@ -14,7 +14,9 @@ export const parseRequestPathname = (requestUrl) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeTerminalInputWsMessageToBuffer = (rawData) => {
|
||||
export const isTerminalWsPathname = (pathname) => pathname === TERMINAL_WS_PATH;
|
||||
|
||||
export const normalizeTerminalWsMessageToBuffer = (rawData) => {
|
||||
if (Buffer.isBuffer(rawData)) {
|
||||
return rawData;
|
||||
}
|
||||
@@ -26,21 +28,21 @@ export const normalizeTerminalInputWsMessageToBuffer = (rawData) => {
|
||||
return Buffer.from(rawData);
|
||||
};
|
||||
|
||||
export const normalizeTerminalInputWsMessageToText = (rawData) => {
|
||||
export const normalizeTerminalWsMessageToText = (rawData) => {
|
||||
if (typeof rawData === 'string') {
|
||||
return rawData;
|
||||
}
|
||||
|
||||
return normalizeTerminalInputWsMessageToBuffer(rawData).toString('utf8');
|
||||
return normalizeTerminalWsMessageToBuffer(rawData).toString('utf8');
|
||||
};
|
||||
|
||||
export const readTerminalInputWsControlFrame = (rawData) => {
|
||||
export const readTerminalWsControlFrame = (rawData) => {
|
||||
if (!rawData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buffer = normalizeTerminalInputWsMessageToBuffer(rawData);
|
||||
if (buffer.length < 2 || buffer[0] !== TERMINAL_INPUT_WS_CONTROL_TAG_JSON) {
|
||||
const buffer = normalizeTerminalWsMessageToBuffer(rawData);
|
||||
if (buffer.length < 2 || buffer[0] !== TERMINAL_WS_CONTROL_TAG_JSON) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -55,9 +57,9 @@ export const readTerminalInputWsControlFrame = (rawData) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const createTerminalInputWsControlFrame = (payload) => {
|
||||
export const createTerminalWsControlFrame = (payload) => {
|
||||
const jsonBytes = Buffer.from(JSON.stringify(payload), 'utf8');
|
||||
return Buffer.concat([Buffer.from([TERMINAL_INPUT_WS_CONTROL_TAG_JSON]), jsonBytes]);
|
||||
return Buffer.concat([Buffer.from([TERMINAL_WS_CONTROL_TAG_JSON]), jsonBytes]);
|
||||
};
|
||||
|
||||
export const pruneRebindTimestamps = (timestamps, now, windowMs) =>
|
||||
+39
-32
@@ -1,86 +1,93 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import {
|
||||
TERMINAL_INPUT_WS_CONTROL_TAG_JSON,
|
||||
TERMINAL_INPUT_WS_PATH,
|
||||
createTerminalInputWsControlFrame,
|
||||
TERMINAL_WS_PATH,
|
||||
TERMINAL_WS_CONTROL_TAG_JSON,
|
||||
createTerminalWsControlFrame,
|
||||
isTerminalWsPathname,
|
||||
isRebindRateLimited,
|
||||
normalizeTerminalInputWsMessageToBuffer,
|
||||
normalizeTerminalInputWsMessageToText,
|
||||
normalizeTerminalWsMessageToBuffer,
|
||||
normalizeTerminalWsMessageToText,
|
||||
parseRequestPathname,
|
||||
pruneRebindTimestamps,
|
||||
readTerminalInputWsControlFrame,
|
||||
} from './input-ws-protocol.js';
|
||||
readTerminalWsControlFrame,
|
||||
} from './terminal-ws-protocol.js';
|
||||
|
||||
describe('terminal input websocket protocol', () => {
|
||||
it('uses fixed websocket path', () => {
|
||||
expect(TERMINAL_INPUT_WS_PATH).toBe('/api/terminal/input-ws');
|
||||
describe('terminal websocket protocol', () => {
|
||||
it('uses fixed websocket paths', () => {
|
||||
expect(TERMINAL_WS_PATH).toBe('/api/terminal/ws');
|
||||
});
|
||||
|
||||
it('matches supported websocket pathnames', () => {
|
||||
expect(isTerminalWsPathname('/api/terminal/ws')).toBe(true);
|
||||
expect(isTerminalWsPathname('/api/terminal/input-ws')).toBe(false);
|
||||
expect(isTerminalWsPathname('/api/terminal/other')).toBe(false);
|
||||
});
|
||||
|
||||
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);
|
||||
const frame = createTerminalWsControlFrame({ t: 'ok', v: 1 });
|
||||
expect(frame[0]).toBe(TERMINAL_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);
|
||||
const frame = createTerminalWsControlFrame(payload);
|
||||
expect(readTerminalWsControlFrame(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();
|
||||
expect(readTerminalWsControlFrame(frame)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects malformed control json', () => {
|
||||
const frame = Buffer.concat([
|
||||
Buffer.from([TERMINAL_INPUT_WS_CONTROL_TAG_JSON]),
|
||||
Buffer.from([TERMINAL_WS_CONTROL_TAG_JSON]),
|
||||
Buffer.from('{not json', 'utf8'),
|
||||
]);
|
||||
expect(readTerminalInputWsControlFrame(frame)).toBeNull();
|
||||
expect(readTerminalWsControlFrame(frame)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects empty control payloads', () => {
|
||||
expect(readTerminalInputWsControlFrame(null)).toBeNull();
|
||||
expect(readTerminalInputWsControlFrame(undefined)).toBeNull();
|
||||
expect(readTerminalInputWsControlFrame(Buffer.alloc(0))).toBeNull();
|
||||
expect(readTerminalWsControlFrame(null)).toBeNull();
|
||||
expect(readTerminalWsControlFrame(undefined)).toBeNull();
|
||||
expect(readTerminalWsControlFrame(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([TERMINAL_WS_CONTROL_TAG_JSON]),
|
||||
Buffer.from('"str"', 'utf8'),
|
||||
]);
|
||||
expect(readTerminalInputWsControlFrame(frame)).toBeNull();
|
||||
expect(readTerminalWsControlFrame(frame)).toBeNull();
|
||||
});
|
||||
|
||||
it('parses control frame from chunk arrays', () => {
|
||||
const frame = createTerminalInputWsControlFrame({ t: 'bok', v: 1 });
|
||||
const frame = createTerminalWsControlFrame({ t: 'bok', v: 1 });
|
||||
const chunks = [frame.subarray(0, 2), frame.subarray(2)];
|
||||
expect(readTerminalInputWsControlFrame(chunks)).toEqual({ t: 'bok', v: 1 });
|
||||
expect(readTerminalWsControlFrame(chunks)).toEqual({ t: 'bok', v: 1 });
|
||||
});
|
||||
|
||||
it('normalizes buffer passthrough', () => {
|
||||
const raw = Buffer.from('abc', 'utf8');
|
||||
const normalized = normalizeTerminalInputWsMessageToBuffer(raw);
|
||||
const normalized = normalizeTerminalWsMessageToBuffer(raw);
|
||||
expect(normalized).toBe(raw);
|
||||
expect(normalized.toString('utf8')).toBe('abc');
|
||||
});
|
||||
|
||||
it('normalizes uint8 arrays', () => {
|
||||
const normalized = normalizeTerminalInputWsMessageToBuffer(new Uint8Array([97, 98, 99]));
|
||||
const normalized = normalizeTerminalWsMessageToBuffer(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);
|
||||
const normalized = normalizeTerminalWsMessageToBuffer(source);
|
||||
expect(normalized.toString('utf8')).toBe('abc');
|
||||
});
|
||||
|
||||
it('normalizes chunk array payloads', () => {
|
||||
const normalized = normalizeTerminalInputWsMessageToBuffer([
|
||||
const normalized = normalizeTerminalWsMessageToBuffer([
|
||||
Buffer.from('ab', 'utf8'),
|
||||
Buffer.from('c', 'utf8'),
|
||||
]);
|
||||
@@ -88,19 +95,19 @@ describe('terminal input websocket protocol', () => {
|
||||
});
|
||||
|
||||
it('normalizes text payload from string', () => {
|
||||
expect(normalizeTerminalInputWsMessageToText('\u001b[A')).toBe('\u001b[A');
|
||||
expect(normalizeTerminalWsMessageToText('\u001b[A')).toBe('\u001b[A');
|
||||
});
|
||||
|
||||
it('normalizes text payload from binary data', () => {
|
||||
expect(normalizeTerminalInputWsMessageToText(Buffer.from('\r', 'utf8'))).toBe('\r');
|
||||
expect(normalizeTerminalWsMessageToText(Buffer.from('\r', 'utf8'))).toBe('\r');
|
||||
});
|
||||
|
||||
it('parses relative request pathname', () => {
|
||||
expect(parseRequestPathname('/api/terminal/input-ws?x=1')).toBe('/api/terminal/input-ws');
|
||||
expect(parseRequestPathname('/api/terminal/ws?x=1')).toBe('/api/terminal/ws');
|
||||
});
|
||||
|
||||
it('parses absolute request pathname', () => {
|
||||
expect(parseRequestPathname('http://localhost:3000/api/terminal/input-ws')).toBe('/api/terminal/input-ws');
|
||||
expect(parseRequestPathname('http://localhost:3000/api/terminal/ws')).toBe('/api/terminal/ws');
|
||||
});
|
||||
|
||||
it('returns empty pathname for non-string request url', () => {
|
||||
@@ -92,6 +92,7 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: `http://127.0.0.1:${process.env.OPENCHAMBER_PORT || 3001}`,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user