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
<base>__ws— the primary transport, one full-duplex socket. - SSE at
<base>__sse— one method-dispatched route:GETopens the server→client event stream,POSTcarries 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 andinitDevframe'shandler/nodeMiddlewareon hosts that never see upgrade events.
__connection.json advertises what's bound; backend names the server's primary transport:
{
"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
// 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:
const client = await connectDevframe({ transport: 'sse' })
client.transport // 'websocket' | 'sse' | 'static' — what actually connectedPinning 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.