diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 445545b..bc2614f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: jobs: lint: runs-on: ubuntu-latest - container: oven/bun:1.3.10-debian + container: oven/bun:1.3.11-alpine steps: - uses: actions/checkout@v4 - run: bun install --frozen-lockfile @@ -16,7 +16,7 @@ jobs: build: runs-on: ubuntu-latest - container: oven/bun:1.3.10-debian + container: oven/bun:1.3.11-alpine steps: - uses: actions/checkout@v4 - run: bun install --frozen-lockfile @@ -24,7 +24,7 @@ jobs: test: runs-on: ubuntu-latest - container: oven/bun:1.3.10-debian + container: oven/bun:1.3.11-alpine steps: - uses: actions/checkout@v4 - run: bun install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c32f92..d1454cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,11 +8,11 @@ on: jobs: release: runs-on: ubuntu-latest - container: oven/bun:1.3.10-debian + container: oven/bun:1.3.11-alpine permissions: contents: write steps: - - run: apt-get update && apt-get install -y --no-install-recommends git ca-certificates + - run: apk add --no-cache git ca-certificates - uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/docs/adrs/018.client.keyboard-bindings.md b/docs/adrs/018.client.keyboard-bindings.md new file mode 100644 index 0000000..ab70e08 --- /dev/null +++ b/docs/adrs/018.client.keyboard-bindings.md @@ -0,0 +1,176 @@ +# ADR 018: Client — Configurable keyboard bindings + +**SPEC:** [client](../specs/client.md), [config](../specs/config.md) +**Status:** Accepted +**Date:** 2026-03-28 + +--- + +## Context + +### The problem + +Browser `KeyboardEvent` objects carry no terminal escape sequence knowledge. When a user presses Shift+Enter in webtty, ghostty-web receives a `keydown` with `key="Enter"` and `shiftKey=true` — and sends `\r` to the PTY, identical to plain Enter. TUI apps that distinguish "new line" from "submit" (e.g. opencode) never receive the `\x1b\r` (ESC CR) sequence they expect. + +Native terminals solve this with explicit custom key bindings. The user's Alacritty config shows the exact mapping: + +```toml +[[keyboard.bindings]] +key = "Return" +mods = "Shift" +chars = "\u001B\r" +``` + +Ghostty uses an equivalent INI form: + +```ini +keybind = shift+enter=text:\x1b\r +``` + +webtty has no equivalent. The gap is structural: the browser terminal layer has no config-driven key mapping, so any modifier+key combo that requires a non-default escape sequence silently breaks. + +### Why Shift+Enter is not the only case + +Other common gaps sharing the same root cause: + +- `Ctrl+Enter` — apps that use kitty keyboard protocol expect `\u001b[13;5u` +- `Alt+Enter` — fullscreen toggle or app-specific action +- `Shift+Tab` — apps that use kitty keyboard protocol expect `\u001b[9;2u` + +Hardcoding Shift+Enter would invite a parade of follow-up issues. A general binding mechanism closes the entire class. + +### Terminal ecosystem survey + +| Terminal | Config format | Key names | Mods | Output field | +|---|---|---|---|---| +| Ghostty | INI `keybind = mods+key=text:\x1b\r` | W3C lowercase (`enter`, `arrow_up`) | plus-separated (`shift+ctrl`) | `text:` prefix | +| Alacritty | TOML `[[keyboard.bindings]]` | PascalCase (`Return`, `ArrowUp`) | pipe-separated (`Control\|Shift`) | `chars = "..."` | +| Windows Terminal | JSON `keybindings` array | lowercase (`enter`) | plus-separated (`ctrl+shift`) | `"input": "..."` | +| xterm.js | custom handler API (`attachCustomKeyEventHandler`) | `KeyboardEvent.key` (`Enter`) | `event.shiftKey` etc. | n/a | + +**Convergences across Ghostty, Windows Terminal, and xterm.js:** +- Lowercase key names +- A `chars`/`input`/`text:` field for the raw byte sequence + +webtty aligns with these conventions. For `mods`, all surveyed terminals use string-based formats (plus- or pipe-separated); webtty uses a **string array** instead — no separator to parse, straightforward set comparison in the implementation. + +--- + +## Decision + +Add a `keyboardBindings` array to `~/.config/webtty/config.json`. The client intercepts matching `keydown` events before ghostty-web sees them and sends the configured `chars` directly to the PTY over WebSocket. + +### Config schema + +```typescript +interface KeyboardBinding { + key: string; // case-insensitive KeyboardEvent.key name; see below + mods?: string[]; // optional array of modifier names; see below + chars: string; // byte sequence sent verbatim to the PTY +} +``` + +**`key`** — case-insensitive. Matched against `event.key.toLowerCase()`. Supported names: + +| Category | Values | +|---|---| +| Control | `enter`, `escape`, `tab`, `backspace`, `delete`, `space` | +| Navigation | `arrowup`, `arrowdown`, `arrowleft`, `arrowright`, `home`, `end`, `pageup`, `pagedown` | +| Function | `f1` – `f12` | +| Printable | `a`–`z`, `0`–`9`, `` ` ``, `-`, `=`, `[`, `]`, `;`, `'`, `,`, `.`, `/`, `\` | + +**`mods`** — an array of modifier name strings. Accepted values: `"shift"`, `"ctrl"`, `"alt"`, `"meta"`. Using an array avoids any string parsing — the implementation does a straightforward set comparison against the active modifier flags from the `KeyboardEvent`. Order is irrelevant; unknown values are silently ignored. + +Examples: `["shift"]`, `["ctrl", "shift"]`, `["alt"]`. Omit the field or pass `[]` for no modifiers. + +**`chars`** — a plain JSON string sent verbatim to the PTY. `JSON.parse` resolves all standard escapes (`\uXXXX`, `\r`, `\n`, `\t`) at config load time. The client sends the resulting string with a single `ws.send(binding.chars)` — no transformation, no lookup, no regex. This is the minimum possible implementation cost. + +The recommended sequence for Shift+Enter is `"\u001b[13;2u"` — the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) encoding for `Enter` (keycode 13) with Shift (modifier value 2). Most modern TUI apps (opencode, Helix, etc.) understand this format. The sequence is a plain JSON string; `JSON.parse` resolves `\u001b` to ESC (byte 0x1B) and the remaining characters `[13;2u` are printable ASCII. `ws.send(binding.chars)` sends the result with zero transformation. + +`\x1b` (hex escape) is **not valid JSON** — `JSON.parse` throws on it. `\u001b` is the correct JSON form, making config load the only processing step needed. + +### Design rationale + +Two priorities, in order: + +**Priority 1 — minimum engineering effort.** The entire client-side cost of `chars` is `ws.send(binding.chars)`. There is nothing else: no escape expansion, no sequence lookup, no format negotiation. `JSON.parse` is the only "processing" that happens, and it runs once at config load for free as part of normal JSON deserialization. + +The `mods` array follows the same principle: four string literals (`"shift"`, `"ctrl"`, `"alt"`, `"meta"`) map directly to four `KeyboardEvent` boolean properties (`shiftKey`, `ctrlKey`, `altKey`, `metaKey`). The match check is a set comparison — six lines of code, no parsing. + +**Priority 2 — align with industry practice.** The schema converges on conventions shared across popular terminals: + +| Design choice | webtty | Ghostty | Alacritty | Windows Terminal | +|---|---|---|---|---| +| Config format | JSON array | INI lines | TOML array | JSON array | +| Key names | lowercase (`"enter"`) | lowercase (`enter`) | PascalCase (`Return`) | lowercase (`enter`) | +| Output field name | `chars` | `text:` prefix | `chars` | `input` | +| Output value | JSON string (`"\u001b[13;2u"`) | Zig literal (`\x1b\r`) | TOML string (`"\u001B\r"`) | JSON string (`"\u001b\r"`) | +| Modifier format | string array | plus-separated string | pipe-separated string | plus-separated string | + +The `chars` field name matches Alacritty directly. The value format matches Windows Terminal (both are JSON). Key names match Ghostty and Windows Terminal. The only intentional divergence is `mods` as a string array instead of a formatted string — this eliminates the only parsing that would otherwise be required. + +### Override semantics + +Built-in defaults and user-supplied bindings are **merged by `(key, mods)` identity**: + +- A user entry whose `(key, mods)` matches a default replaces that default. +- All other defaults are preserved. +- To consume a key without sending anything, set `"chars": ""`. + +`keyboardBindings` defaults to `[]` — no bindings ship with webtty. Users add their own in `~/.config/webtty/config.json`. + +### Client implementation + +A capture-phase `keydown` listener on the terminal container fires before ghostty-web's canvas handlers: + +```typescript +container.addEventListener('keydown', (e: KeyboardEvent) => { + const binding = findBinding(config.keyboardBindings, e); + if (!binding) return; + e.preventDefault(); + e.stopPropagation(); + if (binding.chars && ws.readyState === WebSocket.OPEN) { + ws.send(binding.chars); + } +}, { capture: true }); +``` + +`findBinding` lowercases `e.key`, builds the active mods set from `e.shiftKey` / `e.ctrlKey` / `e.altKey` / `e.metaKey`, and returns the first binding whose `key` matches and whose `mods` array (as a set) matches exactly. + +`stopPropagation` (not `stopImmediatePropagation`) is sufficient: it prevents the event from reaching the canvas, so ghostty-web never fires its default handling. + +> ghostty-web exposes `attachCustomWheelEventHandler` for wheel events (ADR 017). A symmetric `attachCustomKeyEventHandler` would be the cleaner interception point, but ghostty-web does not expose this API for keyboard events. The DOM listener is used instead — if ghostty-web adds the API later it can be swapped in with no behaviour change. + +### Server / config.ts changes + +1. Add `KeyboardBinding` interface and `keyboardBindings` field to `Config`. +2. Add `DEFAULT_KEYBOARD_BINDINGS` constant. +3. `loadConfig()` merges user bindings with defaults by `(key, mods)` identity — analogous to how `theme` is merged. +4. Add `keyboardBindings` to the `/api/config` response in the server. +5. Validation: unknown fields in a binding entry are silently ignored (forward-compat). Unknown strings in the `mods` array are silently ignored. + +--- + +## Considered Options + +### Option A: Hardcode Shift+Enter → `\x1b[13;2u` + +~5 lines in `index.ts`. Fixes the immediate opencode issue. + +**Rejected** — Ctrl+Enter, Alt+Enter, and other combos have the same root cause. Hardcoding one case accumulates hidden tech debt and gives users no control. + +### Option B: Full Ghostty-style binding system with actions + +Support `action:` targets (e.g. `csi:A`, `esc:d`, `ignore`) in addition to `chars:`, matching Ghostty's action vocabulary. + +**Deferred** — webtty is a passthrough terminal with no built-in actions. The only meaningful action today is "send bytes to PTY" (`chars`). The schema is extensible: an `action` field can be added later without breaking existing `chars`-only bindings. + +--- + +## Consequences + +- Shift+Enter, Ctrl+Enter, Shift+Tab, and any other modifier+key combo work correctly in TUI apps that use the kitty keyboard protocol (opencode, Helix, and most modern TUI apps). +- Users configure bindings via `~/.config/webtty/config.json` using kitty keyboard protocol sequences — the same format modern TUI frameworks expect. +- ghostty-web's default handling for any intercepted key combo is fully suppressed — no double-send. +- Keys with no matching binding are unaffected — ghostty-web handles them as before. +- `keyboardBindings` ships empty (`[]`); users opt in explicitly. No built-in defaults to conflict with. diff --git a/docs/specs/client.md b/docs/specs/client.md index 0039120..c0f6de1 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -56,7 +56,8 @@ src/client/ { cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, theme, copyOnSelect, rightClickBehavior, - mouseScrollSpeed // used by the custom wheel handler, not passed to Terminal constructor + mouseScrollSpeed, // used by the custom wheel handler, not passed to Terminal constructor + keyboardBindings // used by the keydown capture handler, not passed to Terminal constructor } ``` @@ -96,6 +97,21 @@ All status messages written to the terminal share a consistent style: | WS close (unexpected) | `Connection lost. Reconnecting in 2s...` | Reconnect after 2s | | WS error | `WebSocket error.` | — | +## Keyboard Bindings + +Browser `KeyboardEvent` objects do not carry terminal escape sequences — the browser has no knowledge of the Alacritty/Ghostty custom-binding convention that maps modifier+key combos to specific byte sequences. As a result, keys like Shift+Enter arrive at ghostty-web as a plain `keydown` with `shiftKey=true`, and ghostty-web sends the same `\r` it would for unmodified Enter — not the `\x1b\r` (ESC CR) that TUI apps such as opencode expect. + +A capture-phase `keydown` listener on the terminal container fires before ghostty-web's canvas handlers and intercepts matching bindings: + +1. Walk `config.keyboardBindings` (user-configured entries). +2. Normalize `event.key` to lowercase and compare against each binding's `key`+`mods`. +3. On match: call `e.preventDefault()` + `e.stopPropagation()` to suppress ghostty-web's default handling, then send `binding.chars` verbatim over WebSocket to the PTY. +4. No match: return immediately — ghostty-web handles as normal. + +**`chars` encoding:** The client sends `binding.chars` verbatim. Standard JSON escapes (`\uXXXX`, `\r`, `\n`, `\t`) are resolved by `JSON.parse` at config load — no further processing occurs. + +See [config SPEC](config.md#keyboard-binding-objects) for the binding object schema and built-in defaults. + ## Copy Behavior Controlled by two config keys from `GET /api/config`: @@ -132,3 +148,4 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Cursor style | `cursorStyle` / `cursorStyleBlink` defaults; DECSCUSR from PTY overrides at runtime via client-side intercept | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | | Non-text paste | Ctrl+V with no `text/plain` in clipboard forwards `\x16` to PTY; TUI apps read non-text content via their native OS clipboard API | [ADR 014](../adrs/014.client.image-paste.md) | ✅ | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), wheel events are forwarded as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | +| Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.client.keyboard-bindings.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index e85fe73..e6a5f5d 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -92,7 +92,7 @@ spawn PTY with fresh: shell, term, colorTerm, scrollback - **Env overrides**: `PORT` overrides `config.port` at runtime. Applied after file load, never written back. - **Hot config reload**: - `port` / `host` — locked at startup (server socket already bound; restart required). - - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `theme`, `copyOnSelect`, `rightClickBehavior`, `mouseScrollSpeed` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the startup defaults; apps override them at runtime via DECSCUSR. + - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `theme`, `copyOnSelect`, `rightClickBehavior`, `mouseScrollSpeed`, `keyboardBindings` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the startup defaults; apps override them at runtime via DECSCUSR. - `shell`, `term`, `colorTerm`, `scrollback` — re-read when a new PTY is spawned (i.e. first connection to a session that has no running shell). - An already-running session is never affected mid-flight. - Historical note: ADR 008/009/012 describe an earlier config flow that used a `cursorBlink` key and different HTML injection mechanics. Those ADRs are considered historical; this spec's `cursorStyle` / `cursorStyleBlink` behavior is authoritative. @@ -120,6 +120,7 @@ All keys are optional — omit any key to use the default value. | `fontSize` | number | `13` | Font size in px | | `fontFamily` | string | `"Menlo, Consolas, 'DejaVu Sans Mono', monospace"` | CSS font-family stack | | `theme` | object | Campbell | Terminal color palette — see theme keys below | +| `keyboardBindings` | array | see below | Custom key-to-sequence bindings sent to the PTY. Merged with defaults by `key`+`mods` identity — see keyboard bindings below. | ### Theme keys @@ -148,6 +149,65 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | `brightCyan` | `#61D6D6` | ANSI 14 | | `brightWhite` | `#F2F2F2` | ANSI 15 | +### Keyboard binding objects + +Each entry in `keyboardBindings` is an object with the following fields: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `key` | string | yes | Key name, matched case-insensitively against `KeyboardEvent.key`. Special keys: `"enter"`, `"escape"`, `"tab"`, `"backspace"`, `"delete"`, `"space"`, `"arrowup"`, `"arrowdown"`, `"arrowleft"`, `"arrowright"`, `"f1"`–`"f12"`. Printable characters: `"a"`–`"z"`, `"0"`–`"9"`, etc. | +| `mods` | string[] | no | Array of modifier names. Accepted values: `"shift"`, `"ctrl"`, `"alt"`, `"meta"`. Unknown values are silently filtered out at config load time. Examples: `["shift"]`, `["ctrl", "shift"]`. Omit or `[]` for no modifiers. Order does not matter — `["ctrl", "shift"]` and `["shift", "ctrl"]` are equivalent. | +| `chars` | string | yes | Escape sequence sent verbatim to the PTY. Must be a valid JSON string — use `\uXXXX` for non-printable bytes (e.g. `"\u001b"` for ESC). `\x` hex notation is **not valid JSON** and will cause a parse error. Standard escapes `\r`, `\n`, `\t` work as expected. All escapes are resolved by `JSON.parse` at config load; the string is sent as-is with no further processing. | + +#### How to define `chars` + +The `chars` value is the byte sequence your TUI app expects to receive for that key combo. + +Take `"\u001b[13;2u"` as an example — the kitty keyboard protocol sequence for Shift+Enter, used by opencode, Helix, and most modern TUI apps. Most TUI apps follow one of two conventions: + +| Convention | Shift+Enter | Used by | +|---|---|---| +| [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) | `"\u001b[13;2u"` | Helix, opencode, modern TUI apps | +| Legacy ESC CR | `"\u001b\r"` | older TUI apps | + +**Legacy convention** — the sequence is app-specific. Check the binding examples below, or search the app's source for `\x1b\r` near its input handling code. + +**Kitty keyboard protocol** — sequences are standardized and can be derived from a formula: + +``` +\u001b [ {keycode} ; {modifier} u +``` + +Modifier value = `1` + sum of active modifiers (Shift `1`, Alt `2`, Ctrl `4`, Meta `8`): + +| Modifiers | Modifier value | Shift+Enter example | +|---|---|---| +| Shift | 1+1 = 2 | `"\u001b[13;2u"` | +| Alt | 1+2 = 3 | `"\u001b[13;3u"` | +| Ctrl | 1+4 = 5 | `"\u001b[13;5u"` | +| Shift+Ctrl | 1+1+4 = 6 | `"\u001b[13;6u"` | + +Common keycodes for the formula: + +| Key | Keycode | Shift example | +|---|---|---| +| Enter | 13 | `"\u001b[13;2u"` | +| Tab | 9 | `"\u001b[9;2u"` | +| Backspace | 127 | `"\u001b[127;2u"` | +| Escape | 27 | `"\u001b[27;2u"` | +| Space | 32 | `"\u001b[32;2u"` | + +Full keycode table: [kitty keyboard protocol — functional key definitions](https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions). + +#### Binding examples + +| Intent | `key` | `mods` | `chars` | +|---|---|---|---| +| Shift+Enter → new line (opencode, Helix, etc.) | `"enter"` | `["shift"]` | `"\u001b[13;2u"` | +| Ctrl+Enter → same | `"enter"` | `["ctrl"]` | `"\u001b[13;5u"` | +| Shift+Tab → backtab | `"tab"` | `["shift"]` | `"\u001b[9;2u"` | +| Suppress a key (consume without sending) | `"enter"` | `["shift"]` | `""` | + ### Example ```json @@ -168,6 +228,11 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter "fontSize": 13, "fontFamily": "Menlo, Consolas, 'DejaVu Sans Mono', monospace", + "keyboardBindings": [ + { "key": "enter", "mods": ["shift"], "chars": "\u001b[13;2u" }, + { "key": "enter", "mods": ["ctrl"], "chars": "\u001b[13;5u" } + ], + "theme": { "background": "#000000", "foreground": "#CCCCCC", @@ -205,3 +270,4 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | Server logs | `logs: true` appends server stdout/stderr to `~/.config/webtty/server.log` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | | Mouse scroll speed | `mouseScrollSpeed` scales SGR events per wheel tick for apps with mouse tracking; default `1` | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | +| Keyboard bindings | `keyboardBindings` — configurable key-to-sequence mappings sent to PTY; defaults to `[]`, users add entries in `~/.config/webtty/config.json` | [ADR 018](../adrs/018.client.keyboard-bindings.md) | ✅ | diff --git a/src/cli/commands.ts b/src/cli/commands.ts index aa8a81a..36ff7ba 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -4,6 +4,11 @@ import path from 'node:path'; import { configDir } from '../config'; import { BASE_URL, isServerRunning, openBrowser, startServer, stopServer } from './http'; +/** + * Opens (or creates) session `id`, starts the server if needed, and opens the URL in the browser. + * + * @param id - The session ID to open (default: `'main'`). + */ export async function cmdGo(id = 'main'): Promise { if (!(await isServerRunning())) { await startServer(); @@ -33,6 +38,11 @@ export async function cmdGo(id = 'main'): Promise { openBrowser(url); } +/** + * Lists all active sessions, optionally filtered by a substring of the session ID. + * + * @param filter - Optional substring to filter session IDs. + */ export async function cmdList(filter?: string): Promise { let res: Response; try { @@ -58,6 +68,11 @@ export async function cmdList(filter?: string): Promise { } } +/** + * Removes session `id` and stops the server if no sessions remain. + * + * @param id - The session ID to remove. + */ export async function cmdRemove(id?: string): Promise { if (!id) { console.error('webtty: rm requires a session id'); @@ -87,6 +102,12 @@ export async function cmdRemove(id?: string): Promise { } } +/** + * Renames session `id` to `newId`. + * + * @param id - The current session ID. + * @param newId - The new session ID. + */ export async function cmdRename(id?: string, newId?: string): Promise { if (!id || !newId) { console.error('webtty: rename requires two arguments: [id] [new-id]'); @@ -115,6 +136,7 @@ export async function cmdRename(id?: string, newId?: string): Promise { } } +/** Stops the server if it is running. */ export async function cmdStop(): Promise { if (!(await isServerRunning())) { console.log('webtty is not running'); @@ -129,6 +151,7 @@ export async function cmdStop(): Promise { } } +/** Starts the server if it is not already running. */ export async function cmdStart(): Promise { if (await isServerRunning()) { console.log('webtty is already running'); @@ -138,6 +161,7 @@ export async function cmdStart(): Promise { console.log('webtty started'); } +/** Opens `~/.config/webtty/config.json` in `$VISUAL` / `$EDITOR`, creating it if absent. */ export function cmdConfig(): void { const dir = configDir(); const configPath = path.join(dir, 'config.json'); diff --git a/src/cli/http.ts b/src/cli/http.ts index 5d810dc..01995a7 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -7,13 +7,18 @@ import { configDir, loadConfig } from '../config'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +/** Active server port, resolved from `PORT` env or config default. */ export const PORT = Number(process.env.PORT) || 2346; + +/** Base URL for the local server (always 127.0.0.1). */ export const BASE_URL = `http://127.0.0.1:${PORT}`; +/** Returns the path to the server log file: `~/.config/webtty/server.log`. */ export function logPath(): string { return path.join(configDir(), 'server.log'); } +/** Returns `true` if the webtty server is reachable and responding to API requests. */ export async function isServerRunning(): Promise { try { const res = await fetch(`${BASE_URL}/api/sessions`); @@ -25,6 +30,13 @@ export async function isServerRunning(): Promise { } } +/** + * Spawns the server process detached, then polls until it is ready or `timeoutMs` expires. + * + * @param timeoutMs - Maximum time to wait for the server to start (default: 10000 ms). + * @param _spawn - Spawn function override for testing (default: childProcess.spawn). + * @throws Exits the process with code 1 if the server entry is not found or fails to start in time. + */ export async function startServer(timeoutMs = 10000, _spawn = childProcess.spawn): Promise { const isBun = typeof (globalThis as Record).Bun !== 'undefined'; const isTs = isBun && __filename.endsWith('.ts'); @@ -61,6 +73,13 @@ export async function startServer(timeoutMs = 10000, _spawn = childProcess.spawn process.exit(1); } +/** + * Sends `POST /api/server/stop`, then polls until the server is no longer reachable. + * + * @param baseUrl - The server base URL (default: BASE_URL). + * @param timeoutMs - Maximum time to wait for the server to stop (default: 5000 ms). + * @returns `true` if the server stopped successfully, `false` otherwise. + */ export async function stopServer(baseUrl: string = BASE_URL, timeoutMs = 5000): Promise { try { const res = await fetch(`${baseUrl}/api/server/stop`, { method: 'POST' }); @@ -76,6 +95,13 @@ export async function stopServer(baseUrl: string = BASE_URL, timeoutMs = 5000): } } +/** + * Opens `url` in the default system browser. + * No-op in test environments or when `WEBTTY_NO_OPEN=1` is set. + * + * @param url - The URL to open. + * @param _spawn - Spawn function override for testing (default: childProcess.spawn). + */ export function openBrowser(url: string, _spawn = childProcess.spawn): void { if (process.env.WEBTTY_NO_OPEN === '1') return; if (process.env.NODE_ENV === 'test') return; diff --git a/src/client/cursor.ts b/src/client/cursor.ts index 7d68c2d..a53dcb2 100644 --- a/src/client/cursor.ts +++ b/src/client/cursor.ts @@ -20,6 +20,14 @@ import type { Terminal } from 'ghostty-web'; const ESC = '\x1b'; const DECSCUSR = new RegExp(`${ESC}\\[(\\d*) q`, 'g'); +/** + * Scans `data` for DECSCUSR sequences (`CSI Ps SP q`) and applies matching cursor + * style/blink changes directly to `term.options`, then forces a full repaint so the + * previous cursor shape is cleared before the new one is drawn. + * + * ghostty-web does not yet propagate cursor style from PTY output — this is a + * client-side workaround until the WASM layer handles it natively. + */ export function applyDecscusr(term: Terminal, data: string): void { const initialStyle = term.options.cursorStyle; const initialBlink = term.options.cursorBlink; diff --git a/src/client/index.ts b/src/client/index.ts index 6070618..0a589a1 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,6 +1,12 @@ import { FitAddon, init, Terminal } from 'ghostty-web'; import { applyDecscusr } from './cursor'; +interface KeyboardBinding { + key: string; + mods?: string[]; + chars: string; +} + interface Theme { background?: string; foreground?: string; @@ -36,6 +42,7 @@ interface ClientConfig { copyOnSelect: boolean; rightClickBehavior: 'default' | 'copyPaste'; mouseScrollSpeed: number; + keyboardBindings: KeyboardBinding[]; } const sessionId = window.location.pathname.split('/s/')[1] ?? 'main'; @@ -145,6 +152,35 @@ term.attachCustomWheelEventHandler((e: WheelEvent): boolean => { return true; }); +// Intercept configured key+mods combos before ghostty-web sees them and send +// the bound chars directly to the PTY. See ADR 018. +container.addEventListener( + 'keydown', + (e: KeyboardEvent) => { + const key = e.key.toLowerCase(); + const active = new Set([ + ...(e.shiftKey ? ['shift'] : []), + ...(e.ctrlKey ? ['ctrl'] : []), + ...(e.altKey ? ['alt'] : []), + ...(e.metaKey ? ['meta'] : []), + ]); + const binding = config.keyboardBindings.find((b) => { + if (b.key.toLowerCase() !== key) return false; + const required = new Set((Array.isArray(b.mods) ? b.mods : []).map((m) => m.toLowerCase())); + if (required.size !== active.size) return false; + for (const m of required) if (!active.has(m)) return false; + return true; + }); + if (!binding) return; + e.preventDefault(); + e.stopPropagation(); + if (binding.chars && ws.readyState === WebSocket.OPEN) { + ws.send(binding.chars); + } + }, + { capture: true }, +); + // Forward terminal keystrokes and input to the PTY over WebSocket. term.onData((data: string) => { if (ws && ws.readyState === WebSocket.OPEN) { diff --git a/src/config.test.ts b/src/config.test.ts index c4ae9f8..3a24cea 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2,7 +2,14 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { DEFAULT_CONFIG, DEFAULT_THEME, initConfig, loadConfig } from './config'; +import { + DEFAULT_CONFIG, + DEFAULT_KEYBOARD_BINDINGS, + DEFAULT_THEME, + initConfig, + loadConfig, + mergeKeyboardBindings, +} from './config'; let tmpDir: string; let configPath: string; @@ -200,3 +207,105 @@ describe('loadConfig — reads and merges', () => { expect(() => loadConfig()).toThrow(/webtty:/); }); }); + +describe('loadConfig — keyboardBindings', () => { + function writeConfig(content: string) { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, content, 'utf8'); + } + + test('returns empty keyboardBindings when not set in file', () => { + writeConfig('{}'); + expect(loadConfig().keyboardBindings).toEqual([]); + }); + + test('user binding is used as-is when no defaults exist', () => { + writeConfig( + JSON.stringify({ keyboardBindings: [{ key: 'enter', mods: ['shift'], chars: 'custom' }] }), + ); + const bindings = loadConfig().keyboardBindings; + expect(bindings).toHaveLength(1); + expect(bindings[0].chars).toBe('custom'); + }); + + test('multiple user bindings are all preserved', () => { + writeConfig( + JSON.stringify({ + keyboardBindings: [ + { key: 'enter', mods: ['ctrl'], chars: '\u001b[13;5u' }, + { key: 'tab', mods: ['shift'], chars: '\u001b[9;2u' }, + ], + }), + ); + const bindings = loadConfig().keyboardBindings; + expect(bindings).toHaveLength(2); + expect(bindings.some((b) => b.key === 'enter' && b.mods?.includes('ctrl'))).toBe(true); + expect(bindings.some((b) => b.key === 'tab' && b.mods?.includes('shift'))).toBe(true); + }); + + test('mods order does not affect identity — ["ctrl","shift"] matches ["shift","ctrl"]', () => { + writeConfig( + JSON.stringify({ + keyboardBindings: [ + { key: 'enter', mods: ['ctrl', 'shift'], chars: 'first' }, + { key: 'enter', mods: ['shift', 'ctrl'], chars: 'last-wins' }, + ], + }), + ); + const bindings = loadConfig().keyboardBindings; + expect(bindings).toHaveLength(1); + expect(bindings[0].chars).toBe('last-wins'); + }); + + test('entries missing key or chars are silently ignored', () => { + writeConfig(JSON.stringify({ keyboardBindings: [{ mods: ['shift'] }, { key: 'enter' }] })); + expect(loadConfig().keyboardBindings).toEqual(DEFAULT_KEYBOARD_BINDINGS); + }); + + test('non-array keyboardBindings is ignored, defaults preserved', () => { + writeConfig(JSON.stringify({ keyboardBindings: 'invalid' })); + expect(loadConfig().keyboardBindings).toEqual(DEFAULT_KEYBOARD_BINDINGS); + }); + + test('binding with non-array mods is rejected', () => { + writeConfig(JSON.stringify({ keyboardBindings: [{ key: 'enter', chars: 'x', mods: {} }] })); + expect(loadConfig().keyboardBindings).toEqual([]); + }); + + test('unknown mods are filtered out at load time', () => { + writeConfig( + JSON.stringify({ + keyboardBindings: [{ key: 'enter', mods: ['shift', 'super', 'unknown'], chars: 'x' }], + }), + ); + const bindings = loadConfig().keyboardBindings; + expect(bindings[0].mods).toEqual(['shift']); + }); +}); + +describe('mergeKeyboardBindings', () => { + const base = { key: 'enter', mods: ['shift'], chars: '\u001b[13;2u' }; + + test('user entry replaces matching default by key+mods identity', () => { + const result = mergeKeyboardBindings([base], [{ ...base, chars: 'custom' }]); + expect(result).toHaveLength(1); + expect(result[0].chars).toBe('custom'); + }); + + test('user entry for different mods is added alongside default', () => { + const user = { key: 'enter', mods: ['ctrl'], chars: '\u001b[13;5u' }; + const result = mergeKeyboardBindings([base], [user]); + expect(result).toHaveLength(2); + }); + + test('default is preserved when user has no matching entry', () => { + const result = mergeKeyboardBindings([base], []); + expect(result).toEqual([base]); + }); + + test('mods order is irrelevant for identity', () => { + const user = { key: 'enter', mods: ['shift'], chars: 'replaced' }; + const result = mergeKeyboardBindings([{ ...base, mods: ['shift'] }], [user]); + expect(result[0].chars).toBe('replaced'); + }); +}); diff --git a/src/config.ts b/src/config.ts index 4529fee..369c679 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,17 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +/** A single keyboard binding: intercepts `key`+`mods` and sends `chars` to the PTY. */ +export interface KeyboardBinding { + /** Key name matched case-insensitively against `KeyboardEvent.key` (e.g. `"enter"`, `"arrowup"`, `"a"`). */ + key: string; + /** Modifier keys that must be active — and no others — for this binding to match. Accepted values: `"shift"`, `"ctrl"`, `"alt"`, `"meta"`. Order irrelevant. Unknown values are filtered out at config load time. */ + mods?: string[]; + /** Byte sequence sent verbatim to the PTY. Standard JSON escapes apply (`\r`, `\uXXXX`, etc.). */ + chars: string; +} + +/** Terminal color palette. All keys are optional; omitted keys fall back to the Campbell defaults. */ export interface Theme { /** Terminal background. */ background?: string; @@ -48,6 +59,7 @@ export interface Theme { /** Right-click behavior: `"copyPaste"` copies selection + clears it if selection exists, otherwise native menu; `"default"` always shows native context menu. */ export type RightClickBehavior = 'default' | 'copyPaste'; +/** Full webtty configuration. All keys are optional in the config file; missing keys fall back to {@link DEFAULT_CONFIG}. */ export interface Config { /** HTTP listen port; env `PORT` takes precedence. */ port: number; @@ -83,8 +95,11 @@ export interface Config { logs: boolean; /** Terminal color palette. */ theme: Theme; + /** Custom key-to-sequence bindings. Merged with built-in defaults by `(key, mods)` identity. */ + keyboardBindings: KeyboardBinding[]; } +/** Returns the webtty config directory: `~/.config/webtty`. */ export function configDir(): string { return path.join(process.env.HOME ?? os.homedir(), '.config', 'webtty'); } @@ -117,6 +132,9 @@ export const DEFAULT_THEME: Theme = { brightWhite: '#F2F2F2', }; +// NOTE: export for testing only; users should use loadConfig() and initConfig() instead +export const DEFAULT_KEYBOARD_BINDINGS: KeyboardBinding[] = []; + // NOTE: export for testing only; users should use loadConfig() and initConfig() instead export const DEFAULT_CONFIG: Config = { port: 2346, @@ -139,8 +157,55 @@ export const DEFAULT_CONFIG: Config = { mouseScrollSpeed: 1, logs: false, theme: DEFAULT_THEME, + keyboardBindings: DEFAULT_KEYBOARD_BINDINGS, }; +function bindingKey(b: KeyboardBinding): string { + const mods = [...(b.mods ?? [])].sort().join('+'); + return `${b.key.toLowerCase()}|${mods}`; +} + +const VALID_MODS = new Set(['shift', 'ctrl', 'alt', 'meta']); + +function isValidBinding(b: unknown): b is KeyboardBinding { + if (!b || typeof b !== 'object') return false; + const o = b as Record; + if (typeof o.key !== 'string' || typeof o.chars !== 'string') return false; + if (o.mods !== undefined && !Array.isArray(o.mods)) return false; + return true; +} + +function normalizeBinding(b: KeyboardBinding): KeyboardBinding { + return { + ...b, + key: b.key.toLowerCase(), + mods: (b.mods ?? []).map((m) => m.toLowerCase()).filter((m) => VALID_MODS.has(m)), + }; +} + +/** + * Merges user-supplied bindings over a set of defaults by `(key, mods)` identity. + * User entries replace matching defaults; unmatched user entries are appended. + * + * @param defaults - Default bindings to merge over. + * @param user - User-supplied bindings that override defaults. + * @returns Merged bindings with user entries replacing matching defaults and new user entries appended. + */ +// NOTE: export for testing only +export function mergeKeyboardBindings( + defaults: KeyboardBinding[], + user: KeyboardBinding[], +): KeyboardBinding[] { + const deduped = [...new Map(user.map((b) => [bindingKey(b), b])).values()]; + const overrides = new Map(deduped.map((b) => [bindingKey(b), b])); + const merged = defaults.map((d) => overrides.get(bindingKey(d)) ?? d); + const defaultKeys = new Set(defaults.map(bindingKey)); + for (const b of deduped) { + if (!defaultKeys.has(bindingKey(b))) merged.push(b); + } + return merged; +} + /** * Load config from `~/.config/webtty/config.json`, merged over `DEFAULT_CONFIG`. * @@ -206,6 +271,12 @@ export function loadConfig(): Config { p.mouseScrollSpeed > 0 && { mouseScrollSpeed: p.mouseScrollSpeed }), ...(typeof p.logs === 'boolean' && { logs: p.logs }), ...(p.theme && typeof p.theme === 'object' && { theme: { ...DEFAULT_THEME, ...p.theme } }), + ...(Array.isArray(p.keyboardBindings) && { + keyboardBindings: mergeKeyboardBindings( + DEFAULT_KEYBOARD_BINDINGS, + p.keyboardBindings.filter(isValidBinding).map(normalizeBinding), + ), + }), }; } diff --git a/src/pty/bun.ts b/src/pty/bun.ts index de9c75e..7330c1a 100644 --- a/src/pty/bun.ts +++ b/src/pty/bun.ts @@ -1,6 +1,16 @@ import { homedir } from 'node:os'; import type { PtyProcess } from './types'; +/** + * Spawns a PTY-backed shell using Bun's native `Bun.spawn` terminal API. + * + * @param shell - Shell executable path (e.g., `/bin/bash`). + * @param cols - Terminal width in columns. + * @param rows - Terminal height in rows. + * @param term - `$TERM` environment variable (e.g., `xterm-256color`). + * @param colorTerm - `$COLORTERM` environment variable (e.g., `truecolor`). + * @returns A {@link PtyProcess} handle for reading/writing and managing the PTY. + */ export function spawn( shell: string, cols: number, diff --git a/src/pty/index.ts b/src/pty/index.ts index 4d35d91..40c6a5b 100644 --- a/src/pty/index.ts +++ b/src/pty/index.ts @@ -7,6 +7,16 @@ const { spawn: _spawn } = await (isBun ? import('./bun') : import('./node')); export const spawn = _spawn; +/** + * Convenience wrapper: spawns a PTY using session-oriented parameters from config. + * + * @param cols - Terminal width in columns. + * @param rows - Terminal height in rows. + * @param shell - Shell executable path (e.g., `/bin/bash`). + * @param term - `$TERM` environment variable (e.g., `xterm-256color`). + * @param colorTerm - `$COLORTERM` environment variable (e.g., `truecolor`). + * @returns A {@link PtyProcess} handle for reading/writing and managing the PTY. + */ export function spawnForSession( cols: number, rows: number, diff --git a/src/pty/node.ts b/src/pty/node.ts index 4dbaf01..f6c0ebd 100644 --- a/src/pty/node.ts +++ b/src/pty/node.ts @@ -2,6 +2,16 @@ import { homedir } from 'node:os'; import nodePty from '@lydell/node-pty'; import type { PtyProcess } from './types'; +/** + * Spawns a PTY-backed shell using the `@lydell/node-pty` native addon. + * + * @param shell - Shell executable path (e.g., `/bin/bash`). + * @param cols - Terminal width in columns. + * @param rows - Terminal height in rows. + * @param term - `$TERM` environment variable (e.g., `xterm-256color`). + * @param colorTerm - `$COLORTERM` environment variable (e.g., `truecolor`). + * @returns A {@link PtyProcess} handle for reading/writing and managing the PTY. + */ export function spawn( shell: string, cols: number, diff --git a/src/pty/types.ts b/src/pty/types.ts index c726cd2..1aefa7e 100644 --- a/src/pty/types.ts +++ b/src/pty/types.ts @@ -1,7 +1,13 @@ +/** Minimal abstraction over a running PTY process. Implemented by both the Bun and node-pty backends. */ export interface PtyProcess { + /** Register a callback that receives raw UTF-8 output from the PTY. */ onData(cb: (data: string) => void): void; + /** Register a callback invoked when the child process exits. */ onExit(cb: (e: { exitCode: number }) => void): void; + /** Write raw input to the PTY (keyboard data, escape sequences, etc.). */ write(data: string): void; + /** Notify the PTY of a terminal resize. */ resize(cols: number, rows: number): void; + /** Terminate the child process. */ kill(): void; } diff --git a/src/server/routes.ts b/src/server/routes.ts index 05a7fc5..4def51a 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -23,6 +23,14 @@ function decodeId(raw: string): string | null { } } +/** + * Reads and JSON-parses the request body (max 64 KB). + * + * @param req - The incoming HTTP request. + * @returns A promise resolving to the parsed JSON object, or an empty object if body is empty. + * @throws {Error} with `status: 413` if body exceeds 64 KB. + * @throws {Error} if the body contains invalid JSON. + */ export function readJson(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { let body = ''; @@ -44,6 +52,17 @@ export function readJson(req: http.IncomingMessage): Promise { }); } +/** + * Main HTTP request handler for the webtty server. + * Dispatches all REST API routes and serves static client assets. + * + * @param req - The incoming HTTP request. + * @param res - The HTTP response object. + * @param distPath - Path to the server-side dist directory. + * @param wasmPath - Path to the ghostty-vt.wasm file. + * @param clientDistPath - Path to the client dist directory. + * @param onStop - Callback invoked when `POST /api/server/stop` is received. + */ export async function handleRequest( req: http.IncomingMessage, res: http.ServerResponse, @@ -77,6 +96,7 @@ export async function handleRequest( copyOnSelect: config.copyOnSelect, rightClickBehavior: config.rightClickBehavior, mouseScrollSpeed: config.mouseScrollSpeed, + keyboardBindings: config.keyboardBindings, }; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(clientConfig)); diff --git a/src/server/session.ts b/src/server/session.ts index d03d7d4..466b8a2 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -1,33 +1,60 @@ import type { WebSocket as WS } from 'ws'; import type { PtyProcess } from '../pty'; +/** A running (or recently created) terminal session. */ export interface Session { + /** Unique session identifier. */ id: string; + /** Unix timestamp (ms) when the session was created. */ createdAt: number; + /** The underlying PTY process, or `null` if no shell has been spawned yet. */ pty: PtyProcess | null; + /** All currently connected WebSocket clients for this session. */ clients: Set; + /** Accumulated PTY output retained for replay when a new client joins. */ scrollback: string; } +/** All active sessions, keyed by session ID. */ export const sessionRegistry = new Map(); + +/** ID of the most recently opened session, used for `GET /` redirect. */ export let lastUsedId: string | null = null; +/** Updates {@link lastUsedId}. */ export function setLastUsedId(id: string | null): void { lastUsedId = id; } const ID_RE = /^[a-z0-9\-_.]{1,64}$/; +/** + * Returns `true` if `id` is a valid session identifier (lowercase alphanumeric + `-_.`, max 64 chars). + * + * @param id - The session ID to validate. + * @returns `true` if the ID matches the valid format, `false` otherwise. + */ export function isValidId(id: string): boolean { return ID_RE.test(id); } +/** + * Generates a random 8-character hex session ID. + * + * @returns A random session ID. + */ export function generateId(): string { return Math.floor(Math.random() * 0xffffffff) .toString(16) .padStart(8, '0'); } +/** + * Creates a new session, registers it in {@link sessionRegistry}, and returns it. + * + * @param id - The session ID. + * @returns The newly created {@link Session}. + */ export function createSession(id: string): Session { const session: Session = { id, @@ -40,6 +67,12 @@ export function createSession(id: string): Session { return session; } +/** + * Returns a plain JSON-safe representation of a session for API responses. + * + * @param s - The session to serialize. + * @returns A JSON-safe object with session ID, creation timestamp, and connection status. + */ export function sessionToJson(s: Session) { return { id: s.id, createdAt: s.createdAt, connected: s.clients.size > 0 }; } diff --git a/src/server/static.ts b/src/server/static.ts index 1b848bf..561b3c9 100644 --- a/src/server/static.ts +++ b/src/server/static.ts @@ -9,6 +9,7 @@ const __dirname = path.dirname(__filename); const require = createRequire(import.meta.url); +/** File extension → MIME type map used by {@link serveFile}. */ export const MIME_TYPES: Record = { '.html': 'text/html', '.js': 'application/javascript', @@ -21,15 +22,34 @@ export const MIME_TYPES: Record = { '.ico': 'image/x-icon', }; +/** + * Returns the MIME type for `filePath` based on its extension, defaulting to `application/octet-stream`. + * + * @param filePath - The file path to determine the MIME type for. + * @returns The MIME type string. + */ export function mimeType(filePath: string): string { const ext = path.extname(filePath); return MIME_TYPES[ext] ?? 'application/octet-stream'; } +/** + * Strips the `dist/…` suffix from a ghostty-web main entry path to get the package root. + * + * @param mainPath - The main entry path from ghostty-web package. + * @returns The package root directory path. + */ export function ghosttyWebRootFromMain(mainPath: string): string { return mainPath.replace(/[/\\]dist[/\\].*$/, ''); } +/** + * Locates the ghostty-web package, preferring assets bundled in `dist/` (npm install) + * over `node_modules/` (local dev). Exits the process if the package cannot be found. + * + * @returns An object with `distPath` (ghostty-web dist directory) and `wasmPath` (ghostty-vt.wasm file). + * @throws Exits the process with code 1 if ghostty-web cannot be found. + */ export function findGhosttyWeb(): { distPath: string; wasmPath: string } { // Prefer assets bundled into dist/ — present when installed via npx/npm. const bundledDist = path.join(__dirname, '..', '..', 'dist'); @@ -54,6 +74,13 @@ export function findGhosttyWeb(): { distPath: string; wasmPath: string } { process.exit(1); } +/** + * Reads `filePath` from disk and writes it to `res` with the correct Content-Type header. + * Responds with 404 if the file cannot be read. + * + * @param filePath - The file path to serve. + * @param res - The HTTP response object. + */ export function serveFile(filePath: string, res: http.ServerResponse): void { const contentType = mimeType(filePath); fs.readFile(filePath, (err, data) => { diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 6aab686..b787c25 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -17,10 +17,16 @@ function closeClients(session: Session, code: number, reason: string): void { for (const client of session.clients) client.close(code, reason); } +/** + * Closes all WebSocket clients for `session` with the SESSION_GONE close code. + * + * @param session - The session to close. + */ export function closeSession(session: Session): void { closeClients(session, WS_CLOSE.SESSION_GONE, 'session deleted'); } +/** Closes all WebSocket clients across every active session with the SERVER_STOPPED close code. */ export function closeAllSessions(): void { for (const session of sessionRegistry.values()) { closeClients(session, WS_CLOSE.SERVER_STOPPED, 'server stopped'); @@ -29,6 +35,11 @@ export function closeAllSessions(): void { let onLastSessionClosed: (() => void) | null = null; +/** + * Registers a callback invoked once the last session closes (e.g. to stop the HTTP server). + * + * @param handler - Callback to invoke when the last session closes. + */ export function setLastSessionClosedHandler(handler: () => void): void { onLastSessionClosed = handler; } @@ -76,6 +87,13 @@ function sessionBanner(): string { ].join(''); } +/** + * Attaches a WebSocket server to `httpServer`, handling PTY I/O, session management, + * scrollback replay, and terminal resize for all `/ws/:id` connections. + * + * @param httpServer - The HTTP server to attach the WebSocket server to. + * @returns The configured {@link WebSocketServer}. + */ export function createWebSocketServer(httpServer: http.Server): WebSocketServer { const wss = new WebSocketServer({ noServer: true });