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.

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:

BackendWhenCapabilities
websocketDev mode (createCac, Kit)Full read/write, broadcasts, shared-state mutation. Requires auth.
staticBuild / SPA outputRead-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 })

See Cross-Devframe Services.

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 (httpws / httpswss). 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 is unauthorized.
  • 'timeout': the call outlived callTimeout.

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.