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.

Augment one of the canonical module specifiers where these interfaces live — declare module 'devframe' or declare module 'devframe/types' (the form @devframes/hub uses). A wrapper package that re-exports the interface under a renamed alias (e.g. DevToolsRpcServerFunctions) is a different declaration, so augmenting the alias no longer merges into the base interface.

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.