Client
The RPC client connects any surface (dock iframe, remote page, standalone SPA) to a devframe's node side 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-tool: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 an external viewer:
import { setupDevframeConnection } from 'devframe/client'
const connection = await setupDevframeConnection({
baseURL: '/__devframe/',
})In the external viewer:
import { connectDevframe } from 'devframe/client'
const rpc = await connectDevframe({ connection })The RPC 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 framework's connection metadata; see External viewer origins):
import { registerDevframeViewerOrigin } from 'devframe/client'
await registerDevframeViewerOrigin(connection)Options
baseURL points at the mount path to probe for __connection.json (default './', relative to document.baseURI); connection adopts one prepared by setupDevframeConnection(). The rest cover auth (authToken), caching (cacheOptions), timeouts (callTimeout), transport hooks (wsOptions), birpc passthrough (rpcOptions), and discovery override (connectionMeta); every option is in the Browser-Side API reference.
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 node side trusts the RPC client's stored token:
const rpc = await connectDevframe()
// Blocks until the node side trusts this RPC 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) when an untrusted RPC client asks for one: call requestAuthCode() when your auth UI shows, passing { reissue: true } from a "re-issue" button to rotate the code first. requestTrustWithCode then exchanges it for a persisted node-issued token shared across sibling tabs:
await rpc.requestAuthCode()
// … the developer reads the code from the terminal …
const ok = await rpc.requestTrustWithCode('047204')A host framework 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 RPC client.
Calling functions
Derive a scoped client for namespaced ids:
const my = (await connectDevframe()).scope('my-tool')
// 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 node side's defineRpcFunction definitions.
Registering client functions
Register functions the node side calls via rpc.broadcast:
import { defineRpcFunction } from 'devframe'
my.rpc.register(defineRpcFunction({
name: 'on-file-changed', // -> my-tool: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-tool: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 node side'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-checkout (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 RPC 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 server.
For static mode:
{ "backend": "static" }Override discovery with connectionMeta:
await connectDevframe({
connectionMeta: { backend: 'static' },
})Remote docks
Supporting host frameworks (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 an external-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
Four events arrive over rpc.events: rpc:is-trusted:updated when trust is granted, denied, or revoked; connection:status when the connection status changes; connection:error on a connection-level failure; and rpc:error when an rpc.call rejects. Payloads are in the Browser-Side API reference.
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). It moves through connecting (calls queue until open), connected (calls are served), unauthorized (socket open, trust refused; prompt for authentication), disconnected, and error; each value's meaning is in the Browser-Side API reference.
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 RPC client isunauthorized.'timeout': the call outlivedcallTimeout.
Set callTimeout to cap an unresponsive node side:
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-tool: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 RPC client doesn't reconnect on its own; reload or re-run your connect routine:
async function reconnect() {
rpc = await connectDevframe() // a new RPC client; re-subscribe your listeners
render()
}In a hub, a hub UI provider reads this status from context.connection.
Standalone CLI with Devframe
npx my-tool starts a dev server serving a Vue/Nuxt/React SPA over type-safe RPC, plus build/mcp.
In-Page Channel
The in-page channel connects a devframe's page script to its panels entirely in the browser: typed events, calls, and page-script-authoritative shared state, with no server involved.