---
title: "RPC"
description: "Type-safe, bidirectional RPC built on birpc, validated against any Standard Schema validator. Dev runs over WebSocket; build/SPA serves a pre-computed static dump."
canonical_url: "https://devfra.me/guide/rpc"
---
# RPC

> Type-safe, bidirectional RPC built on birpc, validated against any Standard Schema validator. Dev runs over WebSocket; build/SPA serves a pre-computed static dump.

Type-safe, bidirectional RPC built on [`birpc`](https://github.com/antfu/birpc), validated against any [Standard Schema](https://standardschema.dev/) validator. Dev runs over WebSocket; build/SPA serves a pre-computed static dump.

## Defining a function

```ts
import { defineRpcFunction } from 'devframe'
import * as v from 'valibot' // npm i valibot (or use zod / arktype)

export const getModules = defineRpcFunction({
  name: 'get-modules', // bare: the scope namespaces it to `my-tool:get-modules`
  type: 'query',
  args: [
    v.object({
      limit: v.number()
    })
  ],
  returns: v.array(v.object({
    id: v.string(),
    size: v.number()
  })),
  setup: ctx => ({
    handler: async ({ limit }) => {
      // `ctx` is the full DevframeNodeContext.
      return loadModules().slice(0, limit)
    },
  }),
})
```

Register it via a [scoped context](https://devfra.me/guide/scoped-context); `ctx.scope(id)` auto-namespaces ids:

```ts
import { defineDevframe } from 'devframe'
import { getModules } from './rpc/functions/get-modules'

export default defineDevframe({
  id: 'my-tool',
  name: 'My Tool',
  setup(ctx) {
    const my = ctx.scope('my-tool')
    my.rpc.register(getModules)
  },
})
```

### Naming convention

Scope with your devframe id, then a kebab-case action: `my-tool:get-modules`.

### Function types

A function's `type` sets its caching and [static-dump](#static-dumps) behavior: `query` for reads that change over time (caching opt-in via `cacheable`, dumping via an explicit `dump`), `static` for data fixed per input (cached indefinitely, dumped automatically), `action` for mutations, and `event` for fire-and-forget notifications. The full matrix is in the [Node-Side API reference](https://devfra.me/references/node-api#rpc-function-types).

### Handler arguments

Handlers accept any serializable arguments. Declared `args` and `returns` schemas are enforced at runtime; extra object fields still reach the handler.

### Setup vs handler

Use `setup(ctx)` (returns `{ handler, dump? }`) when the handler needs `DevframeNodeContext`; otherwise the `handler(...)` shorthand.

## Broadcasting

`rpc.broadcast` sends to every connected RPC client; a scoped context namespaces the method:

```ts
defineDevframe({
  id: 'my-tool',
  name: 'My Tool',
  setup(ctx) {
    const my = ctx.scope('my-tool')
    watcher.on('change', (file) => {
      void my.rpc.broadcast({
        method: 'on-file-changed', // -> my-tool:on-file-changed
        args: [
          { file }
        ],
      })
    })
  },
})
```

Beyond `method` and `args`, `optional` skips throwing when no RPC client is listening, `event` makes the broadcast fire-and-forget, and `filter` skips specific RPC clients; see the [Node-Side API reference](https://devfra.me/references/node-api#broadcast-options).

## Streaming

For node-side→browser-side chunk feeds, use [streaming channels](https://devfra.me/guide/streaming):

```ts
const channel = ctx.rpc.streaming.create<string>('my-tool:chat', {
  replayWindow: 256,
})
const stream = channel.start()
sourceReadable.pipeTo(stream.writable)
```

## Local invocation

A scoped `rpc.call` invokes a node-side function directly, skipping the transport:

```ts
const my = ctx.scope('my-tool')
const modules = await my.rpc.call('get-modules', { limit: 10 })
```

It wraps `ctx.rpc.invokeLocal(...)`; a fully-qualified name (with `:`) calls another tool's function.

## Browser-side calls

From the browser, [`connectDevframe`](https://devfra.me/guide/client) (or `getDevframeRpcClient`) returns an RPC client:

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

const client = await connectDevframe()
const my = client.scope('my-tool')

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

Browser-side registration (node side → browser side) uses `my.rpc.register()`.

## Type-safe RPC-client registry

Two augmentable interfaces, `DevframeRpcServerFunctions` (client→server) and `DevframeRpcClientFunctions` (server→client), type each registered name on the RPC client via `declare module 'devframe'`. Feed a const array through `RpcDefinitionsToFunctionsWithNamespace`, which prefixes each bare name with your id:

```ts
import type { RpcDefinitionsToFunctionsWithNamespace } from 'devframe/rpc'
import { getFile, getModules } from './rpc'

const serverFunctions = [getModules, getFile] as const

declare module 'devframe' {
  interface DevframeRpcServerFunctions
    extends RpcDefinitionsToFunctionsWithNamespace<'my-tool', typeof serverFunctions> {}
}
```

For fully namespaced names, use `RpcDefinitionsToFunctions<typeof serverFunctions>` (no namespace argument) with the unscoped `ctx.rpc.register`. For a one-off, declare a single key with `RpcFunctionDefinitionToFunction<typeof getModules>`.

Augment these interfaces where they live (`devframe` or `devframe/types`); a renamed re-export won't merge into the base.

## Static dumps

For `static` functions, Devframe records the handler output during `createBuild`:

```ts
defineRpcFunction({
  name: 'build-meta',
  type: 'static',
  args: [],
  returns: v.object({
    version: v.string(),
    builtAt: v.number()
  }),
  setup: () => ({
    handler: async () => ({
      version: '1.0.0',
      builtAt: Date.now()
    }),
  }),
})
```

For `query` functions, an explicit `dump` enumerates argument sets to pre-compute:

```ts
defineRpcFunction({
  name: 'get-session',
  type: 'query',
  setup: ctx => ({
    handler: async (id: string) => loadSession(id),
    dump: {
      inputs: [
        ['session-a'],
        ['session-b']
      ],
      fallback: { id: 'unknown', data: null },
    },
  }),
})
```

Static RPC clients resolve from the baked dump; unmatched arguments hit `dump.fallback` (or throw).

## JSON-serializable declaration

The WS transport picks one of two encoders per function:

| `jsonSerializable` | Encoder                 | Wire prefix    | Round-trips                                             |
| ------------------ | ----------------------- | -------------- | ------------------------------------------------------- |
| `false` (default)  | `structured-clone-es`   | `s:`           | `Map`, `Set`, `Date`, `BigInt`, cycles, class instances |
| `true` (opt-in)    | strict `JSON.stringify` | *(unprefixed)* | JSON-only                                               |

When every function is JSON-flagged, the wire stays plain JSON. A `jsonSerializable: true` handler must return JSON-only values; `Map`, `Date`, and friends won't round-trip.

## Agent exposure

Add an `agent` field to expose the function to coding agents over MCP:

```ts
defineRpcFunction({
  name: 'get-modules',
  type: 'query',
  args: [
    v.object({
      limit: v.number()
    })
  ],
  returns: v.array(v.object({
    id: v.string(),
    size: v.number()
  })),
  agent: {
    description: 'List the N largest modules in the current build. Safe to call freely.',
    title: 'List modules',
    // safety inferred from type: 'query' → 'read'
  },
  setup: () => ({
    handler: async ({ limit }) => loadModules().slice(0, limit),
  }),
})
```

The `agent` field implicitly enables strict JSON serialization because MCP consumes JSON-shaped data. Set `jsonSerializable: true` directly when an RPC-only function also benefits from that contract.

## What's next

- [Shared State](https://devfra.me/guide/shared-state): state synced across RPC clients
- [Client](https://devfra.me/guide/client): connecting from the browser
- [Agent-Native](https://devfra.me/guide/agent-native): exposing RPCs to coding agents


## Sitemap

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