Initial commit: restructure to flat layout with ui/ and web/ at root

This commit is contained in:
2026-03-12 21:33:50 +08:00
commit decba25a08
1708 changed files with 199890 additions and 0 deletions

View 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

View 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';

View 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;

View 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);
});
});