Client
The browser client connects any surface — dock iframe, remote page, standalone SPA — to the Devframe server with type-safe RPC, shared state, and a trust handshake.
Connecting
devframe/client exports connectDevframe (an alias of getDevframeRpcClient):
import { connectDevframe } from 'devframe/client'
const rpc = await connectDevframe()
const modules = await rpc.call('my-devframe:get-modules', { limit: 10 })At the default mount path, connectDevframe needs no arguments — it auto-detects the backend via __devframe/__connection.json.
Runtime basePath discovery
One SPA artifact serves at /, /__<id>/, or any subpath, no rebuild. Build with relative asset paths — Vite base: './', Nuxt vite.base: './' + app.baseURL: './'.
Sharing a connection with an external viewer
setupDevframeConnection() prepares a serializable connection for a cross-origin viewer:
import { setupDevframeConnection } from 'devframe/client'
const connection = await setupDevframeConnection({
baseURL: '/__devframe/',
})In the viewer:
import { connectDevframe } from 'devframe/client'
const rpc = await connectDevframe({ connection })The client retains it as rpc.connection; cross-realm viewers read it via getDevframeConnection() or DEVFRAME_CONNECTION_KEY (devframe/constants).
An external viewer registers its origin before the WebSocket opens (needs viewerOriginToken in the host's connection metadata; see External viewer origins):
import { registerDevframeViewerOrigin } from 'devframe/client'
await registerDevframeViewerOrigin(connection)Options
| Option | Description |
|---|---|
connection | Connection prepared by setupDevframeConnection(). |
baseURL | Mount path to probe for __connection.json (array = fallback). Default './' (relative to document.baseURI); use an absolute path ('/__devframe/') from outside the SPA. |
authToken | Override the auth token (default: a locally-persisted id). |
cacheOptions | true for default caching, or an options object. |
callTimeout | Ms before a pending rpc.call rejects with a 'timeout' DevframeConnectionError; 0/omit = wait forever. |
wsOptions | Transport overrides — onConnected / onError / onDisconnected hooks, socket URL. |
rpcOptions | Forwarded to birpc. |
connectionMeta | Descriptor that skips the __connection.json fetch. |
Modes
Per the __devframe/__connection.json backend:
| Backend | When | Capabilities |
|---|---|---|
websocket | Dev mode (createCac, Kit) | Full read/write, broadcasts, shared-state mutation. Requires auth. |
static | Build / SPA output | Read-only — all calls resolve against the baked RPC dump. |
Trust & auth (WebSocket mode)
ensureTrusted() resolves once the server trusts the client's stored token:
const rpc = await connectDevframe()
// Blocks until the server trusts this client (default timeout 60s)
const trusted = await rpc.ensureTrusted()
if (!trusted) {
console.warn('Not authenticated yet')
}connectDevframe() starts the handshake without blocking; rpc.call / rpc.callOptional / rpc.callEvent hold anything issued before it settles.
Authenticating with a one-time code
The dev server prints a single-use 6-digit code (expires in five minutes, rotates after repeated wrong attempts); requestTrustWithCode exchanges it for a persisted node-issued token shared across sibling tabs:
const ok = await rpc.requestTrustWithCode('047204')A host can embed the code in a link (buildOtpAuthUrl(origin)); connectDevframe reads the devframe_otp fragment, exchanges it, and strips the URL. Rename it with otpParam, or set otpParam: false to drive it yourself via authenticateWithUrlOtp(rpc) / consumeOtpFromUrl().
Re-using an existing token
Authenticate with a token obtained elsewhere, without reloading:
const ok = await rpc.requestTrustWithToken('a1b2c3…')Broadcast-channel sync
connectDevframe listens on a shared BroadcastChannel (devframe-auth) for auth-update messages; one tab authenticating trusts every open client.
Calling functions
Derive a scoped client for namespaced ids:
const my = (await connectDevframe()).scope('my-devframe')
// Standard call — awaits a response or throws.
const modules = await my.rpc.call('get-modules', { limit: 10 })
// Optional — returns undefined when no handler responds (useful while HMR is restarting).
const maybe = await my.rpc.callOptional('get-modules', { limit: 10 })
// Event — fire-and-forget, no response expected.
my.rpc.callEvent('notify', { message: 'hello' })Types flow from the server's defineRpcFunction definitions.
Registering client functions
Register functions the server calls via rpc.broadcast:
import { defineRpcFunction } from 'devframe'
my.rpc.register(defineRpcFunction({
name: 'on-file-changed', // -> my-devframe:on-file-changed
type: 'event',
setup: () => ({
handler: async ({ file }: { file: string }) => {
console.log('server says:', file, 'changed')
},
}),
}))Shared state
const state = await my.rpc.sharedState('state') // -> my-devframe:state
console.log(state.value())
state.mutate((draft) => {
draft.count += 1
})
state.on('updated', (next) => {
console.log('new state', next)
})See Shared State.
Services
rpc.services mirrors the server's wire-service advertisements:
if (rpc.services.has('@devframes/service-open'))
await rpc.services.get('@devframes/service-open')!.rpc.call('open-in-editor', { path })Settings
A scoped client exposes a persisted settings store, per-user (global) or per-workspace (project):
await my.settings.project.set('theme', 'dark')
const theme = await my.settings.project.get('theme')See Scoped Context.
Caching
Set cacheOptions: true (or an object):
const rpc = await connectDevframe({ cacheOptions: true })query / static responses are memoized per argument hash; the rpc:cache:invalidate broadcast clears entries after a mutation.
Discovery (__connection.json)
Devframe writes a JSON descriptor at <base>/__connection.json. The socket shares the HTTP port, binding to <base>__ws (advertised relative):
{
"backend": "websocket",
"websocket": { "path": "__ws" }
}The client resolves it against its origin (http→ws / https→wss). The field also accepts a number (port on the page's host), a full ws:///wss:// URL, or { port } / { host } for a cross-origin side-car.
For static mode:
{ "backend": "static" }Override discovery with connectionMeta:
await connectDevframe({
connectionMeta: { backend: 'static' },
})Remote docks
Supporting hosts (Vite DevTools; see its remote-client docs) inject a connection descriptor into the iframe URL that connectDevframe auto-detects:
import { connectDevframe } from 'devframe/client'
const rpc = await connectDevframe()
// Already wired to the local dev server via the injected descriptor.The descriptor's session-only, pre-approved token makes ensureTrusted() resolve immediately. An external hub builds a viewer URL from a trusted connection with buildRemoteDevframeUrl(), keeping the token in the URL fragment:
import {
buildRemoteDevframeUrl,
stripRemoteConnectionFromUrl,
} from '@devframes/hub/client'
const viewerUrl = buildRemoteDevframeUrl('/viewer/', connection)
const displayUrl = stripRemoteConnectionFromUrl(viewerUrl)Events
Emitted over rpc.events:
| Event | Fires when |
|---|---|
rpc:is-trusted:updated | Trust granted, denied, or revoked. Carries the new isTrusted boolean. |
connection:status | The connection status changes. Carries (status, previous). |
connection:error | A connection-level failure — socket error or trust refused. Carries the Error. |
rpc:error | An rpc.call rejects, from the server or a down connection. Carries (error, method). |
rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
if (isTrusted)
console.log('server trusts this client')
else
console.log('trust revoked or denied')
})rpc.isTrusted is the synchronous read.
Handling connection and auth errors
Connection status
rpc.status collapses transport and trust into one value; rpc.connectionError holds the last connection-level Error (null when healthy):
| Status | Meaning |
|---|---|
connecting | Establishing socket / handshake. Calls queue until open. |
connected | Socket open and trusted; calls are served. |
unauthorized | Socket open, trust refused. Prompt for authentication. |
disconnected | Socket closed (dropped mid-session or never opened). |
error | Fatal — the socket errored or connection meta couldn't load. |
A static backend has no live socket, so rpc.status stays connected.
Calls fail fast
When the socket closes or trust is refused, in-flight and new rpc.call promises reject with a DevframeConnectionError, its kind:
'connection'— the transport is down (disconnected/error).'auth'— the client isunauthorized.'timeout'— the call outlivedcallTimeout.
Set callTimeout to cap an unresponsive server:
const rpc = await connectDevframe({ callTimeout: 10_000 })Putting it together
Gate the UI on connection:status and wrap calls to branch on failure:
import { connectDevframe, DevframeConnectionError } from 'devframe/client'
const rpc = await connectDevframe()
// 1. Render from the live status.
function render() {
switch (rpc.status) {
case 'connected': return renderApp()
case 'connecting': return renderSpinner('Connecting…')
case 'unauthorized': return renderMessage('Not authorized — reopen the link from your dev server.')
case 'disconnected': return renderMessage('Disconnected.', { onRetry: reconnect })
case 'error': return renderMessage(rpc.connectionError?.message ?? 'Connection failed.', { onRetry: reconnect })
}
}
rpc.events.on('connection:status', render)
render()
// 2. Handle a failing call.
async function loadModules() {
try {
return await rpc.call('my-devframe:get-modules', { limit: 10 })
}
catch (error) {
if (error instanceof DevframeConnectionError) {
// 'connection' | 'auth' | 'timeout' — the UI already reflects rpc.status.
return null
}
throw error // a real server-side error — surface it.
}
}Recovering
The client doesn't reconnect on its own — reload or re-run your connect routine:
async function reconnect() {
rpc = await connectDevframe() // a new client; re-subscribe your listeners
render()
}In a hub, a viewer reads this status from context.connection.