---
title: "Client"
description: "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."
canonical_url: "https://devfra.me/guide/client"
---
# 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`):

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

```ts
import { setupDevframeConnection } from 'devframe/client'

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

In the external viewer:

```ts
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](https://devfra.me/guide/security#external-viewer-origins)):

```ts
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](#caching) (`cacheOptions`), timeouts (`callTimeout`), transport hooks (`wsOptions`), `birpc` passthrough (`rpcOptions`), and discovery override (`connectionMeta`); every option is in the [Browser-Side API reference](https://devfra.me/references/browser-api#connectdevframe-options).

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

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

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

```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 RPC client.

## Calling functions

Derive a [scoped client](https://devfra.me/guide/scoped-context) for namespaced ids:

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

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

```ts
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](https://devfra.me/guide/shared-state).

## Services

`rpc.services` mirrors the node side'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-Devframe Services](https://devfra.me/guide/services#wire-services).

## Settings

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

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

See [Scoped Context](https://devfra.me/guide/scoped-context#settings).

## Caching

Set `cacheOptions: true` (or an object):

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

With `true`, the RPC client reads declarations from the node side. It memoizes `static` responses and `query` responses marked `cacheable: true`, per argument hash. Actions and events continue to reach the node side. An options object selects an explicit list, for example `{ functions: ['my:query'] }`, and can provide a `keySerializer`.

After changing data that a cached query reads, clear connected RPC clients' caches:

```ts
import { DEVFRAME_EVENTS } from 'devframe/constants'

await ctx.rpc.broadcast({
  method: DEVFRAME_EVENTS.broadcast.cacheInvalidate,
  args: [],
})
```

The RPC client also clears its cache when RPC declarations or connection trust change, and when it disconnects or closes. Older devframes serve automatic-cache calls without caching when their node side lacks cache discovery support. If discovery fails, the waiting calls reject and a later RPC call retries discovery.

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

```json
{ "backend": "static" }
```

Override discovery with `connectionMeta`:

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

## Remote docks

Supporting host frameworks (Vite DevTools; see [its remote-client docs](https://devtools.vite.dev/kit/remote-client)) 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 an external-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

Four events arrive over `rpc.events`: `rpc:is-trusted:updated` when trust is granted, denied, or revoked; `connection:status` when the [connection status](#handling-connection-and-auth-errors) changes; `connection:error` on a connection-level failure; and `rpc:error` when an `rpc.call` rejects. Payloads are in the [Browser-Side API reference](https://devfra.me/references/browser-api#rpc-client-events).

```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). It moves through `connecting` (calls queue until open), `connected` (calls are served), `unauthorized` (socket open, trust refused; prompt for [authentication](#authenticating-with-a-one-time-code)), `disconnected`, and `error`; each value's meaning is in the [Browser-Side API reference](https://devfra.me/references/browser-api#connection-statuses).

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:

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

```ts
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`](https://devfra.me/guide/client-context#the-client-context).


## Sitemap

See the full [sitemap](https://devfra.me/sitemap.md) for all pages.
