From b84974e75b3163e400f58edac7773ab37f4bcb32 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 8 Jun 2026 20:29:54 -0400 Subject: [PATCH 01/10] docs: add named pub/sub channel spec and ADR 025 Co-Authored-By: Claude Sonnet 4.6 --- docs/adrs/025.server.channel.md | 76 +++++++++++++++++++++++++++++++ docs/specs/channel.md | 79 +++++++++++++++++++++++++++++++++ docs/specs/webtty.md | 1 + 3 files changed, 156 insertions(+) create mode 100644 docs/adrs/025.server.channel.md create mode 100644 docs/specs/channel.md diff --git a/docs/adrs/025.server.channel.md b/docs/adrs/025.server.channel.md new file mode 100644 index 0000000..7721599 --- /dev/null +++ b/docs/adrs/025.server.channel.md @@ -0,0 +1,76 @@ +# ADR 025: Server — Named pub/sub channel + +**SPEC:** [channel](../specs/channel.md) +**Status:** Accepted +**Date:** 2026-06-08 + +--- + +## Context + +Agent tools and CLI scripts that produce structured output need a way to stream results to a browser UI in real-time. The pattern is simple: one publisher (a curl call or MCP tool), N subscribers (browser tabs). + +The current workaround in the Fusion project is a standalone `sync-server.ts` — a separate Bun process on a separate port (`2347`) that must be started independently. This creates unnecessary friction: two processes to manage, a hardcoded port, and app-specific code that is really a generic utility. + +webtty already runs an HTTP server when `webtty go ` is invoked. Adding pub/sub channels to that server eliminates the separate process entirely. + +--- + +## Decision + +Add named pub/sub channel support to the existing webtty HTTP server. + +- `POST /channel/:name/publish` — accepts a JSON body and broadcasts it to all current subscribers of `:name` +- `GET /channel/:name/subscribe` — WebSocket upgrade; the connection joins the subscriber set for `:name` and receives all subsequent publishes as text frames + +Channels are implicit (no create/delete API), ephemeral (no persistence or replay), and always-on (no flag or config needed). + +Implementation touches two files: +1. `src/server/routes.ts` — add the `POST` route and WS upgrade route +2. `src/server/websocket.ts` — add `Map>` channel registry and `broadcastToChannel` helper + +--- + +## Reasons + +### Piggyback on the existing server + +webtty's HTTP server is already running whenever a session is open. Adding two routes costs nothing operationally and removes the need for users to start or manage a second process. + +### Named channels over a single global bus + +A single unnamed channel would couple every publisher to every subscriber on the same server. Named channels (`/channel/:name/*`) let multiple independent features coexist on the same webtty instance without interference. + +### No persistence / replay + +The use case is live streaming — agents push results as they arrive, browser UIs render them immediately. Replay requires storage and complicates cleanup. A subscriber that connects late simply misses earlier payloads, which is acceptable for the current use cases (search results, agent status). + +--- + +## Considered Options + +### Option A: Separate standalone server (current `sync-server.ts`) + +Each app ships its own pub/sub server. Works, but requires a second process, a second port, and duplicates the same ~90-line pattern across projects. + +Rejected — the code is generic enough to live in webtty once. + +### Option B: Add a `webtty channel` CLI subcommand that starts a dedicated channel-only server + +A dedicated process for channel-only use without starting a PTY session. Useful if the caller does not want a terminal at all. + +Deferred — the primary use case always has a terminal session alongside the channel. Can be added later without breaking the `/channel/*` routes. + +### Option C: Use SSE instead of WebSocket for subscribe + +Server-Sent Events are simpler for one-way streaming and work natively in `EventSource`. However, webtty already has WebSocket infrastructure and the browser clients (e.g. Fusion) already use WebSocket. Keeping a single transport is simpler. + +Rejected for now — SSE can be added as an additional endpoint later if needed. + +--- + +## Consequences + +- `apps/fusion/sync-server.ts` can be deleted; Fusion points its agent push and UI subscribe at the webtty port instead. +- Channel names must follow session ID character rules to keep routing unambiguous. +- No auth on publish — callers must be on the same host or behind the same network boundary as webtty (consistent with the existing session API). diff --git a/docs/specs/channel.md b/docs/specs/channel.md new file mode 100644 index 0000000..a418916 --- /dev/null +++ b/docs/specs/channel.md @@ -0,0 +1,79 @@ +# SPEC: channel + +**Last Updated:** 2026-06-08 + +--- + +## Description + +A named pub/sub channel built into the webtty server. Any process can push a JSON payload to a named channel via HTTP; any number of WebSocket subscribers receive it in real-time. + +**Persona:** Developers who want to pipe structured output from a CLI agent/tool into a browser UI without running a separate server process. + +**Key property:** the channel is always-on — it piggybacks on the existing webtty server with no extra port or process. Running `bunx webtty go ` is sufficient. + +## Channel model + +Channels are ephemeral and implicit — they are created on first subscribe and destroyed when the last subscriber disconnects. There is no persistent state; payloads are not stored or replayed. + +```typescript +// Server-side (internal) +channels: Map> +``` + +Channel names follow the same rules as session IDs: `a-z`, `0-9`, `-`, `_`, `.`, 1–64 characters. + +## HTTP API + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/channel/:name/publish` | Push a JSON payload to all subscribers of `:name`; `204` on success; `400` if body is not valid JSON; `415` if `Content-Type` is not `application/json` | +| `GET` | `/channel/:name/subscribe` | WebSocket upgrade — joins the subscriber set for `:name`; server sends published payloads as JSON strings | + +### Publish request + +``` +POST /channel/fusion-sync/publish +Content-Type: application/json + +{ "type": "search-result", "items": [...] } +``` + +Response: `204 No Content` + +### Subscribe (WebSocket) + +``` +ws://localhost:2346/channel/fusion-sync/subscribe +``` + +Each published payload arrives as a single WebSocket text frame containing the JSON string. + +## Usage + +Start webtty as usual: + +```sh +bunx webtty go my-session +``` + +From an agent or CLI tool, push results: + +```sh +curl -X POST http://localhost:2346/channel/fusion-sync/publish \ + -H 'Content-Type: application/json' \ + -d '{"type":"result","data":[...]}' +``` + +A browser UI subscribes via WebSocket: + +```js +const ws = new WebSocket('ws://localhost:2346/channel/fusion-sync/subscribe'); +ws.onmessage = (e) => console.log(JSON.parse(e.data)); +``` + +## Features + +| Feature | Description | ADR | Done? | +|---------|-------------|-----|-------| +| Named pub/sub channel | POST publish + WebSocket subscribe on the existing webtty server | [ADR 025](../adrs/025.server.channel.md) | ❌ | diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index 1b021b0..70a4d6d 100644 --- a/docs/specs/webtty.md +++ b/docs/specs/webtty.md @@ -80,3 +80,4 @@ Session IDs appear directly in the URL path (`/s/:id`), so they must be valid UR | Default session | `GET /` redirects to last-used session, or creates `main` if none exists | — | ✅ | | Multi-client sessions | Multiple browser tabs can attach to the same session; PTY output broadcast to all; scrollback replayed on reconnect | [ADR 007](../adrs/007.webtty.session-client.md) | ✅ | | Config file | Shell, port, font, theme from `~/.config/webtty/config.json`; hot-reload on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | +| Named pub/sub channel | POST publish + WebSocket subscribe on the existing server; no extra process or port | [ADR 025](../adrs/025.server.channel.md) | ❌ | From b71ea7808e011cc9e5bc29e0ab22eea6ad346316 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 8 Jun 2026 20:32:47 -0400 Subject: [PATCH 02/10] =?UTF-8?q?docs:=20rename=20channel.md=20=E2=86=92?= =?UTF-8?q?=20client-integration.md=20with=20use-case=20framing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- docs/adrs/025.server.channel.md | 2 +- docs/specs/channel.md | 79 -------------------------- docs/specs/client-integration.md | 97 ++++++++++++++++++++++++++++++++ docs/specs/webtty.md | 2 +- 4 files changed, 99 insertions(+), 81 deletions(-) delete mode 100644 docs/specs/channel.md create mode 100644 docs/specs/client-integration.md diff --git a/docs/adrs/025.server.channel.md b/docs/adrs/025.server.channel.md index 7721599..b3bcb6b 100644 --- a/docs/adrs/025.server.channel.md +++ b/docs/adrs/025.server.channel.md @@ -1,6 +1,6 @@ # ADR 025: Server — Named pub/sub channel -**SPEC:** [channel](../specs/channel.md) +**SPEC:** [client-integration](../specs/client-integration.md) **Status:** Accepted **Date:** 2026-06-08 diff --git a/docs/specs/channel.md b/docs/specs/channel.md deleted file mode 100644 index a418916..0000000 --- a/docs/specs/channel.md +++ /dev/null @@ -1,79 +0,0 @@ -# SPEC: channel - -**Last Updated:** 2026-06-08 - ---- - -## Description - -A named pub/sub channel built into the webtty server. Any process can push a JSON payload to a named channel via HTTP; any number of WebSocket subscribers receive it in real-time. - -**Persona:** Developers who want to pipe structured output from a CLI agent/tool into a browser UI without running a separate server process. - -**Key property:** the channel is always-on — it piggybacks on the existing webtty server with no extra port or process. Running `bunx webtty go ` is sufficient. - -## Channel model - -Channels are ephemeral and implicit — they are created on first subscribe and destroyed when the last subscriber disconnects. There is no persistent state; payloads are not stored or replayed. - -```typescript -// Server-side (internal) -channels: Map> -``` - -Channel names follow the same rules as session IDs: `a-z`, `0-9`, `-`, `_`, `.`, 1–64 characters. - -## HTTP API - -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/channel/:name/publish` | Push a JSON payload to all subscribers of `:name`; `204` on success; `400` if body is not valid JSON; `415` if `Content-Type` is not `application/json` | -| `GET` | `/channel/:name/subscribe` | WebSocket upgrade — joins the subscriber set for `:name`; server sends published payloads as JSON strings | - -### Publish request - -``` -POST /channel/fusion-sync/publish -Content-Type: application/json - -{ "type": "search-result", "items": [...] } -``` - -Response: `204 No Content` - -### Subscribe (WebSocket) - -``` -ws://localhost:2346/channel/fusion-sync/subscribe -``` - -Each published payload arrives as a single WebSocket text frame containing the JSON string. - -## Usage - -Start webtty as usual: - -```sh -bunx webtty go my-session -``` - -From an agent or CLI tool, push results: - -```sh -curl -X POST http://localhost:2346/channel/fusion-sync/publish \ - -H 'Content-Type: application/json' \ - -d '{"type":"result","data":[...]}' -``` - -A browser UI subscribes via WebSocket: - -```js -const ws = new WebSocket('ws://localhost:2346/channel/fusion-sync/subscribe'); -ws.onmessage = (e) => console.log(JSON.parse(e.data)); -``` - -## Features - -| Feature | Description | ADR | Done? | -|---------|-------------|-----|-------| -| Named pub/sub channel | POST publish + WebSocket subscribe on the existing webtty server | [ADR 025](../adrs/025.server.channel.md) | ❌ | diff --git a/docs/specs/client-integration.md b/docs/specs/client-integration.md new file mode 100644 index 0000000..abd7097 --- /dev/null +++ b/docs/specs/client-integration.md @@ -0,0 +1,97 @@ +# SPEC: Client Integration (CLI → Web) + +**Last Updated:** 2026-06-08 + +--- + +## Description + +A pattern for pushing live updates from a CLI tool or agent into a browser UI — without running a separate server process. + +**Persona:** Developers building CLI agents or tools that produce structured output and want to surface that output in a browser UI in real-time. + +**Core idea:** webtty's HTTP server is already running whenever a terminal session is open. A named pub/sub channel piggybacks on that server so any CLI process can `POST` a JSON payload and any number of browser tabs receive it immediately over WebSocket — no extra port, no extra process. + +--- + +## Use Cases + +### Agent streaming results to a UI + +An AI agent or search tool runs in the terminal and streams structured results (search hits, status updates, progress) to a browser panel that renders them as they arrive. + +``` +CLI agent → POST /channel/my-agent/publish → webtty server → WS → browser panel +``` + +### Replacing a bespoke sync server + +Today, projects like Fusion ship a standalone `sync-server.ts` that must be started separately on a dedicated port. The client-integration channel replaces this with two routes on the webtty port that is already running. + +--- + +## Integration Pattern + +### 1. Start webtty + +```sh +bunx webtty go my-session +``` + +This is the only process needed. The channel endpoints are always-on. + +### 2. Subscribe in the browser + +```js +const ws = new WebSocket('ws://localhost:2346/channel/my-agent/subscribe'); +ws.onmessage = (e) => { + const payload = JSON.parse(e.data); + // render payload in UI +}; +``` + +### 3. Publish from the CLI + +From a shell script, agent, or MCP tool: + +```sh +curl -X POST http://localhost:2346/channel/my-agent/publish \ + -H 'Content-Type: application/json' \ + -d '{"type":"result","items":[...]}' +``` + +Or from Node/Bun: + +```ts +await fetch('http://localhost:2346/channel/my-agent/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'status', progress: 0.42 }), +}); +``` + +--- + +## Channel Semantics + +- **Implicit lifecycle** — channels are created on first subscriber and torn down when the last subscriber disconnects. No create/delete API. +- **No persistence** — payloads are not stored or replayed. A subscriber that connects late misses earlier messages (acceptable for live-streaming use cases). +- **Multiple channels** — use distinct names (e.g. `search`, `agent-status`) so independent features on the same webtty instance don't interfere. +- **Channel names** — `a-z`, `0-9`, `-`, `_`, `.`, 1–64 characters (same rules as session IDs). + +--- + +## API Reference + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/channel/:name/publish` | Broadcast a JSON payload to all current subscribers; `204` on success; `400` if body is not valid JSON; `415` if `Content-Type` is not `application/json` | +| `GET` | `/channel/:name/subscribe` | WebSocket upgrade — joins the subscriber set for `:name`; receives published payloads as JSON text frames | + +--- + +## Features + +| Feature | Description | ADR | Done? | +|---------|-------------|-----|-------| +| Named pub/sub channel | POST publish + WebSocket subscribe on the existing webtty server | [ADR 025](../adrs/025.server.channel.md) | ❌ | diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index 70a4d6d..5e8616b 100644 --- a/docs/specs/webtty.md +++ b/docs/specs/webtty.md @@ -80,4 +80,4 @@ Session IDs appear directly in the URL path (`/s/:id`), so they must be valid UR | Default session | `GET /` redirects to last-used session, or creates `main` if none exists | — | ✅ | | Multi-client sessions | Multiple browser tabs can attach to the same session; PTY output broadcast to all; scrollback replayed on reconnect | [ADR 007](../adrs/007.webtty.session-client.md) | ✅ | | Config file | Shell, port, font, theme from `~/.config/webtty/config.json`; hot-reload on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | -| Named pub/sub channel | POST publish + WebSocket subscribe on the existing server; no extra process or port | [ADR 025](../adrs/025.server.channel.md) | ❌ | +| Client integration (CLI → Web) | Named pub/sub channel on the existing server; CLI agents push JSON, browser UIs subscribe via WebSocket; no extra process or port | [ADR 025](../adrs/025.server.channel.md) | ❌ | From 790de0ce37627aaab95d7292f50679b959e1714b Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 8 Jun 2026 20:40:21 -0400 Subject: [PATCH 03/10] docs: session-scoped channel, streaming publish, remove named channels Co-Authored-By: Claude Sonnet 4.6 --- docs/adrs/025.server.channel.md | 52 +++++++++++++++++++----------- docs/specs/client-integration.md | 54 ++++++++++++++++++++++---------- docs/specs/webtty.md | 2 +- 3 files changed, 72 insertions(+), 36 deletions(-) diff --git a/docs/adrs/025.server.channel.md b/docs/adrs/025.server.channel.md index b3bcb6b..d3f2cbe 100644 --- a/docs/adrs/025.server.channel.md +++ b/docs/adrs/025.server.channel.md @@ -1,4 +1,4 @@ -# ADR 025: Server — Named pub/sub channel +# ADR 025: Server — Session-scoped client integration channel **SPEC:** [client-integration](../specs/client-integration.md) **Status:** Accepted @@ -12,58 +12,72 @@ Agent tools and CLI scripts that produce structured output need a way to stream The current workaround in the Fusion project is a standalone `sync-server.ts` — a separate Bun process on a separate port (`2347`) that must be started independently. This creates unnecessary friction: two processes to manage, a hardcoded port, and app-specific code that is really a generic utility. -webtty already runs an HTTP server when `webtty go ` is invoked. Adding pub/sub channels to that server eliminates the separate process entirely. +webtty already runs an HTTP server when `webtty go ` is invoked. Adding publish/subscribe endpoints to that server eliminates the separate process entirely. --- ## Decision -Add named pub/sub channel support to the existing webtty HTTP server. +Add a session-scoped integration channel to the existing webtty HTTP server. -- `POST /channel/:name/publish` — accepts a JSON body and broadcasts it to all current subscribers of `:name` -- `GET /channel/:name/subscribe` — WebSocket upgrade; the connection joins the subscriber set for `:name` and receives all subsequent publishes as text frames +- `POST /s/:id/publish` — accepts either a single JSON body (`application/json`) or a streaming NDJSON body (`application/x-ndjson`); broadcasts each JSON object to all current WebSocket subscribers of that session +- `GET /s/:id/subscribe` — WebSocket upgrade; the connection joins the subscriber set for the session and receives all subsequent publishes as discrete text frames -Channels are implicit (no create/delete API), ephemeral (no persistence or replay), and always-on (no flag or config needed). +One channel per session. No separate channel name or creation step — the session ID is the channel. Implementation touches two files: -1. `src/server/routes.ts` — add the `POST` route and WS upgrade route -2. `src/server/websocket.ts` — add `Map>` channel registry and `broadcastToChannel` helper +1. `src/server/routes.ts` — add the `POST` publish route and WS upgrade route under `/s/:id/` +2. `src/server/websocket.ts` — add `Map>` subscriber registry per session and `broadcastToSession` helper --- ## Reasons +### Session as the natural channel namespace + +A separate `/channel/:name/*` namespace requires the publisher and subscriber to agree on a channel name independently of the session. Session-scoped routing removes that coordination — the session ID both parties already share becomes the channel. One channel per session is sufficient for all current use cases. + ### Piggyback on the existing server webtty's HTTP server is already running whenever a session is open. Adding two routes costs nothing operationally and removes the need for users to start or manage a second process. -### Named channels over a single global bus +### Both streaming and non-streaming publish + +A single JSON POST covers one-shot messages (status updates, completed results). NDJSON streaming over a single chunked request covers agents that emit output incrementally (LLM token streams, long-running search). The server broadcasts each parsed JSON object as a discrete WS frame either way — subscribers see a uniform sequence of messages regardless of how the publisher sent them. + +### WebSocket for subscribe -A single unnamed channel would couple every publisher to every subscriber on the same server. Named channels (`/channel/:name/*`) let multiple independent features coexist on the same webtty instance without interference. +WebSocket is message-based, which maps directly to the event stream model: each broadcast from the server is a discrete frame. Subscribers do not need to handle HTTP chunking or SSE parsing. webtty already has WebSocket infrastructure, so no new transport is introduced. ### No persistence / replay -The use case is live streaming — agents push results as they arrive, browser UIs render them immediately. Replay requires storage and complicates cleanup. A subscriber that connects late simply misses earlier payloads, which is acceptable for the current use cases (search results, agent status). +The use case is live streaming — agents push results as they arrive, browser UIs render them immediately. Replay requires storage and complicates cleanup. A subscriber that connects late simply misses earlier payloads, which is acceptable for the current use cases. --- ## Considered Options -### Option A: Separate standalone server (current `sync-server.ts`) +### Option A: Named channels (`/channel/:name/*`) instead of session-scoped + +A separate namespace allows multiple independent channels on the same webtty instance without tying them to a session. More flexible, but introduces a second naming dimension and requires publisher/subscriber to agree on a name outside the session context. + +Rejected — one channel per session covers all current use cases. Named channels can be layered on top later if needed. + +### Option B: Separate standalone server (current `sync-server.ts`) Each app ships its own pub/sub server. Works, but requires a second process, a second port, and duplicates the same ~90-line pattern across projects. Rejected — the code is generic enough to live in webtty once. -### Option B: Add a `webtty channel` CLI subcommand that starts a dedicated channel-only server +### Option C: Non-streaming publish only -A dedicated process for channel-only use without starting a PTY session. Useful if the caller does not want a terminal at all. +Simpler server implementation — parse the full body, then broadcast once. Does not support agents that stream output incrementally. -Deferred — the primary use case always has a terminal session alongside the channel. Can be added later without breaking the `/channel/*` routes. +Rejected — NDJSON streaming is a first-class use case for LLM agents. The server-side cost is a line-split loop over the request body stream, which Bun supports natively. -### Option C: Use SSE instead of WebSocket for subscribe +### Option D: SSE instead of WebSocket for subscribe -Server-Sent Events are simpler for one-way streaming and work natively in `EventSource`. However, webtty already has WebSocket infrastructure and the browser clients (e.g. Fusion) already use WebSocket. Keeping a single transport is simpler. +Server-Sent Events are simpler for one-way streaming and work natively in `EventSource`. However, webtty already has WebSocket infrastructure and browser clients (e.g. Fusion) already use WebSocket. Keeping a single transport is simpler. Rejected for now — SSE can be added as an additional endpoint later if needed. @@ -71,6 +85,6 @@ Rejected for now — SSE can be added as an additional endpoint later if needed. ## Consequences -- `apps/fusion/sync-server.ts` can be deleted; Fusion points its agent push and UI subscribe at the webtty port instead. -- Channel names must follow session ID character rules to keep routing unambiguous. +- `apps/fusion/sync-server.ts` can be deleted; Fusion points its agent push at `POST /s/:id/publish` and its UI subscribe at `ws://host/s/:id/subscribe` on the webtty port. - No auth on publish — callers must be on the same host or behind the same network boundary as webtty (consistent with the existing session API). +- The subscriber registry (`Map>`) must be cleaned up when a session is deleted; `broadcastToSession` is a no-op when the set is empty or absent. diff --git a/docs/specs/client-integration.md b/docs/specs/client-integration.md index abd7097..a9a03be 100644 --- a/docs/specs/client-integration.md +++ b/docs/specs/client-integration.md @@ -10,7 +10,7 @@ A pattern for pushing live updates from a CLI tool or agent into a browser UI **Persona:** Developers building CLI agents or tools that produce structured output and want to surface that output in a browser UI in real-time. -**Core idea:** webtty's HTTP server is already running whenever a terminal session is open. A named pub/sub channel piggybacks on that server so any CLI process can `POST` a JSON payload and any number of browser tabs receive it immediately over WebSocket — no extra port, no extra process. +**Core idea:** webtty's HTTP server is already running whenever a session is open. Each session has a built-in sidecar channel: a CLI process can `POST` JSON to the session's publish endpoint and any number of browser tabs receive it immediately over WebSocket — no extra port, no extra process, no separate channel name to manage. --- @@ -21,12 +21,12 @@ A pattern for pushing live updates from a CLI tool or agent into a browser UI An AI agent or search tool runs in the terminal and streams structured results (search hits, status updates, progress) to a browser panel that renders them as they arrive. ``` -CLI agent → POST /channel/my-agent/publish → webtty server → WS → browser panel +CLI agent → POST /s/:id/publish → webtty server → WS → browser panel ``` ### Replacing a bespoke sync server -Today, projects like Fusion ship a standalone `sync-server.ts` that must be started separately on a dedicated port. The client-integration channel replaces this with two routes on the webtty port that is already running. +Projects like Fusion today ship a standalone `sync-server.ts` that must be started separately on a dedicated port. The session channel replaces this with two routes on the webtty port that is already running. --- @@ -38,55 +38,75 @@ Today, projects like Fusion ship a standalone `sync-server.ts` that must be star bunx webtty go my-session ``` -This is the only process needed. The channel endpoints are always-on. +This is the only process needed. The publish and subscribe endpoints are always-on for every session. ### 2. Subscribe in the browser ```js -const ws = new WebSocket('ws://localhost:2346/channel/my-agent/subscribe'); +const ws = new WebSocket('ws://localhost:2346/s/my-session/subscribe'); ws.onmessage = (e) => { const payload = JSON.parse(e.data); // render payload in UI }; ``` +Each published event arrives as a single WebSocket text frame containing a JSON string. + ### 3. Publish from the CLI -From a shell script, agent, or MCP tool: +**One-shot** — a single JSON object posted in one request: ```sh -curl -X POST http://localhost:2346/channel/my-agent/publish \ +curl -X POST http://localhost:2346/s/my-session/publish \ -H 'Content-Type: application/json' \ -d '{"type":"result","items":[...]}' ``` +**Streaming** — newline-delimited JSON (NDJSON) over a single chunked request; the server broadcasts each line as it arrives: + +```sh +my-agent --stream | curl -X POST http://localhost:2346/s/my-session/publish \ + -H 'Content-Type: application/x-ndjson' \ + --data-binary @- +``` + Or from Node/Bun: ```ts -await fetch('http://localhost:2346/channel/my-agent/publish', { +// one-shot +await fetch('http://localhost:2346/s/my-session/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'status', progress: 0.42 }), }); + +// streaming (NDJSON) +const { writable } = new TransformStream(); +await fetch('http://localhost:2346/s/my-session/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/x-ndjson' }, + body: readable, // ReadableStream of newline-terminated JSON strings +}); ``` --- ## Channel Semantics -- **Implicit lifecycle** — channels are created on first subscriber and torn down when the last subscriber disconnects. No create/delete API. +- **Session-scoped** — each session has exactly one channel; the session ID is the channel name. No separate channel creation or naming needed. +- **Implicit lifecycle** — the channel is live as long as the session exists. Subscribers can connect and disconnect freely. - **No persistence** — payloads are not stored or replayed. A subscriber that connects late misses earlier messages (acceptable for live-streaming use cases). -- **Multiple channels** — use distinct names (e.g. `search`, `agent-status`) so independent features on the same webtty instance don't interfere. -- **Channel names** — `a-z`, `0-9`, `-`, `_`, `.`, 1–64 characters (same rules as session IDs). +- **Multiple subscribers** — any number of browser tabs can subscribe to the same session channel simultaneously. --- ## API Reference -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/channel/:name/publish` | Broadcast a JSON payload to all current subscribers; `204` on success; `400` if body is not valid JSON; `415` if `Content-Type` is not `application/json` | -| `GET` | `/channel/:name/subscribe` | WebSocket upgrade — joins the subscriber set for `:name`; receives published payloads as JSON text frames | +| Method | Path | Content-Type | Description | +|--------|------|-------------|-------------| +| `POST` | `/s/:id/publish` | `application/json` | Broadcast a single JSON payload to all current subscribers; `204` on success; `400` if body is not valid JSON; `404` if session does not exist | +| `POST` | `/s/:id/publish` | `application/x-ndjson` | Stream NDJSON; each newline-terminated line is parsed and broadcast as it arrives; connection held open until publisher closes it | +| `GET` | `/s/:id/subscribe` | — | WebSocket upgrade — joins the subscriber set for the session; receives published payloads as JSON text frames; `404` if session does not exist | --- @@ -94,4 +114,6 @@ await fetch('http://localhost:2346/channel/my-agent/publish', { | Feature | Description | ADR | Done? | |---------|-------------|-----|-------| -| Named pub/sub channel | POST publish + WebSocket subscribe on the existing webtty server | [ADR 025](../adrs/025.server.channel.md) | ❌ | +| Session channel — one-shot publish | `POST /s/:id/publish` with `application/json` broadcasts a single event to all WebSocket subscribers | [ADR 025](../adrs/025.server.channel.md) | ❌ | +| Session channel — streaming publish | `POST /s/:id/publish` with `application/x-ndjson` broadcasts each line as it arrives | [ADR 025](../adrs/025.server.channel.md) | ❌ | +| Session channel — subscribe | `GET /s/:id/subscribe` WebSocket upgrade; receives published events as discrete frames | [ADR 025](../adrs/025.server.channel.md) | ❌ | diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index 5e8616b..6656245 100644 --- a/docs/specs/webtty.md +++ b/docs/specs/webtty.md @@ -80,4 +80,4 @@ Session IDs appear directly in the URL path (`/s/:id`), so they must be valid UR | Default session | `GET /` redirects to last-used session, or creates `main` if none exists | — | ✅ | | Multi-client sessions | Multiple browser tabs can attach to the same session; PTY output broadcast to all; scrollback replayed on reconnect | [ADR 007](../adrs/007.webtty.session-client.md) | ✅ | | Config file | Shell, port, font, theme from `~/.config/webtty/config.json`; hot-reload on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | -| Client integration (CLI → Web) | Named pub/sub channel on the existing server; CLI agents push JSON, browser UIs subscribe via WebSocket; no extra process or port | [ADR 025](../adrs/025.server.channel.md) | ❌ | +| Client integration (CLI → Web) | Session-scoped publish (`POST /s/:id/publish`, one-shot JSON or streaming NDJSON) + WebSocket subscribe (`GET /s/:id/subscribe`); no extra process or port | [ADR 025](../adrs/025.server.channel.md) | ❌ | From 9fc013757f6053316c8a13b6772fa80710786b05 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 8 Jun 2026 20:59:26 -0400 Subject: [PATCH 04/10] docs: resolve implementation gaps in channel spec and ADR 025 Co-Authored-By: Claude Sonnet 4.6 --- docs/adrs/025.server.channel.md | 59 ++++++++++++++++++++------------ docs/specs/client-integration.md | 46 ++++++++++++++----------- 2 files changed, 63 insertions(+), 42 deletions(-) diff --git a/docs/adrs/025.server.channel.md b/docs/adrs/025.server.channel.md index d3f2cbe..9a358c8 100644 --- a/docs/adrs/025.server.channel.md +++ b/docs/adrs/025.server.channel.md @@ -20,38 +20,52 @@ webtty already runs an HTTP server when `webtty go ` is invoked. Adding Add a session-scoped integration channel to the existing webtty HTTP server. -- `POST /s/:id/publish` — accepts either a single JSON body (`application/json`) or a streaming NDJSON body (`application/x-ndjson`); broadcasts each JSON object to all current WebSocket subscribers of that session -- `GET /s/:id/subscribe` — WebSocket upgrade; the connection joins the subscriber set for the session and receives all subsequent publishes as discrete text frames +- `POST /s/:id/publish` — reads the request body line by line; each newline-terminated line that is valid JSON is broadcast immediately to all current subscribers of that session as a WS text frame; `204` is returned after the publisher closes the connection; lines that fail JSON parsing are silently skipped +- `GET /s/:id/subscribe` — WebSocket upgrade; the connection joins `session.subscribers` for that session and receives all subsequent publishes as discrete text frames -One channel per session. No separate channel name or creation step — the session ID is the channel. +One channel per session. No separate channel name or creation step — the session ID is the channel. One-shot and streaming are the same endpoint: a one-shot publisher sends one line and closes; a streaming publisher keeps the connection open and writes lines over time. -Implementation touches two files: -1. `src/server/routes.ts` — add the `POST` publish route and WS upgrade route under `/s/:id/` -2. `src/server/websocket.ts` — add `Map>` subscriber registry per session and `broadcastToSession` helper +### Implementation — files to touch + +1. **`src/server/session.ts`** — add `subscribers: Set` to the `Session` interface and initialise it as `new Set()` in `createSession` +2. **`src/server/routes.ts`** — add a `POST /s/:id/publish` branch: validate session exists, stream body line by line, call `broadcastToSubscribers` per valid JSON line, respond `204` on close +3. **`src/server/websocket.ts`**: + - Widen the `upgrade` event handler (currently `socket.destroy()` for anything not matching `/ws/:id`) to also accept `/s/:id/subscribe`, routing those to a new `handleSubscribe` function + - Add `handleSubscribe(ws, id)` — looks up session, adds `ws` to `session.subscribers`, removes on close + - Add `broadcastToSubscribers(session, line)` — iterates `session.subscribers` and calls `ws.send(line)` for each open socket + - Update `closeSession` / `closeAllSessions` to also close and clear `session.subscribers` --- ## Reasons -### Session as the natural channel namespace +### Separate subscriber set from PTY clients -A separate `/channel/:name/*` namespace requires the publisher and subscriber to agree on a channel name independently of the session. Session-scoped routing removes that coordination — the session ID both parties already share becomes the channel. One channel per session is sufficient for all current use cases. +`session.clients` holds WebSocket connections for PTY terminal output. `session.subscribers` holds connections for the integration channel. The two are independent — a browser tab can be one, the other, or both. Keeping them as separate `Set` fields on `Session` means cleanup on session delete is automatic and neither set interferes with the other. -### Piggyback on the existing server +### Line framing — one WS frame per line -webtty's HTTP server is already running whenever a session is open. Adding two routes costs nothing operationally and removes the need for users to start or manage a second process. +WebSocket is message-based, so the server needs a rule for what constitutes one frame. Line-by-line (newline as delimiter) is the simplest framing that works for both one-shot and streaming publishers without requiring a content-type distinction. The subscriber always receives one complete JSON object per frame regardless of how the publisher sent it. + +### No content-type distinction between one-shot and streaming -### Both streaming and non-streaming publish +Requiring the publisher to declare `application/x-ndjson` vs `application/json` adds friction without benefit — the server reads line-by-line either way. One-shot is simply the case where the body is one line. Both use `Content-Type: application/json`. -A single JSON POST covers one-shot messages (status updates, completed results). NDJSON streaming over a single chunked request covers agents that emit output incrementally (LLM token streams, long-running search). The server broadcasts each parsed JSON object as a discrete WS frame either way — subscribers see a uniform sequence of messages regardless of how the publisher sent them. +### 204 after publisher closes -### WebSocket for subscribe +Holding the HTTP response until the publisher closes the connection is the correct behaviour for streaming: the server cannot know the publish is complete until the body stream ends. For one-shot publishers this is instantaneous. For streaming publishers the caller's `fetch` / `curl` naturally awaits the response after the pipe closes. -WebSocket is message-based, which maps directly to the event stream model: each broadcast from the server is a discrete frame. Subscribers do not need to handle HTTP chunking or SSE parsing. webtty already has WebSocket infrastructure, so no new transport is introduced. +### Silent skip on invalid JSON lines -### No persistence / replay +Crashing the publish connection on a single malformed line would interrupt an otherwise healthy stream. Skipping silently is more robust — agents occasionally emit partial or diagnostic lines that are not JSON. The subscriber set is unaffected. -The use case is live streaming — agents push results as they arrive, browser UIs render them immediately. Replay requires storage and complicates cleanup. A subscriber that connects late simply misses earlier payloads, which is acceptable for the current use cases. +### Session as the natural channel namespace + +A separate `/channel/:name/*` namespace would require publisher and subscriber to agree on a name outside the session context. Session-scoped routing removes that coordination — both parties already share the session ID. One channel per session covers all current use cases. + +### Piggyback on the existing server + +webtty's HTTP server is already running whenever a session is open. Adding two routes costs nothing operationally and removes the need for users to start or manage a second process. --- @@ -59,7 +73,7 @@ The use case is live streaming — agents push results as they arrive, browser U ### Option A: Named channels (`/channel/:name/*`) instead of session-scoped -A separate namespace allows multiple independent channels on the same webtty instance without tying them to a session. More flexible, but introduces a second naming dimension and requires publisher/subscriber to agree on a name outside the session context. +More flexible — multiple independent channels per webtty instance. But adds a second naming dimension and requires out-of-band coordination between publisher and subscriber. Rejected — one channel per session covers all current use cases. Named channels can be layered on top later if needed. @@ -69,15 +83,15 @@ Each app ships its own pub/sub server. Works, but requires a second process, a s Rejected — the code is generic enough to live in webtty once. -### Option C: Non-streaming publish only +### Option C: Content-type distinction for streaming vs one-shot -Simpler server implementation — parse the full body, then broadcast once. Does not support agents that stream output incrementally. +`application/json` for one-shot, `application/x-ndjson` for streaming. Adds publisher friction and a server branch with no benefit — line-by-line reading handles both identically. -Rejected — NDJSON streaming is a first-class use case for LLM agents. The server-side cost is a line-split loop over the request body stream, which Bun supports natively. +Rejected — one endpoint, one content-type. ### Option D: SSE instead of WebSocket for subscribe -Server-Sent Events are simpler for one-way streaming and work natively in `EventSource`. However, webtty already has WebSocket infrastructure and browser clients (e.g. Fusion) already use WebSocket. Keeping a single transport is simpler. +Server-Sent Events work natively with `EventSource` and are simpler for one-way streaming. However, webtty already has WebSocket infrastructure and browser clients already use WebSocket. Keeping one transport is simpler. Rejected for now — SSE can be added as an additional endpoint later if needed. @@ -87,4 +101,5 @@ Rejected for now — SSE can be added as an additional endpoint later if needed. - `apps/fusion/sync-server.ts` can be deleted; Fusion points its agent push at `POST /s/:id/publish` and its UI subscribe at `ws://host/s/:id/subscribe` on the webtty port. - No auth on publish — callers must be on the same host or behind the same network boundary as webtty (consistent with the existing session API). -- The subscriber registry (`Map>`) must be cleaned up when a session is deleted; `broadcastToSession` is a no-op when the set is empty or absent. +- The `upgrade` handler in `websocket.ts` currently destroys any socket not matching `/ws/:id` — it must be widened to also accept `/s/:id/subscribe` before routing to `handleSubscribe`. +- `session.subscribers` must be closed and cleared in `closeSession` and `closeAllSessions`, alongside the existing `session.clients` handling. diff --git a/docs/specs/client-integration.md b/docs/specs/client-integration.md index a9a03be..127d2a4 100644 --- a/docs/specs/client-integration.md +++ b/docs/specs/client-integration.md @@ -10,7 +10,9 @@ A pattern for pushing live updates from a CLI tool or agent into a browser UI **Persona:** Developers building CLI agents or tools that produce structured output and want to surface that output in a browser UI in real-time. -**Core idea:** webtty's HTTP server is already running whenever a session is open. Each session has a built-in sidecar channel: a CLI process can `POST` JSON to the session's publish endpoint and any number of browser tabs receive it immediately over WebSocket — no extra port, no extra process, no separate channel name to manage. +**Core idea:** webtty's HTTP server is already running whenever a session is open. A CLI process can `POST` JSON to the session's publish endpoint and any number of browser subscribers receive it immediately over WebSocket — no extra port, no extra process. + +**Subscribers here are distinct from terminal clients.** A browser tab that opens the terminal connects to `/ws/:id` (PTY). A browser tab that only needs CLI push-back connects to `/s/:id/subscribe` (channel) and never touches the PTY. --- @@ -50,11 +52,13 @@ ws.onmessage = (e) => { }; ``` -Each published event arrives as a single WebSocket text frame containing a JSON string. +Each published event arrives as a single WebSocket text frame containing a JSON string. The subscriber does not need to know whether the publisher sent one shot or a long stream — it always receives one complete JSON object per frame. ### 3. Publish from the CLI -**One-shot** — a single JSON object posted in one request: +The server reads the publish body line by line. Each newline-terminated line is broadcast to subscribers as it arrives. One-shot and streaming are the same endpoint — the only difference is how long the publisher keeps the connection open. + +**One-shot** — send one JSON object and close: ```sh curl -X POST http://localhost:2346/s/my-session/publish \ @@ -62,11 +66,11 @@ curl -X POST http://localhost:2346/s/my-session/publish \ -d '{"type":"result","items":[...]}' ``` -**Streaming** — newline-delimited JSON (NDJSON) over a single chunked request; the server broadcasts each line as it arrives: +**Streaming** — pipe a long-running agent's output: ```sh my-agent --stream | curl -X POST http://localhost:2346/s/my-session/publish \ - -H 'Content-Type: application/x-ndjson' \ + -H 'Content-Type: application/json' \ --data-binary @- ``` @@ -77,36 +81,39 @@ Or from Node/Bun: await fetch('http://localhost:2346/s/my-session/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ type: 'status', progress: 0.42 }), + body: JSON.stringify({ type: 'status', progress: 0.42 }) + '\n', }); -// streaming (NDJSON) -const { writable } = new TransformStream(); +// streaming — write lines to a ReadableStream as the agent produces them await fetch('http://localhost:2346/s/my-session/publish', { method: 'POST', - headers: { 'Content-Type': 'application/x-ndjson' }, + headers: { 'Content-Type': 'application/json' }, body: readable, // ReadableStream of newline-terminated JSON strings + duplex: 'half', }); ``` +The `204` response is returned after the publisher closes the connection. + --- ## Channel Semantics -- **Session-scoped** — each session has exactly one channel; the session ID is the channel name. No separate channel creation or naming needed. +- **Session-scoped** — one channel per session; the session ID is the channel. No separate creation step. +- **Subscribers ≠ PTY clients** — `session.subscribers` (channel) is a separate set from `session.clients` (PTY terminal). A browser tab can be one, the other, or both. - **Implicit lifecycle** — the channel is live as long as the session exists. Subscribers can connect and disconnect freely. -- **No persistence** — payloads are not stored or replayed. A subscriber that connects late misses earlier messages (acceptable for live-streaming use cases). -- **Multiple subscribers** — any number of browser tabs can subscribe to the same session channel simultaneously. +- **No persistence** — payloads are not stored or replayed. A subscriber that connects late misses earlier messages. +- **Multiple subscribers** — any number of browser tabs can subscribe simultaneously. +- **Line framing** — the server broadcasts one WS frame per newline-terminated line. Lines that are not valid JSON are silently skipped. --- ## API Reference -| Method | Path | Content-Type | Description | -|--------|------|-------------|-------------| -| `POST` | `/s/:id/publish` | `application/json` | Broadcast a single JSON payload to all current subscribers; `204` on success; `400` if body is not valid JSON; `404` if session does not exist | -| `POST` | `/s/:id/publish` | `application/x-ndjson` | Stream NDJSON; each newline-terminated line is parsed and broadcast as it arrives; connection held open until publisher closes it | -| `GET` | `/s/:id/subscribe` | — | WebSocket upgrade — joins the subscriber set for the session; receives published payloads as JSON text frames; `404` if session does not exist | +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/s/:id/publish` | Read body line by line; broadcast each valid JSON line to all current subscribers as a discrete WS text frame; `204` after publisher closes; `400` if `Content-Type` is not `application/json`; `404` if session does not exist | +| `GET` | `/s/:id/subscribe` | WebSocket upgrade — joins `session.subscribers` for the session; receives published payloads as JSON text frames; `404` if session does not exist | --- @@ -114,6 +121,5 @@ await fetch('http://localhost:2346/s/my-session/publish', { | Feature | Description | ADR | Done? | |---------|-------------|-----|-------| -| Session channel — one-shot publish | `POST /s/:id/publish` with `application/json` broadcasts a single event to all WebSocket subscribers | [ADR 025](../adrs/025.server.channel.md) | ❌ | -| Session channel — streaming publish | `POST /s/:id/publish` with `application/x-ndjson` broadcasts each line as it arrives | [ADR 025](../adrs/025.server.channel.md) | ❌ | -| Session channel — subscribe | `GET /s/:id/subscribe` WebSocket upgrade; receives published events as discrete frames | [ADR 025](../adrs/025.server.channel.md) | ❌ | +| Session channel — publish | `POST /s/:id/publish` reads body line by line; each valid JSON line broadcast to subscribers as a WS frame; `204` on close | [ADR 025](../adrs/025.server.channel.md) | ❌ | +| Session channel — subscribe | `GET /s/:id/subscribe` WebSocket upgrade; joins `session.subscribers`; receives one JSON object per frame | [ADR 025](../adrs/025.server.channel.md) | ❌ | From 7d859694aa470312cee93ee4b906c5e57b4d8c04 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 8 Jun 2026 21:02:23 -0400 Subject: [PATCH 05/10] docs: move interface and flow details to ADR, keep spec use-case focused Co-Authored-By: Claude Sonnet 4.6 --- docs/adrs/025.server.channel.md | 98 +++++++++++++++++++------- docs/specs/client-integration.md | 117 ++++++++----------------------- 2 files changed, 102 insertions(+), 113 deletions(-) diff --git a/docs/adrs/025.server.channel.md b/docs/adrs/025.server.channel.md index 9a358c8..0b72540 100644 --- a/docs/adrs/025.server.channel.md +++ b/docs/adrs/025.server.channel.md @@ -20,20 +20,70 @@ webtty already runs an HTTP server when `webtty go ` is invoked. Adding Add a session-scoped integration channel to the existing webtty HTTP server. -- `POST /s/:id/publish` — reads the request body line by line; each newline-terminated line that is valid JSON is broadcast immediately to all current subscribers of that session as a WS text frame; `204` is returned after the publisher closes the connection; lines that fail JSON parsing are silently skipped -- `GET /s/:id/subscribe` — WebSocket upgrade; the connection joins `session.subscribers` for that session and receives all subsequent publishes as discrete text frames +### API -One channel per session. No separate channel name or creation step — the session ID is the channel. One-shot and streaming are the same endpoint: a one-shot publisher sends one line and closes; a streaming publisher keeps the connection open and writes lines over time. +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/s/:id/publish` | Read body line by line; broadcast each valid JSON line to all current subscribers as a discrete WS text frame; `204` after publisher closes; `400` if `Content-Type` is not `application/json`; `404` if session does not exist | +| `GET` | `/s/:id/subscribe` | WebSocket upgrade — joins `session.subscribers`; receives published payloads as JSON text frames; `404` if session does not exist | + +One channel per session. No separate channel name or creation step — the session ID is the channel. One-shot and streaming use the same endpoint: a one-shot publisher sends one line and closes; a streaming publisher keeps the connection open and writes lines over time. + +### Channel flow + +``` +Publisher webtty server Subscriber(s) +───────────────────────────────────────────────────────────────────────── +POST /s/:id/publish + body line 1 ──────────► parse JSON + broadcast ───────────────────► ws.send(line1) + body line 2 ──────────► parse JSON + broadcast ───────────────────► ws.send(line2) + (invalid line) ────────► skip silently + body line N ──────────► parse JSON + broadcast ───────────────────► ws.send(lineN) + [close] ───────────────► respond 204 +``` + +The `204` is returned after the publisher closes the connection — not before — because the server cannot know the stream is complete until the body ends. For one-shot publishers this is instantaneous. + +### Publisher usage + +**One-shot** (single JSON object, close immediately): + +```sh +curl -X POST http://localhost:2346/s/my-session/publish \ + -H 'Content-Type: application/json' \ + -d '{"type":"result","items":[...]}' +``` + +**Streaming** (pipe agent output line by line): + +```sh +my-agent --stream | curl -X POST http://localhost:2346/s/my-session/publish \ + -H 'Content-Type: application/json' \ + --data-binary @- +``` + +### Subscriber usage + +```js +const ws = new WebSocket('ws://localhost:2346/s/my-session/subscribe'); +ws.onmessage = (e) => { + const event = JSON.parse(e.data); + // render event in UI +}; +``` ### Implementation — files to touch -1. **`src/server/session.ts`** — add `subscribers: Set` to the `Session` interface and initialise it as `new Set()` in `createSession` -2. **`src/server/routes.ts`** — add a `POST /s/:id/publish` branch: validate session exists, stream body line by line, call `broadcastToSubscribers` per valid JSON line, respond `204` on close +1. **`src/server/session.ts`** — add `subscribers: Set` to the `Session` interface; initialise as `new Set()` in `createSession` +2. **`src/server/routes.ts`** — add `POST /s/:id/publish` branch: validate session exists, stream body line by line via `on('data')`, buffer incomplete lines, call `broadcastToSubscribers` per valid JSON line, respond `204` on `end` 3. **`src/server/websocket.ts`**: - - Widen the `upgrade` event handler (currently `socket.destroy()` for anything not matching `/ws/:id`) to also accept `/s/:id/subscribe`, routing those to a new `handleSubscribe` function + - Widen the `upgrade` handler (currently destroys any socket not matching `/ws/:id`) to also accept `/s/:id/subscribe`, routing to a new `handleSubscribe` function - Add `handleSubscribe(ws, id)` — looks up session, adds `ws` to `session.subscribers`, removes on close - - Add `broadcastToSubscribers(session, line)` — iterates `session.subscribers` and calls `ws.send(line)` for each open socket - - Update `closeSession` / `closeAllSessions` to also close and clear `session.subscribers` + - Add `broadcastToSubscribers(session, line)` — iterates `session.subscribers`, calls `ws.send(line)` for each open socket + - Update `closeSession` / `closeAllSessions` to close and clear `session.subscribers` alongside `session.clients` --- @@ -41,31 +91,27 @@ One channel per session. No separate channel name or creation step — the sessi ### Separate subscriber set from PTY clients -`session.clients` holds WebSocket connections for PTY terminal output. `session.subscribers` holds connections for the integration channel. The two are independent — a browser tab can be one, the other, or both. Keeping them as separate `Set` fields on `Session` means cleanup on session delete is automatic and neither set interferes with the other. +`session.clients` holds WebSocket connections for PTY terminal output. `session.subscribers` holds connections for the integration channel. Keeping them as separate `Set` fields on `Session` means cleanup on session delete is automatic and neither set interferes with the other. ### Line framing — one WS frame per line -WebSocket is message-based, so the server needs a rule for what constitutes one frame. Line-by-line (newline as delimiter) is the simplest framing that works for both one-shot and streaming publishers without requiring a content-type distinction. The subscriber always receives one complete JSON object per frame regardless of how the publisher sent it. +WebSocket is message-based, so the server needs a framing rule. Line-by-line (newline as delimiter) is the simplest framing that works for both one-shot and streaming publishers without a content-type distinction. The subscriber always receives one complete JSON object per frame regardless of how the publisher sent it. ### No content-type distinction between one-shot and streaming -Requiring the publisher to declare `application/x-ndjson` vs `application/json` adds friction without benefit — the server reads line-by-line either way. One-shot is simply the case where the body is one line. Both use `Content-Type: application/json`. +Requiring `application/x-ndjson` for streaming adds friction with no benefit — the server reads line-by-line either way. Both use `Content-Type: application/json`. ### 204 after publisher closes -Holding the HTTP response until the publisher closes the connection is the correct behaviour for streaming: the server cannot know the publish is complete until the body stream ends. For one-shot publishers this is instantaneous. For streaming publishers the caller's `fetch` / `curl` naturally awaits the response after the pipe closes. +Holding the HTTP response until the publisher closes is correct for streaming: the server cannot know the publish is complete until the body stream ends. For one-shot publishers this is instantaneous; for streaming publishers the caller's `fetch` / `curl` naturally awaits the response after the pipe closes. ### Silent skip on invalid JSON lines -Crashing the publish connection on a single malformed line would interrupt an otherwise healthy stream. Skipping silently is more robust — agents occasionally emit partial or diagnostic lines that are not JSON. The subscriber set is unaffected. +Closing the publish connection on a single malformed line would interrupt an otherwise healthy stream. Agents occasionally emit partial or diagnostic lines that are not JSON. Skipping silently is more robust. ### Session as the natural channel namespace -A separate `/channel/:name/*` namespace would require publisher and subscriber to agree on a name outside the session context. Session-scoped routing removes that coordination — both parties already share the session ID. One channel per session covers all current use cases. - -### Piggyback on the existing server - -webtty's HTTP server is already running whenever a session is open. Adding two routes costs nothing operationally and removes the need for users to start or manage a second process. +Both publisher and subscriber already share the session ID. Session-scoped routing removes the need for out-of-band channel name coordination. --- @@ -73,25 +119,25 @@ webtty's HTTP server is already running whenever a session is open. Adding two r ### Option A: Named channels (`/channel/:name/*`) instead of session-scoped -More flexible — multiple independent channels per webtty instance. But adds a second naming dimension and requires out-of-band coordination between publisher and subscriber. +More flexible — multiple independent channels per instance — but adds a second naming dimension and requires coordination between publisher and subscriber beyond the session ID. -Rejected — one channel per session covers all current use cases. Named channels can be layered on top later if needed. +Rejected — one channel per session covers all current use cases. Named channels can be layered on top later. ### Option B: Separate standalone server (current `sync-server.ts`) -Each app ships its own pub/sub server. Works, but requires a second process, a second port, and duplicates the same ~90-line pattern across projects. +Works but requires a second process, a second port, and duplicates the same ~90-line pattern across projects. Rejected — the code is generic enough to live in webtty once. ### Option C: Content-type distinction for streaming vs one-shot -`application/json` for one-shot, `application/x-ndjson` for streaming. Adds publisher friction and a server branch with no benefit — line-by-line reading handles both identically. +`application/json` for one-shot, `application/x-ndjson` for streaming. Adds publisher friction and a server branch with no benefit. Rejected — one endpoint, one content-type. ### Option D: SSE instead of WebSocket for subscribe -Server-Sent Events work natively with `EventSource` and are simpler for one-way streaming. However, webtty already has WebSocket infrastructure and browser clients already use WebSocket. Keeping one transport is simpler. +Simpler for one-way streaming and works natively with `EventSource`. However, webtty already has WebSocket infrastructure and browser clients already use WebSocket. Rejected for now — SSE can be added as an additional endpoint later if needed. @@ -99,7 +145,7 @@ Rejected for now — SSE can be added as an additional endpoint later if needed. ## Consequences -- `apps/fusion/sync-server.ts` can be deleted; Fusion points its agent push at `POST /s/:id/publish` and its UI subscribe at `ws://host/s/:id/subscribe` on the webtty port. +- `apps/fusion/sync-server.ts` can be deleted; Fusion points its agent push at `POST /s/:id/publish` and its UI at `ws://host/s/:id/subscribe`. - No auth on publish — callers must be on the same host or behind the same network boundary as webtty (consistent with the existing session API). -- The `upgrade` handler in `websocket.ts` currently destroys any socket not matching `/ws/:id` — it must be widened to also accept `/s/:id/subscribe` before routing to `handleSubscribe`. -- `session.subscribers` must be closed and cleared in `closeSession` and `closeAllSessions`, alongside the existing `session.clients` handling. +- The `upgrade` handler in `websocket.ts` currently destroys any socket not matching `/ws/:id` — it must be widened to also accept `/s/:id/subscribe`. +- `session.subscribers` must be closed and cleared in `closeSession` and `closeAllSessions` alongside the existing `session.clients` handling. diff --git a/docs/specs/client-integration.md b/docs/specs/client-integration.md index 127d2a4..074a3b2 100644 --- a/docs/specs/client-integration.md +++ b/docs/specs/client-integration.md @@ -10,110 +10,53 @@ A pattern for pushing live updates from a CLI tool or agent into a browser UI **Persona:** Developers building CLI agents or tools that produce structured output and want to surface that output in a browser UI in real-time. -**Core idea:** webtty's HTTP server is already running whenever a session is open. A CLI process can `POST` JSON to the session's publish endpoint and any number of browser subscribers receive it immediately over WebSocket — no extra port, no extra process. - -**Subscribers here are distinct from terminal clients.** A browser tab that opens the terminal connects to `/ws/:id` (PTY). A browser tab that only needs CLI push-back connects to `/s/:id/subscribe` (channel) and never touches the PTY. +webtty's HTTP server is already running whenever a session is open. The integration channel piggybacks on that server so any CLI process can publish structured events and any number of browser tabs receive them instantly — no extra port, no extra process. --- -## Use Cases - -### Agent streaming results to a UI - -An AI agent or search tool runs in the terminal and streams structured results (search hits, status updates, progress) to a browser panel that renders them as they arrive. +## Architecture ``` -CLI agent → POST /s/:id/publish → webtty server → WS → browser panel +┌─────────────────┐ POST /s/:id/publish ┌──────────────────────┐ +│ CLI / Agent │ ────────────────────────────────► │ │ +│ (publisher) │ │ webtty server │ +└─────────────────┘ │ │ + │ session.subscribers │ +┌─────────────────┐ ws /s/:id/subscribe │ │ +│ Browser panel │ ◄───────────────────────────────── │ │ +│ (subscriber) │ one WS frame per event │ │ +└─────────────────┘ └──────────────────────┘ + +┌─────────────────┐ ws /ws/:id (PTY) ┌──────────────────────┐ +│ Browser tab │ ◄───────────────────────────────── │ │ +│ (terminal) │ ────────────────────────────────► │ session.clients │ +└─────────────────┘ keyboard / resize └──────────────────────┘ ``` -### Replacing a bespoke sync server - -Projects like Fusion today ship a standalone `sync-server.ts` that must be started separately on a dedicated port. The session channel replaces this with two routes on the webtty port that is already running. +Subscribers (integration channel) and terminal clients (PTY) are independent. A browser tab can be one, the other, or both. --- -## Integration Pattern - -### 1. Start webtty - -```sh -bunx webtty go my-session -``` - -This is the only process needed. The publish and subscribe endpoints are always-on for every session. - -### 2. Subscribe in the browser - -```js -const ws = new WebSocket('ws://localhost:2346/s/my-session/subscribe'); -ws.onmessage = (e) => { - const payload = JSON.parse(e.data); - // render payload in UI -}; -``` - -Each published event arrives as a single WebSocket text frame containing a JSON string. The subscriber does not need to know whether the publisher sent one shot or a long stream — it always receives one complete JSON object per frame. - -### 3. Publish from the CLI - -The server reads the publish body line by line. Each newline-terminated line is broadcast to subscribers as it arrives. One-shot and streaming are the same endpoint — the only difference is how long the publisher keeps the connection open. - -**One-shot** — send one JSON object and close: - -```sh -curl -X POST http://localhost:2346/s/my-session/publish \ - -H 'Content-Type: application/json' \ - -d '{"type":"result","items":[...]}' -``` +## Use Cases -**Streaming** — pipe a long-running agent's output: +### Agent streaming results to a UI -```sh -my-agent --stream | curl -X POST http://localhost:2346/s/my-session/publish \ - -H 'Content-Type: application/json' \ - --data-binary @- -``` +An AI agent or search tool runs in the terminal and emits structured results — search hits, status updates, token streams — that a browser panel renders as they arrive. The agent publishes to the session channel; the browser subscribes. -Or from Node/Bun: - -```ts -// one-shot -await fetch('http://localhost:2346/s/my-session/publish', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ type: 'status', progress: 0.42 }) + '\n', -}); - -// streaming — write lines to a ReadableStream as the agent produces them -await fetch('http://localhost:2346/s/my-session/publish', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: readable, // ReadableStream of newline-terminated JSON strings - duplex: 'half', -}); -``` +### Replacing a bespoke sync server -The `204` response is returned after the publisher closes the connection. +Projects like Fusion today ship a standalone `sync-server.ts` on a separate port that must be started independently. The session channel replaces it: one webtty process, one port, zero extra setup. --- -## Channel Semantics - -- **Session-scoped** — one channel per session; the session ID is the channel. No separate creation step. -- **Subscribers ≠ PTY clients** — `session.subscribers` (channel) is a separate set from `session.clients` (PTY terminal). A browser tab can be one, the other, or both. -- **Implicit lifecycle** — the channel is live as long as the session exists. Subscribers can connect and disconnect freely. -- **No persistence** — payloads are not stored or replayed. A subscriber that connects late misses earlier messages. -- **Multiple subscribers** — any number of browser tabs can subscribe simultaneously. -- **Line framing** — the server broadcasts one WS frame per newline-terminated line. Lines that are not valid JSON are silently skipped. - ---- +## How It Works -## API Reference +1. `bunx webtty go my-session` — the only process to start; publish and subscribe endpoints are available immediately +2. Browser panels subscribe via WebSocket on the session's subscribe endpoint +3. CLI tools or agents POST JSON to the session's publish endpoint — one-shot or as a stream of lines +4. Each JSON line is broadcast to all subscribers as a discrete WebSocket frame as it arrives -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/s/:id/publish` | Read body line by line; broadcast each valid JSON line to all current subscribers as a discrete WS text frame; `204` after publisher closes; `400` if `Content-Type` is not `application/json`; `404` if session does not exist | -| `GET` | `/s/:id/subscribe` | WebSocket upgrade — joins `session.subscribers` for the session; receives published payloads as JSON text frames; `404` if session does not exist | +For interface details, channel flow, and API reference see [ADR 025](../adrs/025.server.channel.md). --- @@ -121,5 +64,5 @@ The `204` response is returned after the publisher closes the connection. | Feature | Description | ADR | Done? | |---------|-------------|-----|-------| -| Session channel — publish | `POST /s/:id/publish` reads body line by line; each valid JSON line broadcast to subscribers as a WS frame; `204` on close | [ADR 025](../adrs/025.server.channel.md) | ❌ | -| Session channel — subscribe | `GET /s/:id/subscribe` WebSocket upgrade; joins `session.subscribers`; receives one JSON object per frame | [ADR 025](../adrs/025.server.channel.md) | ❌ | +| Session channel — publish | CLI tools POST JSON (one-shot or streaming) to the session; each event broadcast to subscribers in real-time | [ADR 025](../adrs/025.server.channel.md) | ❌ | +| Session channel — subscribe | Browser panels subscribe via WebSocket and receive one JSON object per frame | [ADR 025](../adrs/025.server.channel.md) | ❌ | From 74f7354c1db08d2b272b1b5d0d25e291b27a7cba Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 8 Jun 2026 22:17:29 -0400 Subject: [PATCH 06/10] docs: PTY-gated channel, /ws/:id/pty + /ws/:id/events URL scheme Co-Authored-By: Claude Sonnet 4.6 --- docs/adrs/025.server.channel.md | 34 ++++++++++++++++++++++++-------- docs/specs/client-integration.md | 25 ++++++++++++----------- docs/specs/webtty.md | 2 +- 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/docs/adrs/025.server.channel.md b/docs/adrs/025.server.channel.md index 0b72540..c1ce867 100644 --- a/docs/adrs/025.server.channel.md +++ b/docs/adrs/025.server.channel.md @@ -24,11 +24,23 @@ Add a session-scoped integration channel to the existing webtty HTTP server. | Method | Path | Description | |--------|------|-------------| -| `POST` | `/s/:id/publish` | Read body line by line; broadcast each valid JSON line to all current subscribers as a discrete WS text frame; `204` after publisher closes; `400` if `Content-Type` is not `application/json`; `404` if session does not exist | -| `GET` | `/s/:id/subscribe` | WebSocket upgrade — joins `session.subscribers`; receives published payloads as JSON text frames; `404` if session does not exist | +| `POST` | `/s/:id/publish` | Read body line by line; broadcast each valid JSON line to all current subscribers as a discrete WS text frame; `204` after publisher closes; `400` if `Content-Type` is not `application/json`; `404` if session does not exist; `409` if session exists but PTY is not running | +| `GET` | `/ws/:id/events` | WebSocket upgrade — joins `session.subscribers`; receives published payloads as JSON text frames; `404` if session does not exist; WS close `4002` if PTY is not running | One channel per session. No separate channel name or creation step — the session ID is the channel. One-shot and streaming use the same endpoint: a one-shot publisher sends one line and closes; a streaming publisher keeps the connection open and writes lines over time. +### Channel lifecycle + +The channel is only available while the session's PTY is running. This keeps the channel lifecycle consistent with the terminal session. + +| Event | `session.clients` (PTY) | `session.subscribers` (channel) | +|-------|-------------------------|---------------------------------| +| PTY not yet spawned | n/a | publish → `409`; subscribe → WS close `4002` | +| PTY running | connected normally | connected normally | +| Shell exits | closed; session deleted | closed; session deleted | +| `DELETE /api/sessions/:id` | closed | closed | +| Server stops | closed | closed | + ### Channel flow ``` @@ -68,7 +80,7 @@ my-agent --stream | curl -X POST http://localhost:2346/s/my-session/publish \ ### Subscriber usage ```js -const ws = new WebSocket('ws://localhost:2346/s/my-session/subscribe'); +const ws = new WebSocket('ws://localhost:2346/ws/my-session/events'); ws.onmessage = (e) => { const event = JSON.parse(e.data); // render event in UI @@ -78,10 +90,11 @@ ws.onmessage = (e) => { ### Implementation — files to touch 1. **`src/server/session.ts`** — add `subscribers: Set` to the `Session` interface; initialise as `new Set()` in `createSession` -2. **`src/server/routes.ts`** — add `POST /s/:id/publish` branch: validate session exists, stream body line by line via `on('data')`, buffer incomplete lines, call `broadcastToSubscribers` per valid JSON line, respond `204` on `end` +2. **`src/server/routes.ts`** — add `POST /s/:id/publish` branch: validate session exists, check `session.pty !== null` (→ `409` if not), stream body line by line via `on('data')`, buffer incomplete lines, call `broadcastToSubscribers` per valid JSON line, respond `204` on `end` 3. **`src/server/websocket.ts`**: - - Widen the `upgrade` handler (currently destroys any socket not matching `/ws/:id`) to also accept `/s/:id/subscribe`, routing to a new `handleSubscribe` function - - Add `handleSubscribe(ws, id)` — looks up session, adds `ws` to `session.subscribers`, removes on close + - Rename the existing PTY upgrade path from `/ws/:id` to `/ws/:id/pty` + - Widen the `upgrade` handler to also accept `/ws/:id/events`, routing to a new `handleSubscribe` function + - In `handleSubscribe(ws, id)` — look up session, check `session.pty !== null` (→ WS close `4002` "PTY not running" if not), add `ws` to `session.subscribers`, remove on close - Add `broadcastToSubscribers(session, line)` — iterates `session.subscribers`, calls `ws.send(line)` for each open socket - Update `closeSession` / `closeAllSessions` to close and clear `session.subscribers` alongside `session.clients` @@ -89,6 +102,10 @@ ws.onmessage = (e) => { ## Reasons +### Channel requires an active PTY + +The channel is only available when `session.pty !== null`. This enforces a clear mental model: the channel is a sidecar to a running terminal session, not a standalone message bus. It also simplifies lifecycle — the channel always closes with the PTY, so there are no orphaned subscribers or dangling publish requests to reason about. + ### Separate subscriber set from PTY clients `session.clients` holds WebSocket connections for PTY terminal output. `session.subscribers` holds connections for the integration channel. Keeping them as separate `Set` fields on `Session` means cleanup on session delete is automatic and neither set interferes with the other. @@ -145,7 +162,8 @@ Rejected for now — SSE can be added as an additional endpoint later if needed. ## Consequences -- `apps/fusion/sync-server.ts` can be deleted; Fusion points its agent push at `POST /s/:id/publish` and its UI at `ws://host/s/:id/subscribe`. +- `apps/fusion/sync-server.ts` can be deleted; Fusion points its agent push at `POST /s/:id/publish` and its UI at `ws://host/ws/:id/events`. +- The PTY WebSocket path changes from `/ws/:id` to `/ws/:id/pty` — existing browser clients must update their connection URL. - No auth on publish — callers must be on the same host or behind the same network boundary as webtty (consistent with the existing session API). -- The `upgrade` handler in `websocket.ts` currently destroys any socket not matching `/ws/:id` — it must be widened to also accept `/s/:id/subscribe`. +- The `upgrade` handler in `websocket.ts` must be updated to route `/ws/:id/pty` (PTY) and `/ws/:id/events` (channel) separately; anything else is destroyed as before. - `session.subscribers` must be closed and cleared in `closeSession` and `closeAllSessions` alongside the existing `session.clients` handling. diff --git a/docs/specs/client-integration.md b/docs/specs/client-integration.md index 074a3b2..07c22b2 100644 --- a/docs/specs/client-integration.md +++ b/docs/specs/client-integration.md @@ -17,20 +17,20 @@ webtty's HTTP server is already running whenever a session is open. The integrat ## Architecture ``` -┌─────────────────┐ POST /s/:id/publish ┌──────────────────────┐ -│ CLI / Agent │ ────────────────────────────────► │ │ +┌─────────────────┐ ws /ws/:id/pty ┌──────────────────────┐ +│ Browser tab │ ◄───────────────────────────────── │ │ +│ (terminal) │ ─────────────────────────────────► │ session.clients │ +└─────────────────┘ keyboard / resize └──────────────────────┘ + +┌─────────────────┐ POST /s/:id/publish ┌──────────────────────┐ +│ CLI / Agent │ ─────────────────────────────────► │ │ │ (publisher) │ │ webtty server │ └─────────────────┘ │ │ │ session.subscribers │ -┌─────────────────┐ ws /s/:id/subscribe │ │ +┌─────────────────┐ ws /ws/:id/events │ │ │ Browser panel │ ◄───────────────────────────────── │ │ │ (subscriber) │ one WS frame per event │ │ └─────────────────┘ └──────────────────────┘ - -┌─────────────────┐ ws /ws/:id (PTY) ┌──────────────────────┐ -│ Browser tab │ ◄───────────────────────────────── │ │ -│ (terminal) │ ────────────────────────────────► │ session.clients │ -└─────────────────┘ keyboard / resize └──────────────────────┘ ``` Subscribers (integration channel) and terminal clients (PTY) are independent. A browser tab can be one, the other, or both. @@ -51,10 +51,11 @@ Projects like Fusion today ship a standalone `sync-server.ts` on a separate port ## How It Works -1. `bunx webtty go my-session` — the only process to start; publish and subscribe endpoints are available immediately -2. Browser panels subscribe via WebSocket on the session's subscribe endpoint -3. CLI tools or agents POST JSON to the session's publish endpoint — one-shot or as a stream of lines -4. Each JSON line is broadcast to all subscribers as a discrete WebSocket frame as it arrives +1. `bunx webtty go my-session` — the only process to start +2. A terminal client connects to `/ws/:id/pty` — this spawns the PTY and activates the channel +3. Browser panels subscribe via `/ws/:id/events` (requires an active PTY) +4. CLI tools or agents POST JSON to `/s/:id/publish` — one-shot or as a stream of lines +5. Each JSON line is broadcast to all subscribers as a discrete WebSocket frame as it arrives For interface details, channel flow, and API reference see [ADR 025](../adrs/025.server.channel.md). diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index 6656245..c4c3043 100644 --- a/docs/specs/webtty.md +++ b/docs/specs/webtty.md @@ -80,4 +80,4 @@ Session IDs appear directly in the URL path (`/s/:id`), so they must be valid UR | Default session | `GET /` redirects to last-used session, or creates `main` if none exists | — | ✅ | | Multi-client sessions | Multiple browser tabs can attach to the same session; PTY output broadcast to all; scrollback replayed on reconnect | [ADR 007](../adrs/007.webtty.session-client.md) | ✅ | | Config file | Shell, port, font, theme from `~/.config/webtty/config.json`; hot-reload on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | -| Client integration (CLI → Web) | Session-scoped publish (`POST /s/:id/publish`, one-shot JSON or streaming NDJSON) + WebSocket subscribe (`GET /s/:id/subscribe`); no extra process or port | [ADR 025](../adrs/025.server.channel.md) | ❌ | +| Client integration (CLI → Web) | `POST /s/:id/publish` (one-shot or streaming JSON) + `ws /ws/:id/events` subscribe; channel active only while PTY is running; no extra process or port | [ADR 025](../adrs/025.server.channel.md) | ❌ | From c5095cd1eb8af3c6449f1bb8e7fae3bcde1b4265 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 8 Jun 2026 22:27:53 -0400 Subject: [PATCH 07/10] docs: clarify PTY path rename is internal refactor, not a breaking change Co-Authored-By: Claude Sonnet 4.6 --- docs/adrs/025.server.channel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adrs/025.server.channel.md b/docs/adrs/025.server.channel.md index c1ce867..aba1695 100644 --- a/docs/adrs/025.server.channel.md +++ b/docs/adrs/025.server.channel.md @@ -163,7 +163,7 @@ Rejected for now — SSE can be added as an additional endpoint later if needed. ## Consequences - `apps/fusion/sync-server.ts` can be deleted; Fusion points its agent push at `POST /s/:id/publish` and its UI at `ws://host/ws/:id/events`. -- The PTY WebSocket path changes from `/ws/:id` to `/ws/:id/pty` — existing browser clients must update their connection URL. +- The PTY WebSocket path changes from `/ws/:id` to `/ws/:id/pty` — server and browser client ship together so this is an internal refactor, not a breaking change. - No auth on publish — callers must be on the same host or behind the same network boundary as webtty (consistent with the existing session API). - The `upgrade` handler in `websocket.ts` must be updated to route `/ws/:id/pty` (PTY) and `/ws/:id/events` (channel) separately; anything else is destroyed as before. - `session.subscribers` must be closed and cleared in `closeSession` and `closeAllSessions` alongside the existing `session.clients` handling. From 10688a74e3d261d1fe6dfcb95795cfb2d555873e Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 8 Jun 2026 22:57:13 -0400 Subject: [PATCH 08/10] feat: session-scoped client integration channel (publish + events) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /s/:id/publish — streams JSON lines to subscribers; 409 if PTY not running - ws /ws/:id/events — WebSocket subscribe; 4002 if PTY not running - ws /ws/:id/pty — PTY renamed from /ws/:id for consistency - session.subscribers set independent from session.clients (PTY) - channel lifecycle tied to PTY: closes with shell exit / server stop - 100% coverage on tracked files (223 tests) Co-Authored-By: Claude Sonnet 4.6 --- src/cli/http.test.ts | 101 ++++++++++++++++++++++++ src/client/index.ts | 2 +- src/server/routes.test.ts | 37 +++++++++ src/server/routes.ts | 59 +++++++++++++- src/server/session.ts | 3 + src/server/websocket.test.ts | 144 ++++++++++++++++++++++++++++++++--- src/server/websocket.ts | 60 +++++++++++++-- 7 files changed, 389 insertions(+), 17 deletions(-) diff --git a/src/cli/http.test.ts b/src/cli/http.test.ts index d6bca24..32f1cb6 100644 --- a/src/cli/http.test.ts +++ b/src/cli/http.test.ts @@ -177,6 +177,107 @@ describe('startServer', () => { configSpy.mockRestore(); }); + test('uses node executor on win32 with Bun', async () => { + const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true); + const fakeChild = { unref: mock(() => {}), on: mock(() => {}) }; + const spawnMock = mock(() => fakeChild); + + globalThis.fetch = mock( + async () => new Response('[]', { status: 200 }), + ) as unknown as typeof fetch; + + const configModule = await import('../config'); + const configSpy = spyOn(configModule, 'loadConfig').mockReturnValue({ + ...configModule.DEFAULT_CONFIG, + }); + + const origPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + + const { startServer } = await import('./http'); + await startServer(10000, spawnMock as never); + + const [exec] = spawnMock.mock.calls[0] as [string, string[]]; + expect(exec).toBe('node'); + + Object.defineProperty(process, 'platform', { value: origPlatform, configurable: true }); + existsSpy.mockRestore(); + configSpy.mockRestore(); + }); + + test('logs error and exits when spawn emits error (non-windows hint)', async () => { + const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true); + const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + + const fakeChild = { + unref: mock(() => {}), + on: mock((event: string, cb: (err: Error) => void) => { + if (event === 'error') cb(new Error('spawn ENOENT')); + }), + }; + const spawnMock = mock(() => fakeChild); + + globalThis.fetch = mock( + async () => new Response('[]', { status: 200 }), + ) as unknown as typeof fetch; + + const configModule = await import('../config'); + const configSpy = spyOn(configModule, 'loadConfig').mockReturnValue({ + ...configModule.DEFAULT_CONFIG, + }); + + const { startServer } = await import('./http'); + await startServer(10000, spawnMock as never); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('failed to start server')); + expect(errorSpy).toHaveBeenCalledWith(expect.not.stringContaining('Node.js must be on PATH')); + expect(exitSpy).toHaveBeenCalledWith(1); + + existsSpy.mockRestore(); + exitSpy.mockRestore(); + errorSpy.mockRestore(); + configSpy.mockRestore(); + }); + + test('logs error with win32 hint when spawn emits error on win32', async () => { + const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true); + const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + + const fakeChild = { + unref: mock(() => {}), + on: mock((event: string, cb: (err: Error) => void) => { + if (event === 'error') cb(new Error('spawn ENOENT')); + }), + }; + const spawnMock = mock(() => fakeChild); + + globalThis.fetch = mock( + async () => new Response('[]', { status: 200 }), + ) as unknown as typeof fetch; + + const configModule = await import('../config'); + const configSpy = spyOn(configModule, 'loadConfig').mockReturnValue({ + ...configModule.DEFAULT_CONFIG, + }); + + const origPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + + const { startServer } = await import('./http'); + await startServer(10000, spawnMock as never); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Node.js must be on PATH')); + expect(exitSpy).toHaveBeenCalledWith(1); + + Object.defineProperty(process, 'platform', { value: origPlatform, configurable: true }); + existsSpy.mockRestore(); + exitSpy.mockRestore(); + errorSpy.mockRestore(); + configSpy.mockRestore(); + }); + test('exits with error when server does not start within timeout', async () => { const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true); const spawnMock = mock(() => ({ unref: () => {}, on: () => {} })); diff --git a/src/client/index.ts b/src/client/index.ts index 8a4485f..142e480 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -114,7 +114,7 @@ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let ws: WebSocket; function connect(): void { - const wsUrl = `${protocol}//${window.location.host}/ws/${sessionId}?cols=${term.cols}&rows=${term.rows}`; + const wsUrl = `${protocol}//${window.location.host}/ws/${sessionId}/pty?cols=${term.cols}&rows=${term.rows}`; ws = new WebSocket(wsUrl); const DIM = '\x1b[2m', diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index 496e488..8c3b8e7 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -195,6 +195,43 @@ describe('server — routes', () => { expect(res.status).toBe(404); }); + test('POST /s/:id/publish returns 404 for unknown session', async () => { + const res = await fetch(`${baseUrl}/s/no-such-session/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'test' }), + }); + expect(res.status).toBe(404); + }); + + test('POST /s/:id/publish returns 400 for wrong content-type', async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'publish-ct-test' }), + }); + const res = await fetch(`${baseUrl}/s/publish-ct-test/publish`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'hello', + }); + expect(res.status).toBe(400); + }); + + test('POST /s/:id/publish returns 409 when PTY not running', async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'publish-no-pty' }), + }); + const res = await fetch(`${baseUrl}/s/publish-no-pty/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'test' }), + }); + expect(res.status).toBe(409); + }); + test('POST /api/server/stop returns 200 and stops server', async () => { const res = await fetch(`${baseUrl}/api/server/stop`, { method: 'POST' }); expect(res.status).toBe(200); diff --git a/src/server/routes.ts b/src/server/routes.ts index 3da4a41..a400357 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -11,7 +11,7 @@ import { setLastUsedId, } from './session'; import { serveFile } from './static'; -import { closeSession } from './websocket'; +import { broadcastToSubscribers, closeSession } from './websocket'; const MAX_BODY = 64 * 1024; @@ -244,6 +244,63 @@ export async function handleRequest( return; } + const publishMatch = pathname.match(/^\/s\/([^/]+)\/publish$/); + if (req.method === 'POST' && publishMatch) { + const id = decodeId(publishMatch[1]); + if (!id) { + res.writeHead(400); + res.end('Bad Request'); + return; + } + if (!(req.headers['content-type'] ?? '').startsWith('application/json')) { + res.writeHead(400); + res.end('Bad Request'); + return; + } + const session = sessionRegistry.get(id); + if (!session) { + res.writeHead(404); + res.end('Not Found'); + return; + } + if (session.pty === null) { + res.writeHead(409); + res.end('PTY not running'); + return; + } + let buf = ''; + req.on('data', (chunk: Buffer) => { + buf += chunk.toString('utf8'); + const lines = buf.split('\n'); + buf = lines.pop() ?? ''; + for (const line of lines) { + try { + JSON.parse(line); + broadcastToSubscribers(session, line); + } catch { + // skip invalid JSON lines + } + } + }); + req.on('end', () => { + if (buf) { + try { + JSON.parse(buf); + broadcastToSubscribers(session, buf); + } catch { + // skip invalid JSON + } + } + res.writeHead(204); + res.end(); + }); + req.on('error', () => { + res.writeHead(500); + res.end(); + }); + return; + } + const pidMatch = pathname.match(/^\/p\/(\d+)$/); if (req.method === 'GET' && pidMatch) { const pid = parseInt(pidMatch[1], 10); diff --git a/src/server/session.ts b/src/server/session.ts index 6fabaff..919d4cc 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -11,6 +11,8 @@ export interface Session { pty: PtyProcess | null; /** All currently connected WebSocket clients for this session. */ clients: Set; + /** All currently connected event subscribers for this session. */ + subscribers: Set; /** Accumulated PTY output retained for replay when a new client joins. */ scrollback: string; } @@ -61,6 +63,7 @@ export function createSession(id: string): Session { createdAt: Date.now(), pty: null, clients: new Set(), + subscribers: new Set(), scrollback: '', }; sessionRegistry.set(id, session); diff --git a/src/server/websocket.test.ts b/src/server/websocket.test.ts index 8cd61b8..7f05f9a 100644 --- a/src/server/websocket.test.ts +++ b/src/server/websocket.test.ts @@ -114,7 +114,7 @@ describe('websocket', () => { }); test('rejects connection for non-existent session with code 4001', async () => { - const ws = new WebSocket(`${wsBase}/ws/no-such-session`); + const ws = new WebSocket(`${wsBase}/ws/no-such-session/pty`); const code = await new Promise((resolve) => ws.on('close', (c) => resolve(c))); expect(code).toBe(4001); }); @@ -126,7 +126,7 @@ describe('websocket', () => { body: JSON.stringify({ id: 'ws-test-banner' }), }); - const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-banner?cols=80&rows=24`); + const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-banner/pty?cols=80&rows=24`); await waitForMessages(messages, 1); await closeWs(ws); @@ -141,13 +141,13 @@ describe('websocket', () => { }); const { ws: ws1, messages: m1 } = await connectWs( - `${wsBase}/ws/ws-test-replay?cols=80&rows=24`, + `${wsBase}/ws/ws-test-replay/pty?cols=80&rows=24`, ); await waitForMessages(m1, 1); await closeWs(ws1); const { ws: ws2, messages: m2 } = await connectWs( - `${wsBase}/ws/ws-test-replay?cols=80&rows=24`, + `${wsBase}/ws/ws-test-replay/pty?cols=80&rows=24`, ); await waitForMessages(m2, 1); await closeWs(ws2); @@ -165,12 +165,12 @@ describe('websocket', () => { }); const { ws: ws1, messages: m1 } = await connectWs( - `${wsBase}/ws/ws-test-fanout?cols=80&rows=24`, + `${wsBase}/ws/ws-test-fanout/pty?cols=80&rows=24`, ); await waitForMessages(m1, 1); const { ws: ws2, messages: m2 } = await connectWs( - `${wsBase}/ws/ws-test-fanout?cols=80&rows=24`, + `${wsBase}/ws/ws-test-fanout/pty?cols=80&rows=24`, ); await waitForMessages(m2, 1); @@ -194,7 +194,7 @@ describe('websocket', () => { body: JSON.stringify({ id: 'ws-test-exit' }), }); - const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-exit?cols=80&rows=24`); + const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-exit/pty?cols=80&rows=24`); await waitForMessages(messages, 1); const closeCode = new Promise((resolve) => ws.on('close', (code) => resolve(code))); @@ -212,7 +212,7 @@ describe('websocket', () => { body: JSON.stringify({ id: 'ws-test-resize' }), }); - const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-resize?cols=80&rows=24`); + const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-resize/pty?cols=80&rows=24`); await waitForMessages(messages, 1); await waitForPrompt(messages); @@ -232,7 +232,7 @@ describe('websocket', () => { body: JSON.stringify({ id: 'ws-test-pid-route' }), }); - const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-pid-route?cols=80&rows=24`); + const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-pid-route/pty?cols=80&rows=24`); await waitForMessages(messages, 1); await closeWs(ws); @@ -249,6 +249,130 @@ describe('websocket', () => { expect(res.headers.get('location')).toBe('/s/ws-test-pid-route'); }); + test('GET /ws/:id/events rejects with 4001 for non-existent session', async () => { + const ws = new WebSocket(`${wsBase}/ws/no-such-session/events`); + const code = await new Promise((resolve) => ws.on('close', (c) => resolve(c))); + expect(code).toBe(4001); + }); + + test('GET /ws/:id/events rejects with 4002 when PTY not running', async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'events-no-pty' }), + }); + const ws = new WebSocket(`${wsBase}/ws/events-no-pty/events`); + const code = await new Promise((resolve) => ws.on('close', (c) => resolve(c))); + expect(code).toBe(4002); + }); + + test('subscriber receives published event', async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'events-pubsub' }), + }); + const { ws: ptyWs, messages: ptyMessages } = await connectWs( + `${wsBase}/ws/events-pubsub/pty?cols=80&rows=24`, + ); + await waitForMessages(ptyMessages, 1); + + const { ws: subWs, messages: subMessages } = await connectWs( + `${wsBase}/ws/events-pubsub/events`, + ); + + await fetch(`${baseUrl}/s/events-pubsub/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'test', value: 42 }), + }); + + await waitForMessages(subMessages, 1); + expect(JSON.parse(subMessages[0])).toEqual({ type: 'test', value: 42 }); + + await closeWs(subWs); + await closeWs(ptyWs); + }); + + test('multi-line publish delivers one frame per line', async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'events-multiline' }), + }); + const { ws: ptyWs, messages: ptyMessages } = await connectWs( + `${wsBase}/ws/events-multiline/pty?cols=80&rows=24`, + ); + await waitForMessages(ptyMessages, 1); + + const { ws: subWs, messages: subMessages } = await connectWs( + `${wsBase}/ws/events-multiline/events`, + ); + + await fetch(`${baseUrl}/s/events-multiline/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: [{ seq: 1 }, { seq: 2 }, { seq: 3 }].map((o) => JSON.stringify(o)).join('\n'), + }); + + await waitForMessages(subMessages, 3); + expect(JSON.parse(subMessages[0])).toEqual({ seq: 1 }); + expect(JSON.parse(subMessages[1])).toEqual({ seq: 2 }); + expect(JSON.parse(subMessages[2])).toEqual({ seq: 3 }); + + await closeWs(subWs); + await closeWs(ptyWs); + }); + + test('invalid JSON lines are silently skipped', async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'events-skip-invalid' }), + }); + const { ws: ptyWs, messages: ptyMessages } = await connectWs( + `${wsBase}/ws/events-skip-invalid/pty?cols=80&rows=24`, + ); + await waitForMessages(ptyMessages, 1); + + const { ws: subWs, messages: subMessages } = await connectWs( + `${wsBase}/ws/events-skip-invalid/events`, + ); + + await fetch(`${baseUrl}/s/events-skip-invalid/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: [JSON.stringify({ seq: 1 }), 'not valid json', JSON.stringify({ seq: 2 })].join('\n'), + }); + + await waitForMessages(subMessages, 2); + expect(JSON.parse(subMessages[0])).toEqual({ seq: 1 }); + expect(JSON.parse(subMessages[1])).toEqual({ seq: 2 }); + expect(subMessages.length).toBe(2); + + await closeWs(subWs); + await closeWs(ptyWs); + }); + + test('subscribers are closed with 4001 when shell exits', async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'events-shell-exit' }), + }); + const { ws: ptyWs, messages: ptyMessages } = await connectWs( + `${wsBase}/ws/events-shell-exit/pty?cols=80&rows=24`, + ); + await waitForMessages(ptyMessages, 1); + await waitForPrompt(ptyMessages); + + const { ws: subWs } = await connectWs(`${wsBase}/ws/events-shell-exit/events`); + const closeCode = new Promise((resolve) => subWs.on('close', (c) => resolve(c))); + + ptyWs.send(`exit${NL}`); + expect(await closeCode).toBe(4001); + }); + test('server shuts down when last session exits', async () => { await fetch(`${baseUrl}/api/sessions`, { method: 'POST', @@ -263,7 +387,7 @@ describe('websocket', () => { } } - const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-last?cols=80&rows=24`); + const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-last/pty?cols=80&rows=24`); await waitForMessages(messages, 1); ws.send(`exit${NL}`); diff --git a/src/server/websocket.ts b/src/server/websocket.ts index b787c25..a4d6fa7 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -10,11 +10,14 @@ const WS_CLOSE = { SERVER_STOPPED: 1001 as const, // RFC 6455: server going away BAD_REQUEST: 1008 as const, // RFC 6455: policy violation / bad data SESSION_GONE: 4001 as const, // app-level: session deleted or shell exited + PTY_NOT_RUNNING: 4002 as const, // app-level: PTY not running } as const; function closeClients(session: Session, code: number, reason: string): void { session.pty?.kill(); for (const client of session.clients) client.close(code, reason); + for (const sub of session.subscribers) sub.close(code, reason); + session.subscribers.clear(); } /** @@ -33,6 +36,18 @@ export function closeAllSessions(): void { } } +/** + * Sends `line` to all open event subscribers of `session`. + * + * @param session - The session whose subscribers to notify. + * @param line - The line to send. + */ +export function broadcastToSubscribers(session: Session, line: string): void { + for (const sub of session.subscribers) { + if (sub.readyState === sub.OPEN) sub.send(line); + } +} + let onLastSessionClosed: (() => void) | null = null; /** @@ -87,9 +102,27 @@ function sessionBanner(): string { ].join(''); } +function handleSubscribe(ws: WS, id: string): void { + const session = sessionRegistry.get(id); + if (!session) { + ws.close(WS_CLOSE.SESSION_GONE, 'session deleted'); + return; + } + if (session.pty === null) { + ws.close(WS_CLOSE.PTY_NOT_RUNNING, 'PTY not running'); + return; + } + session.subscribers.add(ws); + ws.on('close', () => { + session.subscribers.delete(ws); + }); + ws.on('error', () => {}); +} + /** * Attaches a WebSocket server to `httpServer`, handling PTY I/O, session management, - * scrollback replay, and terminal resize for all `/ws/:id` connections. + * scrollback replay, and terminal resize for all `/ws/:id/pty` connections, + * and event subscriptions for `/ws/:id/events` connections. * * @param httpServer - The HTTP server to attach the WebSocket server to. * @returns The configured {@link WebSocketServer}. @@ -99,7 +132,10 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer httpServer.on('upgrade', (req, socket, head) => { const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); - if (url.pathname.match(/^\/ws\/([^/]+)$/)) { + if ( + url.pathname.match(/^\/ws\/([^/]+)\/pty$/) || + url.pathname.match(/^\/ws\/([^/]+)\/events$/) + ) { wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req)); } else { socket.destroy(); @@ -108,19 +144,27 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer wss.on('connection', (ws: WS, req: http.IncomingMessage) => { const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); - const wsMatch = url.pathname.match(/^\/ws\/([^/]+)$/); - if (!wsMatch) { + const ptyMatch = url.pathname.match(/^\/ws\/([^/]+)\/pty$/); + const eventsMatch = url.pathname.match(/^\/ws\/([^/]+)\/events$/); + + if (!ptyMatch && !eventsMatch) { ws.close(); return; } let id: string; try { - id = decodeURIComponent(wsMatch[1]); + id = decodeURIComponent((ptyMatch ?? eventsMatch)![1]); } catch { ws.close(WS_CLOSE.BAD_REQUEST, 'Bad Request'); return; } + + if (eventsMatch) { + handleSubscribe(ws, id); + return; + } + const cols = Math.max( 1, Math.min(1000, Number.parseInt(url.searchParams.get('cols') ?? '80', 10) || 80), @@ -163,6 +207,12 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer client.close(WS_CLOSE.SESSION_GONE, 'shell exited'); } } + for (const sub of session.subscribers) { + if (sub.readyState === sub.OPEN) { + sub.close(WS_CLOSE.SESSION_GONE, 'shell exited'); + } + } + session.subscribers.clear(); session.pty = null; if (sessionRegistry.size === 0) onLastSessionClosed?.(); }); From b866ee7d9a7c8dc454ce9719f89ae299de6e6e6f Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 8 Jun 2026 22:58:59 -0400 Subject: [PATCH 09/10] fix: resolve lint errors in http.test.ts and websocket.ts Co-Authored-By: Claude Sonnet 4.6 --- src/cli/http.test.ts | 2 +- src/server/websocket.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/cli/http.test.ts b/src/cli/http.test.ts index 32f1cb6..f06cc9a 100644 --- a/src/cli/http.test.ts +++ b/src/cli/http.test.ts @@ -197,7 +197,7 @@ describe('startServer', () => { const { startServer } = await import('./http'); await startServer(10000, spawnMock as never); - const [exec] = spawnMock.mock.calls[0] as [string, string[]]; + const [exec] = spawnMock.mock.calls[0] as unknown as [string, string[]]; expect(exec).toBe('node'); Object.defineProperty(process, 'platform', { value: origPlatform, configurable: true }); diff --git a/src/server/websocket.ts b/src/server/websocket.ts index a4d6fa7..b1888b3 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -146,15 +146,16 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); const ptyMatch = url.pathname.match(/^\/ws\/([^/]+)\/pty$/); const eventsMatch = url.pathname.match(/^\/ws\/([^/]+)\/events$/); + const match = ptyMatch ?? eventsMatch; - if (!ptyMatch && !eventsMatch) { + if (!match) { ws.close(); return; } let id: string; try { - id = decodeURIComponent((ptyMatch ?? eventsMatch)![1]); + id = decodeURIComponent(match[1]); } catch { ws.close(WS_CLOSE.BAD_REQUEST, 'Bad Request'); return; From 550f9644d2eb96831a672d210bb7d8040be6fb28 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 8 Jun 2026 23:55:10 -0400 Subject: [PATCH 10/10] fix: strip \r from lines, cap oversized buffer, mark features done, fix ADR subscribe error code Co-Authored-By: Claude Sonnet 4.6 --- docs/adrs/025.server.channel.md | 4 ++-- docs/specs/client-integration.md | 4 ++-- docs/specs/webtty.md | 2 +- src/server/routes.ts | 14 +++++++++----- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/adrs/025.server.channel.md b/docs/adrs/025.server.channel.md index aba1695..7066dc9 100644 --- a/docs/adrs/025.server.channel.md +++ b/docs/adrs/025.server.channel.md @@ -25,7 +25,7 @@ Add a session-scoped integration channel to the existing webtty HTTP server. | Method | Path | Description | |--------|------|-------------| | `POST` | `/s/:id/publish` | Read body line by line; broadcast each valid JSON line to all current subscribers as a discrete WS text frame; `204` after publisher closes; `400` if `Content-Type` is not `application/json`; `404` if session does not exist; `409` if session exists but PTY is not running | -| `GET` | `/ws/:id/events` | WebSocket upgrade — joins `session.subscribers`; receives published payloads as JSON text frames; `404` if session does not exist; WS close `4002` if PTY is not running | +| `GET` | `/ws/:id/events` | WebSocket upgrade — joins `session.subscribers`; receives published payloads as JSON text frames; WS close `4001` if session does not exist; WS close `4002` if PTY is not running | One channel per session. No separate channel name or creation step — the session ID is the channel. One-shot and streaming use the same endpoint: a one-shot publisher sends one line and closes; a streaming publisher keeps the connection open and writes lines over time. @@ -35,7 +35,7 @@ The channel is only available while the session's PTY is running. This keeps the | Event | `session.clients` (PTY) | `session.subscribers` (channel) | |-------|-------------------------|---------------------------------| -| PTY not yet spawned | n/a | publish → `409`; subscribe → WS close `4002` | +| PTY not yet spawned | n/a | publish → `409`; subscribe → WS close `4002` (session exists but PTY not running) | | PTY running | connected normally | connected normally | | Shell exits | closed; session deleted | closed; session deleted | | `DELETE /api/sessions/:id` | closed | closed | diff --git a/docs/specs/client-integration.md b/docs/specs/client-integration.md index 07c22b2..25f75a3 100644 --- a/docs/specs/client-integration.md +++ b/docs/specs/client-integration.md @@ -65,5 +65,5 @@ For interface details, channel flow, and API reference see [ADR 025](../adrs/025 | Feature | Description | ADR | Done? | |---------|-------------|-----|-------| -| Session channel — publish | CLI tools POST JSON (one-shot or streaming) to the session; each event broadcast to subscribers in real-time | [ADR 025](../adrs/025.server.channel.md) | ❌ | -| Session channel — subscribe | Browser panels subscribe via WebSocket and receive one JSON object per frame | [ADR 025](../adrs/025.server.channel.md) | ❌ | +| Session channel — publish | CLI tools POST JSON (one-shot or streaming) to the session; each event broadcast to subscribers in real-time | [ADR 025](../adrs/025.server.channel.md) | ✅ | +| Session channel — subscribe | Browser panels subscribe via WebSocket and receive one JSON object per frame | [ADR 025](../adrs/025.server.channel.md) | ✅ | diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index c4c3043..d3059fa 100644 --- a/docs/specs/webtty.md +++ b/docs/specs/webtty.md @@ -80,4 +80,4 @@ Session IDs appear directly in the URL path (`/s/:id`), so they must be valid UR | Default session | `GET /` redirects to last-used session, or creates `main` if none exists | — | ✅ | | Multi-client sessions | Multiple browser tabs can attach to the same session; PTY output broadcast to all; scrollback replayed on reconnect | [ADR 007](../adrs/007.webtty.session-client.md) | ✅ | | Config file | Shell, port, font, theme from `~/.config/webtty/config.json`; hot-reload on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | -| Client integration (CLI → Web) | `POST /s/:id/publish` (one-shot or streaming JSON) + `ws /ws/:id/events` subscribe; channel active only while PTY is running; no extra process or port | [ADR 025](../adrs/025.server.channel.md) | ❌ | +| Client integration (CLI → Web) | `POST /s/:id/publish` (one-shot or streaming JSON) + `ws /ws/:id/events` subscribe; channel active only while PTY is running; no extra process or port | [ADR 025](../adrs/025.server.channel.md) | ✅ | diff --git a/src/server/routes.ts b/src/server/routes.ts index a400357..c9204a6 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -273,20 +273,24 @@ export async function handleRequest( buf += chunk.toString('utf8'); const lines = buf.split('\n'); buf = lines.pop() ?? ''; + if (buf.length > MAX_BODY) buf = ''; for (const line of lines) { + const trimmed = line.replace(/\r$/, ''); + if (!trimmed) continue; try { - JSON.parse(line); - broadcastToSubscribers(session, line); + JSON.parse(trimmed); + broadcastToSubscribers(session, trimmed); } catch { // skip invalid JSON lines } } }); req.on('end', () => { - if (buf) { + const trimmed = buf.replace(/\r$/, ''); + if (trimmed) { try { - JSON.parse(buf); - broadcastToSubscribers(session, buf); + JSON.parse(trimmed); + broadcastToSubscribers(session, trimmed); } catch { // skip invalid JSON }