Initial commit: restructure to flat layout with ui/ and web/ at root
This commit is contained in:
114
web/server/lib/terminal/DOCUMENTATION.md
Normal file
114
web/server/lib/terminal/DOCUMENTATION.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# 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.
|
||||
|
||||
## Entrypoints and structure
|
||||
- `packages/web/server/lib/terminal/`: Terminal module directory.
|
||||
- `index.js`: Stable module entrypoint that re-exports protocol helpers/constants.
|
||||
- `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.
|
||||
|
||||
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).
|
||||
|
||||
### Request Parsing
|
||||
- `parseRequestPathname(requestUrl)`: Extracts pathname from request URL string. Returns empty string for invalid inputs.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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
|
||||
|
||||
## 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
|
||||
- Rate limiting for session rebind operations
|
||||
- Request pathname parsing for WebSocket routing
|
||||
|
||||
The web server uses these utilities in combination with `bun-pty` or `node-pty` for PTY session management.
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
|
||||
### 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
|
||||
12
web/server/lib/terminal/index.js
Normal file
12
web/server/lib/terminal/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
TERMINAL_INPUT_WS_PATH,
|
||||
TERMINAL_INPUT_WS_CONTROL_TAG_JSON,
|
||||
TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
|
||||
parseRequestPathname,
|
||||
normalizeTerminalInputWsMessageToBuffer,
|
||||
normalizeTerminalInputWsMessageToText,
|
||||
readTerminalInputWsControlFrame,
|
||||
createTerminalInputWsControlFrame,
|
||||
pruneRebindTimestamps,
|
||||
isRebindRateLimited,
|
||||
} from './input-ws-protocol.js';
|
||||
66
web/server/lib/terminal/input-ws-protocol.js
Normal file
66
web/server/lib/terminal/input-ws-protocol.js
Normal file
@@ -0,0 +1,66 @@
|
||||
export const TERMINAL_INPUT_WS_PATH = '/api/terminal/input-ws';
|
||||
export const TERMINAL_INPUT_WS_CONTROL_TAG_JSON = 0x01;
|
||||
export const TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES = 64 * 1024;
|
||||
|
||||
export const parseRequestPathname = (requestUrl) => {
|
||||
if (typeof requestUrl !== 'string' || requestUrl.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(requestUrl, 'http://localhost').pathname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeTerminalInputWsMessageToBuffer = (rawData) => {
|
||||
if (Buffer.isBuffer(rawData)) {
|
||||
return rawData;
|
||||
}
|
||||
|
||||
if (Array.isArray(rawData)) {
|
||||
return Buffer.concat(rawData.map((chunk) => (Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))));
|
||||
}
|
||||
|
||||
return Buffer.from(rawData);
|
||||
};
|
||||
|
||||
export const normalizeTerminalInputWsMessageToText = (rawData) => {
|
||||
if (typeof rawData === 'string') {
|
||||
return rawData;
|
||||
}
|
||||
|
||||
return normalizeTerminalInputWsMessageToBuffer(rawData).toString('utf8');
|
||||
};
|
||||
|
||||
export const readTerminalInputWsControlFrame = (rawData) => {
|
||||
if (!rawData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buffer = normalizeTerminalInputWsMessageToBuffer(rawData);
|
||||
if (buffer.length < 2 || buffer[0] !== TERMINAL_INPUT_WS_CONTROL_TAG_JSON) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(buffer.subarray(1).toString('utf8'));
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const createTerminalInputWsControlFrame = (payload) => {
|
||||
const jsonBytes = Buffer.from(JSON.stringify(payload), 'utf8');
|
||||
return Buffer.concat([Buffer.from([TERMINAL_INPUT_WS_CONTROL_TAG_JSON]), jsonBytes]);
|
||||
};
|
||||
|
||||
export const pruneRebindTimestamps = (timestamps, now, windowMs) =>
|
||||
timestamps.filter((timestamp) => now - timestamp < windowMs);
|
||||
|
||||
export const isRebindRateLimited = (timestamps, maxPerWindow) => timestamps.length >= maxPerWindow;
|
||||
138
web/server/lib/terminal/input-ws-protocol.test.js
Normal file
138
web/server/lib/terminal/input-ws-protocol.test.js
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import {
|
||||
TERMINAL_INPUT_WS_CONTROL_TAG_JSON,
|
||||
TERMINAL_INPUT_WS_PATH,
|
||||
createTerminalInputWsControlFrame,
|
||||
isRebindRateLimited,
|
||||
normalizeTerminalInputWsMessageToBuffer,
|
||||
normalizeTerminalInputWsMessageToText,
|
||||
parseRequestPathname,
|
||||
pruneRebindTimestamps,
|
||||
readTerminalInputWsControlFrame,
|
||||
} from './input-ws-protocol.js';
|
||||
|
||||
describe('terminal input websocket protocol', () => {
|
||||
it('uses fixed websocket path', () => {
|
||||
expect(TERMINAL_INPUT_WS_PATH).toBe('/api/terminal/input-ws');
|
||||
});
|
||||
|
||||
it('encodes control frames with control tag prefix', () => {
|
||||
const frame = createTerminalInputWsControlFrame({ t: 'ok', v: 1 });
|
||||
expect(frame[0]).toBe(TERMINAL_INPUT_WS_CONTROL_TAG_JSON);
|
||||
});
|
||||
|
||||
it('roundtrips control frame payload', () => {
|
||||
const payload = { t: 'b', s: 'abc123', v: 1 };
|
||||
const frame = createTerminalInputWsControlFrame(payload);
|
||||
expect(readTerminalInputWsControlFrame(frame)).toEqual(payload);
|
||||
});
|
||||
|
||||
it('rejects control frame without protocol tag', () => {
|
||||
const frame = Buffer.from(JSON.stringify({ t: 'b', s: 'abc123' }), 'utf8');
|
||||
expect(readTerminalInputWsControlFrame(frame)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects malformed control json', () => {
|
||||
const frame = Buffer.concat([
|
||||
Buffer.from([TERMINAL_INPUT_WS_CONTROL_TAG_JSON]),
|
||||
Buffer.from('{not json', 'utf8'),
|
||||
]);
|
||||
expect(readTerminalInputWsControlFrame(frame)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects empty control payloads', () => {
|
||||
expect(readTerminalInputWsControlFrame(null)).toBeNull();
|
||||
expect(readTerminalInputWsControlFrame(undefined)).toBeNull();
|
||||
expect(readTerminalInputWsControlFrame(Buffer.alloc(0))).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects control json that is not object', () => {
|
||||
const frame = Buffer.concat([
|
||||
Buffer.from([TERMINAL_INPUT_WS_CONTROL_TAG_JSON]),
|
||||
Buffer.from('"str"', 'utf8'),
|
||||
]);
|
||||
expect(readTerminalInputWsControlFrame(frame)).toBeNull();
|
||||
});
|
||||
|
||||
it('parses control frame from chunk arrays', () => {
|
||||
const frame = createTerminalInputWsControlFrame({ t: 'bok', v: 1 });
|
||||
const chunks = [frame.subarray(0, 2), frame.subarray(2)];
|
||||
expect(readTerminalInputWsControlFrame(chunks)).toEqual({ t: 'bok', v: 1 });
|
||||
});
|
||||
|
||||
it('normalizes buffer passthrough', () => {
|
||||
const raw = Buffer.from('abc', 'utf8');
|
||||
const normalized = normalizeTerminalInputWsMessageToBuffer(raw);
|
||||
expect(normalized).toBe(raw);
|
||||
expect(normalized.toString('utf8')).toBe('abc');
|
||||
});
|
||||
|
||||
it('normalizes uint8 arrays', () => {
|
||||
const normalized = normalizeTerminalInputWsMessageToBuffer(new Uint8Array([97, 98, 99]));
|
||||
expect(normalized.toString('utf8')).toBe('abc');
|
||||
});
|
||||
|
||||
it('normalizes array buffer payloads', () => {
|
||||
const source = new Uint8Array([97, 98, 99]).buffer;
|
||||
const normalized = normalizeTerminalInputWsMessageToBuffer(source);
|
||||
expect(normalized.toString('utf8')).toBe('abc');
|
||||
});
|
||||
|
||||
it('normalizes chunk array payloads', () => {
|
||||
const normalized = normalizeTerminalInputWsMessageToBuffer([
|
||||
Buffer.from('ab', 'utf8'),
|
||||
Buffer.from('c', 'utf8'),
|
||||
]);
|
||||
expect(normalized.toString('utf8')).toBe('abc');
|
||||
});
|
||||
|
||||
it('normalizes text payload from string', () => {
|
||||
expect(normalizeTerminalInputWsMessageToText('\u001b[A')).toBe('\u001b[A');
|
||||
});
|
||||
|
||||
it('normalizes text payload from binary data', () => {
|
||||
expect(normalizeTerminalInputWsMessageToText(Buffer.from('\r', 'utf8'))).toBe('\r');
|
||||
});
|
||||
|
||||
it('parses relative request pathname', () => {
|
||||
expect(parseRequestPathname('/api/terminal/input-ws?x=1')).toBe('/api/terminal/input-ws');
|
||||
});
|
||||
|
||||
it('parses absolute request pathname', () => {
|
||||
expect(parseRequestPathname('http://localhost:3000/api/terminal/input-ws')).toBe('/api/terminal/input-ws');
|
||||
});
|
||||
|
||||
it('returns empty pathname for non-string request url', () => {
|
||||
expect(parseRequestPathname(null)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty pathname for invalid request url', () => {
|
||||
expect(parseRequestPathname('http://')).toBe('');
|
||||
expect(parseRequestPathname('')).toBe('');
|
||||
});
|
||||
|
||||
it('prunes stale rebind timestamps', () => {
|
||||
const now = 1_000;
|
||||
const pruned = pruneRebindTimestamps([100, 200, 950, 999], now, 100);
|
||||
expect(pruned).toEqual([950, 999]);
|
||||
});
|
||||
|
||||
it('keeps rebind timestamps within active window', () => {
|
||||
const now = 1_000;
|
||||
const pruned = pruneRebindTimestamps([920, 950, 999], now, 100);
|
||||
expect(pruned).toEqual([920, 950, 999]);
|
||||
});
|
||||
|
||||
it('does not rate limit below threshold', () => {
|
||||
expect(isRebindRateLimited([1, 2, 3], 4)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not rate limit empty window', () => {
|
||||
expect(isRebindRateLimited([], 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('rate limits at threshold', () => {
|
||||
expect(isRebindRateLimited([1, 2, 3, 4], 4)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user