Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co
## Conventions

- RPC functions must use `defineRpcFunction`; always namespace IDs `devframes:plugin:<slug>:<fn-name>` (matching the plugin's `@devframes/plugin-<slug>` package name).
- **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).
- Shared state via `devframe/utils/shared-state`; keep values serializable.
- Utility imports use the package-path form `devframe/utils/*`, never relative `../utils/*`.
- 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`.
Expand Down
1 change: 1 addition & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const alias = {
'devframe/utils/nanoid': r('devframe/src/utils/nanoid.ts'),
'devframe/utils/open': r('devframe/src/utils/open.ts'),
'devframe/utils/promise': r('devframe/src/utils/promise.ts'),
'devframe/utils/simple-schema': r('devframe/src/utils/simple-schema.ts'),
'devframe/utils/scope': r('devframe/src/utils/scope.ts'),
'devframe/utils/serve-static': r('devframe/src/utils/serve-static.ts'),
'devframe/utils/shared-state': r('devframe/src/utils/shared-state.ts'),
Expand Down
2 changes: 1 addition & 1 deletion docs/errors/DF0019.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ outline: deep

## Cause

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.
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.

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

Expand Down
38 changes: 38 additions & 0 deletions docs/errors/DF0043.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
outline: deep
---

# DF0043: Invalid RPC Argument

## Message

> RPC function "`{name}`" received an invalid argument at position `{index}`: `{issues}`
## Cause

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.

## Example

```ts
const greet = defineRpcFunction({
name: 'greet',
args: [v.string()],
returns: v.string(),
handler: name => `hi ${name}`,
})

// ✓ Good
await ctx.rpc.functions.greet('ada')

// ✗ Bad — a number where a string is required → DF0043 at position 0
await ctx.rpc.functions.greet(42 as never)
```

## Fix

Pass a value that satisfies the `args` schema declared for the function, or widen the schema if the value is legitimately allowed.

## Source

- [`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.
33 changes: 33 additions & 0 deletions docs/errors/DF0044.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
outline: deep
---

# DF0044: Invalid RPC Return Value

## Message

> RPC function "`{name}`" returned a value that failed its `returns` schema: `{issues}`

## Cause

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.

## Example

```ts
const count = defineRpcFunction({
name: 'count',
args: [],
returns: v.number(),
// ✗ Bad — returns a string where a number is declared → DF0044
handler: () => 'twelve' as never,
})
```

## Fix

Make the handler return a value that satisfies the `returns` schema, or relax the schema so it describes the value the handler actually produces.

## Source

- [`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.
2 changes: 1 addition & 1 deletion docs/guide/devframe-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Every Devframe tool starts with a single `defineDevframe` call. The returned `De

```ts twoslash
import { defineDevframe, defineRpcFunction } from 'devframe'
import * as v from 'valibot'
import * as v from 'valibot' // npm i valibot
export default defineDevframe({
id: 'my-devframe',
Expand Down
14 changes: 11 additions & 3 deletions docs/guide/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ outline: deep

# RPC

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.
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.

## Overview

Expand All @@ -22,7 +22,7 @@ sequenceDiagram

```ts
import { defineRpcFunction } from 'devframe'
import * as v from 'valibot'
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-devframe:get-modules`
Expand Down Expand Up @@ -75,7 +75,12 @@ Use `static` for data collected once during `setup` and shipped to read-only sta

### Handler arguments

Handlers accept any serializable arguments. With `args` valibot schemas, arguments are validated at the boundary:
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.

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.

> [!TIP]
> 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.

```ts
defineRpcFunction({
Expand All @@ -94,6 +99,9 @@ defineRpcFunction({

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

> [!WARNING]
> 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.

### Setup vs handler

Two ways to wire a handler:
Expand Down
4 changes: 2 additions & 2 deletions docs/guide/standalone-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,13 @@ const payload = await my.rpc.call('get-payload')

## Typed CLI flags

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:
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:

```ts
import type { InferCliFlags } from 'devframe/adapters/cac'
import { defineDevframe } from 'devframe'
import { defineCliFlags } from 'devframe/adapters/cac'
import * as v from 'valibot'
import * as v from 'valibot' // npm i valibot

const appFlags = defineCliFlags({
depth: v.pipe(v.number(), v.integer()),
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Create the channel once in `setup`. Channels are framework-neutral, so the same

```ts
import { defineDevframe, defineRpcFunction } from 'devframe'
import * as v from 'valibot'
import * as v from 'valibot' // npm i valibot

export default defineDevframe({
id: 'my-devframe',
Expand Down
2 changes: 1 addition & 1 deletion docs/helpers/common-rpc-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ defineDevframe({
| `KNOWN_EDITORS` || `readonly string[]` || The editor commands `openInEditor`'s `editor` argument accepts (`code`, `vim`, `subl`, `idea`, …). |
| `KnownEditor` || type || Union of `KNOWN_EDITORS`. |

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.
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.

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`.

Expand Down
7 changes: 4 additions & 3 deletions packages/devframe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"./utils/nanoid": "./dist/utils/nanoid.mjs",
"./utils/open": "./dist/utils/open.mjs",
"./utils/promise": "./dist/utils/promise.mjs",
"./utils/simple-schema": "./dist/utils/simple-schema.mjs",
"./utils/scope": "./dist/utils/scope.mjs",
"./utils/serve-static": "./dist/utils/serve-static.mjs",
"./utils/shared-state": "./dist/utils/shared-state.mjs",
Expand Down Expand Up @@ -82,16 +83,15 @@
}
},
"dependencies": {
"@valibot/to-json-schema": "catalog:deps",
"@standard-schema/spec": "catalog:deps",
"birpc": "catalog:deps",
"crossws": "catalog:deps",
"destr": "catalog:deps",
"h3": "catalog:deps",
"mrmime": "catalog:deps",
"nostics": "catalog:deps",
"pathe": "catalog:deps",
"ufo": "catalog:deps",
"valibot": "catalog:deps"
"ufo": "catalog:deps"
},
"devDependencies": {
"@modelcontextprotocol/client": "catalog:deps",
Expand All @@ -110,6 +110,7 @@
"tinyglobby": "catalog:deps",
"tsdown": "catalog:build",
"ua-parser-modern": "catalog:inlined",
"valibot": "catalog:deps",
"whenexpr": "catalog:deps",
"ws": "catalog:deps"
}
Expand Down
45 changes: 26 additions & 19 deletions packages/devframe/src/adapters/flags.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { GenericSchema, InferOutput } from 'valibot'
import { safeParse } from 'valibot'
import type { StandardSchemaV1 } from '@standard-schema/spec'

/**
* Schema map for typed CLI flags. Keys are flag names in camelCase —
* this matches CAC's parsed-flag output ( `--no-open` → `noOpen` ). Each
* value is a valibot schema used to both (a) derive the CAC option type
* when the flag is registered and (b) validate / coerce the parsed
* value before it's forwarded to `setup(ctx, { flags })`.
* value is any [Standard Schema](https://standardschema.dev/) validator
* (valibot, zod, arktype, devframe's built-in `s`, …) used to both (a)
* derive the CAC option type when the flag is registered and (b) validate
* the parsed value before it's forwarded to `setup(ctx, { flags })`.
*/
export type CliFlagsSchema = Record<string, GenericSchema>
export type CliFlagsSchema = Record<string, StandardSchemaV1>

/**
* Identity helper that preserves the literal schema-map type — use this
Expand Down Expand Up @@ -36,16 +36,18 @@ export function defineCliFlags<T extends CliFlagsSchema>(flags: T): T {

/** Extract the parsed-output type from a {@link CliFlagsSchema}. */
export type InferCliFlags<T extends CliFlagsSchema> = {
[K in keyof T]: InferOutput<T[K]>
[K in keyof T]: StandardSchemaV1.InferOutput<T[K]>
}

/**
* Best-effort probe of a valibot schema to decide whether the
* corresponding CAC option takes a value. Unwraps `optional` / `nullable`
* / `nullish` / `default` / `pipe` wrappers then matches on the inner
* type's kind.
* Best-effort, dependency-free probe of a schema to decide whether the
* corresponding CAC option takes a value. Duck-types the `type` /
* `wrapped` / `inner` / `pipe` fields exposed by valibot and by devframe's
* built-in `s` builder, unwrapping `optional` / `nullable` / `nullish` /
* `pipe` wrappers then matching on the inner kind. Validators that don't
* expose these fields (e.g. zod) fall through to a value-taking option.
*/
function getSchemaKind(schema: GenericSchema): string {
function getSchemaKind(schema: StandardSchemaV1): string {
let current: any = schema
while (current) {
const kind = current.type
Expand All @@ -57,31 +59,36 @@ function getSchemaKind(schema: GenericSchema): string {
current = current.pipe[0]
continue
}
return kind
return kind ?? 'unknown'
}
return 'unknown'
}

/** Whether the CAC option for this schema should be a boolean flag. */
export function isBooleanFlag(schema: GenericSchema): boolean {
export function isBooleanFlag(schema: StandardSchemaV1): boolean {
return getSchemaKind(schema) === 'boolean'
}

/** Validate and coerce the raw cac-parsed bag against a {@link CliFlagsSchema}. */
/** Validate the raw cac-parsed bag against a {@link CliFlagsSchema}. */
export function parseCliFlags(
schema: CliFlagsSchema,
raw: Record<string, unknown>,
): { flags: Record<string, unknown>, issues?: string[] } {
const flags: Record<string, unknown> = {}
const issues: string[] = []
for (const [key, fieldSchema] of Object.entries(schema)) {
const result = safeParse(fieldSchema, raw[key])
if (result.success) {
flags[key] = result.output
const result = fieldSchema['~standard'].validate(raw[key])
if (result instanceof Promise) {
// CLI parsing is synchronous; an async validator can't be awaited here.
issues.push(`--${toKebab(key)}: async flag validation is not supported`)
continue
}
else {
if (result.issues) {
issues.push(`--${toKebab(key)}: ${result.issues.map(i => i.message).join(', ')}`)
}
else {
flags[key] = result.value
}
}
// Preserve any raw flags that aren't in the schema (e.g. --host, --port,
// or options contributed via cli.configure) so authors keep access to
Expand Down
Loading
Loading