Skip to content

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):

ts
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:

ts
import { setupDevframeConnection } from 'devframe/client'

const connection = await setupDevframeConnection({
  baseURL: '/__devframe/',
})

In the viewer:

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

ts
import { registerDevframeViewerOrigin } from 'devframe/client'

await registerDevframeViewerOrigin(connection)

Options ​

OptionDescription
connectionConnection prepared by setupDevframeConnection().
baseURLMount path to probe for __connection.json (array = fallback). Default './' (relative to document.baseURI); use an absolute path ('/__devframe/') from outside the SPA.
authTokenOverride the auth token (default: a locally-persisted id).
cacheOptionstrue for default caching, or an options object.
callTimeoutMs before a pending rpc.call rejects with a 'timeout' DevframeConnectionError; 0/omit = wait forever.
wsOptionsTransport overrides — onConnected / onError / onDisconnected hooks, socket URL.
rpcOptionsForwarded to birpc.
connectionMetaDescriptor that skips the __connection.json fetch.

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 server trusts the client's stored token:

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

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:

ts
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:

ts
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:

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

Types flow from the server's defineRpcFunction definitions.

Registering client functions ​

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

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

See Shared State.

Services ​

rpc.services mirrors the server's wire-service advertisements:

ts
if (rpc.services.has('@devframes/service-open'))
  await rpc.services.get('@devframes/service-open')!.rpc.call('open-in-editor', { path })

See Cross-Plugin Services.

Settings ​

A scoped client exposes a persisted settings store, per-user (global) or per-workspace (project):

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

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

json
{
  "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:

json
{ "backend": "static" }

Override discovery with connectionMeta:

ts
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:

ts
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:

ts
import {
  buildRemoteDevframeUrl,
  stripRemoteConnectionFromUrl,
} from '@devframes/hub/client'

const viewerUrl = buildRemoteDevframeUrl('/viewer/', connection)
const displayUrl = stripRemoteConnectionFromUrl(viewerUrl)

Events ​

Emitted over rpc.events:

EventFires when
rpc:is-trusted:updatedTrust granted, denied, or revoked. Carries the new isTrusted boolean.
connection:statusThe connection status changes. Carries (status, previous).
connection:errorA connection-level failure — socket error or trust 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.

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):

StatusMeaning
connectingEstablishing socket / handshake. Calls queue until open.
connectedSocket open and trusted; calls are served.
unauthorizedSocket open, trust refused. Prompt for authentication.
disconnectedSocket closed (dropped mid-session or never opened).
errorFatal — 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 is unauthorized.
  • 'timeout' — the call outlived callTimeout.

Set callTimeout to cap an 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 ​

The client doesn't reconnect on its own — reload or re-run your connect routine:

ts
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.

Released under the MIT License.