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:
Yifan
2026-04-01 19:31:21 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent d68bec491c
commit 2b70ec6f3a
14 changed files with 1012 additions and 462 deletions
@@ -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 && (
+12 -9
View File
@@ -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;
};
}
File diff suppressed because it is too large Load Diff
+45 -5
View File
@@ -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.
+27 -8
View File
@@ -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);
});
});
+107 -20
View File
@@ -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' });
@@ -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) =>
@@ -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', () => {
+1
View File
@@ -92,6 +92,7 @@ export default defineConfig({
'/api': {
target: `http://127.0.0.1:${process.env.OPENCHAMBER_PORT || 3001}`,
changeOrigin: true,
ws: true,
},
},
},