Skip to content

RPC

Devframe's RPC layer is type-safe bidirectional communication between your server (Node.js) and client (browser), built on birpc and validated at runtime with valibot. In dev mode it runs over WebSocket; in build / SPA mode it serves a pre-computed static dump so the client still works offline.

Overview

Defining a function

ts
import { defineRpcFunction } from 'devframe'
import * as v from 'valibot'

export const getModules = defineRpcFunction({
  name: 'get-modules', // bare — the scope namespaces it to `my-devframe: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 in setup through a scoped contextctx.scope(id) auto-namespaces ids, so you register and call by bare name:

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

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

The unscoped ctx.rpc.register(getModules) works too — it's the underlying primitive the scoped surface wraps.

Place each function in its own file under src/rpc/functions/, and barrel them in src/rpc/index.ts as const serverFunctions = [...] as const. The same array feeds the type-safe client registry and keeps registration order explicit. When per-file functions need to share setup-time state (channels, shared state handles, loaders), expose it through a WeakMap<DevframeNodeContext, T> in a sibling src/context.ts.

Naming convention

Scope with your devframe id and use kebab-case for the action: my-devframe:get-modules, my-devframe:read-file, my-devframe:trigger-rebuild. A scoped context applies this prefix for you: ctx.scope('my-devframe').rpc.register({ name: 'get-modules' }) stores my-devframe:get-modules. Define each function with a bare name and let the scope namespace it.

Function types

TypeDescriptionCachedStatic Dump
queryRead operation that can change over time.Opt-in via cacheableManual (declare dump)
staticData that never changes for a given input.IndefinitelyAutomatic
actionMutation with side effects.NeverNever
eventFire-and-forget; no response.NeverNever

Use static for data collected once during setup and shipped to read-only static / SPA clients.

Handler arguments

Handlers accept any serializable arguments. With args valibot schemas, arguments are validated at the boundary:

ts
defineRpcFunction({
  name: 'get-file',
  type: 'query',
  args: [v.object({ path: v.string(), includeSource: v.optional(v.boolean()) })],
  returns: v.object({ path: v.string(), source: v.optional(v.string()) }),
  setup: () => ({
    handler: async ({ path, includeSource }) => ({
      path,
      source: includeSource ? await readFile(path, 'utf-8') : undefined,
    }),
  }),
})

Prefer a single object argument (args: [v.object({ ... })]) over positional args — property names are self-describing and agents/IDEs work best with object shapes.

Setup vs handler

Two ways to wire a handler:

  • setup(ctx) — receives the DevframeNodeContext and returns { handler, dump? }. Use this when you need the context (shared state, logs, ctx.mode, etc.).
  • handler(...) — shorthand when the handler is pure and doesn't touch the context.
ts
// With setup:
defineRpcFunction({
  name: 'count',
  type: 'query',
  setup: ctx => ({
    handler: async () => ctx.rpc.sharedState.keys().length,
  }),
})

// Shorthand:
defineRpcFunction({
  name: 'echo',
  type: 'query',
  handler: (msg: string) => msg,
})

Broadcasting

rpc.broadcast sends a message from the server to every connected client. Through a scoped context the client method name is namespaced for you:

ts
defineDevframe({
  id: 'my-devframe',
  name: 'My Devframe',
  setup(ctx) {
    const my = ctx.scope('my-devframe')
    watcher.on('change', (file) => {
      void my.rpc.broadcast({
        method: 'on-file-changed', // -> my-devframe:on-file-changed
        args: [{ file }],
      })
    })
  },
})
OptionTypeDescription
methodclient RPC nameFunction registered on the client side.
argsany[]Arguments passed to the client function.
optionalbooleanDon't throw if no client is listening.
eventbooleanFire-and-forget (don't wait for responses).
filter(client) => booleanSkip specific clients.

Streaming

For chunk-style server→client feeds (chat deltas, log lines, build progress), use streaming channels — they handle stream IDs, cancellation, replay, and Web Streams interop for you:

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

See the Streaming guide for the full API.

Local invocation

A scoped rpc.call invokes a registered server function directly, skipping the transport — useful for cross-function composition on the server side. Bare names resolve within the namespace:

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

This wraps ctx.rpc.invokeLocal('my-devframe:get-modules', { limit: 10 }). Pass a fully-qualified name (containing :) to call another tool's function.

Client-side calls

From the browser, connectDevframe (or getDevframeRpcClient) returns a client. Scope it the same way to call registered functions by bare name:

ts
import { connectDevframe } from 'devframe/client'

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

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

Client-side registration (for server→client calls) goes through my.rpc.register() — the mirror API of the server-side scoped rpc.register().

Type-safe client registry

Devframe exposes two augmentable interfaces — DevframeRpcServerFunctions (client→server calls) and DevframeRpcClientFunctions (server→client calls) — so each registered RPC name shows up on the typed client. Augment them once per devframe via declare module 'devframe'.

The recommended pattern collects every server-side definition into a const array and feeds it through RpcDefinitionsToFunctionsWithNamespace — it prefixes each bare definition name with your devframe id, matching the ids the scoped register stores at runtime:

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-devframe', typeof serverFunctions> {}
}

If you define functions with full namespaced names instead, use RpcDefinitionsToFunctions<typeof serverFunctions> (no namespace argument) and register them through the unscoped ctx.rpc.register.

Now connectDevframe() returns a client where every registered name is autocompletable and argument-typed:

ts
import { connectDevframe } from 'devframe/client'

const my = (await connectDevframe()).scope('my-devframe')
const modules = await my.rpc.call('get-modules', { limit: 10 })
//                          ^? typed from the augmentation above

For one-off augmentations, declare a single key with RpcFunctionDefinitionToFunction:

ts
import type { RpcFunctionDefinitionToFunction } from 'devframe/rpc'

declare module 'devframe' {
  interface DevframeRpcServerFunctions {
    'my-devframe:get-modules': RpcFunctionDefinitionToFunction<typeof getModules>
  }
}

For server→client calls invoked via ctx.rpc.broadcast, augment DevframeRpcClientFunctions the same way.

Static dumps

For static functions, Devframe records the handler's output during createBuild and bakes it into the build:

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, provide an explicit dump to enumerate which 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 },
    },
  }),
})

At runtime, static clients resolve my.rpc.call('get-session', 'session-a') from the baked dump; unmatched arguments resolve to dump.fallback (or throw without one).

JSON-serializable declaration

Devframe's WS transport ships payloads using one of two encoders, picked per RPC 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

The wire stays plain JSON when every participating function is JSON-flagged — debuggable in Devframe, friendly to MCP, and a good default for tools that already speak JSON.

Discovering shape errors during dev

jsonSerializable: true is a contract. When a handler returns a value JSON cannot round-trip (a Map, a Date, a class instance, …), the strict serializer throws DF0020 synchronously on the offending call — surfacing the bad value next to the call site in dev:

ts
defineRpcFunction({
  name: 'graph',
  jsonSerializable: true,
  // ⚠ throws DF0020 because Map cannot round-trip through JSON
  handler: () => ({ nodes: new Map([['a', 1]]) }),
})

For richer types, leave the flag unset (or false) — structured-clone-es preserves them on the wire and in build dumps. The flag is opt-in, so existing code keeps working untouched.

MCP requires JSON

MCP tools expose their schemas as JSON Schema, and agent harnesses assume JSON-shaped data. agent: {...} therefore requires jsonSerializable: true; registering one without the other throws DF0019. See the next section for how to attach the agent field once your function is JSON-safe.

Agent exposure

Add an agent field to surface the function to coding agents over MCP. Agent exposure is opt-in; functions without an agent field stay private. Agent-exposed functions must also declare jsonSerializable: true (see above).

ts
defineRpcFunction({
  name: 'get-modules',
  type: 'query',
  jsonSerializable: true,
  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),
  }),
})

See Agent-Native for the full safety model and MCP integration.

What's next

Released under the MIT License.