From f7228471cc9b7de503f164f0cde2ea25ec6c028b Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 13 Aug 2026 03:43:18 +0000 Subject: [PATCH 1/2] feat: SSE transport as a portable alternative to the WebSocket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an SSE + HTTP POST RPC transport alongside the WebSocket, for hosts and proxies where the upgrade isn't available: - Server: `attachSseRpcTransport` (devframe/rpc/transports/sse-server) — a fetch-style handler mirroring birpc's SSE wire semantics (session id as the stream's first event, echoed in `x-birpc-session` on POSTs, client-initiated responses parked in the POST body), with devframe's origin gate, dual strict-JSON/structured-clone codec, connect-time token auth, and a 30s keep-alive comment. Mounted by the instance shell at `__sse` on the same app serving `__connection.json`, so every HTTP-backed tier — including the Vite bridge's middleware — serves it with no upgrade wiring. - Client: `createSseRpcChannel` (fetch-streaming, no EventSource) plus an SSE client mode sharing the WS mode's status machine, call guarding, and trust handshake (extracted into `client/rpc-live.ts`). `connectDevframe` gains `transport: 'auto' | 'websocket' | 'sse'` (auto trusts the server's advertised primary) and a readonly `transport` field. - Config: `ws: false` runs SSE-only (`backend: 'sse'`); `sse: false` disables the endpoint; both off is an RPC-less shell (`backend: 'none'`). `ConnectionMeta` advertises the `sse` endpoint with the same proxy-safe resolution rules as the WebSocket. - The wire codec shared by all four transport halves is factored into `createRpcWireCodec` (rpc/serialization). - New diagnostic DF0057 (upgrade wiring with `ws: false`) + docs page, a Transports guide, hub example drawers gain a transport indicator + Auto/WS/SSE toggle (hub-vite + hub-next, parity), and a new `examples/sse-basic` minimal SSE-only app. --- alias.ts | 2 + docs/.vitepress/config.ts | 1 + docs/errors/DF0057.md | 36 ++ docs/guide/transports.md | 58 +++ examples/hub-next/README.md | 2 +- examples/hub-next/src/client/app/page.tsx | 62 +++- .../hub-next/tests/next-devframe-hub.test.ts | 1 + examples/hub-vite/README.md | 2 +- examples/hub-vite/index.html | 10 +- examples/hub-vite/src/client/main.ts | 46 ++- examples/sse-basic/README.md | 25 ++ examples/sse-basic/index.html | 42 +++ examples/sse-basic/package.json | 22 ++ examples/sse-basic/src/main.ts | 39 ++ examples/sse-basic/tsconfig.json | 23 ++ examples/sse-basic/uno.config.ts | 12 + examples/sse-basic/vite.config.ts | 71 ++++ knip.jsonc | 2 +- packages/devframe/package.json | 2 + .../src/adapters/__tests__/dev.test.ts | 10 +- .../src/adapters/__tests__/initiate.test.ts | 6 +- .../src/adapters/__tests__/sse-e2e.test.ts | 288 +++++++++++++++ packages/devframe/src/adapters/dev.ts | 14 +- packages/devframe/src/adapters/initiate.ts | 13 +- packages/devframe/src/client/index.ts | 1 + packages/devframe/src/client/rpc-live.ts | 340 ++++++++++++++++++ packages/devframe/src/client/rpc-sse.ts | 118 ++++++ packages/devframe/src/client/rpc-static.ts | 1 + packages/devframe/src/client/rpc-ws.ts | 326 ++--------------- packages/devframe/src/client/rpc.ts | 135 +++++-- packages/devframe/src/constants.ts | 18 + packages/devframe/src/node/diagnostics.ts | 4 + packages/devframe/src/node/instance-shell.ts | 227 ++++++++---- packages/devframe/src/rpc/serialization.ts | 77 +++- .../devframe/src/rpc/transports/sse-client.ts | 201 +++++++++++ .../devframe/src/rpc/transports/sse-server.ts | 322 +++++++++++++++++ .../devframe/src/rpc/transports/sse.test.ts | 196 ++++++++++ .../devframe/src/rpc/transports/ws-client.ts | 38 +- .../devframe/src/rpc/transports/ws-server.ts | 40 +-- packages/devframe/src/types/devframe.ts | 27 +- packages/devframe/tsdown.config.ts | 2 + .../hub/src/node/__tests__/initiate.test.ts | 2 + packages/hub/src/node/initiate.ts | 15 +- pnpm-lock.yaml | 22 ++ .../@devframes/hub/initiate.snapshot.d.ts | 3 +- .../devframe/adapters/dev.snapshot.d.ts | 3 +- .../tsnapi/devframe/client.snapshot.d.ts | 6 + .../tsnapi/devframe/client.snapshot.js | 2 + .../tsnapi/devframe/constants.snapshot.d.ts | 2 + .../tsnapi/devframe/constants.snapshot.js | 2 + .../tsnapi/devframe/index.snapshot.d.ts | 6 +- .../tsnapi/devframe/initiate.snapshot.d.ts | 3 +- .../tsnapi/devframe/rpc.snapshot.d.ts | 3 + .../tsnapi/devframe/rpc.snapshot.js | 2 + .../rpc/transports/sse-client.snapshot.d.ts | 7 + .../rpc/transports/sse-client.snapshot.js | 6 + .../rpc/transports/sse-server.snapshot.d.ts | 22 ++ .../rpc/transports/sse-server.snapshot.js | 6 + .../tsnapi/devframe/types.snapshot.d.ts | 1 + tsconfig.base.json | 6 + 60 files changed, 2485 insertions(+), 496 deletions(-) create mode 100644 docs/errors/DF0057.md create mode 100644 docs/guide/transports.md create mode 100644 examples/sse-basic/README.md create mode 100644 examples/sse-basic/index.html create mode 100644 examples/sse-basic/package.json create mode 100644 examples/sse-basic/src/main.ts create mode 100644 examples/sse-basic/tsconfig.json create mode 100644 examples/sse-basic/uno.config.ts create mode 100644 examples/sse-basic/vite.config.ts create mode 100644 packages/devframe/src/adapters/__tests__/sse-e2e.test.ts create mode 100644 packages/devframe/src/client/rpc-live.ts create mode 100644 packages/devframe/src/client/rpc-sse.ts create mode 100644 packages/devframe/src/rpc/transports/sse-client.ts create mode 100644 packages/devframe/src/rpc/transports/sse-server.ts create mode 100644 packages/devframe/src/rpc/transports/sse.test.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/rpc/transports/sse-client.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/rpc/transports/sse-client.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/devframe/rpc/transports/sse-server.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/rpc/transports/sse-server.snapshot.js diff --git a/alias.ts b/alias.ts index 659cabda..6a8d6c37 100644 --- a/alias.ts +++ b/alias.ts @@ -7,6 +7,8 @@ const r = (path: string) => fileURLToPath(new URL(`./packages/${path}`, import.m const p = (path: string) => fileURLToPath(new URL(`./plugins/${path}`, import.meta.url)) export const alias = { + 'devframe/rpc/transports/sse-client': r('devframe/src/rpc/transports/sse-client.ts'), + 'devframe/rpc/transports/sse-server': r('devframe/src/rpc/transports/sse-server.ts'), 'devframe/rpc/transports/ws-bun': r('devframe/src/rpc/transports/ws-bun.ts'), 'devframe/rpc/transports/ws-server': r('devframe/src/rpc/transports/ws-server.ts'), 'devframe/rpc/transports/ws-client': r('devframe/src/rpc/transports/ws-client.ts'), diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 2c37dd44..09427588 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -29,6 +29,7 @@ function guideItems(prefix: string) { { text: 'When Clauses', link: `${prefix}/guide/when-clauses` }, { text: 'Structured Diagnostics', link: `${prefix}/guide/diagnostics` }, { text: 'Client', link: `${prefix}/guide/client` }, + { text: 'Transports', link: `${prefix}/guide/transports` }, { text: 'Security', link: `${prefix}/guide/security` }, { text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` }, { text: 'Hub', link: `${prefix}/guide/hub` }, diff --git a/docs/errors/DF0057.md b/docs/errors/DF0057.md new file mode 100644 index 00000000..79835190 --- /dev/null +++ b/docs/errors/DF0057.md @@ -0,0 +1,36 @@ +--- +outline: deep +--- + +# DF0057: WebSocket Transport Disabled + +## Message + +> This instance disables its WebSocket transport (`ws: false`), so there is no socket to drive upgrades into. + +## Cause + +`ws: false` runs the instance without a WebSocket: clients connect over the SSE endpoint instead (`backend: 'sse'`). There is therefore no socket for `attach(server)` / `handleUpgrade(req, socket, head)` to feed — the host wiring that exists solely to route `upgrade` events has nothing to route to. + +## Example + +```ts +import { initDevframe } from 'devframe/initiate' + +const sseOnly = initDevframe(def, { + base: '/__my-tool/', + ws: false, +}) +sseOnly.attach(myServer) // ✗ throws DF0057 — there is no socket + +// ✓ SSE needs no upgrade wiring; serve the HTTP surface and you're done. +myServer.on('request', (req, res) => sseOnly.nodeMiddleware(req, res)) +``` + +## Fix + +Drop the `attach` / `handleUpgrade` wiring — the SSE endpoint rides the instance's ordinary HTTP surface (`handler` / `nodeMiddleware`), so serving requests is all a host needs to do. Remove `ws: false` if the instance should serve a WebSocket after all. + +## Source + +- [`packages/devframe/src/node/instance-shell.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/instance-shell.ts) — the shared instance shell throws this from `attach` / `handleUpgrade` when the WebSocket tier is `disabled`, for both `initDevframe` and `initHub`. diff --git a/docs/guide/transports.md b/docs/guide/transports.md new file mode 100644 index 00000000..ea3d34db --- /dev/null +++ b/docs/guide/transports.md @@ -0,0 +1,58 @@ +--- +outline: deep +--- + +# Transports + +Devframe serves live RPC over two interchangeable transports — a WebSocket and an SSE endpoint — so a client connects even where the WebSocket upgrade is unavailable (serverless platforms, buffering reverse proxies, restrictive corporate networks). Both speak the identical birpc wire protocol with the same per-method serialization, auth handshake, origin policy, shared state, and streaming; switching transports changes nothing about how you write or call RPC functions. + +## What the server binds + +A live instance binds both by default: + +- **WebSocket** at `__ws` — the primary transport, one full-duplex socket. +- **SSE** at `__sse` — one method-dispatched route: `GET` opens the server→client event stream, `POST` carries client→server RPC frames. It rides the same HTTP surface that serves `__connection.json`, so wherever discovery works, SSE works — including through the Vite bridge's middleware and `initDevframe`'s `handler` / `nodeMiddleware` on hosts that never see upgrade events. + +`__connection.json` advertises what's bound; `backend` names the server's primary transport: + +```json +{ + "backend": "websocket", + "websocket": { "path": "__ws" }, + "sse": { "path": "__sse" } +} +``` + +The SSE stream carries a keep-alive comment every 30 seconds so idle connections survive intermediaries. Both endpoints share one session space — auth trust, shared-state subscriptions, and streaming replay behave identically on either. + +### Configuring + +```ts +// SSE-only — hosts/proxies where the upgrade can't happen. Clients +// connect over SSE automatically (backend: 'sse'). +initDevframe(def, { base: '/__my-tool/', ws: false }) + +// WebSocket-only — opt out of the SSE endpoint. +initDevframe(def, { base: '/__my-tool/', server, sse: false }) + +// Rename the SSE route. +initDevframe(def, { base: '/__my-tool/', server, sse: { route: '__events' } }) +``` + +`ws: false` together with `sse: false` runs an RPC-less shell (`backend: 'none'`) — the SPA, discovery, and MCP routes still serve. The same options apply to `createDevServer`, `initHub`, and a definition's `cli.ws` / `cli.sse` defaults. + +## What the client picks + +`connectDevframe` trusts the server's advertisement: it connects over the declared primary, preferring the WebSocket when both endpoints are present. A server that binds no socket advertises SSE as its primary, so the client lands there with no probing or fallback logic. + +Pin a transport explicitly when you know better than the advertisement — the typical case is an intermediary that silently strips WS upgrades, which the server cannot detect: + +```ts +const client = await connectDevframe({ transport: 'sse' }) + +client.transport // 'websocket' | 'sse' | 'static' — what actually connected +``` + +Pinning a transport the server doesn't advertise rejects with a clear error. SSE endpoints resolve with the same proxy-safe rules as WebSocket ones: relative paths against `__connection.json`'s own URL, an explicit `host`/`port` only for a genuinely cross-origin endpoint. + +A dropped SSE stream ends the client exactly like a closed socket — pending calls reject, the status moves to `disconnected`, and reconnecting means calling `connectDevframe` again. diff --git a/examples/hub-next/README.md b/examples/hub-next/README.md index f3d134e7..841964de 100644 --- a/examples/hub-next/README.md +++ b/examples/hub-next/README.md @@ -16,7 +16,7 @@ Open the printed URL. The dock on the left lists every mounted tool with its ico - **Git**, **Terminals**, **Code Server**, **RPC & State Inspector**, **A11y Inspector** — the built-in plugins, each an entry in `initHub`'s `devframes` list - **Next Demo Tool** / **Next Demo Tool B** — two trivial static SPAs that show the bare mount path -Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's **Commands**, **Messages**, and **Terminals** subsystems, plus a button that dispatches a command through `hub:commands:execute`. +Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's **Commands**, **Messages**, and **Terminals** subsystems, plus a button that dispatches a command through `hub:commands:execute`, and a **Transport** section showing which RPC transport the connection runs on (`websocket` or `sse`) with a segmented Auto / WS / SSE toggle — the choice rides a `?transport=` URL param and reconnects the whole client host on the pinned transport. The A11y Inspector shows a live axe-core report of this hub's own page: the host serves the plugin's in-page agent module (`a11yAgentBundlePath`) same-origin inside the hub namespace and attaches it as the a11y dock's `clientScript` (the `{ devframe, dock }` entry form); the hub client runtime — `createDevframeClientHost()` booted in `app/page.tsx` — imports it into the page, so the docked panel and the agent share the origin their BroadcastChannel rides. diff --git a/examples/hub-next/src/client/app/page.tsx b/examples/hub-next/src/client/app/page.tsx index cdfbc06d..4b2da07e 100644 --- a/examples/hub-next/src/client/app/page.tsx +++ b/examples/hub-next/src/client/app/page.tsx @@ -168,8 +168,38 @@ function DockIcon({ entry }: { entry: DevframeDockEntry }) { return {initial} } +// ── transport preference (`?transport=` param) ────────────────────────────── +// The hub serves both live transports (WS at `__ws`, SSE at `__sse`); the +// client's `transport` option picks one, `auto` trusting the server's +// advertisement. A closed client has no reconnect, so the toggle writes the +// preference into the URL and reloads — the whole host boots on the chosen +// transport. + +const TRANSPORT_PREFS = ['auto', 'websocket', 'sse'] as const +type TransportPref = (typeof TRANSPORT_PREFS)[number] + +function readTransportPref(): TransportPref { + const raw = new URLSearchParams(window.location.search).get('transport') + return (TRANSPORT_PREFS as readonly string[]).includes(raw ?? '') ? raw as TransportPref : 'auto' +} + +function applyTransportPref(pref: TransportPref) { + const url = new URL(window.location.href) + if (pref === 'auto') + url.searchParams.delete('transport') + else + url.searchParams.set('transport', pref) + window.location.href = url.href +} + +function transportLabel(pref: TransportPref): string { + return pref === 'websocket' ? 'WS' : pref === 'sse' ? 'SSE' : 'Auto' +} + export default function Page() { const [status, setStatus] = useState({ text: 'Connecting...' }) + const [transport, setTransport] = useState(null) + const [transportPref, setTransportPref] = useState('auto') const [docks, setDocks] = useState([]) const [commands, setCommands] = useState([]) const [messages, setMessages] = useState([]) @@ -191,12 +221,15 @@ export default function Page() { async function run() { try { - const rpc = await connectDevframe({ baseURL: HUB_BASE }) + const pref = readTransportPref() + setTransportPref(pref) + const rpc = await connectDevframe({ baseURL: HUB_BASE, transport: pref }) if (cancelled) return rpcRef.current = rpc - setStatus({ text: `Connected: backend=${rpc.connectionMeta.backend}`, kind: 'ready' }) + setTransport(rpc.transport) + setStatus({ text: `Connected: transport=${rpc.transport}`, kind: 'ready' }) // Boot the framework-level client host: it builds the shared client // context and imports each dock's client script into this page — e.g. @@ -485,7 +518,30 @@ export default function Page() { -