From 856f417fdd4571ae8ced5cb178129120d0197f2c Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 1 May 2026 17:02:39 +0900 Subject: [PATCH 1/7] feat(devframe): jsonSerializable declaration + per-call wire dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in `jsonSerializable: boolean` field to RPC function definitions. When true, the WS transport encodes the function's messages as plain JSON via a strict single-pass `JSON.stringify` that throws DF0019 synchronously on Map/Set/Date/BigInt/class instances/ undefined-in-array. When false (default) the function uses structured-clone-es with an `s:` wire prefix, preserving fancy types. The wire dispatcher reads the prefix on decode (no defs lookup needed) and consults local defs on encode, so request and response can independently choose their encoder per channel. Build dumps tag each manifest entry with `serialization: 'json' | 'structured-clone'` and dispatch the matching encoder/decoder; static client revives sc-tagged entries via `scDeserialize`. Agent exposure now requires `jsonSerializable: true` — registration throws DF0018 if `agent` is set without it. Aligns the MCP contract with the wire contract: only JSON-serializable functions are advertised to coding agents. Co-Authored-By: Claude Opus 4.7 (1M context) --- devframe/docs/.vitepress/sidebar.ts | 2 +- devframe/docs/errors/DF0018.md | 54 ++++++ devframe/docs/errors/DF0019.md | 58 ++++++ devframe/docs/errors/index.md | 2 + devframe/docs/guide/rpc.md | 33 +++- .../devframe-files-inspector/src/devtool.ts | 2 + .../tests/static-build.test.ts | 2 +- .../tests/static-serve.test.ts | 2 +- .../packages/devframe/src/adapters/build.ts | 23 ++- .../packages/devframe/src/client/rpc-ws.ts | 9 + .../devframe/src/client/static-rpc.test.ts | 60 ++++++ .../devframe/src/client/static-rpc.ts | 32 +++- .../src/node/__tests__/host-agent.test.ts | 7 + .../src/node/__tests__/static-dump.test.ts | 26 ++- .../packages/devframe/src/node/static-dump.ts | 31 +++- .../devframe/src/rpc/collector.test.ts | 52 +++++- .../packages/devframe/src/rpc/collector.ts | 9 + .../packages/devframe/src/rpc/diagnostics.ts | 18 +- devframe/packages/devframe/src/rpc/index.ts | 1 + .../devframe/src/rpc/serialization.test.ts | 152 ++++++++++++++++ .../devframe/src/rpc/serialization.ts | 172 ++++++++++++++++++ .../devframe/src/rpc/transports/ws-client.ts | 19 +- .../devframe/src/rpc/transports/ws-server.ts | 29 ++- devframe/packages/devframe/src/rpc/types.ts | 34 ++++ .../packages/devframe/src/types/context.ts | 9 + .../tsnapi/devframe/node.snapshot.d.ts | 10 +- .../recipes/open-helpers.snapshot.d.ts | 4 + .../tsnapi/devframe/rpc.snapshot.d.ts | 6 + .../tsnapi/devframe/rpc.snapshot.js | 6 + packages/core/src/node/ws.ts | 7 + skills/devframe/SKILL.md | 17 +- .../references/rpc-patterns.md | 24 +++ 32 files changed, 882 insertions(+), 30 deletions(-) create mode 100644 devframe/docs/errors/DF0018.md create mode 100644 devframe/docs/errors/DF0019.md create mode 100644 devframe/packages/devframe/src/rpc/serialization.test.ts create mode 100644 devframe/packages/devframe/src/rpc/serialization.ts diff --git a/devframe/docs/.vitepress/sidebar.ts b/devframe/docs/.vitepress/sidebar.ts index 2817d7d6..df1e4dd7 100644 --- a/devframe/docs/.vitepress/sidebar.ts +++ b/devframe/docs/.vitepress/sidebar.ts @@ -25,7 +25,7 @@ export default function devframeSidebar(prefix = ''): DefaultTheme.SidebarItem[] text: 'Error Reference', link: `${prefix}/errors/`, collapsed: true, - items: Array.from({ length: 17 }, (_, i) => { + items: Array.from({ length: 19 }, (_, i) => { const code = `DF${String(i + 1).padStart(4, '0')}` return { text: code, link: `${prefix}/errors/${code}` } }), diff --git a/devframe/docs/errors/DF0018.md b/devframe/docs/errors/DF0018.md new file mode 100644 index 00000000..ab2de2af --- /dev/null +++ b/devframe/docs/errors/DF0018.md @@ -0,0 +1,54 @@ +--- +outline: deep +--- + +# DF0018: Agent Requires JSON-Serializable RPC + +> Package: `devframe` + +## Message + +> RPC function "`{name}`" has `agent` set but `jsonSerializable` is not `true` — MCP requires JSON-serializable data. + +## 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. + +A registered function is rejected when `agent` is present and `jsonSerializable` is not explicitly `true`. + +## Example + +```ts +defineRpcFunction({ + name: 'my-plugin:summary', + agent: { description: 'Returns a summary' }, + // missing `jsonSerializable: true` → registration throws DF0018 + handler: () => ({ items: [1, 2, 3] }), +}) +``` + +## Fix + +Either declare the payload as JSON-safe: + +```ts +defineRpcFunction({ + name: 'my-plugin:summary', + jsonSerializable: true, + agent: { description: 'Returns a summary' }, + handler: () => ({ items: [1, 2, 3] }), +}) +``` + +Or remove `agent` to keep the function as an internal RPC (no agent exposure): + +```ts +defineRpcFunction({ + name: 'my-plugin:summary', + handler: () => new Map([['a', 1]]), +}) +``` + +## Source + +`packages/devframe/src/rpc/collector.ts` diff --git a/devframe/docs/errors/DF0019.md b/devframe/docs/errors/DF0019.md new file mode 100644 index 00000000..5c194a87 --- /dev/null +++ b/devframe/docs/errors/DF0019.md @@ -0,0 +1,58 @@ +--- +outline: deep +--- + +# DF0019: Non-JSON Value in JSON-Serializable RPC + +> Package: `devframe` + +## Message + +> RPC function "`{name}`" declares `jsonSerializable: true` but the value at "`{path}`" is a `{type}`. + +## Cause + +The function is declared `jsonSerializable: true`, which means its args and return value are encoded with strict `JSON.stringify` (both on the wire and in build dumps). The strict serializer rejects any value that JSON cannot round-trip losslessly: + +- `Map`, `Set`, `WeakMap`, `WeakSet` +- `Date` (silently coerced to ISO string by JSON) +- `BigInt` +- circular references +- non-plain class instances +- `undefined` leaves +- `Symbol` +- `Function` + +When the strict serializer encounters one of these, it throws synchronously at the offending call rather than producing a corrupt payload. + +## Example + +```ts +defineRpcFunction({ + name: 'my-plugin:graph', + jsonSerializable: true, + handler: () => ({ + nodes: new Map([['a', 1]]), // ← throws DF0019 with type=Map, path="nodes" + }), +}) +``` + +## Fix + +Either drop `jsonSerializable: true` so the function uses `structured-clone-es` (round-trips `Map`, `Set`, etc.): + +```ts +defineRpcFunction({ + name: 'my-plugin:graph', + // jsonSerializable: false (default) — Map/Set survive the wire and the dump + handler: () => ({ + nodes: new Map([['a', 1]]), + }), +}) +``` + +Or convert the payload to a JSON-safe shape (e.g. an array of entries, an ISO string, a plain object) before returning. Note: removing `jsonSerializable: true` also disables `agent` exposure; if you need MCP, you must use a JSON-safe shape. + +## Source + +`packages/devframe/src/rpc/serialization.ts` diff --git a/devframe/docs/errors/index.md b/devframe/docs/errors/index.md index 220d012b..7c956e0f 100644 --- a/devframe/docs/errors/index.md +++ b/devframe/docs/errors/index.md @@ -35,3 +35,5 @@ Emitted by `devframe` — framework-neutral host / shared-state / auth surface. | [DF0015](./DF0015) | error | Agent Tool Already Registered | — | | [DF0016](./DF0016) | error | Agent Resource Already Registered | — | | [DF0017](./DF0017) | error | MCP Server Start Failure | — | +| [DF0018](./DF0018) | error | Agent Requires JSON-Serializable RPC | — | +| [DF0019](./DF0019) | error | Non-JSON Value in JSON-Serializable RPC | — | diff --git a/devframe/docs/guide/rpc.md b/devframe/docs/guide/rpc.md index 3135881a..42384eb3 100644 --- a/devframe/docs/guide/rpc.md +++ b/devframe/docs/guide/rpc.md @@ -200,14 +200,45 @@ defineRpcFunction({ At runtime, static clients resolve `rpc.call('my-devtool:get-session', 'session-a')` from the baked dump; misses fall back to `dump.fallback` (or throw if not provided). +## JSON-Serializable Declaration + +DevFrame's WS transport ships payloads using one of two encoders, picked **per RPC function**: + +| `jsonSerializable` | Encoder | Wire prefix | Round-trips | +|---|---|---|---| +| `false` (default) | `structured-clone-es` | `s:` | `Map`, `Set`, `Date`, `BigInt`, cycles, class instances | +| `true` (opt-in) | strict `JSON.stringify` | _(unprefixed)_ | JSON-only | + +The wire is plain JSON when all participating functions are JSON-flagged — debuggable in DevTools, friendlier to MCP, and a good default for tools that already speak JSON. + +### Discovering shape errors during dev + +Setting `jsonSerializable: true` is a contract: if your handler ever returns a value JSON cannot round-trip losslessly (a `Map`, a `Date`, a class instance, …), the strict serializer **throws `DF0019` synchronously** on the offending call. The error fires in dev, not at build time, so you see it next to the call site that introduced the bad value: + +```ts +defineRpcFunction({ + name: 'my-devtool:graph', + jsonSerializable: true, + // ⚠ throws DF0019 because Map cannot round-trip through JSON + handler: () => ({ nodes: new Map([['a', 1]]) }), +}) +``` + +If you do need fancy types, leave the flag unset (or `false`) — `structured-clone-es` will preserve 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. So **`agent: {...}` requires `jsonSerializable: true`** — otherwise registration throws `DF0018`. 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. Functions without an `agent` field are not exposed (default-deny). +Add an `agent` field to surface the function to coding agents over MCP. Functions without an `agent` field are not exposed (default-deny). 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: { diff --git a/devframe/examples/devframe-files-inspector/src/devtool.ts b/devframe/examples/devframe-files-inspector/src/devtool.ts index 160b2a70..684dd813 100644 --- a/devframe/examples/devframe-files-inspector/src/devtool.ts +++ b/devframe/examples/devframe-files-inspector/src/devtool.ts @@ -21,12 +21,14 @@ export default defineDevtool({ ctx.rpc.register(defineRpcFunction({ name: 'devframe-files-inspector:get-cwd', type: 'static', + jsonSerializable: true, handler: () => ({ cwd: ctx.cwd }), })) ctx.rpc.register(defineRpcFunction({ name: 'devframe-files-inspector:list-files', type: 'query', + jsonSerializable: true, handler: async () => { const files = await glob(['*'], { cwd: ctx.cwd, onlyFiles: true, dot: false }) return files.map(f => f.replace(/\\/g, '/')).sort() diff --git a/devframe/examples/devframe-files-inspector/tests/static-build.test.ts b/devframe/examples/devframe-files-inspector/tests/static-build.test.ts index 7f618c29..fc58762c 100644 --- a/devframe/examples/devframe-files-inspector/tests/static-build.test.ts +++ b/devframe/examples/devframe-files-inspector/tests/static-build.test.ts @@ -62,7 +62,7 @@ describe('static build (CLI build surface)', () => { 'utf-8', ), ) as { backend: string } - expect(meta).toEqual({ backend: 'static' }) + expect(meta).toMatchObject({ backend: 'static' }) // Guard the design: nothing should land under a `.devtools/` subdir. expect(existsSync(path.join(outBuild, '.devtools'))).toBe(false) }) diff --git a/devframe/examples/devframe-files-inspector/tests/static-serve.test.ts b/devframe/examples/devframe-files-inspector/tests/static-serve.test.ts index 35dfcea4..cbce9ef1 100644 --- a/devframe/examples/devframe-files-inspector/tests/static-serve.test.ts +++ b/devframe/examples/devframe-files-inspector/tests/static-serve.test.ts @@ -93,7 +93,7 @@ describe('static serve (deployed SPA contract)', () => { const res = await fetch(`${server.origin}${mountBase}.connection.json`) expect(res.status).toBe(200) const meta = await res.json() as { backend: string } - expect(meta).toEqual({ backend: 'static' }) + expect(meta).toMatchObject({ backend: 'static' }) }) it('serves the RPC dump manifest and reachable shard records', async () => { diff --git a/devframe/packages/devframe/src/adapters/build.ts b/devframe/packages/devframe/src/adapters/build.ts index 5e9681f5..9f40348f 100644 --- a/devframe/packages/devframe/src/adapters/build.ts +++ b/devframe/packages/devframe/src/adapters/build.ts @@ -13,6 +13,7 @@ import { import { createHostContext } from '../node/context' import { createH3DevToolsHost } from '../node/host-h3' import { collectStaticRpcDump } from '../node/static-dump' +import { scStringify, strictJsonStringify } from '../rpc/serialization' import { resolveBasePath } from './_shared' export interface CreateBuildOptions { @@ -69,19 +70,35 @@ export async function createBuild(d: DevtoolDefinition, options: CreateBuildOpti await d.setup(ctx) await fs.mkdir(resolve(outDir, DEVTOOLS_RPC_DUMP_DIRNAME), { recursive: true }) + + const jsonSerializableMethods: string[] = [] + for (const def of ctx.rpc.definitions.values()) { + if (def.jsonSerializable === true) + jsonSerializableMethods.push(def.name) + } await fs.writeFile( resolve(outDir, DEVTOOLS_CONNECTION_META_FILENAME), - JSON.stringify({ backend: 'static' }, null, 2), + JSON.stringify({ backend: 'static', jsonSerializableMethods }, null, 2), 'utf-8', ) console.log(c.cyan`[devframe] writing RPC dump to ${resolve(outDir, DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME)}`) const dump = await collectStaticRpcDump(ctx.rpc.definitions.values(), ctx) const indent = options.pretty ? 2 : undefined - for (const [filepath, data] of Object.entries(dump.files)) { + for (const [filepath, file] of Object.entries(dump.files)) { const fullpath = resolve(outDir, filepath) await fs.mkdir(dirname(fullpath), { recursive: true }) - await fs.writeFile(fullpath, JSON.stringify(data, null, indent), 'utf-8') + const text = file.serialization === 'structured-clone' + ? scStringify(file.data) + : strictJsonStringify(file.data, file.fnName) + await fs.writeFile( + fullpath, + // structured-clone-es output is single-line; only JSON honors `indent`. + file.serialization === 'json' && indent != null + ? JSON.stringify(JSON.parse(text), null, indent) + : text, + 'utf-8', + ) } await fs.writeFile( resolve(outDir, DEVTOOLS_RPC_DUMP_MANIFEST_FILENAME), diff --git a/devframe/packages/devframe/src/client/rpc-ws.ts b/devframe/packages/devframe/src/client/rpc-ws.ts index 2c0f2e8e..bfea2aba 100644 --- a/devframe/packages/devframe/src/client/rpc-ws.ts +++ b/devframe/packages/devframe/src/client/rpc-ws.ts @@ -39,12 +39,21 @@ export function createWsRpcClientMode( ? `${location.protocol.replace('http', 'ws')}//${location.hostname}:${connectionMeta.websocket}` : connectionMeta.websocket as string + // Build a minimal `defs` map from the connection meta so the per-call + // wire serializer dispatches outgoing requests with the correct + // encoding (JSON for `jsonSerializable: true` methods; structured- + // clone for the rest). + const definitions = new Map() + for (const name of connectionMeta.jsonSerializableMethods ?? []) + definitions.set(name, { jsonSerializable: true }) + const serverRpc = createRpcClient( clientRpc.functions, { channel: createWsRpcChannel({ url, authToken, + definitions, ...wsOptions, }), rpcOptions, diff --git a/devframe/packages/devframe/src/client/static-rpc.test.ts b/devframe/packages/devframe/src/client/static-rpc.test.ts index 46d1fa38..142af909 100644 --- a/devframe/packages/devframe/src/client/static-rpc.test.ts +++ b/devframe/packages/devframe/src/client/static-rpc.test.ts @@ -1,4 +1,5 @@ import { DEVTOOLS_RPC_DUMP_DIRNAME } from 'devframe/constants' +import { scStringify } from 'devframe/rpc' import { hash } from 'ohash' import { describe, expect, it } from 'vitest' import { createStaticRpcCaller } from './static-rpc' @@ -110,4 +111,63 @@ describe('createStaticRpcCaller', () => { await expect(caller.call('demo:legacy', [])).resolves.toEqual({ ok: true }) }) + + it('revives structured-clone-tagged static entries (preserves Map)', async () => { + const caller = createStaticRpcCaller( + { + 'demo:graph': { + type: 'static', + path: `${DEVTOOLS_RPC_DUMP_DIRNAME}/demo~graph.static.json`, + serialization: 'structured-clone', + }, + }, + async () => { + // What a server would have written: SC-stringified, then read + // back via fetch.json() (i.e. JSON.parse of the SC text). + const payload = { output: new Map([['a', 1], ['b', 2]]) } + return JSON.parse(scStringify(payload)) + }, + ) + + const result = await caller.call('demo:graph', []) as Map + expect(result).toBeInstanceOf(Map) + expect(result.get('a')).toBe(1) + expect(result.get('b')).toBe(2) + }) + + it('revives structured-clone-tagged query records (preserves Set)', async () => { + const recordPath = `${DEMO_QUERY_BASE_PATH}.record.${hash(['k'])}.json` + const caller = createStaticRpcCaller( + { + 'demo:query-set': { + type: 'query', + serialization: 'structured-clone', + records: { [hash(['k'])]: recordPath }, + }, + }, + async () => { + const payload = { inputs: ['k'], output: new Set(['x', 'y']) } + return JSON.parse(scStringify(payload)) + }, + ) + + const result = await caller.call('demo:query-set', ['k']) as Set + expect(result).toBeInstanceOf(Set) + expect(result.has('x')).toBe(true) + }) + + it('treats untagged manifest entries as JSON (back-compat)', async () => { + const caller = createStaticRpcCaller( + { + 'demo:legacy-static': { + type: 'static', + path: `${DEVTOOLS_RPC_DUMP_DIRNAME}/demo~legacy.static.json`, + // no `serialization` field — must default to JSON parsing + }, + }, + async () => ({ output: { items: [1, 2, 3] } }), + ) + + await expect(caller.call('demo:legacy-static', [])).resolves.toEqual({ items: [1, 2, 3] }) + }) }) diff --git a/devframe/packages/devframe/src/client/static-rpc.ts b/devframe/packages/devframe/src/client/static-rpc.ts index 894b98d5..21b2c20e 100644 --- a/devframe/packages/devframe/src/client/static-rpc.ts +++ b/devframe/packages/devframe/src/client/static-rpc.ts @@ -1,14 +1,21 @@ import { hash } from 'ohash' +import { scDeserialize } from '../rpc/serialization' + +export type StaticRpcSerialization = 'json' | 'structured-clone' export interface StaticRpcManifestStaticEntry { type: 'static' path: string + /** Encoder used when this entry's file was written. Default: `'json'`. */ + serialization?: StaticRpcSerialization } export interface StaticRpcManifestQueryEntry { type: 'query' records: Record fallback?: string + /** Encoder used when each record/fallback file was written. Default: `'json'`. */ + serialization?: StaticRpcSerialization } export type StaticRpcManifestEntry @@ -64,9 +71,18 @@ export function createStaticRpcCaller( const staticCache = new Map>() const queryRecordCache = new Map>() + function reviveIfStructuredClone(value: unknown, serialization: StaticRpcSerialization | undefined): any { + if (serialization === 'structured-clone') + return scDeserialize(value as any) + return value + } + async function loadStatic(entry: StaticRpcManifestStaticEntry): Promise { if (!staticCache.has(entry.path)) { - staticCache.set(entry.path, fetchJson(entry.path)) + staticCache.set( + entry.path, + fetchJson(entry.path).then(raw => reviveIfStructuredClone(raw, entry.serialization)), + ) } const data = await staticCache.get(entry.path)! if (isRecord(data)) { @@ -75,9 +91,15 @@ export function createStaticRpcCaller( return data } - async function loadQueryRecord(path: string): Promise { + async function loadQueryRecord( + path: string, + serialization: StaticRpcSerialization | undefined, + ): Promise { if (!queryRecordCache.has(path)) { - queryRecordCache.set(path, fetchJson(path)) + queryRecordCache.set( + path, + fetchJson(path).then(raw => reviveIfStructuredClone(raw, serialization)), + ) } return await queryRecordCache.get(path)! } @@ -102,12 +124,12 @@ export function createStaticRpcCaller( const recordPath = entry.records[argsHash] if (recordPath) { - const record = await loadQueryRecord(recordPath) + const record = await loadQueryRecord(recordPath, entry.serialization) return resolveRecordOutput(record) } if (entry.fallback) { - const fallback = await loadQueryRecord(entry.fallback) + const fallback = await loadQueryRecord(entry.fallback, entry.serialization) return resolveRecordOutput(fallback) } diff --git a/devframe/packages/devframe/src/node/__tests__/host-agent.test.ts b/devframe/packages/devframe/src/node/__tests__/host-agent.test.ts index ec3a4aa8..d0dac669 100644 --- a/devframe/packages/devframe/src/node/__tests__/host-agent.test.ts +++ b/devframe/packages/devframe/src/node/__tests__/host-agent.test.ts @@ -82,6 +82,7 @@ describe('devToolsAgentHost', () => { ctx.rpc.register(rpcDef({ name: 'shared-id', type: 'query', + jsonSerializable: true, agent: { description: 'An RPC' }, setup: () => ({ handler: async () => 'rpc' }), })) @@ -116,6 +117,7 @@ describe('devToolsAgentHost', () => { ctx.rpc.register(rpcDef({ name: 'exposed-rpc', type: 'query', + jsonSerializable: true, agent: { description: 'An exposed RPC.', title: 'Exposed', @@ -151,18 +153,21 @@ describe('devToolsAgentHost', () => { ctx.rpc.register(rpcDef({ name: 'q', type: 'query', + jsonSerializable: true, agent: { description: 'q' }, setup: () => ({ handler: async () => {} }), })) ctx.rpc.register(rpcDef({ name: 'a', type: 'action', + jsonSerializable: true, agent: { description: 'a' }, setup: () => ({ handler: async () => {} }), })) ctx.rpc.register(rpcDef({ name: 's', type: 'static', + jsonSerializable: true, agent: { description: 's' }, setup: () => ({ handler: async () => {} }), })) @@ -182,6 +187,7 @@ describe('devToolsAgentHost', () => { ctx.rpc.register(rpcDef({ name: 'x', type: 'query', + jsonSerializable: true, agent: { description: 'x' }, setup: () => ({ handler: async () => {} }), })) @@ -210,6 +216,7 @@ describe('devToolsAgentHost', () => { ctx.rpc.register(rpcDef({ name: 'my-rpc', type: 'query', + jsonSerializable: true, agent: { description: 'rpc' }, setup: () => ({ handler: async (a: number, b: number) => a + b, diff --git a/devframe/packages/devframe/src/node/__tests__/static-dump.test.ts b/devframe/packages/devframe/src/node/__tests__/static-dump.test.ts index f60f9013..ad113565 100644 --- a/devframe/packages/devframe/src/node/__tests__/static-dump.test.ts +++ b/devframe/packages/devframe/src/node/__tests__/static-dump.test.ts @@ -4,6 +4,25 @@ import { describe, expect, it } from 'vitest' import { collectStaticRpcDump } from '../static-dump' describe('collectStaticRpcDump', () => { + it('tags entries as JSON when jsonSerializable: true is declared', async () => { + const getVersion = defineRpcFunction({ + name: 'test:json-version', + type: 'static', + jsonSerializable: true, + handler: () => '1.0.0', + }) + + const result = await collectStaticRpcDump([getVersion], {}) + const expectedPath = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~json-version.static.json` + + expect(result.manifest['test:json-version']).toEqual({ + type: 'static', + path: expectedPath, + serialization: 'json', + }) + expect(result.files[expectedPath]?.serialization).toBe('json') + }) + it('collects static rpc output into sharded file entries', async () => { const getVersion = defineRpcFunction({ name: 'test:get-version', @@ -17,9 +36,13 @@ describe('collectStaticRpcDump', () => { expect(result.manifest['test:get-version']).toEqual({ type: 'static', path: expectedPath, + // Default `jsonSerializable: false` → structured-clone-encoded shard. + serialization: 'structured-clone', }) expect(result.files[expectedPath]).toEqual({ - output: '1.0.0', + serialization: 'structured-clone', + fnName: 'test:get-version', + data: { output: '1.0.0' }, }) }) @@ -49,6 +72,7 @@ describe('collectStaticRpcDump', () => { type: 'query', records: expect.any(Object), fallback: `${basePath}.fallback.json`, + serialization: 'structured-clone', }) expect(Object.keys(manifest.records)).toHaveLength(2) diff --git a/devframe/packages/devframe/src/node/static-dump.ts b/devframe/packages/devframe/src/node/static-dump.ts index 705352b4..27839a28 100644 --- a/devframe/packages/devframe/src/node/static-dump.ts +++ b/devframe/packages/devframe/src/node/static-dump.ts @@ -4,15 +4,21 @@ import { } from 'devframe/constants' import { dumpFunctions, getRpcHandler } from 'devframe/rpc' +export type StaticRpcDumpSerialization = 'json' | 'structured-clone' + export interface StaticRpcDumpManifestStaticEntry { type: 'static' path: string + /** Encoder used when this entry's file was written. Default: `'json'`. */ + serialization?: StaticRpcDumpSerialization } export interface StaticRpcDumpManifestQueryEntry { type: 'query' records: Record fallback?: string + /** Encoder used when each record/fallback file was written. Default: `'json'`. */ + serialization?: StaticRpcDumpSerialization } export type StaticRpcDumpManifestValue @@ -22,9 +28,18 @@ export type StaticRpcDumpManifestValue export type StaticRpcDumpManifest = Record +export interface StaticRpcDumpFile { + /** Whether this file was written via `JSON.stringify` or `structured-clone-es.stringify`. */ + serialization: StaticRpcDumpSerialization + /** Function name the file belongs to — used to scope `DF0019` errors during write. */ + fnName: string + /** Payload to encode. */ + data: unknown +} + export interface StaticRpcDumpCollection { manifest: StaticRpcDumpManifest - files: Record + files: Record } function makeDumpKey(name: string): string { @@ -54,20 +69,25 @@ export async function collectStaticRpcDump( context: any, ): Promise { const manifest: StaticRpcDumpManifest = {} - const files: Record = {} + const files: Record = {} for (const definition of definitions) { const type = definition.type ?? 'query' + const serialization: StaticRpcDumpSerialization + = definition.jsonSerializable === true ? 'json' : 'structured-clone' if (type === 'static') { const handler = await getRpcHandler(definition, context) const path = makeStaticPath(definition.name) files[path] = { - output: await Promise.resolve(handler()), + serialization, + fnName: definition.name, + data: { output: await Promise.resolve(handler()) }, } manifest[definition.name] = { type: 'static', path, + serialization, } continue } @@ -83,6 +103,7 @@ export async function collectStaticRpcDump( const queryEntry: StaticRpcDumpManifestQueryEntry = { type: 'query', records: {}, + serialization, } const prefix = `${definition.name}---` @@ -96,12 +117,12 @@ export async function collectStaticRpcDump( if (key === 'fallback') { const path = makeQueryFallbackPath(definition.name) - files[path] = record + files[path] = { serialization, fnName: definition.name, data: record } queryEntry.fallback = path } else { const path = makeQueryRecordPath(definition.name, key) - files[path] = record + files[path] = { serialization, fnName: definition.name, data: record } queryEntry.records[key] = path } } diff --git a/devframe/packages/devframe/src/rpc/collector.test.ts b/devframe/packages/devframe/src/rpc/collector.test.ts index 15709059..0c9f10da 100644 --- a/devframe/packages/devframe/src/rpc/collector.test.ts +++ b/devframe/packages/devframe/src/rpc/collector.test.ts @@ -1,6 +1,56 @@ -import { expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { RpcFunctionsCollectorBase } from './collector' +describe('agent gating (DF0018)', () => { + it('rejects registration when agent is set without jsonSerializable: true', () => { + const collector = new RpcFunctionsCollectorBase({}) + expect(() => collector.register({ + name: 'plugin:fn', + agent: { description: 'x' }, + handler: () => 0, + } as any)).toThrowError(/MCP requires JSON-serializable/) + }) + + it('rejects when agent + jsonSerializable: false', () => { + const collector = new RpcFunctionsCollectorBase({}) + expect(() => collector.register({ + name: 'plugin:fn', + agent: { description: 'x' }, + jsonSerializable: false, + handler: () => 0, + } as any)).toThrowError(/MCP requires JSON-serializable/) + }) + + it('accepts agent + jsonSerializable: true', () => { + const collector = new RpcFunctionsCollectorBase({}) + expect(() => collector.register({ + name: 'plugin:fn', + agent: { description: 'x' }, + jsonSerializable: true, + handler: () => 0, + } as any)).not.toThrow() + }) + + it('accepts jsonSerializable: false without agent (RPC-only)', () => { + const collector = new RpcFunctionsCollectorBase({}) + expect(() => collector.register({ + name: 'plugin:fn', + jsonSerializable: false, + handler: () => 0, + } as any)).not.toThrow() + }) + + it('also enforces the gate on update()', () => { + const collector = new RpcFunctionsCollectorBase({}) + collector.register({ name: 'plugin:fn', handler: () => 0 } as any) + expect(() => collector.update({ + name: 'plugin:fn', + agent: { description: 'x' }, + handler: () => 0, + } as any)).toThrowError(/MCP requires JSON-serializable/) + }) +}) + it('collector', async () => { const context = { name: 'test', diff --git a/devframe/packages/devframe/src/rpc/collector.ts b/devframe/packages/devframe/src/rpc/collector.ts index b568f4c2..865ff59b 100644 --- a/devframe/packages/devframe/src/rpc/collector.ts +++ b/devframe/packages/devframe/src/rpc/collector.ts @@ -43,6 +43,7 @@ export class RpcFunctionsCollectorBase< if (this.definitions.has(fn.name) && !force) { throw logger.DTK0001({ name: fn.name }).throw() } + assertAgentJsonSerializable(fn) this.definitions.set(fn.name, fn) this._onChanged.forEach(cb => cb(fn.name)) } @@ -51,6 +52,7 @@ export class RpcFunctionsCollectorBase< if (!this.definitions.has(fn.name) && !force) { throw logger.DTK0002({ name: fn.name }).throw() } + assertAgentJsonSerializable(fn) this.definitions.set(fn.name, fn) this._onChanged.forEach(cb => cb(fn.name)) } @@ -91,3 +93,10 @@ export class RpcFunctionsCollectorBase< return Array.from(this.definitions.keys()) } } + +function assertAgentJsonSerializable( + fn: RpcFunctionDefinition, +): void { + if (fn.agent && fn.jsonSerializable !== true) + throw logger.DF0018({ name: fn.name }).throw() +} diff --git a/devframe/packages/devframe/src/rpc/diagnostics.ts b/devframe/packages/devframe/src/rpc/diagnostics.ts index 26b71c73..6a5d7419 100644 --- a/devframe/packages/devframe/src/rpc/diagnostics.ts +++ b/devframe/packages/devframe/src/rpc/diagnostics.ts @@ -32,8 +32,24 @@ export const diagnostics = defineDiagnostics({ }, }) +export const dfDiagnostics = defineDiagnostics({ + docsBase: 'https://devtools.vite.dev/devframe/errors', + codes: { + DF0018: { + message: (p: { name: string }) => + `RPC function "${p.name}" has \`agent\` set but \`jsonSerializable\` is not \`true\` — MCP requires JSON-serializable data.`, + hint: 'Set `jsonSerializable: true` if the payload is JSON-safe, or remove `agent` to keep it RPC-only.', + }, + DF0019: { + message: (p: { name: string, type: string, path: string }) => + `RPC function "${p.name}" declares \`jsonSerializable: true\` but the value at "${p.path}" is a ${p.type}.`, + hint: 'Either drop `jsonSerializable: true` (falls back to structured-clone) or change the value to a JSON-safe shape.', + }, + }, +}) + export const logger = createLogger({ - diagnostics: [diagnostics], + diagnostics: [diagnostics, dfDiagnostics], formatter: plainFormatter, reporters: consoleReporter, }) diff --git a/devframe/packages/devframe/src/rpc/index.ts b/devframe/packages/devframe/src/rpc/index.ts index ef021396..1a2a1e8f 100644 --- a/devframe/packages/devframe/src/rpc/index.ts +++ b/devframe/packages/devframe/src/rpc/index.ts @@ -3,5 +3,6 @@ export * from './collector' export * from './define' export * from './dumps' export * from './handler' +export * from './serialization' export * from './types' export * from './validation' diff --git a/devframe/packages/devframe/src/rpc/serialization.test.ts b/devframe/packages/devframe/src/rpc/serialization.test.ts new file mode 100644 index 00000000..591fd781 --- /dev/null +++ b/devframe/packages/devframe/src/rpc/serialization.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from 'vitest' +import { + makePerCallChannelOptions, + strictJsonStringify, + STRUCTURED_CLONE_PREFIX, +} from './serialization' + +describe('strictJsonStringify', () => { + it('matches JSON.stringify for plain JSON values', () => { + const value = { a: 1, b: 'two', c: [true, null, 3.14] } + expect(strictJsonStringify(value)).toBe(JSON.stringify(value)) + }) + + it('rejects Map', () => { + expect(() => strictJsonStringify({ a: new Map([['k', 1]]) }, 'fn')) + .toThrowError(/jsonSerializable: true.*is a Map/) + }) + + it('rejects Set', () => { + expect(() => strictJsonStringify({ a: new Set([1, 2]) }, 'fn')) + .toThrowError(/is a Set/) + }) + + it('rejects Date', () => { + expect(() => strictJsonStringify({ when: new Date() }, 'fn')) + .toThrowError(/is a Date/) + }) + + it('rejects BigInt', () => { + expect(() => strictJsonStringify({ n: 1n }, 'fn')) + .toThrowError(/is a BigInt/) + }) + + it('rejects class instances', () => { + class Thing { + x = 1 + } + expect(() => strictJsonStringify({ t: new Thing() }, 'fn')) + .toThrowError(/is a Thing/) + }) + + it('rejects undefined inside an array (lossy → null in JSON)', () => { + expect(() => strictJsonStringify({ items: [1, undefined, 3] }, 'fn')) + .toThrowError(/is a undefined/) + }) + + it('allows undefined as an object property (legitimate optional field)', () => { + expect(strictJsonStringify({ a: 1, missing: undefined })).toBe('{"a":1}') + }) + + it('allows undefined at the root (action returning nothing)', () => { + expect(strictJsonStringify(undefined)).toBe(undefined as any) + }) + + it('rejects circular references via the native TypeError', () => { + const obj: any = { a: 1 } + obj.self = obj + expect(() => strictJsonStringify(obj, 'fn')) + .toThrowError(/circular|Converting circular/i) + }) + + it('mentions the function name in the diagnostic', () => { + expect(() => strictJsonStringify({ a: new Map() }, 'plugin:my-fn')) + .toThrowError(/plugin:my-fn/) + }) + + it('walks each node only once (single pass)', () => { + const replacerCalls: string[] = [] + const orig = JSON.stringify + const spy = vi.spyOn(JSON, 'stringify').mockImplementation((value, replacer) => { + const wrappedReplacer + = typeof replacer === 'function' + ? function (this: unknown, key: string, val: unknown) { + replacerCalls.push(key) + return (replacer as (k: string, v: unknown) => unknown).call(this, key, val) + } + : replacer + return orig(value, wrappedReplacer as any) + }) + try { + strictJsonStringify({ a: 1, b: { c: [2, 3] } }) + } + finally { + spy.mockRestore() + } + // 1 root + a + b + c + [0] + [1] = 6 nodes + expect(replacerCalls).toEqual(['', 'a', 'b', 'c', '0', '1']) + }) +}) + +describe('makePerCallChannelOptions', () => { + function makeChannel(jsonMethods: string[]) { + const defs = new Map( + jsonMethods.map(name => [name, { jsonSerializable: true as const }]), + ) + return makePerCallChannelOptions(defs) + } + + it('encodes JSON-flagged requests without a prefix', () => { + const ch = makeChannel(['fn']) + const wire = ch.serialize!({ t: 'q', i: '1', m: 'fn', a: [1, 2] }) + expect(wire).toBe('{"t":"q","i":"1","m":"fn","a":[1,2]}') + expect(wire.startsWith('s:')).toBe(false) + }) + + it('encodes structured-clone requests with the s: prefix', () => { + const ch = makeChannel([]) + const wire = ch.serialize!({ t: 'q', i: '1', m: 'fn', a: [new Map([['k', 1]])] }) + expect(typeof wire).toBe('string') + expect((wire as string).startsWith(STRUCTURED_CLONE_PREFIX)).toBe(true) + }) + + it('decodes per the wire prefix without consulting defs', () => { + const ch = makeChannel([]) // no defs at all on this channel + // JSON-encoded request — no prefix. + const json = ch.deserialize!('{"t":"q","i":"1","m":"fn","a":[1]}') + expect(json).toEqual({ t: 'q', i: '1', m: 'fn', a: [1] }) + + // SC-encoded message: produce a real SC wire string from a sender + // that doesn't know `fn` (so it falls through to SC), then route + // that string through this channel's deserialize. Map round-trips. + const sender = makeChannel([]) + const wire = sender.serialize!({ t: 'q', i: '2', m: 'fn', a: [new Map([['k', 1]])] }) as string + expect(wire.startsWith(STRUCTURED_CLONE_PREFIX)).toBe(true) + const decoded = ch.deserialize!(wire) as { t: 'q', a: [Map] } + expect(decoded.a[0]).toBeInstanceOf(Map) + expect(decoded.a[0].get('k')).toBe(1) + }) + + it('mirrors the originating method to dispatch the response encoding', () => { + const ch = makeChannel(['fn']) + // Receive a request → record method + ch.deserialize!('{"t":"q","i":"abc","m":"fn","a":[]}') + // Send the response → uses JSON because fn is jsonSerializable: true + const wire = ch.serialize!({ t: 's', i: 'abc', r: { ok: 1 } }) as string + expect(wire.startsWith(STRUCTURED_CLONE_PREFIX)).toBe(false) + expect(JSON.parse(wire)).toEqual({ t: 's', i: 'abc', r: { ok: 1 } }) + }) + + it('falls back to structured-clone for unknown methods', () => { + const ch = makeChannel(['known']) + const wire = ch.serialize!({ t: 'q', i: '1', m: 'unknown', a: [] }) as string + expect(wire.startsWith(STRUCTURED_CLONE_PREFIX)).toBe(true) + }) + + it('throws DF0019 when a JSON-flagged request carries non-JSON args', () => { + const ch = makeChannel(['fn']) + expect(() => + ch.serialize!({ t: 'q', i: '1', m: 'fn', a: [new Map()] }), + ).toThrowError(/jsonSerializable: true/) + }) +}) diff --git a/devframe/packages/devframe/src/rpc/serialization.ts b/devframe/packages/devframe/src/rpc/serialization.ts new file mode 100644 index 00000000..08e219bb --- /dev/null +++ b/devframe/packages/devframe/src/rpc/serialization.ts @@ -0,0 +1,172 @@ +import type { ChannelOptions } from 'birpc' +import { + deserialize as scDeserialize, + parse as scParse, + stringify as scStringify, +} from 'structured-clone-es' +import { logger } from './diagnostics' + +export { scDeserialize, scParse, scStringify } + +/** + * Wire format used by the WS RPC transport. + * + * - **JSON (default, unprefixed):** payload is plain JSON text. Used when + * the dispatched method is declared `jsonSerializable: true`. Encoded + * via {@link strictJsonStringify} (rejects non-JSON values), decoded + * via `JSON.parse`. + * - **Structured-clone (`s:` prefix):** payload is `s:` followed by + * `structured-clone-es` text. Used when the method is declared + * `jsonSerializable: false` (or omitted, the default). Round-trips + * `Map`, `Set`, `Date`, `BigInt`, cycles, and class instances. + * + * birpc envelopes always start with `{`, so a leading byte that is not + * `s` is unambiguously JSON. Each direction independently chooses its + * encoding from local definitions — request and response are not + * coupled by a mirror rule. + */ +export const STRUCTURED_CLONE_PREFIX = 's:' + +interface BirpcRequest { + t: 'q' + i?: string + m: string + a: unknown[] + o?: boolean +} + +interface BirpcResponse { + t: 's' + i: string + r?: unknown + e?: unknown +} + +type BirpcMessage = BirpcRequest | BirpcResponse + +function isJsonMethod( + defs: ReadonlyMap, + name: string | undefined, +): boolean { + return !!name && defs.get(name)?.jsonSerializable === true +} + +/** + * Build a per-call `serialize`/`deserialize` pair for birpc channels. + * + * The returned options switch encoder per-message based on the + * `jsonSerializable` flag of the dispatched function. Outgoing requests + * read the method from `msg.m`; outgoing responses look the method back + * up from a per-channel `pendingRequestMethods` map populated whenever + * a request is observed in `deserialize`. + * + * Pass an empty/partial `defs` map on peers that don't have the full + * registry — encoding falls back to structured-clone (the safer + * superset), and decoding still routes correctly via the wire prefix. + */ +export function makePerCallChannelOptions( + defs: ReadonlyMap, +): Pick { + const pendingRequestMethods = new Map() + + return { + serialize(msg: BirpcMessage): string { + let method: string | undefined + if (msg.t === 'q') { + method = msg.m + } + else { + method = pendingRequestMethods.get(msg.i) + pendingRequestMethods.delete(msg.i) + } + const useJson = isJsonMethod(defs, method) + if (useJson) + return strictJsonStringify(msg, method ?? '') + return `${STRUCTURED_CLONE_PREFIX}${scStringify(msg)}` + }, + deserialize(raw: string): BirpcMessage { + const msg: BirpcMessage = raw.startsWith(STRUCTURED_CLONE_PREFIX) + ? (scParse(raw.slice(STRUCTURED_CLONE_PREFIX.length)) as BirpcMessage) + : (JSON.parse(raw) as BirpcMessage) + if (msg.t === 'q' && msg.i && msg.m) + pendingRequestMethods.set(msg.i, msg.m) + return msg + }, + } +} + +/** + * `JSON.stringify` with a single-pass strict replacer. + * + * Throws `DF0019` synchronously when the value contains a type JSON + * cannot round-trip losslessly: `Map`, `Set`, `Date`, `BigInt`, class + * instances, or `undefined` inside an array (silently becomes `null`). + * + * Native pass-throughs (no extra work needed): + * - circular references — `JSON.stringify` raises `TypeError`. + * - `BigInt` at top level — caught here for a friendlier error path. + * + * Lenient cases (allowed without throwing): + * - `undefined` as an object property — legitimate optional field; + * JSON.stringify just omits it. + * - `undefined` at the root — legitimate "action returned nothing". + * - `Symbol` / `Function` values — semantically "drop me" in JSON. + * + * `fnName` is used only for the diagnostic message — pass the RPC + * function name when calling from a wire serializer / dump writer so + * the error points at the offending function. + */ +export function strictJsonStringify(value: unknown, fnName: string = ''): string { + return JSON.stringify(value, function strictReplacer(this: unknown, key: string, val: unknown): unknown { + // The replacer receives the value AFTER any `toJSON()` coercion + // (e.g. `Date` already became an ISO string). To detect raw types, + // peek at the holder's original property via `this[key]`. At the + // root, `this` is the wrapper `{ '': value }` so `this['']` is the + // raw root value. + const holder = this as Record | unknown[] | undefined + const original = holder != null ? (holder as any)[key] : val + + if (original === undefined) { + if (Array.isArray(holder)) + throw nonJsonAt(fnName, 'undefined', holder, key) + return val + } + if (original === null) + return val + + if (typeof original === 'bigint') + throw nonJsonAt(fnName, 'BigInt', holder, key) + + if (typeof original === 'object') { + if (original instanceof Map) + throw nonJsonAt(fnName, 'Map', holder, key) + if (original instanceof Set) + throw nonJsonAt(fnName, 'Set', holder, key) + if (original instanceof Date) + throw nonJsonAt(fnName, 'Date', holder, key) + if (Array.isArray(original)) + return val + const proto = Object.getPrototypeOf(original) + if (proto !== null && proto !== Object.prototype) { + const ctorName = (original as { constructor?: { name?: string } }).constructor?.name + ?? 'class instance' + throw nonJsonAt(fnName, ctorName, holder, key) + } + } + + return val + }) +} + +function nonJsonAt(fnName: string, type: string, parent: unknown, key: string): Error { + const path = formatPath(parent, key) + return logger.DF0019({ name: fnName || '', type, path }).throw() +} + +function formatPath(parent: unknown, key: string): string { + if (Array.isArray(parent)) + return `[${key}]` + if (key === '') + return '' + return key +} diff --git a/devframe/packages/devframe/src/rpc/transports/ws-client.ts b/devframe/packages/devframe/src/rpc/transports/ws-client.ts index 1b59689d..3dcb660e 100644 --- a/devframe/packages/devframe/src/rpc/transports/ws-client.ts +++ b/devframe/packages/devframe/src/rpc/transports/ws-client.ts @@ -1,5 +1,6 @@ import type { ChannelOptions } from 'birpc' -import { parse, stringify } from 'structured-clone-es' +import type { RpcFunctionDefinitionAny } from '../types' +import { makePerCallChannelOptions } from '../serialization' export interface WsRpcChannelOptions { url: string @@ -7,10 +8,20 @@ export interface WsRpcChannelOptions { onError?: (e: Error) => void onDisconnected?: (e: CloseEvent) => void authToken?: string + /** + * RPC function definitions (or just the `jsonSerializable` flag per + * method) used to dispatch the per-call wire serializer. Pass an + * empty / partial map on clients that don't have the full registry — + * encoding falls back to structured-clone (the safer superset) and + * decoding still routes correctly via the wire prefix. + */ + definitions?: ReadonlyMap> } function NOOP() {} +const EMPTY_DEFS: ReadonlyMap> = new Map() + /** * Build a birpc `ChannelOptions` object backed by a browser `WebSocket`. * Pass the result straight to `createRpcClient`'s `channel` option. @@ -25,6 +36,7 @@ export function createWsRpcChannel(options: WsRpcChannelOptions): ChannelOptions onConnected = NOOP, onError = NOOP, onDisconnected = NOOP, + definitions = EMPTY_DEFS, } = options ws.addEventListener('open', (e) => { @@ -40,6 +52,7 @@ export function createWsRpcChannel(options: WsRpcChannelOptions): ChannelOptions onDisconnected(e) }) + const perCall = makePerCallChannelOptions(definitions) return { on: (handler: (data: string) => void) => { ws.addEventListener('message', (e) => { @@ -58,7 +71,7 @@ export function createWsRpcChannel(options: WsRpcChannelOptions): ChannelOptions ws.addEventListener('open', handler) } }, - serialize: stringify, - deserialize: parse, + serialize: perCall.serialize, + deserialize: perCall.deserialize, } } diff --git a/devframe/packages/devframe/src/rpc/transports/ws-server.ts b/devframe/packages/devframe/src/rpc/transports/ws-server.ts index 514fd1be..2240ea11 100644 --- a/devframe/packages/devframe/src/rpc/transports/ws-server.ts +++ b/devframe/packages/devframe/src/rpc/transports/ws-server.ts @@ -2,9 +2,10 @@ import type { BirpcGroup, ChannelOptions } from 'birpc' import type { IncomingMessage } from 'node:http' import type { ServerOptions as HttpsServerOptions } from 'node:https' import type { WebSocket } from 'ws' +import type { RpcFunctionDefinitionAny } from '../types' import { createServer as createHttpsServer } from 'node:https' -import { parse, stringify } from 'structured-clone-es' import { WebSocketServer } from 'ws' +import { makePerCallChannelOptions } from '../serialization' export interface DevToolsNodeRpcSessionMeta { id: number @@ -23,14 +24,27 @@ export interface WsRpcTransportOptions { host?: string /** When set, a new https.Server is created and the WebSocketServer is attached to it. */ https?: HttpsServerOptions + /** + * RPC function definitions, used by the per-call wire serializer to + * dispatch between strict-JSON and structured-clone encoding based + * on each function's `jsonSerializable` flag. + * + * When omitted, all messages fall back to structured-clone — safe but + * loses dev-time validation for `jsonSerializable: true` declarations. + */ + definitions?: ReadonlyMap> onConnected?: (ws: WebSocket, req: IncomingMessage, meta: DevToolsNodeRpcSessionMeta) => void onDisconnected?: (ws: WebSocket, meta: DevToolsNodeRpcSessionMeta) => void + /** Override the default per-call serializer. Most callers should leave this unset. */ serialize?: ChannelOptions['serialize'] + /** Override the default per-call deserializer. Most callers should leave this unset. */ deserialize?: ChannelOptions['deserialize'] } let sessionId = 0 +const EMPTY_DEFS: ReadonlyMap> = new Map() + function NOOP() {} /** @@ -52,8 +66,9 @@ export function attachWsRpcTransport< https, onConnected = NOOP, onDisconnected = NOOP, - serialize = stringify, - deserialize = parse, + definitions = EMPTY_DEFS, + serialize: serializeOverride, + deserialize: deserializeOverride, } = options let wss: WebSocketServer @@ -76,6 +91,10 @@ export function attachWsRpcTransport< subscribedStates: new Set(), } + // Per-connection serializer state (the pending request-id map that + // mirrors method metadata from request to response). Each WS gets + // its own so request-id spaces don't collide across sessions. + const perCall = makePerCallChannelOptions(definitions) const channel: ChannelOptions = { post: (data) => { ws.send(data) @@ -85,8 +104,8 @@ export function attachWsRpcTransport< fn(data.toString()) }) }, - serialize, - deserialize, + serialize: serializeOverride ?? perCall.serialize, + deserialize: deserializeOverride ?? perCall.deserialize, meta, } diff --git a/devframe/packages/devframe/src/rpc/types.ts b/devframe/packages/devframe/src/rpc/types.ts index 85cf4315..b504a360 100644 --- a/devframe/packages/devframe/src/rpc/types.ts +++ b/devframe/packages/devframe/src/rpc/types.ts @@ -148,6 +148,20 @@ export interface RpcFunctionDefinitionBase { name: string /** Function type (static, action, event, or query) */ type?: RpcFunctionType + /** + * Declares whether this function's args/return are JSON-serializable + * — i.e. no `Map`, `Set`, `Date`, `BigInt`, class instances, circular + * references, `undefined` leaves, `Symbol`, or `Function` values. + * + * - `true` — args and return are encoded with strict `JSON.stringify` + * on the wire and on disk. Misshapen values throw `DF0019` at the + * sender, surfacing the bug *during the offending call* rather than + * silently coercing to `{}` later. Required for `agent` exposure. + * - `false` (default) — payloads use `structured-clone-es`, which + * round-trips Maps/Sets/cycles. Functions in this mode cannot be + * exposed via the `agent` field — registration throws `DF0018`. + */ + jsonSerializable?: boolean } /** @@ -208,6 +222,16 @@ export type RpcFunctionDefinition< args?: AS /** Valibot schema for validating function return value */ returns?: RS + /** + * Declares whether this function's args/return are JSON-serializable + * (no Map/Set/Date/BigInt/cycles/class instances/undefined/Symbol/Function). + * + * - `true` — wire and dump use strict `JSON.stringify`; misshapen + * values throw `DF0019` at the call site. Required for `agent`. + * - `false` (default) — `structured-clone-es` round-trips fancy + * types. Cannot be `agent`-exposed (registration throws `DF0018`). + */ + jsonSerializable?: boolean /** * Expose this function to agents (e.g. via the MCP adapter). * When omitted, the function is not agent-exposed (default-deny). @@ -244,6 +268,16 @@ export type RpcFunctionDefinition< args: AS /** Valibot schema for validating function return value */ returns: RS + /** + * Declares whether this function's args/return are JSON-serializable + * (no Map/Set/Date/BigInt/cycles/class instances/undefined/Symbol/Function). + * + * - `true` — wire and dump use strict `JSON.stringify`; misshapen + * values throw `DF0019` at the call site. Required for `agent`. + * - `false` (default) — `structured-clone-es` round-trips fancy + * types. Cannot be `agent`-exposed (registration throws `DF0018`). + */ + jsonSerializable?: boolean /** * Expose this function to agents (e.g. via the MCP adapter). * When omitted, the function is not agent-exposed (default-deny). diff --git a/devframe/packages/devframe/src/types/context.ts b/devframe/packages/devframe/src/types/context.ts index ad769304..43aedf5b 100644 --- a/devframe/packages/devframe/src/types/context.ts +++ b/devframe/packages/devframe/src/types/context.ts @@ -51,6 +51,15 @@ export interface DevToolsNodeUtils { export interface ConnectionMeta { backend: 'websocket' | 'static' websocket?: number | string + /** + * Names of RPC functions that have declared `jsonSerializable: true`. + * Used by the WS / static client to dispatch the per-call wire + * serializer (strict JSON for these methods, structured-clone for + * the rest). Populated by the server / build adapter; absent on + * legacy clients, in which case all outgoing messages fall back to + * structured-clone. + */ + jsonSerializableMethods?: string[] } export interface RemoteConnectionInfo extends ConnectionMeta { diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/devframe/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index 72c01956..deab7450 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/devframe/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -80,22 +80,30 @@ export interface StartHttpAndWsOptions { } export interface StaticRpcDumpCollection { manifest: StaticRpcDumpManifest; - files: Record; + files: Record; +} +export interface StaticRpcDumpFile { + serialization: StaticRpcDumpSerialization; + fnName: string; + data: unknown; } export interface StaticRpcDumpManifestQueryEntry { type: 'query'; records: Record; fallback?: string; + serialization?: StaticRpcDumpSerialization; } export interface StaticRpcDumpManifestStaticEntry { type: 'static'; path: string; + serialization?: StaticRpcDumpSerialization; } // #endregion // #region Types export type StaticRpcDumpManifest = Record; export type StaticRpcDumpManifestValue = StaticRpcDumpManifestStaticEntry | StaticRpcDumpManifestQueryEntry | any; +export type StaticRpcDumpSerialization = 'json' | 'structured-clone'; // #endregion // #region Classes diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts b/devframe/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts index 38ae8fbf..e97fdc77 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts +++ b/devframe/tests/__snapshots__/tsnapi/devframe/recipes/open-helpers.snapshot.d.ts @@ -8,6 +8,7 @@ export declare const openHelpers: readonly [{ cacheable?: boolean; args: readonly [v.StringSchema]; returns: v.VoidSchema; + jsonSerializable?: boolean; agent?: RpcFunctionAgentOptions; setup?: ((context: undefined) => Thenable>) | undefined; handler?: ((args_0: string) => void) | undefined; @@ -21,6 +22,7 @@ export declare const openHelpers: readonly [{ cacheable?: boolean; args: readonly [v.StringSchema]; returns: v.VoidSchema; + jsonSerializable?: boolean; agent?: RpcFunctionAgentOptions; setup?: ((context: undefined) => Thenable>) | undefined; handler?: ((args_0: string) => void) | undefined; @@ -35,6 +37,7 @@ export declare const openInEditor: { cacheable?: boolean; args: readonly [v.StringSchema]; returns: v.VoidSchema; + jsonSerializable?: boolean; agent?: RpcFunctionAgentOptions; setup?: ((context: undefined) => Thenable>) | undefined; handler?: ((args_0: string) => void) | undefined; @@ -49,6 +52,7 @@ export declare const openInFinder: { cacheable?: boolean; args: readonly [v.StringSchema]; returns: v.VoidSchema; + jsonSerializable?: boolean; agent?: RpcFunctionAgentOptions; setup?: ((context: undefined) => Thenable>) | undefined; handler?: ((args_0: string) => void) | undefined; diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts b/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts index df748661..e62537f9 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts +++ b/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts @@ -12,6 +12,7 @@ export { EntriesToObject } export { getDefinitionsWithDumps } export { getRpcHandler } export { getRpcResolvedSetupResult } +export { makePerCallChannelOptions } export { RpcArgsSchema } export { RpcCacheManager } export { RpcCacheOptions } @@ -35,6 +36,11 @@ export { RpcFunctionsCollectorBase } export { RpcFunctionSetupResult } export { RpcFunctionType } export { RpcReturnSchema } +export { scDeserialize } +export { scParse } +export { scStringify } +export { strictJsonStringify } +export { STRUCTURED_CLONE_PREFIX } export { Thenable } export { validateDefinition } export { validateDefinitions } diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js b/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js index 835614eb..9f7d22e0 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js +++ b/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js @@ -9,8 +9,14 @@ export { dumpFunctions } export { getDefinitionsWithDumps } export { getRpcHandler } export { getRpcResolvedSetupResult } +export { makePerCallChannelOptions } export { RpcCacheManager } export { RpcFunctionsCollectorBase } +export { scDeserialize } +export { scParse } +export { scStringify } +export { strictJsonStringify } +export { STRUCTURED_CLONE_PREFIX } export { validateDefinition } export { validateDefinitions } // #endregion \ No newline at end of file diff --git a/packages/core/src/node/ws.ts b/packages/core/src/node/ws.ts index a0a07417..0aa0ac8d 100644 --- a/packages/core/src/node/ws.ts +++ b/packages/core/src/node/ws.ts @@ -113,6 +113,7 @@ export async function createWsServer(options: CreateWsServerOptions) { port, host, https, + definitions: rpcHost.definitions, onConnected: (ws, req, meta) => { const url = new URL(req.url ?? '', 'http://localhost') const authToken = url.searchParams.get('vite_devtools_auth_token') ?? undefined @@ -147,9 +148,15 @@ export async function createWsServer(options: CreateWsServerOptions) { rpcHost._asyncStorage = asyncStorage const getConnectionMeta = async (): Promise => { + const jsonSerializableMethods: string[] = [] + for (const def of rpcHost.definitions.values()) { + if (def.jsonSerializable === true) + jsonSerializableMethods.push(def.name) + } return { backend: 'websocket', websocket: port, + jsonSerializableMethods, } } diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 422c546e..94964b47 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -97,6 +97,7 @@ import * as v from 'valibot' const getModules = defineRpcFunction({ name: 'my-inspector:get-modules', type: 'query', + jsonSerializable: true, args: [v.object({ limit: v.number() })], returns: v.array(v.object({ id: v.string(), size: v.number() })), setup: ctx => ({ @@ -114,6 +115,19 @@ const getModules = defineRpcFunction({ Add valibot schemas when the RPC is user-facing, when you want static dumps, or when you expose it to agents. Prefer a **single object arg** (`args: [v.object({ ... })]`) over positional args — property names self-document and agents rely on them. +### `jsonSerializable` (wire + dump format) + +`jsonSerializable` declares the on-wire / on-disk shape contract: + +| Value | Encoder | Wire prefix | Round-trips | +|-------|---------|-------------|-------------| +| `false` (default) | `structured-clone-es` | `s:` | `Map`, `Set`, `Date`, `BigInt`, cycles, class instances | +| `true` (opt-in) | strict `JSON.stringify` | _(unprefixed)_ | JSON-only | + +Set `jsonSerializable: true` when your handler returns plain JSON shapes — the strict serializer **throws `DF0019`** synchronously on the offending call when it sees a value JSON cannot round-trip (Map/Set/Date/BigInt/class instance/`undefined`-in-array). Errors surface in dev next to the call that introduced them, not silently at build time. + +`agent: {...}` requires `jsonSerializable: true` (registration throws `DF0018` otherwise). MCP tools speak JSON — opting into the agent surface is also opting into JSON-only data. + `ctx.rpc.broadcast({ method, args, optional?, event?, filter? })` pushes to every connected client. `ctx.rpc.invokeLocal(name, ...args)` calls a server function without going through transport (useful for cross-function composition). ## Shared state @@ -222,12 +236,13 @@ Built-in context: `clientType` (`'embedded' | 'standalone'`), `dockOpen`, `palet ## Agent-native surface (experimental) -Opt an RPC function into the agent surface with an `agent` field — default-deny otherwise: +Opt an RPC function into the agent surface with an `agent` field — default-deny otherwise. Agent-exposed functions **must declare `jsonSerializable: true`** (registration throws `DF0018` otherwise): ```ts defineRpcFunction({ name: 'my-inspector:get-stats', type: 'query', + jsonSerializable: true, args: [v.object({ limit: v.number() })], returns: v.object({ count: v.number() }), agent: { diff --git a/skills/vite-devtools-kit/references/rpc-patterns.md b/skills/vite-devtools-kit/references/rpc-patterns.md index 9f02ab9d..e9b2c8d6 100644 --- a/skills/vite-devtools-kit/references/rpc-patterns.md +++ b/skills/vite-devtools-kit/references/rpc-patterns.md @@ -10,6 +10,30 @@ Advanced patterns for server-client communication in DevTools integrations. | `action` | Never cached | Mutations, side effects | | `static` | Cached indefinitely | Constants, configuration | +## JSON-Serializable Declaration + +Declare the wire/dump shape contract with `jsonSerializable`: + +| Value | Encoder | Round-trips | +|-------|---------|-------------| +| `false` (default) | `structured-clone-es` | `Map`, `Set`, `Date`, `BigInt`, cycles, class instances | +| `true` (opt-in) | strict `JSON.stringify` | JSON-only | + +```ts +defineRpcFunction({ + name: 'my-plugin:list-modules', + type: 'query', + jsonSerializable: true, + setup: () => ({ + handler: async (): Promise => Array.from(moduleMap.values()), + }), +}) +``` + +**`jsonSerializable: true` throws `DF0019` synchronously** when the handler returns a value JSON cannot round-trip (e.g. a `Map`). The error fires in dev right at the call site, not silently at build time. Use it whenever your data is genuinely JSON-shaped — it unlocks plain-JSON wire format and is the default expectation for MCP-exposed tools. + +**`agent` requires `jsonSerializable: true`.** Registration throws `DF0018` if you set `agent: { description: ... }` without also declaring the function JSON-safe. + ## Type-Safe RPC Setup ### Step 1: Define Types From 3adae77179b7e072263df83a75b62d08ecdc112d Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 1 May 2026 17:06:56 +0900 Subject: [PATCH 2/7] test(devframe): cover structured-clone dumps end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `structured-clone dumps` group to `collectStaticRpcDump` tests: - Verifies Map/Set values survive in `files[].data` for default (structured-clone) functions. - Round-trips a Map through scStringify → JSON.parse → scDeserialize to confirm the build-write / client-read pipeline is lossless. - Covers query-mode records and fallback shards both encode as structured-clone when default. - Confirms `jsonSerializable: true` produces plain JSON shards that parse losslessly. - Asserts DF0019 fires at build time when a JSON-flagged handler returns non-JSON data. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/node/__tests__/static-dump.test.ts | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/devframe/packages/devframe/src/node/__tests__/static-dump.test.ts b/devframe/packages/devframe/src/node/__tests__/static-dump.test.ts index ad113565..ca3da505 100644 --- a/devframe/packages/devframe/src/node/__tests__/static-dump.test.ts +++ b/devframe/packages/devframe/src/node/__tests__/static-dump.test.ts @@ -1,5 +1,6 @@ import { defineRpcFunction } from 'devframe' import { DEVTOOLS_RPC_DUMP_DIRNAME } from 'devframe/constants' +import { scDeserialize, scStringify, strictJsonStringify } from 'devframe/rpc' import { describe, expect, it } from 'vitest' import { collectStaticRpcDump } from '../static-dump' @@ -98,4 +99,120 @@ describe('collectStaticRpcDump', () => { expect(result.manifest).toEqual({}) expect(result.files).toEqual({}) }) + + describe('structured-clone dumps', () => { + it('keeps Map/Set values intact in the in-memory file payload', async () => { + const getGraph = defineRpcFunction({ + name: 'test:graph', + type: 'static', + // jsonSerializable: false (default) — fancy types must survive + handler: () => ({ + nodes: new Map([['a', 1], ['b', 2]]), + tags: new Set(['x', 'y']), + }), + }) + + const result = await collectStaticRpcDump([getGraph], {}) + const path = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~graph.static.json` + const file = result.files[path]! + + expect(file.serialization).toBe('structured-clone') + const output = (file.data as { output: { nodes: Map, tags: Set } }).output + expect(output.nodes).toBeInstanceOf(Map) + expect(output.nodes.get('a')).toBe(1) + expect(output.tags).toBeInstanceOf(Set) + expect(output.tags.has('x')).toBe(true) + }) + + it('survives a full write→read round-trip (Map preserved end-to-end)', async () => { + // Mirrors what `createBuild` does: collect, sc-stringify the file + // payload, write JSON text to disk. The static client later reads + // the JSON and revives via `scDeserialize`. + const getMap = defineRpcFunction({ + name: 'test:roundtrip-map', + type: 'static', + handler: () => new Map([['k', 42]]), + }) + + const result = await collectStaticRpcDump([getMap], {}) + const path = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~roundtrip-map.static.json` + const file = result.files[path]! + + // Server side: write to disk as sc-encoded text. + const wireText = scStringify(file.data) + // Client side: fetch().json() (i.e. JSON.parse) + scDeserialize revive. + const revived = scDeserialize(JSON.parse(wireText)) as { output: Map } + expect(revived.output).toBeInstanceOf(Map) + expect(revived.output.get('k')).toBe(42) + }) + + it('encodes query records and fallback as structured-clone when default', async () => { + const getEntries = defineRpcFunction({ + name: 'test:entries', + type: 'query', + // default jsonSerializable: false → sc shards. + handler: (key: string) => new Map([[key, key.length]]), + dump: { + inputs: [['hello']], + fallback: new Map([['_', 0]]), + }, + }) + + const result = await collectStaticRpcDump([getEntries], {}) + const fallbackPath = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~entries.fallback.json` + const fallback = result.files[fallbackPath]! + expect(fallback.serialization).toBe('structured-clone') + + // Round-trip the fallback shard. + const revived = scDeserialize(JSON.parse(scStringify(fallback.data))) as { output: Map } + expect(revived.output).toBeInstanceOf(Map) + expect(revived.output.get('_')).toBe(0) + + // And one of the input records. + const recordPath = Object.values( + (result.manifest['test:entries'] as { records: Record }).records, + )[0]! + const record = result.files[recordPath]! + expect(record.serialization).toBe('structured-clone') + const revivedRecord = scDeserialize(JSON.parse(scStringify(record.data))) as { output: Map } + expect(revivedRecord.output.get('hello')).toBe(5) + }) + + it('writes plain JSON when jsonSerializable: true is declared', async () => { + const getList = defineRpcFunction({ + name: 'test:json-list', + type: 'static', + jsonSerializable: true, + handler: () => ['a', 'b', 'c'], + }) + + const result = await collectStaticRpcDump([getList], {}) + const path = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~json-list.static.json` + const file = result.files[path]! + + expect(file.serialization).toBe('json') + // Strict JSON serializer round-trips losslessly via JSON.parse. + const wireText = strictJsonStringify(file.data, file.fnName) + expect(JSON.parse(wireText)).toEqual({ output: ['a', 'b', 'c'] }) + }) + + it('throws DF0019 at build time when a JSON-flagged fn returns non-JSON', async () => { + const getMapJson = defineRpcFunction({ + name: 'test:bad-json', + type: 'static', + jsonSerializable: true, + // Lying about the contract: handler returns a Map. + handler: () => new Map([['k', 1]]) as any, + }) + + const result = await collectStaticRpcDump([getMapJson], {}) + const path = `${DEVTOOLS_RPC_DUMP_DIRNAME}/test~bad-json.static.json` + const file = result.files[path]! + + // collectStaticRpcDump records the value as-is; the strict + // serializer throws when build.ts tries to write it. + expect(() => strictJsonStringify(file.data, file.fnName)) + .toThrowError(/jsonSerializable: true.*is a Map/) + }) + }) }) From 5b17ee9fcd624deabe9e3a73ccab784e832bb8e3 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 1 May 2026 17:24:21 +0900 Subject: [PATCH 3/7] refactor(devframe): rename sc* exports + migrate DTK0001-0008 to DF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the structured-clone-es re-exports for clarity: - scStringify → structuredCloneStringify - scParse → structuredCloneParse - scDeserialize → structuredCloneDeserialize Migrates the leftover DTK0001-DTK0008 codes in `rpc/diagnostics.ts` to DF0020-DF0027. The DTK prefix was misleading — these emit from the devframe package, not @vitejs/devtools, and they shadowed the docs URL space of the genuinely-DTK codes in `packages/core`. Moving them to DF lets `rpc/diagnostics.ts` use a single `defineDiagnostics` block with the correct devframe docsBase, dropping the previous two-block split. Adds DF0020-DF0027 docs pages with their DTK migration mapping; updates `errors/index.md` and the sidebar count. Co-Authored-By: Claude Opus 4.7 (1M context) --- devframe/docs/.vitepress/sidebar.ts | 2 +- devframe/docs/errors/DF0020.md | 29 +++++++++ devframe/docs/errors/DF0021.md | 25 ++++++++ devframe/docs/errors/DF0022.md | 25 ++++++++ devframe/docs/errors/DF0023.md | 25 ++++++++ devframe/docs/errors/DF0024.md | 25 ++++++++ devframe/docs/errors/DF0025.md | 35 +++++++++++ devframe/docs/errors/DF0026.md | 25 ++++++++ devframe/docs/errors/DF0027.md | 25 ++++++++ devframe/docs/errors/index.md | 8 +++ .../packages/devframe/src/adapters/build.ts | 4 +- .../devframe/src/client/static-rpc.test.ts | 6 +- .../devframe/src/client/static-rpc.ts | 4 +- .../src/node/__tests__/static-dump.test.ts | 14 ++--- .../packages/devframe/src/rpc/collector.ts | 6 +- .../packages/devframe/src/rpc/diagnostics.ts | 59 +++++++++++-------- devframe/packages/devframe/src/rpc/dumps.ts | 6 +- devframe/packages/devframe/src/rpc/handler.ts | 2 +- .../devframe/src/rpc/serialization.ts | 12 ++-- .../packages/devframe/src/rpc/validation.ts | 4 +- .../tsnapi/devframe/rpc.snapshot.d.ts | 6 +- .../tsnapi/devframe/rpc.snapshot.js | 6 +- 22 files changed, 291 insertions(+), 62 deletions(-) create mode 100644 devframe/docs/errors/DF0020.md create mode 100644 devframe/docs/errors/DF0021.md create mode 100644 devframe/docs/errors/DF0022.md create mode 100644 devframe/docs/errors/DF0023.md create mode 100644 devframe/docs/errors/DF0024.md create mode 100644 devframe/docs/errors/DF0025.md create mode 100644 devframe/docs/errors/DF0026.md create mode 100644 devframe/docs/errors/DF0027.md diff --git a/devframe/docs/.vitepress/sidebar.ts b/devframe/docs/.vitepress/sidebar.ts index df1e4dd7..5c273f62 100644 --- a/devframe/docs/.vitepress/sidebar.ts +++ b/devframe/docs/.vitepress/sidebar.ts @@ -25,7 +25,7 @@ export default function devframeSidebar(prefix = ''): DefaultTheme.SidebarItem[] text: 'Error Reference', link: `${prefix}/errors/`, collapsed: true, - items: Array.from({ length: 19 }, (_, i) => { + items: Array.from({ length: 27 }, (_, i) => { const code = `DF${String(i + 1).padStart(4, '0')}` return { text: code, link: `${prefix}/errors/${code}` } }), diff --git a/devframe/docs/errors/DF0020.md b/devframe/docs/errors/DF0020.md new file mode 100644 index 00000000..9550c516 --- /dev/null +++ b/devframe/docs/errors/DF0020.md @@ -0,0 +1,29 @@ +--- +outline: deep +--- + +# DF0020: RPC Function Already Registered + +> Package: `devframe` + +> Migrated from `DTK0001`. + +## Message + +> RPC function "`{name}`" is already registered + +## Cause + +`ctx.rpc.register()` was called twice with the same `name`. RPC names must be unique within a devtool. + +## Fix + +Either give the second registration a distinct name, or pass `force: true` to overwrite the previous one (e.g. during HMR-driven re-registration). + +```ts +ctx.rpc.register(defineRpcFunction({ name: 'my-plugin:fn', handler: () => 1 }), true /* force */) +``` + +## Source + +`packages/devframe/src/rpc/collector.ts` diff --git a/devframe/docs/errors/DF0021.md b/devframe/docs/errors/DF0021.md new file mode 100644 index 00000000..0befaea4 --- /dev/null +++ b/devframe/docs/errors/DF0021.md @@ -0,0 +1,25 @@ +--- +outline: deep +--- + +# DF0021: RPC Function Not Registered (Update) + +> Package: `devframe` + +> Migrated from `DTK0002`. + +## Message + +> RPC function "`{name}`" is not registered. Use register() to add new functions. + +## Cause + +`ctx.rpc.update()` was called for a function that was never registered. `update()` is for replacing an existing definition. + +## Fix + +Call `ctx.rpc.register()` first, or pass `force: true` to `update()` to register-or-replace in one call. + +## Source + +`packages/devframe/src/rpc/collector.ts` diff --git a/devframe/docs/errors/DF0022.md b/devframe/docs/errors/DF0022.md new file mode 100644 index 00000000..a3e5deb6 --- /dev/null +++ b/devframe/docs/errors/DF0022.md @@ -0,0 +1,25 @@ +--- +outline: deep +--- + +# DF0022: RPC Function Not Registered (Get) + +> Package: `devframe` + +> Migrated from `DTK0003`. + +## Message + +> RPC function "`{name}`" is not registered + +## Cause + +A consumer asked for the schema or handler of a function that has never been registered with `ctx.rpc.register()`. + +## Fix + +Confirm the function name matches a registration. RPC names are namespaced — typos in the prefix are a common cause. + +## Source + +`packages/devframe/src/rpc/collector.ts` diff --git a/devframe/docs/errors/DF0023.md b/devframe/docs/errors/DF0023.md new file mode 100644 index 00000000..bc0e089d --- /dev/null +++ b/devframe/docs/errors/DF0023.md @@ -0,0 +1,25 @@ +--- +outline: deep +--- + +# DF0023: Missing RPC Handler + +> Package: `devframe` + +> Migrated from `DTK0004`. + +## Message + +> Either handler or setup function must be provided for RPC function "`{name}`" + +## Cause + +The RPC definition has neither a `handler` nor a `setup` returning `{ handler }`. devframe has nothing to invoke when the function is called. + +## Fix + +Add either `handler: ...` directly on the definition, or `setup: ctx => ({ handler: ... })` if the handler depends on context. + +## Source + +`packages/devframe/src/rpc/handler.ts` diff --git a/devframe/docs/errors/DF0024.md b/devframe/docs/errors/DF0024.md new file mode 100644 index 00000000..e4ceff50 --- /dev/null +++ b/devframe/docs/errors/DF0024.md @@ -0,0 +1,25 @@ +--- +outline: deep +--- + +# DF0024: Function Not in Dump Store + +> Package: `devframe` + +> Migrated from `DTK0005`. + +## Message + +> Function "`{name}`" not found in dump store + +## Cause + +A static-mode client called an RPC function that was not baked into the build dump. This usually means the function was added after the dump was generated, or its name changed between build and runtime. + +## Fix + +Re-run `createBuild` to regenerate the dump, or check that the call site uses the same name registered on the server. + +## Source + +`packages/devframe/src/rpc/dumps.ts` diff --git a/devframe/docs/errors/DF0025.md b/devframe/docs/errors/DF0025.md new file mode 100644 index 00000000..c513f879 --- /dev/null +++ b/devframe/docs/errors/DF0025.md @@ -0,0 +1,35 @@ +--- +outline: deep +--- + +# DF0025: No Dump Match + +> Package: `devframe` + +> Migrated from `DTK0006`. + +## Message + +> No dump match for "`{name}`" with args: `{args}` + +## Cause + +A static-mode client called an RPC function with arguments that don't match any pre-computed record, and no `fallback` was set on the dump. + +## Fix + +Either widen the function's `dump.inputs` to cover the requested arguments, or provide `dump.fallback` so unmatched calls resolve to a default value instead of throwing. + +```ts +defineRpcFunction({ + name: 'my-plugin:get', + dump: { + inputs: [['known-id']], + fallback: null, + }, +}) +``` + +## Source + +`packages/devframe/src/rpc/dumps.ts` diff --git a/devframe/docs/errors/DF0026.md b/devframe/docs/errors/DF0026.md new file mode 100644 index 00000000..9326aee7 --- /dev/null +++ b/devframe/docs/errors/DF0026.md @@ -0,0 +1,25 @@ +--- +outline: deep +--- + +# DF0026: Invalid Dump Configuration + +> Package: `devframe` + +> Migrated from `DTK0007`. + +## Message + +> Function "`{name}`" with type "`{type}`" cannot have dump configuration. Only "static" and "query" types support dumps. + +## Cause + +A `dump` field was attached to an `'action'` or `'event'` function. These types perform side effects rather than returning queryable data — there is nothing meaningful to pre-compute. + +## Fix + +Drop the `dump` field, or change the function `type` to `'static'` / `'query'` if pre-computation is appropriate. + +## Source + +`packages/devframe/src/rpc/validation.ts` diff --git a/devframe/docs/errors/DF0027.md b/devframe/docs/errors/DF0027.md new file mode 100644 index 00000000..3ce2fc4f --- /dev/null +++ b/devframe/docs/errors/DF0027.md @@ -0,0 +1,25 @@ +--- +outline: deep +--- + +# DF0027: Snapshot Type Mismatch + +> Package: `devframe` + +> Migrated from `DTK0008`. + +## Message + +> Function "`{name}`" with type "`{type}`" cannot use `snapshot: true`. Only "query" functions support this sugar; "static" functions have equivalent default behavior already. + +## Cause + +`snapshot: true` is sugar for "query in dev, single baked snapshot in build". It is only meaningful on `'query'` functions — `'static'` already has equivalent default behavior, and `'action'` / `'event'` have nothing to snapshot. + +## Fix + +Remove `snapshot: true`, or change the function `type` to `'query'`. + +## Source + +`packages/devframe/src/rpc/validation.ts` diff --git a/devframe/docs/errors/index.md b/devframe/docs/errors/index.md index 7c956e0f..c4742b7a 100644 --- a/devframe/docs/errors/index.md +++ b/devframe/docs/errors/index.md @@ -37,3 +37,11 @@ Emitted by `devframe` — framework-neutral host / shared-state / auth surface. | [DF0017](./DF0017) | error | MCP Server Start Failure | — | | [DF0018](./DF0018) | error | Agent Requires JSON-Serializable RPC | — | | [DF0019](./DF0019) | error | Non-JSON Value in JSON-Serializable RPC | — | +| [DF0020](./DF0020) | error | RPC Function Already Registered | DTK0001 | +| [DF0021](./DF0021) | error | RPC Function Not Registered (Update) | DTK0002 | +| [DF0022](./DF0022) | error | RPC Function Not Registered (Get) | DTK0003 | +| [DF0023](./DF0023) | error | Missing RPC Handler | DTK0004 | +| [DF0024](./DF0024) | error | Function Not in Dump Store | DTK0005 | +| [DF0025](./DF0025) | error | No Dump Match | DTK0006 | +| [DF0026](./DF0026) | error | Invalid Dump Configuration | DTK0007 | +| [DF0027](./DF0027) | error | Snapshot Type Mismatch | DTK0008 | diff --git a/devframe/packages/devframe/src/adapters/build.ts b/devframe/packages/devframe/src/adapters/build.ts index 9f40348f..b61689da 100644 --- a/devframe/packages/devframe/src/adapters/build.ts +++ b/devframe/packages/devframe/src/adapters/build.ts @@ -13,7 +13,7 @@ import { import { createHostContext } from '../node/context' import { createH3DevToolsHost } from '../node/host-h3' import { collectStaticRpcDump } from '../node/static-dump' -import { scStringify, strictJsonStringify } from '../rpc/serialization' +import { strictJsonStringify, structuredCloneStringify } from '../rpc/serialization' import { resolveBasePath } from './_shared' export interface CreateBuildOptions { @@ -89,7 +89,7 @@ export async function createBuild(d: DevtoolDefinition, options: CreateBuildOpti const fullpath = resolve(outDir, filepath) await fs.mkdir(dirname(fullpath), { recursive: true }) const text = file.serialization === 'structured-clone' - ? scStringify(file.data) + ? structuredCloneStringify(file.data) : strictJsonStringify(file.data, file.fnName) await fs.writeFile( fullpath, diff --git a/devframe/packages/devframe/src/client/static-rpc.test.ts b/devframe/packages/devframe/src/client/static-rpc.test.ts index 142af909..57872a5a 100644 --- a/devframe/packages/devframe/src/client/static-rpc.test.ts +++ b/devframe/packages/devframe/src/client/static-rpc.test.ts @@ -1,5 +1,5 @@ import { DEVTOOLS_RPC_DUMP_DIRNAME } from 'devframe/constants' -import { scStringify } from 'devframe/rpc' +import { structuredCloneStringify } from 'devframe/rpc' import { hash } from 'ohash' import { describe, expect, it } from 'vitest' import { createStaticRpcCaller } from './static-rpc' @@ -125,7 +125,7 @@ describe('createStaticRpcCaller', () => { // What a server would have written: SC-stringified, then read // back via fetch.json() (i.e. JSON.parse of the SC text). const payload = { output: new Map([['a', 1], ['b', 2]]) } - return JSON.parse(scStringify(payload)) + return JSON.parse(structuredCloneStringify(payload)) }, ) @@ -147,7 +147,7 @@ describe('createStaticRpcCaller', () => { }, async () => { const payload = { inputs: ['k'], output: new Set(['x', 'y']) } - return JSON.parse(scStringify(payload)) + return JSON.parse(structuredCloneStringify(payload)) }, ) diff --git a/devframe/packages/devframe/src/client/static-rpc.ts b/devframe/packages/devframe/src/client/static-rpc.ts index 21b2c20e..3f39b2f7 100644 --- a/devframe/packages/devframe/src/client/static-rpc.ts +++ b/devframe/packages/devframe/src/client/static-rpc.ts @@ -1,5 +1,5 @@ import { hash } from 'ohash' -import { scDeserialize } from '../rpc/serialization' +import { structuredCloneDeserialize } from '../rpc/serialization' export type StaticRpcSerialization = 'json' | 'structured-clone' @@ -73,7 +73,7 @@ export function createStaticRpcCaller( function reviveIfStructuredClone(value: unknown, serialization: StaticRpcSerialization | undefined): any { if (serialization === 'structured-clone') - return scDeserialize(value as any) + return structuredCloneDeserialize(value as any) return value } diff --git a/devframe/packages/devframe/src/node/__tests__/static-dump.test.ts b/devframe/packages/devframe/src/node/__tests__/static-dump.test.ts index ca3da505..cfa32c22 100644 --- a/devframe/packages/devframe/src/node/__tests__/static-dump.test.ts +++ b/devframe/packages/devframe/src/node/__tests__/static-dump.test.ts @@ -1,6 +1,6 @@ import { defineRpcFunction } from 'devframe' import { DEVTOOLS_RPC_DUMP_DIRNAME } from 'devframe/constants' -import { scDeserialize, scStringify, strictJsonStringify } from 'devframe/rpc' +import { strictJsonStringify, structuredCloneDeserialize, structuredCloneStringify } from 'devframe/rpc' import { describe, expect, it } from 'vitest' import { collectStaticRpcDump } from '../static-dump' @@ -127,7 +127,7 @@ describe('collectStaticRpcDump', () => { it('survives a full write→read round-trip (Map preserved end-to-end)', async () => { // Mirrors what `createBuild` does: collect, sc-stringify the file // payload, write JSON text to disk. The static client later reads - // the JSON and revives via `scDeserialize`. + // the JSON and revives via `structuredCloneDeserialize`. const getMap = defineRpcFunction({ name: 'test:roundtrip-map', type: 'static', @@ -139,9 +139,9 @@ describe('collectStaticRpcDump', () => { const file = result.files[path]! // Server side: write to disk as sc-encoded text. - const wireText = scStringify(file.data) - // Client side: fetch().json() (i.e. JSON.parse) + scDeserialize revive. - const revived = scDeserialize(JSON.parse(wireText)) as { output: Map } + const wireText = structuredCloneStringify(file.data) + // Client side: fetch().json() (i.e. JSON.parse) + structuredCloneDeserialize revive. + const revived = structuredCloneDeserialize(JSON.parse(wireText)) as { output: Map } expect(revived.output).toBeInstanceOf(Map) expect(revived.output.get('k')).toBe(42) }) @@ -164,7 +164,7 @@ describe('collectStaticRpcDump', () => { expect(fallback.serialization).toBe('structured-clone') // Round-trip the fallback shard. - const revived = scDeserialize(JSON.parse(scStringify(fallback.data))) as { output: Map } + const revived = structuredCloneDeserialize(JSON.parse(structuredCloneStringify(fallback.data))) as { output: Map } expect(revived.output).toBeInstanceOf(Map) expect(revived.output.get('_')).toBe(0) @@ -174,7 +174,7 @@ describe('collectStaticRpcDump', () => { )[0]! const record = result.files[recordPath]! expect(record.serialization).toBe('structured-clone') - const revivedRecord = scDeserialize(JSON.parse(scStringify(record.data))) as { output: Map } + const revivedRecord = structuredCloneDeserialize(JSON.parse(structuredCloneStringify(record.data))) as { output: Map } expect(revivedRecord.output.get('hello')).toBe(5) }) diff --git a/devframe/packages/devframe/src/rpc/collector.ts b/devframe/packages/devframe/src/rpc/collector.ts index 865ff59b..3bc108a2 100644 --- a/devframe/packages/devframe/src/rpc/collector.ts +++ b/devframe/packages/devframe/src/rpc/collector.ts @@ -41,7 +41,7 @@ export class RpcFunctionsCollectorBase< register(fn: RpcFunctionDefinition, force = false): void { if (this.definitions.has(fn.name) && !force) { - throw logger.DTK0001({ name: fn.name }).throw() + throw logger.DF0020({ name: fn.name }).throw() } assertAgentJsonSerializable(fn) this.definitions.set(fn.name, fn) @@ -50,7 +50,7 @@ export class RpcFunctionsCollectorBase< update(fn: RpcFunctionDefinition, force = false): void { if (!this.definitions.has(fn.name) && !force) { - throw logger.DTK0002({ name: fn.name }).throw() + throw logger.DF0021({ name: fn.name }).throw() } assertAgentJsonSerializable(fn) this.definitions.set(fn.name, fn) @@ -74,7 +74,7 @@ export class RpcFunctionsCollectorBase< getSchema(name: T): { args: RpcArgsSchema | undefined, returns: RpcReturnSchema | undefined } { const definition = this.definitions.get(name as string) if (!definition) - throw logger.DTK0003({ name: String(name) }).throw() + throw logger.DF0022({ name: String(name) }).throw() return { args: definition.args, returns: definition.returns, diff --git a/devframe/packages/devframe/src/rpc/diagnostics.ts b/devframe/packages/devframe/src/rpc/diagnostics.ts index 6a5d7419..fa3c1b50 100644 --- a/devframe/packages/devframe/src/rpc/diagnostics.ts +++ b/devframe/packages/devframe/src/rpc/diagnostics.ts @@ -1,55 +1,62 @@ import { consoleReporter, createLogger, defineDiagnostics, plainFormatter } from 'logs-sdk' +// `DF` codes own the framework-neutral devframe surface (RPC, dump, +// agent contract). The prior generation of these codes used the `DTK` +// prefix and lived under the @vitejs/devtools docs site; they have +// been migrated to `DF` here so all devframe-package codes share one +// numbering space and one docsBase. export const diagnostics = defineDiagnostics({ - docsBase: 'https://devtools.vite.dev/errors', + docsBase: 'https://devtools.vite.dev/devframe/errors', codes: { - DTK0001: { + DF0018: { + message: (p: { name: string }) => + `RPC function "${p.name}" has \`agent\` set but \`jsonSerializable\` is not \`true\` — MCP requires JSON-serializable data.`, + hint: 'Set `jsonSerializable: true` if the payload is JSON-safe, or remove `agent` to keep it RPC-only.', + }, + DF0019: { + message: (p: { name: string, type: string, path: string }) => + `RPC function "${p.name}" declares \`jsonSerializable: true\` but the value at "${p.path}" is a ${p.type}.`, + hint: 'Either drop `jsonSerializable: true` (falls back to structured-clone) or change the value to a JSON-safe shape.', + }, + // Migrated from DTK0001 + DF0020: { message: (p: { name: string }) => `RPC function "${p.name}" is already registered`, hint: 'Use the `force` parameter to overwrite an existing registration.', }, - DTK0002: { + // Migrated from DTK0002 + DF0021: { message: (p: { name: string }) => `RPC function "${p.name}" is not registered. Use register() to add new functions.`, }, - DTK0003: { + // Migrated from DTK0003 + DF0022: { message: (p: { name: string }) => `RPC function "${p.name}" is not registered`, }, - DTK0004: { + // Migrated from DTK0004 + DF0023: { message: (p: { name: string }) => `Either handler or setup function must be provided for RPC function "${p.name}"`, }, - DTK0005: { + // Migrated from DTK0005 + DF0024: { message: (p: { name: string }) => `Function "${p.name}" not found in dump store`, }, - DTK0006: { + // Migrated from DTK0006 + DF0025: { message: (p: { name: string, args: string }) => `No dump match for "${p.name}" with args: ${p.args}`, }, - DTK0007: { + // Migrated from DTK0007 + DF0026: { message: (p: { name: string, type: string }) => `Function "${p.name}" with type "${p.type}" cannot have dump configuration. Only "static" and "query" types support dumps.`, }, - DTK0008: { + // Migrated from DTK0008 + DF0027: { message: (p: { name: string, type: string }) => `Function "${p.name}" with type "${p.type}" cannot use \`snapshot: true\`. Only "query" functions support this sugar; "static" functions have equivalent default behavior already.`, hint: 'Remove `snapshot: true`, or change the function type to `query`.', }, }, }) -export const dfDiagnostics = defineDiagnostics({ - docsBase: 'https://devtools.vite.dev/devframe/errors', - codes: { - DF0018: { - message: (p: { name: string }) => - `RPC function "${p.name}" has \`agent\` set but \`jsonSerializable\` is not \`true\` — MCP requires JSON-serializable data.`, - hint: 'Set `jsonSerializable: true` if the payload is JSON-safe, or remove `agent` to keep it RPC-only.', - }, - DF0019: { - message: (p: { name: string, type: string, path: string }) => - `RPC function "${p.name}" declares \`jsonSerializable: true\` but the value at "${p.path}" is a ${p.type}.`, - hint: 'Either drop `jsonSerializable: true` (falls back to structured-clone) or change the value to a JSON-safe shape.', - }, - }, -}) - export const logger = createLogger({ - diagnostics: [diagnostics, dfDiagnostics], + diagnostics: [diagnostics], formatter: plainFormatter, reporters: consoleReporter, }) diff --git a/devframe/packages/devframe/src/rpc/dumps.ts b/devframe/packages/devframe/src/rpc/dumps.ts index d1a3c19a..6f41fc77 100644 --- a/devframe/packages/devframe/src/rpc/dumps.ts +++ b/devframe/packages/devframe/src/rpc/dumps.ts @@ -74,7 +74,7 @@ export async function dumpFunctions< const handler = setupResult.handler || definition.handler if (!handler) { - throw logger.DTK0004({ name: definition.name }).throw() + throw logger.DF0023({ name: definition.name }).throw() } let dump = setupResult.dump ?? definition.dump @@ -213,7 +213,7 @@ export function createClientFromDump>( const client = new Proxy({} as T, { get(_, functionName: string) { if (!(functionName in store.definitions)) { - throw logger.DTK0005({ name: functionName }).throw() + throw logger.DF0024({ name: functionName }).throw() } return async (...args: any[]) => { @@ -252,7 +252,7 @@ export function createClientFromDump>( return fallbackRecord.output } - throw logger.DTK0006({ name: functionName, args: JSON.stringify(args) }).throw() + throw logger.DF0025({ name: functionName, args: JSON.stringify(args) }).throw() } }, has(_, functionName: string) { diff --git a/devframe/packages/devframe/src/rpc/handler.ts b/devframe/packages/devframe/src/rpc/handler.ts index ac4728f0..c1c81d9e 100644 --- a/devframe/packages/devframe/src/rpc/handler.ts +++ b/devframe/packages/devframe/src/rpc/handler.ts @@ -42,7 +42,7 @@ export async function getRpcHandler< } const result = await getRpcResolvedSetupResult(definition, context) if (!result.handler) { - throw logger.DTK0004({ name: definition.name }).throw() + throw logger.DF0023({ name: definition.name }).throw() } return result.handler } diff --git a/devframe/packages/devframe/src/rpc/serialization.ts b/devframe/packages/devframe/src/rpc/serialization.ts index 08e219bb..815c0822 100644 --- a/devframe/packages/devframe/src/rpc/serialization.ts +++ b/devframe/packages/devframe/src/rpc/serialization.ts @@ -1,12 +1,12 @@ import type { ChannelOptions } from 'birpc' import { - deserialize as scDeserialize, - parse as scParse, - stringify as scStringify, + deserialize as structuredCloneDeserialize, + parse as structuredCloneParse, + stringify as structuredCloneStringify, } from 'structured-clone-es' import { logger } from './diagnostics' -export { scDeserialize, scParse, scStringify } +export { structuredCloneDeserialize, structuredCloneParse, structuredCloneStringify } /** * Wire format used by the WS RPC transport. @@ -82,11 +82,11 @@ export function makePerCallChannelOptions( const useJson = isJsonMethod(defs, method) if (useJson) return strictJsonStringify(msg, method ?? '') - return `${STRUCTURED_CLONE_PREFIX}${scStringify(msg)}` + return `${STRUCTURED_CLONE_PREFIX}${structuredCloneStringify(msg)}` }, deserialize(raw: string): BirpcMessage { const msg: BirpcMessage = raw.startsWith(STRUCTURED_CLONE_PREFIX) - ? (scParse(raw.slice(STRUCTURED_CLONE_PREFIX.length)) as BirpcMessage) + ? (structuredCloneParse(raw.slice(STRUCTURED_CLONE_PREFIX.length)) as BirpcMessage) : (JSON.parse(raw) as BirpcMessage) if (msg.t === 'q' && msg.i && msg.m) pendingRequestMethods.set(msg.i, msg.m) diff --git a/devframe/packages/devframe/src/rpc/validation.ts b/devframe/packages/devframe/src/rpc/validation.ts index b4436f09..f93683e6 100644 --- a/devframe/packages/devframe/src/rpc/validation.ts +++ b/devframe/packages/devframe/src/rpc/validation.ts @@ -12,11 +12,11 @@ export function validateDefinitions(definitions: readonly RpcFunctionDefinitionA const type = definition.type || 'query' if ((type === 'action' || type === 'event') && definition.dump) { - throw logger.DTK0007({ name: definition.name, type }).throw() + throw logger.DF0026({ name: definition.name, type }).throw() } if (definition.snapshot && type !== 'query') { - throw logger.DTK0008({ name: definition.name, type }).throw() + throw logger.DF0027({ name: definition.name, type }).throw() } } } diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts b/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts index e62537f9..c1a2968b 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts +++ b/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts @@ -36,11 +36,11 @@ export { RpcFunctionsCollectorBase } export { RpcFunctionSetupResult } export { RpcFunctionType } export { RpcReturnSchema } -export { scDeserialize } -export { scParse } -export { scStringify } export { strictJsonStringify } export { STRUCTURED_CLONE_PREFIX } +export { structuredCloneDeserialize } +export { structuredCloneParse } +export { structuredCloneStringify } export { Thenable } export { validateDefinition } export { validateDefinitions } diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js b/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js index 9f7d22e0..afd95f9a 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js +++ b/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js @@ -12,11 +12,11 @@ export { getRpcResolvedSetupResult } export { makePerCallChannelOptions } export { RpcCacheManager } export { RpcFunctionsCollectorBase } -export { scDeserialize } -export { scParse } -export { scStringify } export { strictJsonStringify } export { STRUCTURED_CLONE_PREFIX } +export { structuredCloneDeserialize } +export { structuredCloneParse } +export { structuredCloneStringify } export { validateDefinition } export { validateDefinitions } // #endregion \ No newline at end of file From 31de7cfe2ee234c4f3e5e84a1fc986d6d26d1eb6 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 1 May 2026 17:46:43 +0900 Subject: [PATCH 4/7] refactor(devframe): inline per-call channel options into ws transports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes `makePerCallChannelOptions` from `rpc/serialization.ts` and inlines the wire dispatch logic directly into `ws-server.ts` and `ws-client.ts`. The helper had only two callers and the abstraction wasn't pulling its weight — a single function name shouldn't need a factory builder. `STRUCTURED_CLONE_PREFIX`, `strictJsonStringify`, and the `structuredClone*` re-exports remain — they're the actual reusable primitives. The unit tests targeting `makePerCallChannelOptions` are dropped; the dispatch behavior is now exercised end-to-end via the existing static-rpc and static-dump tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../devframe/src/rpc/serialization.test.ts | 69 +------------------ .../devframe/src/rpc/serialization.ts | 69 ------------------- .../devframe/src/rpc/transports/ws-client.ts | 36 ++++++++-- .../devframe/src/rpc/transports/ws-server.ts | 40 +++++++++-- .../tsnapi/devframe/rpc.snapshot.d.ts | 1 - .../tsnapi/devframe/rpc.snapshot.js | 1 - 6 files changed, 66 insertions(+), 150 deletions(-) diff --git a/devframe/packages/devframe/src/rpc/serialization.test.ts b/devframe/packages/devframe/src/rpc/serialization.test.ts index 591fd781..8715ba48 100644 --- a/devframe/packages/devframe/src/rpc/serialization.test.ts +++ b/devframe/packages/devframe/src/rpc/serialization.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { - makePerCallChannelOptions, - strictJsonStringify, - STRUCTURED_CLONE_PREFIX, -} from './serialization' +import { strictJsonStringify } from './serialization' describe('strictJsonStringify', () => { it('matches JSON.stringify for plain JSON values', () => { @@ -87,66 +83,3 @@ describe('strictJsonStringify', () => { expect(replacerCalls).toEqual(['', 'a', 'b', 'c', '0', '1']) }) }) - -describe('makePerCallChannelOptions', () => { - function makeChannel(jsonMethods: string[]) { - const defs = new Map( - jsonMethods.map(name => [name, { jsonSerializable: true as const }]), - ) - return makePerCallChannelOptions(defs) - } - - it('encodes JSON-flagged requests without a prefix', () => { - const ch = makeChannel(['fn']) - const wire = ch.serialize!({ t: 'q', i: '1', m: 'fn', a: [1, 2] }) - expect(wire).toBe('{"t":"q","i":"1","m":"fn","a":[1,2]}') - expect(wire.startsWith('s:')).toBe(false) - }) - - it('encodes structured-clone requests with the s: prefix', () => { - const ch = makeChannel([]) - const wire = ch.serialize!({ t: 'q', i: '1', m: 'fn', a: [new Map([['k', 1]])] }) - expect(typeof wire).toBe('string') - expect((wire as string).startsWith(STRUCTURED_CLONE_PREFIX)).toBe(true) - }) - - it('decodes per the wire prefix without consulting defs', () => { - const ch = makeChannel([]) // no defs at all on this channel - // JSON-encoded request — no prefix. - const json = ch.deserialize!('{"t":"q","i":"1","m":"fn","a":[1]}') - expect(json).toEqual({ t: 'q', i: '1', m: 'fn', a: [1] }) - - // SC-encoded message: produce a real SC wire string from a sender - // that doesn't know `fn` (so it falls through to SC), then route - // that string through this channel's deserialize. Map round-trips. - const sender = makeChannel([]) - const wire = sender.serialize!({ t: 'q', i: '2', m: 'fn', a: [new Map([['k', 1]])] }) as string - expect(wire.startsWith(STRUCTURED_CLONE_PREFIX)).toBe(true) - const decoded = ch.deserialize!(wire) as { t: 'q', a: [Map] } - expect(decoded.a[0]).toBeInstanceOf(Map) - expect(decoded.a[0].get('k')).toBe(1) - }) - - it('mirrors the originating method to dispatch the response encoding', () => { - const ch = makeChannel(['fn']) - // Receive a request → record method - ch.deserialize!('{"t":"q","i":"abc","m":"fn","a":[]}') - // Send the response → uses JSON because fn is jsonSerializable: true - const wire = ch.serialize!({ t: 's', i: 'abc', r: { ok: 1 } }) as string - expect(wire.startsWith(STRUCTURED_CLONE_PREFIX)).toBe(false) - expect(JSON.parse(wire)).toEqual({ t: 's', i: 'abc', r: { ok: 1 } }) - }) - - it('falls back to structured-clone for unknown methods', () => { - const ch = makeChannel(['known']) - const wire = ch.serialize!({ t: 'q', i: '1', m: 'unknown', a: [] }) as string - expect(wire.startsWith(STRUCTURED_CLONE_PREFIX)).toBe(true) - }) - - it('throws DF0019 when a JSON-flagged request carries non-JSON args', () => { - const ch = makeChannel(['fn']) - expect(() => - ch.serialize!({ t: 'q', i: '1', m: 'fn', a: [new Map()] }), - ).toThrowError(/jsonSerializable: true/) - }) -}) diff --git a/devframe/packages/devframe/src/rpc/serialization.ts b/devframe/packages/devframe/src/rpc/serialization.ts index 815c0822..b8b8c2cc 100644 --- a/devframe/packages/devframe/src/rpc/serialization.ts +++ b/devframe/packages/devframe/src/rpc/serialization.ts @@ -1,4 +1,3 @@ -import type { ChannelOptions } from 'birpc' import { deserialize as structuredCloneDeserialize, parse as structuredCloneParse, @@ -27,74 +26,6 @@ export { structuredCloneDeserialize, structuredCloneParse, structuredCloneString */ export const STRUCTURED_CLONE_PREFIX = 's:' -interface BirpcRequest { - t: 'q' - i?: string - m: string - a: unknown[] - o?: boolean -} - -interface BirpcResponse { - t: 's' - i: string - r?: unknown - e?: unknown -} - -type BirpcMessage = BirpcRequest | BirpcResponse - -function isJsonMethod( - defs: ReadonlyMap, - name: string | undefined, -): boolean { - return !!name && defs.get(name)?.jsonSerializable === true -} - -/** - * Build a per-call `serialize`/`deserialize` pair for birpc channels. - * - * The returned options switch encoder per-message based on the - * `jsonSerializable` flag of the dispatched function. Outgoing requests - * read the method from `msg.m`; outgoing responses look the method back - * up from a per-channel `pendingRequestMethods` map populated whenever - * a request is observed in `deserialize`. - * - * Pass an empty/partial `defs` map on peers that don't have the full - * registry — encoding falls back to structured-clone (the safer - * superset), and decoding still routes correctly via the wire prefix. - */ -export function makePerCallChannelOptions( - defs: ReadonlyMap, -): Pick { - const pendingRequestMethods = new Map() - - return { - serialize(msg: BirpcMessage): string { - let method: string | undefined - if (msg.t === 'q') { - method = msg.m - } - else { - method = pendingRequestMethods.get(msg.i) - pendingRequestMethods.delete(msg.i) - } - const useJson = isJsonMethod(defs, method) - if (useJson) - return strictJsonStringify(msg, method ?? '') - return `${STRUCTURED_CLONE_PREFIX}${structuredCloneStringify(msg)}` - }, - deserialize(raw: string): BirpcMessage { - const msg: BirpcMessage = raw.startsWith(STRUCTURED_CLONE_PREFIX) - ? (structuredCloneParse(raw.slice(STRUCTURED_CLONE_PREFIX.length)) as BirpcMessage) - : (JSON.parse(raw) as BirpcMessage) - if (msg.t === 'q' && msg.i && msg.m) - pendingRequestMethods.set(msg.i, msg.m) - return msg - }, - } -} - /** * `JSON.stringify` with a single-pass strict replacer. * diff --git a/devframe/packages/devframe/src/rpc/transports/ws-client.ts b/devframe/packages/devframe/src/rpc/transports/ws-client.ts index 3dcb660e..1c624b4c 100644 --- a/devframe/packages/devframe/src/rpc/transports/ws-client.ts +++ b/devframe/packages/devframe/src/rpc/transports/ws-client.ts @@ -1,6 +1,11 @@ import type { ChannelOptions } from 'birpc' import type { RpcFunctionDefinitionAny } from '../types' -import { makePerCallChannelOptions } from '../serialization' +import { + strictJsonStringify, + STRUCTURED_CLONE_PREFIX, + structuredCloneParse, + structuredCloneStringify, +} from '../serialization' export interface WsRpcChannelOptions { url: string @@ -52,7 +57,10 @@ export function createWsRpcChannel(options: WsRpcChannelOptions): ChannelOptions onDisconnected(e) }) - const perCall = makePerCallChannelOptions(definitions) + // Per-channel state: maps an incoming request id to its method name + // so the matching outgoing response can independently look the + // method up in `definitions` and pick the right encoder. + const pendingRequestMethods = new Map() return { on: (handler: (data: string) => void) => { ws.addEventListener('message', (e) => { @@ -71,7 +79,27 @@ export function createWsRpcChannel(options: WsRpcChannelOptions): ChannelOptions ws.addEventListener('open', handler) } }, - serialize: perCall.serialize, - deserialize: perCall.deserialize, + serialize: (msg: any): string => { + let method: string | undefined + if (msg.t === 'q') { + method = msg.m + } + else { + method = pendingRequestMethods.get(msg.i) + pendingRequestMethods.delete(msg.i) + } + const useJson = !!method && definitions.get(method)?.jsonSerializable === true + if (useJson) + return strictJsonStringify(msg, method ?? '') + return `${STRUCTURED_CLONE_PREFIX}${structuredCloneStringify(msg)}` + }, + deserialize: (raw: string): any => { + const msg: any = raw.startsWith(STRUCTURED_CLONE_PREFIX) + ? structuredCloneParse(raw.slice(STRUCTURED_CLONE_PREFIX.length)) + : JSON.parse(raw) + if (msg.t === 'q' && msg.i && msg.m) + pendingRequestMethods.set(msg.i, msg.m) + return msg + }, } } diff --git a/devframe/packages/devframe/src/rpc/transports/ws-server.ts b/devframe/packages/devframe/src/rpc/transports/ws-server.ts index 2240ea11..1b9a98e4 100644 --- a/devframe/packages/devframe/src/rpc/transports/ws-server.ts +++ b/devframe/packages/devframe/src/rpc/transports/ws-server.ts @@ -5,7 +5,12 @@ import type { WebSocket } from 'ws' import type { RpcFunctionDefinitionAny } from '../types' import { createServer as createHttpsServer } from 'node:https' import { WebSocketServer } from 'ws' -import { makePerCallChannelOptions } from '../serialization' +import { + strictJsonStringify, + STRUCTURED_CLONE_PREFIX, + structuredCloneParse, + structuredCloneStringify, +} from '../serialization' export interface DevToolsNodeRpcSessionMeta { id: number @@ -91,10 +96,11 @@ export function attachWsRpcTransport< subscribedStates: new Set(), } - // Per-connection serializer state (the pending request-id map that - // mirrors method metadata from request to response). Each WS gets - // its own so request-id spaces don't collide across sessions. - const perCall = makePerCallChannelOptions(definitions) + // Per-connection state: maps an incoming request id to its method + // name so the matching outgoing response can look the method back + // up in `definitions` and pick the right encoder. One map per WS + // session — request-id spaces don't collide across sessions. + const pendingRequestMethods = new Map() const channel: ChannelOptions = { post: (data) => { ws.send(data) @@ -104,8 +110,28 @@ export function attachWsRpcTransport< fn(data.toString()) }) }, - serialize: serializeOverride ?? perCall.serialize, - deserialize: deserializeOverride ?? perCall.deserialize, + serialize: serializeOverride ?? ((msg: any): string => { + let method: string | undefined + if (msg.t === 'q') { + method = msg.m + } + else { + method = pendingRequestMethods.get(msg.i) + pendingRequestMethods.delete(msg.i) + } + const useJson = !!method && definitions.get(method)?.jsonSerializable === true + if (useJson) + return strictJsonStringify(msg, method ?? '') + return `${STRUCTURED_CLONE_PREFIX}${structuredCloneStringify(msg)}` + }), + deserialize: deserializeOverride ?? ((raw: string): any => { + const msg: any = raw.startsWith(STRUCTURED_CLONE_PREFIX) + ? structuredCloneParse(raw.slice(STRUCTURED_CLONE_PREFIX.length)) + : JSON.parse(raw) + if (msg.t === 'q' && msg.i && msg.m) + pendingRequestMethods.set(msg.i, msg.m) + return msg + }), meta, } diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts b/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts index c1a2968b..00848c2f 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts +++ b/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts @@ -12,7 +12,6 @@ export { EntriesToObject } export { getDefinitionsWithDumps } export { getRpcHandler } export { getRpcResolvedSetupResult } -export { makePerCallChannelOptions } export { RpcArgsSchema } export { RpcCacheManager } export { RpcCacheOptions } diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js b/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js index afd95f9a..949e9122 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js +++ b/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js @@ -9,7 +9,6 @@ export { dumpFunctions } export { getDefinitionsWithDumps } export { getRpcHandler } export { getRpcResolvedSetupResult } -export { makePerCallChannelOptions } export { RpcCacheManager } export { RpcFunctionsCollectorBase } export { strictJsonStringify } From d810023fbc302dbc4be34c56b471af4a1f90d0f2 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 1 May 2026 18:20:23 +0900 Subject: [PATCH 5/7] test: refresh @vitejs/devtools-kit tsnapi snapshots after merge Turbo had cached a pre-merge kit build, so the previous `-u` ran against stale dist and lost the new exports introduced upstream (`DevToolsDiagnosticsHost`, `DevToolsMessage*`, etc.). CI built fresh and tripped on the mismatch. Forced rebuild + snapshot update brings the kit snapshots in line with the merged code. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tsnapi/@vitejs/devtools-kit/client.snapshot.d.ts | 3 ++- .../tsnapi/@vitejs/devtools-kit/index.snapshot.d.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/test/__snapshots__/tsnapi/@vitejs/devtools-kit/client.snapshot.d.ts b/test/__snapshots__/tsnapi/@vitejs/devtools-kit/client.snapshot.d.ts index f5b7e8e5..346b5475 100644 --- a/test/__snapshots__/tsnapi/@vitejs/devtools-kit/client.snapshot.d.ts +++ b/test/__snapshots__/tsnapi/@vitejs/devtools-kit/client.snapshot.d.ts @@ -4,7 +4,8 @@ // #region Interfaces export interface DockClientScriptContext extends DocksContext { current: DockEntryState; - logs: DevToolsLogsClient; + messages: DevToolsMessagesClient; + readonly logs: DevToolsMessagesClient; } // #endregion diff --git a/test/__snapshots__/tsnapi/@vitejs/devtools-kit/index.snapshot.d.ts b/test/__snapshots__/tsnapi/@vitejs/devtools-kit/index.snapshot.d.ts index 838546ee..84e057a3 100644 --- a/test/__snapshots__/tsnapi/@vitejs/devtools-kit/index.snapshot.d.ts +++ b/test/__snapshots__/tsnapi/@vitejs/devtools-kit/index.snapshot.d.ts @@ -29,6 +29,9 @@ export { DevToolsCommandKeybinding } export { DevToolsCommandShortcutOverrides } export { DevToolsCommandsHost } export { DevToolsCommandsHostEvents } +export { DevToolsDiagnosticsDefinition } +export { DevToolsDiagnosticsHost } +export { DevToolsDiagnosticsLogger } export { DevToolsDockEntriesGrouped } export { DevToolsDockEntry } export { DevToolsDockEntryBase } @@ -47,6 +50,15 @@ export { DevToolsLogHandle } export { DevToolsLogLevel } export { DevToolsLogsClient } export { DevToolsLogsHost } +export { DevToolsMessageElementPosition } +export { DevToolsMessageEntry } +export { DevToolsMessageEntryFrom } +export { DevToolsMessageEntryInput } +export { DevToolsMessageFilePosition } +export { DevToolsMessageHandle } +export { DevToolsMessageLevel } +export { DevToolsMessagesClient } +export { DevToolsMessagesHost } export { DevToolsNodeContext } export { DevToolsNodeRpcSession } export { DevToolsNodeUtils } From a59aea52dc9d19f9c0028acc1eea2c912a9dfcb9 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 1 May 2026 18:43:34 +0900 Subject: [PATCH 6/7] chore: bump inlinedDependencies after pnpm install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - devframe: tinyexec 1.1.1 → 1.1.2 - core: @clack/core + @clack/prompts 1.2.0 → 1.3.0; fast-string-truncated-width 1.2.1 → 3.0.3; fast-string-width 1.1.0 → 3.0.2; fast-wrap-ansi 0.1.6 → 0.2.0 Picked up automatically from the workspace catalog ranges. Co-Authored-By: Claude Opus 4.7 (1M context) --- devframe/packages/devframe/package.json | 2 +- packages/core/package.json | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/devframe/packages/devframe/package.json b/devframe/packages/devframe/package.json index 185fe968..79c83539 100644 --- a/devframe/packages/devframe/package.json +++ b/devframe/packages/devframe/package.json @@ -113,7 +113,7 @@ "perfect-debounce": "2.1.0", "powershell-utils": "0.1.0", "run-applescript": "7.1.0", - "tinyexec": "1.1.1", + "tinyexec": "1.1.2", "ua-parser-modern": "0.1.1", "whenexpr": "0.1.2", "wsl-utils": "0.3.1", diff --git a/packages/core/package.json b/packages/core/package.json index 8c1fda7d..b59f8f57 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -93,8 +93,8 @@ "vue-tsc": "catalog:devtools" }, "inlinedDependencies": { - "@clack/core": "1.2.0", - "@clack/prompts": "1.2.0", + "@clack/core": "1.3.0", + "@clack/prompts": "1.3.0", "@json-render/core": "0.13.0", "@json-render/vue": "0.13.0", "@vueuse/core": "14.2.1", @@ -103,9 +103,9 @@ "@xterm/xterm": "6.0.0", "ansis": "4.2.0", "dompurify": "3.4.1", - "fast-string-truncated-width": "1.2.1", - "fast-string-width": "1.1.0", - "fast-wrap-ansi": "0.1.6", + "fast-string-truncated-width": "3.0.3", + "fast-string-width": "3.0.2", + "fast-wrap-ansi": "0.2.0", "fuse.js": "7.3.0", "get-port-please": "3.2.0", "sisteransi": "1.0.5", From 3f125ad1f6b96b54e1832dedfc6b848e3820e5a2 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 1 May 2026 18:46:24 +0900 Subject: [PATCH 7/7] feat(devframe): expose structuredCloneSerialize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-export `serialize` from structured-clone-es alongside the existing `structuredCloneDeserialize` / `structuredCloneParse` / `structuredCloneStringify` so callers can build the tagged intermediate representation without going through JSON text — useful when piping into another transport or storage that already accepts plain JSON values. Co-Authored-By: Claude Opus 4.7 (1M context) --- devframe/packages/devframe/src/rpc/serialization.ts | 8 +++++++- .../tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts | 1 + .../tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/devframe/packages/devframe/src/rpc/serialization.ts b/devframe/packages/devframe/src/rpc/serialization.ts index 7ab5a83c..d5bc1fbe 100644 --- a/devframe/packages/devframe/src/rpc/serialization.ts +++ b/devframe/packages/devframe/src/rpc/serialization.ts @@ -1,11 +1,17 @@ import { deserialize as structuredCloneDeserialize, parse as structuredCloneParse, + serialize as structuredCloneSerialize, stringify as structuredCloneStringify, } from 'structured-clone-es' import { logger } from './diagnostics' -export { structuredCloneDeserialize, structuredCloneParse, structuredCloneStringify } +export { + structuredCloneDeserialize, + structuredCloneParse, + structuredCloneSerialize, + structuredCloneStringify, +} /** * Wire format used by the WS RPC transport. diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts b/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts index 00848c2f..cfca4209 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts +++ b/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts @@ -39,6 +39,7 @@ export { strictJsonStringify } export { STRUCTURED_CLONE_PREFIX } export { structuredCloneDeserialize } export { structuredCloneParse } +export { structuredCloneSerialize } export { structuredCloneStringify } export { Thenable } export { validateDefinition } diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js b/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js index 949e9122..5ee8c86b 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js +++ b/devframe/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.js @@ -15,6 +15,7 @@ export { strictJsonStringify } export { STRUCTURED_CLONE_PREFIX } export { structuredCloneDeserialize } export { structuredCloneParse } +export { structuredCloneSerialize } export { structuredCloneStringify } export { validateDefinition } export { validateDefinitions }