Skip to content

Commit f722847

Browse files
committed
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.
1 parent 9361d30 commit f722847

60 files changed

Lines changed: 2485 additions & 496 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

alias.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ const r = (path: string) => fileURLToPath(new URL(`./packages/${path}`, import.m
77
const p = (path: string) => fileURLToPath(new URL(`./plugins/${path}`, import.meta.url))
88

99
export const alias = {
10+
'devframe/rpc/transports/sse-client': r('devframe/src/rpc/transports/sse-client.ts'),
11+
'devframe/rpc/transports/sse-server': r('devframe/src/rpc/transports/sse-server.ts'),
1012
'devframe/rpc/transports/ws-bun': r('devframe/src/rpc/transports/ws-bun.ts'),
1113
'devframe/rpc/transports/ws-server': r('devframe/src/rpc/transports/ws-server.ts'),
1214
'devframe/rpc/transports/ws-client': r('devframe/src/rpc/transports/ws-client.ts'),

docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ function guideItems(prefix: string) {
2929
{ text: 'When Clauses', link: `${prefix}/guide/when-clauses` },
3030
{ text: 'Structured Diagnostics', link: `${prefix}/guide/diagnostics` },
3131
{ text: 'Client', link: `${prefix}/guide/client` },
32+
{ text: 'Transports', link: `${prefix}/guide/transports` },
3233
{ text: 'Security', link: `${prefix}/guide/security` },
3334
{ text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` },
3435
{ text: 'Hub', link: `${prefix}/guide/hub` },

docs/errors/DF0057.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0057: WebSocket Transport Disabled
6+
7+
## Message
8+
9+
> 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.
27+
myServer.on('request', (req, res) => sseOnly.nodeMiddleware(req, res))
28+
```
29+
30+
## Fix
31+
32+
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`.

docs/guide/transports.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# Transports
6+
7+
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').
33+
initDevframe(def, { base: '/__my-tool/', ws: false })
34+
35+
// WebSocket-only — opt out of the SSE endpoint.
36+
initDevframe(def, { base: '/__my-tool/', server, sse: false })
37+
38+
// Rename the SSE route.
39+
initDevframe(def, { base: '/__my-tool/', server, sse: { route: '__events' } })
40+
```
41+
42+
`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:
49+
50+
```ts
51+
const client = await connectDevframe({ transport: 'sse' })
52+
53+
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.

examples/hub-next/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Open the printed URL. The dock on the left lists every mounted tool with its ico
1616
- **Git**, **Terminals**, **Code Server**, **RPC & State Inspector**, **A11y Inspector** — the built-in plugins, each an entry in `initHub`'s `devframes` list
1717
- **Next Demo Tool** / **Next Demo Tool B** — two trivial static SPAs that show the bare mount path
1818

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.
2020

2121
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.
2222

examples/hub-next/src/client/app/page.tsx

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,38 @@ function DockIcon({ entry }: { entry: DevframeDockEntry }) {
168168
return <span className="grid h-5 w-5 shrink-0 place-items-center rounded bg-active text-[0.7rem] font-bold">{initial}</span>
169169
}
170170

171+
// ── transport preference (`?transport=` param) ──────────────────────────────
172+
// The hub serves both live transports (WS at `__ws`, SSE at `__sse`); the
173+
// client's `transport` option picks one, `auto` trusting the server's
174+
// advertisement. A closed client has no reconnect, so the toggle writes the
175+
// preference into the URL and reloads — the whole host boots on the chosen
176+
// transport.
177+
178+
const TRANSPORT_PREFS = ['auto', 'websocket', 'sse'] as const
179+
type TransportPref = (typeof TRANSPORT_PREFS)[number]
180+
181+
function readTransportPref(): TransportPref {
182+
const raw = new URLSearchParams(window.location.search).get('transport')
183+
return (TRANSPORT_PREFS as readonly string[]).includes(raw ?? '') ? raw as TransportPref : 'auto'
184+
}
185+
186+
function applyTransportPref(pref: TransportPref) {
187+
const url = new URL(window.location.href)
188+
if (pref === 'auto')
189+
url.searchParams.delete('transport')
190+
else
191+
url.searchParams.set('transport', pref)
192+
window.location.href = url.href
193+
}
194+
195+
function transportLabel(pref: TransportPref): string {
196+
return pref === 'websocket' ? 'WS' : pref === 'sse' ? 'SSE' : 'Auto'
197+
}
198+
171199
export default function Page() {
172200
const [status, setStatus] = useState<Status>({ text: 'Connecting...' })
201+
const [transport, setTransport] = useState<string | null>(null)
202+
const [transportPref, setTransportPref] = useState<TransportPref>('auto')
173203
const [docks, setDocks] = useState<DevframeDockEntry[]>([])
174204
const [commands, setCommands] = useState<DevframeCommandEntry[]>([])
175205
const [messages, setMessages] = useState<DevframeMessageEntry[]>([])
@@ -191,12 +221,15 @@ export default function Page() {
191221

192222
async function run() {
193223
try {
194-
const rpc = await connectDevframe({ baseURL: HUB_BASE })
224+
const pref = readTransportPref()
225+
setTransportPref(pref)
226+
const rpc = await connectDevframe({ baseURL: HUB_BASE, transport: pref })
195227
if (cancelled)
196228
return
197229

198230
rpcRef.current = rpc
199-
setStatus({ text: `Connected: backend=${rpc.connectionMeta.backend}`, kind: 'ready' })
231+
setTransport(rpc.transport)
232+
setStatus({ text: `Connected: transport=${rpc.transport}`, kind: 'ready' })
200233

201234
// Boot the framework-level client host: it builds the shared client
202235
// context and imports each dock's client script into this page — e.g.
@@ -485,7 +518,30 @@ export default function Page() {
485518
</main>
486519
</div>
487520

488-
<footer className="grid grid-cols-3 shrink-0 gap-5 border-t border-base bg-base px4 py3 max-h-30vh of-auto">
521+
<footer className="grid grid-cols-4 shrink-0 gap-5 border-t border-base bg-base px4 py3 max-h-30vh of-auto">
522+
<section className="min-w-0">
523+
<h2 className={titleClass}>Transport</h2>
524+
<p className="m0 rounded-lg border border-base bg-base border-dashed px2.5 py1.5 text-xs font-mono op-mute">
525+
{transport
526+
? `Connected over ${transport} (${transportPref === 'auto' ? 'auto-selected' : 'pinned'})`
527+
: 'Connecting…'}
528+
</p>
529+
{/* Segmented selector (LayoutTabs variant="segment" port): a
530+
bg-secondary track whose active trigger gets bg-base. */}
531+
<div className="mt2.5 inline-flex gap-0.5 rounded-lg bg-secondary p0.5">
532+
{TRANSPORT_PREFS.map(pref => (
533+
<button
534+
key={pref}
535+
type="button"
536+
onClick={() => applyTransportPref(pref)}
537+
className={`rounded-md border-none bg-transparent px2 py0.5 text-xs font-medium cursor-pointer ${pref === transportPref ? 'bg-base color-active shadow-sm' : 'color-muted hover:color-base'}`}
538+
>
539+
{transportLabel(pref)}
540+
</button>
541+
))}
542+
</div>
543+
</section>
544+
489545
<section className="min-w-0">
490546
<h2 className={titleClass}>Commands</h2>
491547
<ul className="m0 flex flex-col list-none gap-1.5 p0">

examples/hub-next/tests/next-devframe-hub.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ describe('next-devframe-hub (example)', () => {
3737
expect(hub.connectionMeta()).toEqual({
3838
backend: 'websocket',
3939
websocket: { port, path: '__ws' },
40+
sse: { path: '/__devframes/__sse' },
4041
mcp: { path: '__mcp' },
4142
})
4243
})

examples/hub-vite/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Open the printed URL. The dock on the left lists every mounted tool with its ico
1616
- **Git**, **Terminals**, **Code Server**, **RPC & State Inspector**, **A11y Inspector** — the built-in plugins, each a published `DevframeDefinition` passed to the host's `devframes` option
1717
- **Demo Tool** / **Demo Tool B** — two trivial static SPAs that show the bare mount path
1818

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.
2020

2121
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.
2222

examples/hub-vite/index.html

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,15 @@ <h2 class="px2 py1 text-[0.68rem] uppercase tracking-wider color-muted">Docks</h
3939
</main>
4040
</div>
4141

42-
<footer class="grid grid-cols-3 shrink-0 gap-5 border-t border-base bg-base px4 py3 max-h-30vh of-auto">
42+
<footer class="grid grid-cols-4 shrink-0 gap-5 border-t border-base bg-base px4 py3 max-h-30vh of-auto">
43+
<section class="min-w-0">
44+
<h2 class="mb2 text-[0.68rem] uppercase tracking-wider color-muted">Transport</h2>
45+
<p id="transport" class="m0 rounded-lg border border-base bg-base border-dashed px2.5 py1.5 text-xs font-mono op-mute">Connecting…</p>
46+
<!-- Segmented selector (LayoutTabs variant="segment" port): a
47+
bg-secondary track whose active trigger gets bg-base. -->
48+
<div id="transport-toggle" class="mt2.5 inline-flex gap-0.5 rounded-lg bg-secondary p0.5"></div>
49+
</section>
50+
4351
<section class="min-w-0">
4452
<h2 class="mb2 text-[0.68rem] uppercase tracking-wider color-muted">Commands</h2>
4553
<ul id="commands" class="m0 flex flex-col list-none gap-1.5 p0"><li class="rounded-lg border border-base bg-base border-dashed px2.5 py1.5 text-xs font-mono op-mute">Waiting for snapshot…</li></ul>

examples/hub-vite/src/client/main.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import '@antfu/design/styles.css'
1616
const HUB_BASE = '/__devframes/'
1717

1818
const connEl = document.querySelector<HTMLElement>('#conn')!
19+
const transportEl = document.querySelector<HTMLElement>('#transport')!
20+
const transportToggleEl = document.querySelector<HTMLElement>('#transport-toggle')!
1921
const docksEl = document.querySelector<HTMLElement>('#docks')!
2022
const commandsEl = document.querySelector<HTMLElement>('#commands')!
2123
const messagesEl = document.querySelector<HTMLElement>('#messages')!
@@ -32,6 +34,42 @@ function setStatus(text: string, kind?: 'ready' | 'error') {
3234
connEl.innerHTML = `<span class="inline-block size-1.5 rounded-full shrink-0 ${dot} mr-1.5 align-middle"></span>${text}`
3335
}
3436

37+
// ── transport preference (`?transport=` param) ──────────────────────────────
38+
// The hub serves both live transports (WS at `__ws`, SSE at `__sse`); the
39+
// client's `transport` option picks one, `auto` trusting the server's
40+
// advertisement. A closed client has no reconnect, so the toggle writes the
41+
// preference into the URL and reloads — the whole host boots on the chosen
42+
// transport.
43+
44+
const TRANSPORT_PREFS = ['auto', 'websocket', 'sse'] as const
45+
type TransportPref = (typeof TRANSPORT_PREFS)[number]
46+
47+
function readTransportPref(): TransportPref {
48+
const raw = new URLSearchParams(location.search).get('transport')
49+
return (TRANSPORT_PREFS as readonly string[]).includes(raw ?? '') ? raw as TransportPref : 'auto'
50+
}
51+
52+
function applyTransportPref(pref: TransportPref) {
53+
const url = new URL(location.href)
54+
if (pref === 'auto')
55+
url.searchParams.delete('transport')
56+
else
57+
url.searchParams.set('transport', pref)
58+
location.href = url.href
59+
}
60+
61+
function renderTransportToggle(current: TransportPref) {
62+
transportToggleEl.innerHTML = TRANSPORT_PREFS.map((pref) => {
63+
const active = pref === current
64+
? 'bg-base color-active shadow-sm'
65+
: 'color-muted hover:color-base'
66+
const label = pref === 'websocket' ? 'WS' : pref === 'sse' ? 'SSE' : 'Auto'
67+
return `<button type="button" data-transport="${pref}" class="rounded-md border-none bg-transparent px2 py0.5 text-xs font-medium cursor-pointer ${active}">${label}</button>`
68+
}).join('')
69+
for (const button of transportToggleEl.querySelectorAll<HTMLButtonElement>('[data-transport]'))
70+
button.addEventListener('click', () => applyTransportPref(button.dataset.transport as TransportPref))
71+
}
72+
3573
function renderList<T>(host: HTMLElement, items: readonly T[], render: (item: T) => string) {
3674
if (!items.length) {
3775
host.innerHTML = '<li class="rounded-lg border border-base bg-base border-dashed px2.5 py1.5 text-xs font-mono op-mute">empty</li>'
@@ -192,8 +230,12 @@ function createClientPlaygroundSpec(clientType: string): DevframeJsonRenderSpec
192230
async function main() {
193231
setStatus('Connecting…')
194232

195-
const rpc = await connectDevframe({ baseURL: HUB_BASE })
196-
setStatus(`Connected · backend=${rpc.connectionMeta.backend}`, 'ready')
233+
const transportPref = readTransportPref()
234+
renderTransportToggle(transportPref)
235+
236+
const rpc = await connectDevframe({ baseURL: HUB_BASE, transport: transportPref })
237+
setStatus(`Connected · transport=${rpc.transport}`, 'ready')
238+
transportEl.textContent = `Connected over ${rpc.transport} (${transportPref === 'auto' ? 'auto-selected' : 'pinned'})`
197239

198240
// Boot the framework-level client host: it builds the shared client context
199241
// and imports each dock's client script into this page — e.g. the a11y

0 commit comments

Comments
 (0)