You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat: SSE transport as a portable alternative to the WebSocket
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 `<base>__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.
> This instance disables its WebSocket transport (`ws: false`), so there is no socket to drive upgrades into.
10
+
11
+
## Cause
12
+
13
+
`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.
14
+
15
+
## Example
16
+
17
+
```ts
18
+
import { initDevframe } from'devframe/initiate'
19
+
20
+
const sseOnly =initDevframe(def, {
21
+
base: '/__my-tool/',
22
+
ws: false,
23
+
})
24
+
sseOnly.attach(myServer) // ✗ throws DF0057 — there is no socket
25
+
26
+
// ✓ SSE needs no upgrade wiring; serve the HTTP surface and you're done.
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.
33
+
34
+
## Source
35
+
36
+
-[`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`.
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.
8
+
9
+
## What the server binds
10
+
11
+
A live instance binds both by default:
12
+
13
+
-**WebSocket** at `<base>__ws` — the primary transport, one full-duplex socket.
14
+
-**SSE** at `<base>__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.
15
+
16
+
`__connection.json` advertises what's bound; `backend` names the server's primary transport:
17
+
18
+
```json
19
+
{
20
+
"backend": "websocket",
21
+
"websocket": { "path": "__ws" },
22
+
"sse": { "path": "__sse" }
23
+
}
24
+
```
25
+
26
+
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.
27
+
28
+
### Configuring
29
+
30
+
```ts
31
+
// SSE-only — hosts/proxies where the upgrade can't happen. Clients
32
+
// connect over SSE automatically (backend: 'sse').
`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.
43
+
44
+
## What the client picks
45
+
46
+
`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.
47
+
48
+
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:
client.transport// 'websocket' | 'sse' | 'static' — what actually connected
54
+
```
55
+
56
+
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.
57
+
58
+
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.
Copy file name to clipboardExpand all lines: examples/hub-next/README.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -16,7 +16,7 @@ Open the printed URL. The dock on the left lists every mounted tool with its ico
16
16
-**Git**, **Terminals**, **Code Server**, **RPC & State Inspector**, **A11y Inspector** — the built-in plugins, each an entry in `initHub`'s `devframes` list
17
17
-**Next Demo Tool** / **Next Demo Tool B** — two trivial static SPAs that show the bare mount path
18
18
19
-
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`.
19
+
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.
20
20
21
21
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.
Copy file name to clipboardExpand all lines: examples/hub-vite/README.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -16,7 +16,7 @@ Open the printed URL. The dock on the left lists every mounted tool with its ico
16
16
-**Git**, **Terminals**, **Code Server**, **RPC & State Inspector**, **A11y Inspector** — the built-in plugins, each a published `DevframeDefinition` passed to the host's `devframes` option
17
17
-**Demo Tool** / **Demo Tool B** — two trivial static SPAs that show the bare mount path
18
18
19
-
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`.
19
+
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.
20
20
21
21
The A11y Inspector shows a live axe-core report of this hub's own page. `vite.config.ts` attaches the plugin's in-page agent as the a11y dock's `clientScript` (served via `/@fs/`), and the hub client runtime — `createDevframeClientHost()` booted in `src/client/main.ts` — imports it into the host page. Panel and agent share the Vite origin their BroadcastChannel rides; hover a violation to ring the offending element in the hub UI.
0 commit comments