Skip to content

Commit df12ef6

Browse files
committed
feat(rpc)!: make devframe fully validator-neutral (drop forced valibot dep)
Remove valibot and @valibot/to-json-schema from devframe's runtime dependencies so nothing forces a specific Standard Schema validator on users. Consumers bring their own (valibot, zod, arktype) or use the new built-in zero-dependency builder. - Add devframe/utils/schema: a tiny zero-dep Standard Schema builder (s) exposing string/number/boolean/void/null/picklist/array/object/ optional/nullable, used by the recipes. - Convert recipes (common-rpc-functions, interactive-auth) off valibot to the built-in builder. - Make the CLI-flags parser validator-neutral: validate via the ~standard contract and duck-type the schema kind (works for the builder and valibot; other vendors degrade to value-flags). - Drop @valibot/to-json-schema; the agent/MCP surface advertises a permissive object schema for any declared args/returns. - valibot stays a devDependency for tests only. BREAKING CHANGE: the exported recipe definitions (openInEditor, openInFinder, interactive-auth functions) now type their args/returns as the built-in DevframeSchema instead of valibot schema types, and @valibot/to-json-schema is no longer a devframe dependency. Agent tool JSON schemas are now permissive objects rather than precise valibot conversions.
1 parent f31156e commit df12ef6

23 files changed

Lines changed: 455 additions & 187 deletions

‎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/schema': r('devframe/src/utils/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/guide/rpc.md‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@ sequenceDiagram
2222

2323
```ts
2424
import { defineRpcFunction } from 'devframe'
25-
import * as v from 'valibot'
25+
import { s } from 'devframe/utils/schema' // or bring your own: valibot / zod / arktype
2626
2727
export const getModules = defineRpcFunction({
2828
name: 'get-modules', // bare — the scope namespaces it to `my-devframe:get-modules`
2929
type: 'query',
30-
args: [v.object({ limit: v.number() })],
31-
returns: v.array(v.object({ id: v.string(), size: v.number() })),
30+
args: [s.object({ limit: s.number() })],
31+
returns: s.array(s.object({ id: s.string(), size: s.number() })),
3232
setup: ctx => ({
3333
handler: async ({ limit }) => {
3434
// `ctx` is the full DevframeNodeContext.
@@ -75,14 +75,16 @@ 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. Declare `args` schemas — any [Standard Schema](https://standardschema.dev/) validator, valibot below — 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:
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 the one you already use, or reach for the built-in zero-dependency builder at `devframe/utils/schema` (imported as `s` below):
7981

8082
```ts
8183
defineRpcFunction({
8284
name: 'get-file',
8385
type: 'query',
84-
args: [v.object({ path: v.string(), includeSource: v.optional(v.boolean()) })],
85-
returns: v.object({ path: v.string(), source: v.optional(v.string()) }),
86+
args: [s.object({ path: s.string(), includeSource: s.optional(s.boolean()) })],
87+
returns: s.object({ path: s.string(), source: s.optional(s.string()) }),
8688
setup: () => ({
8789
handler: async ({ path, includeSource }) => ({
8890
path,
@@ -92,7 +94,7 @@ defineRpcFunction({
9294
})
9395
```
9496

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

9799
> [!WARNING]
98100
> 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.
@@ -245,7 +247,7 @@ defineRpcFunction({
245247
name: 'build-meta',
246248
type: 'static',
247249
args: [],
248-
returns: v.object({ version: v.string(), builtAt: v.number() }),
250+
returns: s.object({ version: s.string(), builtAt: s.number() }),
249251
setup: () => ({
250252
handler: async () => ({ version: '1.0.0', builtAt: Date.now() }),
251253
}),
@@ -309,8 +311,8 @@ defineRpcFunction({
309311
name: 'get-modules',
310312
type: 'query',
311313
jsonSerializable: true,
312-
args: [v.object({ limit: v.number() })],
313-
returns: v.array(v.object({ id: v.string(), size: v.number() })),
314+
args: [s.object({ limit: s.number() })],
315+
returns: s.array(s.object({ id: s.string(), size: s.number() })),
314316
agent: {
315317
description: 'List the N largest modules in the current build. Safe to call freely.',
316318
title: 'List modules',

‎docs/guide/standalone-cli.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ 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, or zod / arktype / devframe's built-in `s`) so they're validated at parse time and typed at the call site:
172172

173173
```ts
174174
import type { InferCliFlags } from 'devframe/adapters/cac'

‎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 declare their arguments with devframe's built-in zero-dependency `s` builder from `devframe/utils/schema` — `openInEditor`'s `editor` argument is `s.optional(s.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.
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

‎packages/devframe/package.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"./utils/nanoid": "./dist/utils/nanoid.mjs",
5151
"./utils/open": "./dist/utils/open.mjs",
5252
"./utils/promise": "./dist/utils/promise.mjs",
53+
"./utils/schema": "./dist/utils/schema.mjs",
5354
"./utils/scope": "./dist/utils/scope.mjs",
5455
"./utils/serve-static": "./dist/utils/serve-static.mjs",
5556
"./utils/shared-state": "./dist/utils/shared-state.mjs",
@@ -83,16 +84,14 @@
8384
},
8485
"dependencies": {
8586
"@standard-schema/spec": "catalog:deps",
86-
"@valibot/to-json-schema": "catalog:deps",
8787
"birpc": "catalog:deps",
8888
"crossws": "catalog:deps",
8989
"destr": "catalog:deps",
9090
"h3": "catalog:deps",
9191
"mrmime": "catalog:deps",
9292
"nostics": "catalog:deps",
9393
"pathe": "catalog:deps",
94-
"ufo": "catalog:deps",
95-
"valibot": "catalog:deps"
94+
"ufo": "catalog:deps"
9695
},
9796
"devDependencies": {
9897
"@modelcontextprotocol/client": "catalog:deps",
@@ -111,6 +110,7 @@
111110
"tinyglobby": "catalog:deps",
112111
"tsdown": "catalog:build",
113112
"ua-parser-modern": "catalog:inlined",
113+
"valibot": "catalog:deps",
114114
"whenexpr": "catalog:deps",
115115
"ws": "catalog:deps"
116116
}

‎packages/devframe/src/adapters/flags.ts‎

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import type { GenericSchema, InferOutput } from 'valibot'
2-
import { safeParse } from 'valibot'
1+
import type { StandardSchemaV1 } from '@standard-schema/spec'
32

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

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

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

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

6567
/** Whether the CAC option for this schema should be a boolean flag. */
66-
export function isBooleanFlag(schema: GenericSchema): boolean {
68+
export function isBooleanFlag(schema: StandardSchemaV1): boolean {
6769
return getSchemaKind(schema) === 'boolean'
6870
}
6971

70-
/** Validate and coerce the raw cac-parsed bag against a {@link CliFlagsSchema}. */
72+
/** Validate the raw cac-parsed bag against a {@link CliFlagsSchema}. */
7173
export function parseCliFlags(
7274
schema: CliFlagsSchema,
7375
raw: Record<string, unknown>,
7476
): { flags: Record<string, unknown>, issues?: string[] } {
7577
const flags: Record<string, unknown> = {}
7678
const issues: string[] = []
7779
for (const [key, fieldSchema] of Object.entries(schema)) {
78-
const result = safeParse(fieldSchema, raw[key])
79-
if (result.success) {
80-
flags[key] = result.output
80+
const result = fieldSchema['~standard'].validate(raw[key])
81+
if (result instanceof Promise) {
82+
// CLI parsing is synchronous; an async validator can't be awaited here.
83+
issues.push(`--${toKebab(key)}: async flag validation is not supported`)
84+
continue
8185
}
82-
else {
86+
if (result.issues) {
8387
issues.push(`--${toKebab(key)}: ${result.issues.map(i => i.message).join(', ')}`)
8488
}
89+
else {
90+
flags[key] = result.value
91+
}
8592
}
8693
// Preserve any raw flags that aren't in the schema (e.g. --host, --port,
8794
// or options contributed via cli.configure) so authors keep access to
Lines changed: 20 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,42 @@
1-
import * as v from 'valibot'
1+
import { s } from 'devframe/utils/schema'
22
import { describe, expect, it } from 'vitest'
33
import { argsToJsonSchema, returnToJsonSchema } from '../to-json-schema'
44

5+
const PERMISSIVE = { type: 'object', additionalProperties: true }
6+
57
describe('argsToJsonSchema', () => {
68
it('returns an empty object schema when no args', () => {
79
const { schema, unwrapped } = argsToJsonSchema(undefined)
810
expect(unwrapped).toBe(false)
911
expect(schema).toEqual({ type: 'object', properties: {} })
1012
})
1113

12-
it('wraps multiple positional args under arg0/arg1/...', () => {
13-
const { schema, unwrapped } = argsToJsonSchema([v.string(), v.number()])
14+
it('advertises each positional arg as a permissive object under arg0/arg1/...', () => {
15+
const { schema, unwrapped } = argsToJsonSchema([s.string(), s.number()])
1416
expect(unwrapped).toBe(false)
1517
expect(schema).toMatchObject({
1618
type: 'object',
1719
required: ['arg0', 'arg1'],
1820
additionalProperties: false,
1921
})
2022
const props = (schema as any).properties
21-
expect(props.arg0).toMatchObject({ type: 'string' })
22-
expect(props.arg1).toMatchObject({ type: 'number' })
23-
})
24-
25-
it('unwraps a single object schema for nicer agent UX', () => {
26-
const { schema, unwrapped } = argsToJsonSchema([
27-
v.object({ name: v.string(), age: v.number() }),
28-
])
29-
expect(unwrapped).toBe(true)
30-
expect((schema as any).type).toBe('object')
31-
const props = (schema as any).properties
32-
expect(props.name).toBeDefined()
33-
expect(props.age).toBeDefined()
23+
expect(props.arg0).toEqual(PERMISSIVE)
24+
expect(props.arg1).toEqual(PERMISSIVE)
3425
})
3526

36-
it('keeps arg0 shape when the single arg is a primitive', () => {
37-
const { schema, unwrapped } = argsToJsonSchema([v.string()])
27+
it('wraps a single arg under arg0 (no vendor-specific unwrapping)', () => {
28+
const { schema, unwrapped } = argsToJsonSchema([s.object({ name: s.string() })])
3829
expect(unwrapped).toBe(false)
3930
expect(schema).toMatchObject({ type: 'object', required: ['arg0'] })
31+
expect((schema as any).properties.arg0).toEqual(PERMISSIVE)
32+
})
33+
34+
it('works with any Standard Schema vendor (falls back the same way)', () => {
35+
const foreign = {
36+
'~standard': { version: 1 as const, vendor: 'acme', validate: (value: unknown) => ({ value }) },
37+
}
38+
const { schema } = argsToJsonSchema([foreign])
39+
expect((schema as any).properties.arg0).toEqual(PERMISSIVE)
4040
})
4141
})
4242

@@ -45,38 +45,7 @@ describe('returnToJsonSchema', () => {
4545
expect(returnToJsonSchema(undefined)).toBeUndefined()
4646
})
4747

48-
it('converts a simple schema', () => {
49-
const schema = returnToJsonSchema(v.object({ ok: v.boolean() }))
50-
expect((schema as any).type).toBe('object')
51-
expect((schema as any).properties.ok).toMatchObject({ type: 'boolean' })
52-
})
53-
})
54-
55-
describe('non-valibot Standard Schemas', () => {
56-
// A minimal Standard Schema from a made-up vendor (mirrors zod/arktype,
57-
// which devframe core does not depend on) — no valibot internals.
58-
const foreign = {
59-
'~standard': {
60-
version: 1 as const,
61-
vendor: 'acme',
62-
validate: (value: unknown) => ({ value }),
63-
},
64-
}
65-
66-
it('falls back to a permissive object per positional arg', () => {
67-
const { schema, unwrapped } = argsToJsonSchema([foreign, foreign])
68-
expect(unwrapped).toBe(false)
69-
expect(schema).toMatchObject({ type: 'object', required: ['arg0', 'arg1'] })
70-
expect((schema as any).properties.arg0).toEqual({ type: 'object', additionalProperties: true })
71-
})
72-
73-
it('unwraps a single foreign arg to the permissive object', () => {
74-
const { schema, unwrapped } = argsToJsonSchema([foreign])
75-
expect(unwrapped).toBe(true)
76-
expect(schema).toEqual({ type: 'object', additionalProperties: true })
77-
})
78-
79-
it('falls back to a permissive object schema for returns', () => {
80-
expect(returnToJsonSchema(foreign)).toEqual({ type: 'object', additionalProperties: true })
48+
it('advertises a permissive object for any declared return schema', () => {
49+
expect(returnToJsonSchema(s.object({ ok: s.boolean() }))).toEqual(PERMISSIVE)
8150
})
8251
})

0 commit comments

Comments
 (0)