Skip to content

Client

The browser-side client is how a dock iframe, remote-hosted page, or standalone SPA talks to the Devframe server. It provides type-safe RPC calls, access to shared state, and (in dev mode) a trust handshake against the local dev server.

Connecting

devframe/client exports connectDevframe (an alias of getDevframeRpcClient) — use either name:

ts
import { connectDevframe } from 'devframe/client'

const rpc = await connectDevframe()

const modules = await rpc.call('my-devframe:get-modules', { limit: 10 })

connectDevframe auto-detects the backend via __devframe/__connection.json, with a sequence of base URLs as fallback. No arguments are needed when the client is hosted from the default mount path.

Runtime basePath discovery

Devframe SPAs are base-agnostic — the same artifact can be served at /, /__<id>/, or any custom subpath without rebuilding. connectDevframe resolves __connection.json at runtime by reading document.baseURI and the executing script's URL.

For SPA authors, that means:

  • Build with relative asset paths — Vite base: './', Nuxt vite.base: './' + app.baseURL: './'.
  • Leave the mount path out of the HTML. The server serves files at some base; the client figures out which.
  • Skip the baseURL option on connectDevframe unless you're connecting across origins or to a non-colocated devframe server.

That's how createBuild deploys SPA output verbatim under any URL — no build-time HTML rewriting needed.

Options

ts
await connectDevframe({
  baseURL: './', // string or string[] fallback list — see notes below
  authToken: 'user-provided-token',
  cacheOptions: true, // enable response caching
  wsOptions: { /* … */ },
  rpcOptions: { /* birpc options */ },
})
OptionDescription
baseURLMount path to probe for __connection.json. Accepts an array for fallback. Default: './' — resolved relative to document.baseURI so the SPA finds its meta wherever it was deployed. Pass an explicit absolute path (e.g. '/__devframe/') when calling from outside the SPA — say, an embedded webcomponent injected into a host app.
authTokenOverride the auth token. Defaults to a locally-persisted human-readable id.
cacheOptionstrue to enable caching with defaults, or an options object.
callTimeoutMilliseconds after which a pending rpc.call rejects with a DevframeConnectionError of kind 'timeout'. Omit (or 0) to wait indefinitely. See Handling connection and auth errors.
wsOptionsLow-level WebSocket transport overrides — onConnected / onError / onDisconnected lifecycle hooks and the socket URL.
rpcOptionsForwarded to birpc.
connectionMetaPre-known descriptor that skips the __connection.json fetch.

Modes

The client runs in one of two modes depending on the backend advertised in __devframe/__connection.json:

BackendWhenCapabilities
websocketDev mode (createCli, Kit)Full read/write, broadcasts, shared-state mutation. Requires auth.
staticBuild / SPA outputRead-only — all calls resolve against the baked RPC dump.

The client picks a mode automatically from the backend field. Mode-specific code paths like broadcast are scoped to websocket.

Trust & auth (WebSocket mode)

Dev-mode connections become trusted by authenticating. A client that authenticated before presents its stored token automatically on reconnect, and ensureTrusted() resolves once the server accepts it:

ts
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')
}

Authenticating with a one-time code

A fresh client holds no token. The dev server prints a 6-digit one-time code; pass it to requestTrustWithCode to exchange it for a node-issued token. The token is persisted for future reconnections and shared with sibling tabs, which become trusted without re-entering the code:

ts
const ok = await rpc.requestTrustWithCode('047204')

The code is single-use, expires after five minutes, and is rotated after repeated wrong attempts, so re-display the current code if an exchange fails.

To authenticate without typing, a host can print a link embedding the code (buildOtpAuthUrl(origin)); connectDevframe reads the devframe_otp query parameter, exchanges it, and strips it from the URL. Rename it with the otpParam option, or set otpParam: false and drive authentication yourself with the exposed authenticateWithUrlOtp(rpc) / consumeOtpFromUrl() utilities.

Re-using an existing token

Authenticate with a token obtained elsewhere (e.g. another surface) without reloading:

ts
const ok = await rpc.requestTrustWithToken('a1b2c3…')

Broadcast-channel sync

connectDevframe listens on a shared BroadcastChannel (named devframe-auth for cross-tab handshake interop with Vite DevTools' auth page) for auth-update messages. When another tab authenticates — or an auth page announces a token — every open client trusts it automatically, no reload required.

Calling functions

Derive a scoped client so ids are namespaced for you:

ts
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' })

The unscoped rpc.call('my-devframe:get-modules', ...) works too. Either way, TypeScript types flow through from the server's defineRpcFunction definitions, so argument and return shapes are known at the call site.

Registering client functions

The client can register functions that the server calls via rpc.broadcast:

ts
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')
    },
  }),
}))

That's how the server pushes live updates into the UI — file-watcher events, shared-state sync, and so on.

Shared state

ts
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)
})

Client-side mutations round-trip through the server before reappearing locally. See Shared State for the full API.

Settings

A scoped client also exposes a top-level persisted settings store, synced from the server. Read and write per-user (global) or per-workspace (project) values:

ts
await my.settings.project.set('theme', 'dark')
const theme = await my.settings.project.get('theme')

See Scoped Context for the full API.

Caching

Set cacheOptions: true (or an options object) when constructing the client:

ts
const rpc = await connectDevframe({ cacheOptions: true })

With caching on, query / static function responses are memoized per argument hash. Server-side broadcasts like rpc:cache:invalidate clear entries automatically — plugins that mutate state should broadcast that message after the change.

Discovery (__connection.json)

Devframe writes a JSON descriptor at <base>/__connection.json so the client knows where to connect. The dev server shares one port for HTTP and the WebSocket — the socket is bound to a route (<base>__devframe_ws) next to the meta file — and advertises it as a relative path:

json
{
  "backend": "websocket",
  "websocket": { "path": "__devframe_ws" }
}

The client resolves that path against the origin it loaded from, swapping httpws / httpswss. It never trusts a host or port baked into the descriptor, so the connection follows the page through a reverse proxy that rewrites the domain, port, or subpath.

The websocket field also accepts:

  • A number — a port on the page's hostname (ws(s)://<host>:<port>).
  • A full ws:///wss:// URL string — used verbatim for a fixed cross-origin endpoint.
  • { port } / { host } — a cross-origin endpoint (e.g. a side-car server on its own port), rooted at that host/port rather than the page origin.

For static mode:

json
{ "backend": "static" }

The client handles this for you. To override discovery (testing, advanced setups), pass connectionMeta directly:

ts
await connectDevframe({
  connectionMeta: { backend: 'static' },
})

Remote docks

Remote docks are a host-side feature — hosts that support them (Vite DevTools is one; see its remote-client docs for that implementation) inject a connection descriptor into the iframe URL. On the hosted page, connectDevframe auto-detects the descriptor from the URL fragment / query string — call it as usual:

ts
import { connectDevframe } from 'devframe/client'

const rpc = await connectDevframe()
// Already wired to the local dev server via the injected descriptor.

The descriptor carries a session-only, pre-approved auth token, so ensureTrusted() resolves immediately.

Events

The client emits over rpc.events:

EventFires when
rpc:is-trusted:updatedTrust is granted, denied, or revoked. Carries the new isTrusted boolean.
connection:statusThe connection status changes. Carries (status, previous).
connection:errorA connection-level failure occurs — the socket errors, or trust is refused. Carries the Error.
rpc:errorAn rpc.call rejects, from the server or a down connection. Carries (error, method).
ts
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. Subscribe to rpc:is-trusted:updated to drive reauth flows or gate rendering until the client is trusted.

Handling connection and auth errors

A dev-mode client rides a live WebSocket, so it can lose the server mid-session or be refused authentication. Surface those states in your UI — a devtool that keeps spinning with no feedback leaves the user guessing whether it's loading or broken. The client gives you a single status to render from, events to react to, and calls that fail fast instead of hanging.

Connection status

rpc.status collapses the transport and the trust handshake into one value, and rpc.connectionError holds the last connection-level Error (or null when healthy):

StatusMeaning
connectingEstablishing the socket / running the initial handshake. Calls issued now queue until it opens.
connectedSocket open and trusted; calls are served.
unauthorizedSocket open, but the server refused trust. Prompt for authentication.
disconnectedThe socket closed — dropped mid-session, or never opened.
errorA fatal connection error, e.g. the socket errored or the connection meta couldn't load.

A static backend has no live socket, so rpc.status is connected for its whole life — gating on it is a no-op there, and a build-time SPA never shows a connection state.

Calls fail fast

Once the socket closes or trust is refused, in-flight and new rpc.call promises reject with a DevframeConnectionError rather than hanging forever. Its kind tells you why, so a catch can branch without string-matching:

  • 'connection' — the transport is down (disconnected / error).
  • 'auth' — the client is unauthorized.
  • 'timeout' — the call outlived the callTimeout option.

Set callTimeout when constructing the client to also cap a live-but-unresponsive server:

ts
const rpc = await connectDevframe({ callTimeout: 10_000 })

Putting it together

Gate the UI on connection:status, and wrap calls to branch on failure:

ts
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

Recovery is explicit — the client doesn't reconnect on its own. The simplest path is a full page reload, which re-runs connectDevframe and the trust handshake; that's what the built-in plugins do behind their Reload button. An app that wants to reconnect without a reload can own it by re-running its connect routine to build a fresh client:

ts
async function reconnect() {
  rpc = await connectDevframe() // a new client; re-subscribe your listeners
  render()
}

The five built-in plugins are worked references — each gates its surface on rpc.status and offers a reload. In a hub, a viewer can read the same status centrally from context.connection instead of every plugin surfacing its own.

Released under the MIT License.