Skip to content

Commit ead15e9

Browse files
antfubotantfu
andauthored
feat(rpc)!: support Standard Schema for RPC definitions with runtime validation (#157)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent eebdb13 commit ead15e9

68 files changed

Lines changed: 1679 additions & 1195 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co
3636
## Conventions
3737

3838
- RPC functions must use `defineRpcFunction`; always namespace IDs `devframes:plugin:<slug>:<fn-name>` (matching the plugin's `@devframes/plugin-<slug>` package name).
39+
- **Stay validator-neutral.** `devframe` and every `@devframes/*` package must not introduce a preferred schema validator dependency — no `valibot`, `zod`, `arktype`, etc. in their runtime `dependencies`. `args`/`returns`/flag schemas are typed against [Standard Schema](https://standardschema.dev/) (`@standard-schema/spec`, types-only); first-party code that needs to author a schema uses the built-in zero-dep `devframe/utils/simple-schema` builder (deliberately minimal — not a general validator). JSON-schema conversion uses each schema's own Standard JSON Schema converter (`~standard.jsonSchema`, implemented by e.g. zod 4) when present and degrades to a permissive object otherwise — no converter library and no vendor dependency is required. Docs, by contrast, should point *users* at a real validator for their own integrations — recommend **valibot** (lightest) or **zod** (worth reusing if they already pull it via the JSON-render or MCP integrations).
3940
- Shared state via `devframe/utils/shared-state`; keep values serializable.
4041
- Utility imports use the package-path form `devframe/utils/*`, never relative `../utils/*`.
4142
- Dependencies go through the pnpm catalogs in `pnpm-workspace.yaml` (`cli`, `inlined`, `testing`, `types`) — add to a catalog and reference as `catalog:<name>`, don't pin versions in `package.json`.

‎alias.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const alias = {
2626
'devframe/utils/nanoid': r('devframe/src/utils/nanoid.ts'),
2727
'devframe/utils/open': r('devframe/src/utils/open.ts'),
2828
'devframe/utils/promise': r('devframe/src/utils/promise.ts'),
29+
'devframe/utils/simple-schema': r('devframe/src/utils/simple-schema.ts'),
2930
'devframe/utils/scope': r('devframe/src/utils/scope.ts'),
3031
'devframe/utils/serve-static': r('devframe/src/utils/serve-static.ts'),
3132
'devframe/utils/shared-state': r('devframe/src/utils/shared-state.ts'),

‎docs/errors/DF0019.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ outline: deep
1010
1111
## Cause
1212

13-
The `agent` field exposes an RPC function as an MCP tool. MCP and the underlying schema-conversion path (`@valibot/to-json-schema`) only consume JSON-shaped data. Functions whose payloads can include `Map`, `Set`, `Date`, `BigInt`, circular references, or class instances cannot be safely advertised to agents.
13+
The `agent` field exposes an RPC function as an MCP tool. MCP only consumes JSON-shaped data. Functions whose payloads can include `Map`, `Set`, `Date`, `BigInt`, circular references, or class instances cannot be safely advertised to agents.
1414

1515
A registered function is rejected when `agent` is present and `jsonSerializable` is not explicitly `true`.
1616

‎docs/errors/DF0043.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0043: Invalid RPC Argument
6+
7+
## Message
8+
9+
> RPC function "`{name}`" received an invalid argument at position `{index}`: `{issues}`
10+
11+
## Cause
12+
13+
When an RPC function declares `args` schemas, each incoming argument is validated against its positional [Standard Schema](https://standardschema.dev/) (valibot, zod, arktype, …) before the handler runs — on every path: local calls, over-the-wire calls, and the agent/MCP bridge. The argument at `{index}` failed that schema. Validation guards the payload without rewriting it, so extra object fields the schema doesn't mention still reach the handler.
14+
15+
## Example
16+
17+
```ts
18+
const greet = defineRpcFunction({
19+
name: 'greet',
20+
args: [v.string()],
21+
returns: v.string(),
22+
handler: name => `hi ${name}`,
23+
})
24+
25+
// ✓ Good
26+
await ctx.rpc.functions.greet('ada')
27+
28+
// ✗ Bad — a number where a string is required → DF0043 at position 0
29+
await ctx.rpc.functions.greet(42 as never)
30+
```
31+
32+
## Fix
33+
34+
Pass a value that satisfies the `args` schema declared for the function, or widen the schema if the value is legitimately allowed.
35+
36+
## Source
37+
38+
- [`packages/devframe/src/rpc/validate-io.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/rpc/validate-io.ts)`validateRpcArgs()` throws `DF0043` on the first argument that fails its declared schema.

‎docs/errors/DF0044.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0044: Invalid RPC Return Value
6+
7+
## Message
8+
9+
> RPC function "`{name}`" returned a value that failed its `returns` schema: `{issues}`
10+
11+
## Cause
12+
13+
When an RPC function declares a `returns` schema, the handler's resolved value is validated against that [Standard Schema](https://standardschema.dev/) (valibot, zod, arktype, …) before it is sent back to the caller. The value the handler produced does not satisfy the schema — a bug in the handler or a schema that is narrower than the real result. Validation guards the payload without rewriting it, so a value that merely carries extra object fields is accepted.
14+
15+
## Example
16+
17+
```ts
18+
const count = defineRpcFunction({
19+
name: 'count',
20+
args: [],
21+
returns: v.number(),
22+
// ✗ Bad — returns a string where a number is declared → DF0044
23+
handler: () => 'twelve' as never,
24+
})
25+
```
26+
27+
## Fix
28+
29+
Make the handler return a value that satisfies the `returns` schema, or relax the schema so it describes the value the handler actually produces.
30+
31+
## Source
32+
33+
- [`packages/devframe/src/rpc/validate-io.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/rpc/validate-io.ts)`validateRpcReturn()` throws `DF0044` when the handler's resolved value fails its declared schema.

‎docs/guide/devframe-definition.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Every Devframe tool starts with a single `defineDevframe` call. The returned `De
1010

1111
```ts twoslash
1212
import { defineDevframe, defineRpcFunction } from 'devframe'
13-
import * as v from 'valibot'
13+
import * as v from 'valibot' // npm i valibot
1414
1515
export default defineDevframe({
1616
id: 'my-devframe',

‎docs/guide/rpc.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ outline: deep
44

55
# RPC
66

7-
Devframe's RPC layer is type-safe bidirectional communication between your server (Node.js) and client (browser), built on [`birpc`](https://github.com/antfu/birpc) and validated at runtime with [`valibot`](https://valibot.dev/). In dev mode it runs over WebSocket; in build / SPA mode it serves a pre-computed static dump so the client still works offline.
7+
Devframe's RPC layer is type-safe bidirectional communication between your server (Node.js) and client (browser), built on [`birpc`](https://github.com/antfu/birpc) and validated at runtime against any [Standard Schema](https://standardschema.dev/) validator — valibot, zod, arktype, and others all work. In dev mode it runs over WebSocket; in build / SPA mode it serves a pre-computed static dump so the client still works offline.
88

99
## Overview
1010

@@ -22,7 +22,7 @@ sequenceDiagram
2222

2323
```ts
2424
import { defineRpcFunction } from 'devframe'
25-
import * as v from 'valibot'
25+
import * as v from 'valibot' // npm i valibot (or use zod / arktype)
2626
2727
export const getModules = defineRpcFunction({
2828
name: 'get-modules', // bare — the scope namespaces it to `my-devframe:get-modules`
@@ -75,7 +75,12 @@ Use `static` for data collected once during `setup` and shipped to read-only sta
7575

7676
### Handler arguments
7777

78-
Handlers accept any serializable arguments. With `args` valibot schemas, arguments are validated at the boundary:
78+
Handlers accept any serializable arguments. Declare `args` schemas — any [Standard Schema](https://standardschema.dev/) validator (valibot, zod, arktype, …) — and each argument is validated at the boundary before the handler runs; a mismatch is rejected with a coded diagnostic. Validation guards the payload without rewriting it, so extra object fields the schema doesn't mention still reach the handler.
79+
80+
Devframe forces no validator on you: bring whichever [Standard Schema](https://standardschema.dev/) validator you prefer (valibot, zod, arktype) and install it yourself. The examples here use valibot (`npm i valibot`) — it's the lightest option and a good default.
81+
82+
> [!TIP]
83+
> If your app already pulls in **zod** — the JSON-render integration and the MCP server both use it — prefer zod for your RPC schemas too, and you'll reuse a dependency you're already shipping instead of adding valibot. Any Standard Schema validator works either way; this is purely about dependency reuse.
7984
8085
```ts
8186
defineRpcFunction({
@@ -94,6 +99,9 @@ defineRpcFunction({
9499

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

102+
> [!WARNING]
103+
> Declared `args`/`returns` schemas are enforced at runtime — a call whose arguments, or a handler whose return value, fail the schema is rejected with `DF0043` / `DF0044`. Make sure each schema matches what the function actually accepts and returns; a schema stricter than reality will now reject calls that previously ran.
104+
97105
### Setup vs handler
98106

99107
Two ways to wire a handler:

‎docs/guide/standalone-cli.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,13 @@ const payload = await my.rpc.call('get-payload')
168168

169169
## Typed CLI flags
170170

171-
For flags that are specific to your tool, declare them as valibot schemas so they're validated at parse time and typed at the call site:
171+
For flags that are specific to your tool, declare them with any [Standard Schema](https://standardschema.dev/) validator (valibot below — `npm i valibot`, the lightest option — or zod / arktype) so they're validated at parse time and typed at the call site. If you already depend on zod through the JSON-render or MCP integrations, prefer zod here to avoid adding a second validator:
172172

173173
```ts
174174
import type { InferCliFlags } from 'devframe/adapters/cac'
175175
import { defineDevframe } from 'devframe'
176176
import { defineCliFlags } from 'devframe/adapters/cac'
177-
import * as v from 'valibot'
177+
import * as v from 'valibot' // npm i valibot
178178
179179
const appFlags = defineCliFlags({
180180
depth: v.pipe(v.number(), v.integer()),

‎docs/guide/streaming.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Create the channel once in `setup`. Channels are framework-neutral, so the same
2929

3030
```ts
3131
import { defineDevframe, defineRpcFunction } from 'devframe'
32-
import * as v from 'valibot'
32+
import * as v from 'valibot' // npm i valibot
3333
3434
export default defineDevframe({
3535
id: 'my-devframe',

‎docs/helpers/common-rpc-functions.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ defineDevframe({
2828
| `KNOWN_EDITORS` || `readonly string[]` || The editor commands `openInEditor`'s `editor` argument accepts (`code`, `vim`, `subl`, `idea`, …). |
2929
| `KnownEditor` || type || Union of `KNOWN_EDITORS`. |
3030

31-
Both functions are `action`-type RPCs returning `void` and use `valibot` schemas for their arguments — `openInEditor`'s `editor` argument is `v.optional(v.picklist(KNOWN_EDITORS))`, so a value outside `KNOWN_EDITORS` fails validation rather than reaching the underlying `launch-editor` process spawn. Both handlers dynamically `import()` their underlying `devframe/utils/*` implementation, so the `launch-editor` and `open` dependencies only load when the recipe actually runs.
31+
Both functions are `action`-type RPCs returning `void`, and their arguments are schema-validated `openInEditor`'s `editor` argument is restricted to `KNOWN_EDITORS`, so a value outside that list fails validation rather than reaching the underlying `launch-editor` process spawn. Both handlers dynamically `import()` their underlying `devframe/utils/*` implementation, so the `launch-editor` and `open` dependencies only load when the recipe actually runs.
3232

3333
The `devframe/recipes/open-helpers` entry (`openHelpers`) remains as a deprecated alias for this module — new code should import `commonRpcFunctions` from `devframe/recipes/common-rpc-functions`.
3434

0 commit comments

Comments
 (0)