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.
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:
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:
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: state synced across RPC clients
- Client: connecting from the browser
- Agent-Native: exposing RPCs to coding agents