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, validated against any Standard Schema validator. Dev runs over WebSocket; build/SPA serves a pre-computed static dump.

Defining a function

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; ctx.scope(id) auto-namespaces ids:

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

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:

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.

Streaming

For node-side→browser-side chunk feeds, use streaming channels:

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:

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 (or getDevframeRpcClient) returns an RPC client:

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:

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:

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:

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:

jsonSerializableEncoderWire prefixRound-trips
false (default)structured-clone-ess: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:

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