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: 'my-devtool: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 DevToolsNodeContext.
      return loadModules().slice(0, limit)
    },
  }),
})

Register it in setup:

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

export default defineDevtool({
  id: 'my-devtool',
  name: 'My Devtool',
  setup(ctx) {
    ctx.rpc.register(getModules)
  },
})

Naming convention

Scope with your devtool id and use kebab-case for the action: my-devtool:get-modules, my-devtool:read-file, my-devtool:trigger-rebuild.

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: 'my-devtool: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 DevToolsNodeContext 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: 'my-devtool:count',
  type: 'query',
  setup: ctx => ({
    handler: async () => ctx.rpc.sharedState.keys().length,
  }),
})

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

Broadcasting

ctx.rpc.broadcast sends a message from the server to every connected client:

ts
defineDevtool({
  id: 'my-devtool',
  name: 'My Devtool',
  setup(ctx) {
    watcher.on('change', (file) => {
      void ctx.rpc.broadcast({
        method: 'my-devtool: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-devtool:chat', {
  replayWindow: 256,
})
const stream = channel.start()
sourceReadable.pipeTo(stream.writable)

See the Streaming guide for the full API.

Local invocation

ctx.rpc.invokeLocal calls a registered server function directly, skipping the transport — useful for cross-function composition on the server side:

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

Client-side calls

From the browser, connectDevtool (or getDevToolsRpcClient) returns a client for calling registered functions:

ts
import { connectDevtool } from 'devframe/client'

const rpc = await connectDevtool()

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

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

Static dumps

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

ts
defineRpcFunction({
  name: 'my-devtool: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: 'my-devtool: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 rpc.call('my-devtool: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 DevTools, 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: 'my-devtool: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: 'my-devtool: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.