diff --git a/AGENTS.md b/AGENTS.md index 636126ee..f8cca527 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co ## Conventions +- **Be very strict about the public API surface.** Every exported symbol on a published subpath is a contract users can depend on — additions and changes must be deliberate, not a side effect of where code happens to live. Before exporting anything new, ask whether it needs to be public at all: helpers shared between first-party packages and transports belong on **`devframe/internal`** (explicitly unstable, can change in any minor release), and module-local code should simply not be exported. Barrel files that `export *` make accidental exposure easy — when adding to a star-exported module, check what rides along. The `tsnapi` snapshots under `tests/__snapshots__/tsnapi/` guard the entire surface: review every snapshot diff as an API-design decision, never regenerate it as a chore, and treat a `TSNAPI_ALLOW_BREAKING` update as something that needs the same scrutiny as the breaking change itself. - RPC functions must use `defineRpcFunction`; always namespace IDs `devframes:plugin::` (matching the plugin's `@devframes/plugin-` package name). - **Stay validator-neutral.** `devframe` and every `@devframes/*` package must not introduce a preferred schema validator dependency — no `valibot`, `zod`, `arktype`, etc. in their runtime `dependencies`. `args`/`returns`/flag schemas are typed against [Standard Schema](https://standardschema.dev/) (`@standard-schema/spec`, types-only); first-party code that needs to author a schema uses the built-in zero-dep `devframe/utils/simple-schema` builder (deliberately minimal — not a general validator). JSON-schema conversion uses each schema's own Standard JSON Schema converter (`~standard.jsonSchema`, implemented by e.g. zod 4) when present and degrades to a permissive object otherwise — no converter library and no vendor dependency is required. Docs, by contrast, should point *users* at a real validator for their own integrations — recommend **valibot** (lightest) or **zod** (worth reusing if they already pull it via the JSON-render or MCP integrations). - Shared state via `devframe/utils/shared-state`; keep values serializable. 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() { -