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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user