From 517f6b22c156481e592b3c47de0843c8b6ab39f8 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Tue, 12 May 2026 21:45:03 +0900 Subject: [PATCH 1/4] fix: fallback to structured-clone for RPC error envelopes --- .../devframe/src/rpc/transports/ws-client.ts | 7 +++- .../devframe/src/rpc/transports/ws-server.ts | 7 +++- .../devframe/src/rpc/transports/ws.test.ts | 34 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/devframe/src/rpc/transports/ws-client.ts b/packages/devframe/src/rpc/transports/ws-client.ts index 1c388f5..f25b01c 100644 --- a/packages/devframe/src/rpc/transports/ws-client.ts +++ b/packages/devframe/src/rpc/transports/ws-client.ts @@ -84,7 +84,12 @@ export function createWsRpcChannel(options: WsRpcChannelOptions): ChannelOptions method = pendingRequestMethods.get(msg.i) pendingRequestMethods.delete(msg.i) } - const useJson = !!method && definitions.get(method)?.jsonSerializable === true + // `jsonSerializable` constrains the return-value path (args + return). + // Error envelopes (`{ t: 's', i, e }`) carry a thrown value — fall back + // to structured-clone so they round-trip instead of crashing the serializer. + // Detect via `'e' in msg` so `throw undefined` still routes through SC. + const isErrorResponse = msg.t === 's' && 'e' in msg + const useJson = !isErrorResponse && !!method && definitions.get(method)?.jsonSerializable === true if (useJson) return strictJsonStringify(msg, method ?? '') return `${STRUCTURED_CLONE_PREFIX}${structuredCloneStringify(msg)}` diff --git a/packages/devframe/src/rpc/transports/ws-server.ts b/packages/devframe/src/rpc/transports/ws-server.ts index af8a465..5c8d3a4 100644 --- a/packages/devframe/src/rpc/transports/ws-server.ts +++ b/packages/devframe/src/rpc/transports/ws-server.ts @@ -127,7 +127,12 @@ export function attachWsRpcTransport< method = pendingRequestMethods.get(msg.i) pendingRequestMethods.delete(msg.i) } - const useJson = !!method && definitions.get(method)?.jsonSerializable === true + // `jsonSerializable` constrains the return-value path (args + return). + // Error envelopes (`{ t: 's', i, e }`) carry a thrown value — fall back + // to structured-clone so they round-trip instead of crashing the serializer. + // Detect via `'e' in msg` so `throw undefined` still routes through SC. + const isErrorResponse = msg.t === 's' && 'e' in msg + const useJson = !isErrorResponse && !!method && definitions.get(method)?.jsonSerializable === true if (useJson) return strictJsonStringify(msg, method ?? '') return `${STRUCTURED_CLONE_PREFIX}${structuredCloneStringify(msg)}` diff --git a/packages/devframe/src/rpc/transports/ws.test.ts b/packages/devframe/src/rpc/transports/ws.test.ts index 03321e0..71f6e37 100644 --- a/packages/devframe/src/rpc/transports/ws.test.ts +++ b/packages/devframe/src/rpc/transports/ws.test.ts @@ -52,4 +52,38 @@ describe('devtools rpc', () => { expect(await server.broadcast.$call('hey', 'server')).toEqual(expect.arrayContaining(['hey server, I\'m client 1', 'hey server, I\'m client 2'])) }) + + // Regression: a `jsonSerializable: true` RPC that throws used to crash the + // WS serializer with DF0020 because the error envelope was strict-JSON-encoded + // alongside the result path. + it('returns a rejection (not a serialization crash) when a jsonSerializable RPC throws', async () => { + const PORT = 3334 + const HOST = '127.0.0.1' + const WS_URL = `ws://${HOST}:${PORT}` + + const serverFunctions = { + explode: async () => { + throw new Error('boom') + }, + } + + const definitions = new Map([ + ['explode', { jsonSerializable: true }], + ]) + + const server = createRpcServer, typeof serverFunctions>(serverFunctions) + const { wss } = attachWsRpcTransport(server, { port: PORT, host: HOST, definitions: definitions as any }) + + try { + const client = createRpcClient>({}, { + channel: createWsRpcChannel({ url: WS_URL, definitions: definitions as any }), + }) + + await expect(client.$call('explode')).rejects.toThrow(/boom/) + } + finally { + for (const c of wss.clients) c.terminate() + await new Promise(resolve => wss.close(() => resolve())) + } + }) }) From 1666fb127ed4b304857de439fb6249ea38953d28 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Wed, 13 May 2026 10:04:21 +0900 Subject: [PATCH 2/4] test(rpc): address review feedback on ws regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop unnecessary `as any` casts on `definitions` — the local map type is structurally compatible with the transport's expected shape. - Allocate the port via `get-port-please` instead of hardcoding `3334`, matching the pattern in `adapters/__tests__/dev.test.ts` and avoiding flakes when the port is already in use. --- packages/devframe/src/rpc/transports/ws.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/devframe/src/rpc/transports/ws.test.ts b/packages/devframe/src/rpc/transports/ws.test.ts index 71f6e37..0d8370c 100644 --- a/packages/devframe/src/rpc/transports/ws.test.ts +++ b/packages/devframe/src/rpc/transports/ws.test.ts @@ -1,3 +1,4 @@ +import { getPort } from 'get-port-please' import { describe, expect, it, vi } from 'vitest' import { WebSocket } from 'ws' import { createRpcClient } from '../client' @@ -57,8 +58,8 @@ describe('devtools rpc', () => { // WS serializer with DF0020 because the error envelope was strict-JSON-encoded // alongside the result path. it('returns a rejection (not a serialization crash) when a jsonSerializable RPC throws', async () => { - const PORT = 3334 const HOST = '127.0.0.1' + const PORT = await getPort({ port: 3334, host: HOST }) const WS_URL = `ws://${HOST}:${PORT}` const serverFunctions = { @@ -72,11 +73,11 @@ describe('devtools rpc', () => { ]) const server = createRpcServer, typeof serverFunctions>(serverFunctions) - const { wss } = attachWsRpcTransport(server, { port: PORT, host: HOST, definitions: definitions as any }) + const { wss } = attachWsRpcTransport(server, { port: PORT, host: HOST, definitions }) try { const client = createRpcClient>({}, { - channel: createWsRpcChannel({ url: WS_URL, definitions: definitions as any }), + channel: createWsRpcChannel({ url: WS_URL, definitions }), }) await expect(client.$call('explode')).rejects.toThrow(/boom/) From 076ffe70c61bbff70f8b59ea915eed7b34c93371 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Wed, 13 May 2026 10:32:29 +0900 Subject: [PATCH 3/4] feat: preserve rich errors in dumps and MCP output Extends the structured-clone error fallback shipped for the WS RPC transport to the dump and MCP surfaces: - Dumps capture `Error.cause` chains and own properties instead of reducing to `{ message, name }`. Static-dump promotes error-bearing records to structured-clone per-record (via new `recordSerializations`/`fallbackSerialization` manifest fields) so `jsonSerializable: true` functions still round-trip thrown errors losslessly. - MCP replaces the bare `JSON.stringify` with a coercing helper for `BigInt`/`Date`/`Map`/`Set`/`Error`/cycles, since MCP wire format is text-only and cannot use the `s:` structured-clone prefix. Error responses now include `name` and `cause.message`. --- packages/devframe/src/client/static-rpc.ts | 29 +++-- .../src/node/__tests__/static-dump.test.ts | 107 +++++++++++++++++ .../src/node/mcp/__tests__/mcp-server.test.ts | 41 +++++++ .../src/node/mcp/__tests__/stringify.test.ts | 113 ++++++++++++++++++ .../devframe/src/node/mcp/build-server.ts | 22 +--- packages/devframe/src/node/mcp/stringify.ts | 72 +++++++++++ packages/devframe/src/node/static-dump.ts | 28 ++++- packages/devframe/src/rpc/dump-error.ts | 67 +++++++++++ packages/devframe/src/rpc/dumps.test.ts | 58 +++++++++ packages/devframe/src/rpc/dumps.ts | 12 +- packages/devframe/src/rpc/types.ts | 27 ++++- .../tsnapi/devframe/node.snapshot.d.ts | 2 + .../tsnapi/devframe/rpc.snapshot.d.ts | 1 + 13 files changed, 535 insertions(+), 44 deletions(-) create mode 100644 packages/devframe/src/node/mcp/__tests__/stringify.test.ts create mode 100644 packages/devframe/src/node/mcp/stringify.ts create mode 100644 packages/devframe/src/rpc/dump-error.ts diff --git a/packages/devframe/src/client/static-rpc.ts b/packages/devframe/src/client/static-rpc.ts index 757a050..7e326e4 100644 --- a/packages/devframe/src/client/static-rpc.ts +++ b/packages/devframe/src/client/static-rpc.ts @@ -1,3 +1,5 @@ +import type { RpcDumpRecordError } from '../rpc/types' +import { reviveDumpError } from '../rpc/dump-error' import { hash } from '../utils/hash' import { structuredCloneDeserialize } from '../utils/structured-clone' @@ -16,6 +18,15 @@ export interface StaticRpcManifestQueryEntry { fallback?: string /** Encoder used when each record/fallback file was written. Default: `'json'`. */ serialization?: StaticRpcSerialization + /** + * Per-record encoder override. When a record file was written with a + * different serializer than {@link serialization} (e.g. an error-bearing + * record promoted to `'structured-clone'` for a `jsonSerializable: true` + * function), the override is recorded here. + */ + recordSerializations?: Record + /** Encoder override for the fallback shard. */ + fallbackSerialization?: StaticRpcSerialization } export type StaticRpcManifestEntry @@ -28,10 +39,7 @@ export type StaticRpcManifest = Record export interface StaticRpcRecord { inputs?: any[] output?: any - error?: { - message: string - name: string - } + error?: RpcDumpRecordError } function isStaticEntry(value: unknown): value is StaticRpcManifestStaticEntry { @@ -56,11 +64,8 @@ function isRecord(value: unknown): value is StaticRpcRecord { } function resolveRecordOutput(record: StaticRpcRecord): any { - if (record.error) { - const error = new Error(record.error.message) - error.name = record.error.name - throw error - } + if (record.error) + throw reviveDumpError(record.error) return record.output } @@ -124,12 +129,14 @@ export function createStaticRpcCaller( const recordPath = entry.records[argsHash] if (recordPath) { - const record = await loadQueryRecord(recordPath, entry.serialization) + const recordSerialization = entry.recordSerializations?.[argsHash] ?? entry.serialization + const record = await loadQueryRecord(recordPath, recordSerialization) return resolveRecordOutput(record) } if (entry.fallback) { - const fallback = await loadQueryRecord(entry.fallback, entry.serialization) + const fallbackSerialization = entry.fallbackSerialization ?? entry.serialization + const fallback = await loadQueryRecord(entry.fallback, fallbackSerialization) return resolveRecordOutput(fallback) } diff --git a/packages/devframe/src/node/__tests__/static-dump.test.ts b/packages/devframe/src/node/__tests__/static-dump.test.ts index 18906b1..123ac95 100644 --- a/packages/devframe/src/node/__tests__/static-dump.test.ts +++ b/packages/devframe/src/node/__tests__/static-dump.test.ts @@ -216,4 +216,111 @@ describe('collectStaticRpcDump', () => { .toThrowError(/jsonSerializable: true.*is a Map/) }) }) + + describe('error-bearing records', () => { + it('promotes error-bearing query records to structured-clone for jsonSerializable: true', async () => { + const flaky = defineRpcFunction({ + name: 'test:flaky', + type: 'query', + jsonSerializable: true, + handler: (id: string) => { + if (id === 'bad') + throw new Error('boom') + return { id } + }, + dump: { + inputs: [['good'], ['bad']], + }, + }) + + const result = await collectStaticRpcDump([flaky], {}) + const entry = result.manifest['test:flaky'] as { + type: 'query' + records: Record + serialization: 'json' + recordSerializations?: Record + } + + expect(entry.serialization).toBe('json') + expect(entry.recordSerializations).toBeDefined() + + const erroredKeys = Object.keys(entry.recordSerializations!) + expect(erroredKeys).toHaveLength(1) + + const erroredPath = entry.records[erroredKeys[0]!]! + expect(result.files[erroredPath]!.serialization).toBe('structured-clone') + + const goodPath = entry.records[Object.keys(entry.records).find(k => k !== erroredKeys[0])!]! + expect(result.files[goodPath]!.serialization).toBe('json') + }) + + it('keeps the entry serialization on error-bearing records when default is structured-clone', async () => { + const flaky = defineRpcFunction({ + name: 'test:flaky-sc', + type: 'query', + // default jsonSerializable: false → structured-clone shards + handler: (id: string) => { + if (id === 'bad') + throw new Error('boom') + return { id } + }, + dump: { + inputs: [['good'], ['bad']], + }, + }) + + const result = await collectStaticRpcDump([flaky], {}) + const entry = result.manifest['test:flaky-sc'] as { + serialization: 'json' | 'structured-clone' + recordSerializations?: Record + } + + expect(entry.serialization).toBe('structured-clone') + // No overrides — everything is already SC. + expect(entry.recordSerializations).toBeUndefined() + }) + + it('preserves rich error info (cause + custom props) through a full read round-trip', async () => { + const tags = new Map([['a', 1]]) + const flaky = defineRpcFunction({ + name: 'test:flaky-roundtrip', + type: 'query', + jsonSerializable: true, + handler: (id: string) => { + if (id === 'bad') { + const err = new TypeError('boom', { cause: new Error('inner') }) as Error & { tags?: unknown } + err.tags = tags + throw err + } + return { id } + }, + dump: { + inputs: [['bad']], + }, + }) + + const result = await collectStaticRpcDump([flaky], {}) + const entry = result.manifest['test:flaky-roundtrip'] as { + records: Record + recordSerializations?: Record + } + + const recordKey = Object.keys(entry.records)[0]! + const recordPath = entry.records[recordKey]! + const file = result.files[recordPath]! + + // The shard was promoted to structured-clone — verify the Error + // (and its Map property) round-trips losslessly through the wire format. + expect(file.serialization).toBe('structured-clone') + const wireText = structuredCloneStringify(file.data) + const revived = structuredCloneDeserialize(JSON.parse(wireText)) as { + error: { name: string, message: string, cause: { message: string }, tags: Map } + } + expect(revived.error.name).toBe('TypeError') + expect(revived.error.message).toBe('boom') + expect(revived.error.cause.message).toBe('inner') + expect(revived.error.tags).toBeInstanceOf(Map) + expect(revived.error.tags.get('a')).toBe(1) + }) + }) }) diff --git a/packages/devframe/src/node/mcp/__tests__/mcp-server.test.ts b/packages/devframe/src/node/mcp/__tests__/mcp-server.test.ts index 377a0f1..4e6a9bb 100644 --- a/packages/devframe/src/node/mcp/__tests__/mcp-server.test.ts +++ b/packages/devframe/src/node/mcp/__tests__/mcp-server.test.ts @@ -80,6 +80,47 @@ describe('mcp adapter (in-memory)', () => { } }) + it('coerces non-JSON values returned from a tool', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + ctx.agent.registerTool({ + id: 'rich', + description: 'Returns BigInt + Date.', + handler: () => ({ count: 42n, when: new Date(0) }), + }) + + const result = await client.callTool({ name: 'rich', arguments: {} }) + const content = result.content as Array<{ type: string, text: string }> + expect(content[0]!.text).toContain('"42n"') + expect(content[0]!.text).toContain('1970-01-01T00:00:00.000Z') + } + finally { + await cleanup() + } + }) + + it('surfaces Error name and cause when a tool throws', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + ctx.agent.registerTool({ + id: 'crash', + description: 'Throws.', + handler: () => { + throw new TypeError('boom', { cause: new Error('inner') }) + }, + }) + + const result = await client.callTool({ name: 'crash', arguments: {} }) + expect(result.isError).toBe(true) + const content = result.content as Array<{ type: string, text: string }> + expect(content[0]!.text).toContain('TypeError: boom') + expect(content[0]!.text).toContain('cause: inner') + } + finally { + await cleanup() + } + }) + it('lists and reads registered resources', async () => { const { ctx, client, cleanup } = await bootPair() try { diff --git a/packages/devframe/src/node/mcp/__tests__/stringify.test.ts b/packages/devframe/src/node/mcp/__tests__/stringify.test.ts new file mode 100644 index 0000000..9cdcecd --- /dev/null +++ b/packages/devframe/src/node/mcp/__tests__/stringify.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { formatMcpError, stringifyForMcp } from '../stringify' + +describe('stringifyForMcp', () => { + it('returns "undefined" sentinel for undefined', () => { + expect(stringifyForMcp(undefined)).toBe('undefined') + }) + + it('passes strings through unchanged', () => { + expect(stringifyForMcp('hello')).toBe('hello') + }) + + it('serializes plain JSON-safe objects with indentation', () => { + expect(stringifyForMcp({ a: 1, b: 'two' })).toBe('{\n "a": 1,\n "b": "two"\n}') + }) + + it('coerces BigInt to a trailing-n string', () => { + expect(JSON.parse(stringifyForMcp({ count: 42n }))).toEqual({ count: '42n' }) + }) + + it('coerces Date to ISO string via toJSON', () => { + expect(JSON.parse(stringifyForMcp({ when: new Date(0) }))).toEqual({ + when: '1970-01-01T00:00:00.000Z', + }) + }) + + it('coerces Map to a tagged entries object', () => { + const value = new Map([['a', 1], ['b', 2]]) + expect(JSON.parse(stringifyForMcp(value))).toEqual({ + __type: 'Map', + entries: [['a', 1], ['b', 2]], + }) + }) + + it('coerces Set to a tagged entries object', () => { + const value = new Set(['x', 'y']) + expect(JSON.parse(stringifyForMcp(value))).toEqual({ + __type: 'Set', + entries: ['x', 'y'], + }) + }) + + it('serializes Error with name, message, stack, and cause', () => { + const inner = new Error('inner') + const outer = new TypeError('boom', { cause: inner }) + const parsed = JSON.parse(stringifyForMcp(outer)) + expect(parsed.name).toBe('TypeError') + expect(parsed.message).toBe('boom') + expect(typeof parsed.stack).toBe('string') + expect(parsed.cause.name).toBe('Error') + expect(parsed.cause.message).toBe('inner') + }) + + it('coerces Function to a readable token', () => { + function namedFn() {} + expect(JSON.parse(stringifyForMcp({ fn: namedFn }))).toEqual({ + fn: '[Function: namedFn]', + }) + }) + + it('coerces anonymous functions', () => { + expect(JSON.parse(stringifyForMcp({ fn: () => {} }))).toEqual({ + fn: '[Function: fn]', + }) + }) + + it('coerces Symbol to its description', () => { + expect(JSON.parse(stringifyForMcp({ s: Symbol('hi') }))).toEqual({ + s: 'Symbol(hi)', + }) + }) + + it('replaces circular refs with [Circular]', () => { + const obj: Record = { name: 'root' } + obj.self = obj + const parsed = JSON.parse(stringifyForMcp(obj)) + expect(parsed.name).toBe('root') + expect(parsed.self).toBe('[Circular]') + }) + + it('handles a mixed payload end-to-end', () => { + const value = { + count: 42n, + when: new Date(0), + tags: new Set(['a', 'b']), + } + const text = stringifyForMcp(value) + expect(text).toContain('"42n"') + expect(text).toContain('1970-01-01T00:00:00.000Z') + expect(text).toContain('"__type": "Set"') + }) +}) + +describe('formatMcpError', () => { + it('returns String(value) for non-Error throws', () => { + expect(formatMcpError('boom')).toBe('boom') + expect(formatMcpError(42)).toBe('42') + }) + + it('formats an Error as "name: message"', () => { + expect(formatMcpError(new TypeError('bad'))).toBe('TypeError: bad') + }) + + it('appends cause.message for Error causes', () => { + const err = new Error('outer', { cause: new Error('inner') }) + expect(formatMcpError(err)).toBe('Error: outer (cause: inner)') + }) + + it('appends String(cause) for non-Error causes', () => { + const err = new Error('outer', { cause: 'bad input' }) + expect(formatMcpError(err)).toBe('Error: outer (cause: bad input)') + }) +}) diff --git a/packages/devframe/src/node/mcp/build-server.ts b/packages/devframe/src/node/mcp/build-server.ts index e946fb0..a50852a 100644 --- a/packages/devframe/src/node/mcp/build-server.ts +++ b/packages/devframe/src/node/mcp/build-server.ts @@ -13,6 +13,7 @@ import { import { join } from 'pathe' import { createHostContext } from '../context' import { logger } from '../diagnostics' +import { formatMcpError, stringifyForMcp } from './stringify' import { valibotArgsToJsonSchema, valibotReturnToJsonSchema } from './to-json-schema' export interface CreateMcpServerOptions { @@ -158,7 +159,7 @@ function registerToolHandlers(server: Server, ctx: DevToolsNodeContext): void { content: [ { type: 'text', - text: stringify(result), + text: stringifyForMcp(result), }, ], } @@ -169,7 +170,7 @@ function registerToolHandlers(server: Server, ctx: DevToolsNodeContext): void { content: [ { type: 'text', - text: `Error invoking "${name}": ${error instanceof Error ? error.message : String(error)}`, + text: `Error invoking "${name}": ${formatMcpError(error)}`, }, ], } @@ -218,7 +219,7 @@ function registerResourceHandlers( { uri, mimeType: content.mimeType ?? 'application/json', - text: content.text ?? stringify(content.json), + text: content.text ?? stringifyForMcp(content.json), }, ], } @@ -231,7 +232,7 @@ function registerResourceHandlers( { uri, mimeType: 'application/json', - text: stringify(state.value()), + text: stringifyForMcp(state.value()), }, ], } @@ -287,16 +288,3 @@ function parseResourceUri(uri: string): { kind: 'resource', id: string } | { kin return { kind: 'resource', id: decoded } return { kind: 'state', key: decoded } } - -function stringify(value: unknown): string { - if (value === undefined) - return 'undefined' - if (typeof value === 'string') - return value - try { - return JSON.stringify(value, null, 2) - } - catch { - return String(value) - } -} diff --git a/packages/devframe/src/node/mcp/stringify.ts b/packages/devframe/src/node/mcp/stringify.ts new file mode 100644 index 0000000..8e26035 --- /dev/null +++ b/packages/devframe/src/node/mcp/stringify.ts @@ -0,0 +1,72 @@ +/** + * JSON-coercing serializer for MCP text payloads. + * + * MCP carries tool results and resource reads as plain text over a + * JSON-RPC transport, so we cannot use the `s:`-prefixed structured-clone + * format the WS RPC transport falls back to for non-JSON values. Instead, + * we coerce common non-JSON types into JSON-friendly forms so the LLM + * client sees something useful instead of `[object Object]`. + * + * Coercions: + * - `BigInt` → `"123n"` + * - `Date` → ISO string (via the native `toJSON`) + * - `Map` → `{ __type: 'Map', entries: [[k, v], …] }` + * - `Set` → `{ __type: 'Set', entries: [v, …] }` + * - `Error` → `{ name, message, stack, cause? }` (cause recurses) + * - `Function` → `"[Function: name]"` + * - `Symbol` → `value.toString()` + * - cycles → `"[Circular]"` + */ +export function stringifyForMcp(value: unknown): string { + if (value === undefined) + return 'undefined' + if (typeof value === 'string') + return value + + const seen = new WeakSet() + return JSON.stringify(value, (_key, val) => { + if (typeof val === 'bigint') + return `${val}n` + if (val instanceof Error) { + const out: Record = { + name: val.name, + message: val.message, + stack: val.stack, + } + if ((val as { cause?: unknown }).cause !== undefined) + out.cause = (val as { cause?: unknown }).cause + return out + } + if (val instanceof Map) + return { __type: 'Map', entries: [...val.entries()] } + if (val instanceof Set) + return { __type: 'Set', entries: [...val] } + if (typeof val === 'function') + return `[Function: ${val.name || 'anonymous'}]` + if (typeof val === 'symbol') + return val.toString() + if (val !== null && typeof val === 'object') { + if (seen.has(val)) + return '[Circular]' + seen.add(val) + } + return val + }, 2) +} + +/** + * Format a thrown value for an MCP `isError` text payload. Surfaces the + * `Error.name`/`message`, and one level of `cause.message` so context + * isn't dropped silently. + */ +export function formatMcpError(error: unknown): string { + if (!(error instanceof Error)) + return String(error) + const cause = (error as { cause?: unknown }).cause + const causeText = cause instanceof Error + ? ` (cause: ${cause.message})` + : cause !== undefined + ? ` (cause: ${String(cause)})` + : '' + return `${error.name}: ${error.message}${causeText}` +} diff --git a/packages/devframe/src/node/static-dump.ts b/packages/devframe/src/node/static-dump.ts index 27839a2..4c6fa9d 100644 --- a/packages/devframe/src/node/static-dump.ts +++ b/packages/devframe/src/node/static-dump.ts @@ -19,6 +19,16 @@ export interface StaticRpcDumpManifestQueryEntry { fallback?: string /** Encoder used when each record/fallback file was written. Default: `'json'`. */ serialization?: StaticRpcDumpSerialization + /** + * Per-record encoder override. When a record file is written with a + * different serializer than {@link serialization} (e.g. an error-bearing + * record promoted to `'structured-clone'` for a `jsonSerializable: true` + * function), the override is recorded here so the client picks the + * right decoder. + */ + recordSerializations?: Record + /** Encoder override for the fallback shard, with the same semantics as {@link recordSerializations}. */ + fallbackSerialization?: StaticRpcDumpSerialization } export type StaticRpcDumpManifestValue @@ -115,15 +125,29 @@ export async function collectStaticRpcDump( const key = recordKey.slice(prefix.length) const record = await resolveRecord(recordOrGetter) + // Error-bearing records can contain non-JSON values (e.g. an + // `Error.cause` chain, or a `Map` attached to the thrown error). + // For `jsonSerializable: true` functions, promote just this one + // file to structured-clone so the rich error round-trips losslessly. + const recordSerialization: StaticRpcDumpSerialization + = record.error !== undefined && serialization === 'json' + ? 'structured-clone' + : serialization + if (key === 'fallback') { const path = makeQueryFallbackPath(definition.name) - files[path] = { serialization, fnName: definition.name, data: record } + files[path] = { serialization: recordSerialization, fnName: definition.name, data: record } queryEntry.fallback = path + if (recordSerialization !== serialization) + queryEntry.fallbackSerialization = recordSerialization } else { const path = makeQueryRecordPath(definition.name, key) - files[path] = { serialization, fnName: definition.name, data: record } + files[path] = { serialization: recordSerialization, fnName: definition.name, data: record } queryEntry.records[key] = path + if (recordSerialization !== serialization) { + ;(queryEntry.recordSerializations ??= {})[key] = recordSerialization + } } } diff --git a/packages/devframe/src/rpc/dump-error.ts b/packages/devframe/src/rpc/dump-error.ts new file mode 100644 index 0000000..cbdc330 --- /dev/null +++ b/packages/devframe/src/rpc/dump-error.ts @@ -0,0 +1,67 @@ +import type { RpcDumpRecordError } from './types' + +/** + * Normalize a thrown value into a plain object suitable for storage in + * a dump record. Preserves `message`, `name`, `cause`, and any own + * enumerable properties of an `Error` so consumers reading the dump can + * reconstruct a richer Error than just `{ message, name }`. + * + * Non-`Error` throws are wrapped as `{ name: 'Error', message: String(thrown) }`. + */ +export function serializeDumpError(error: unknown): RpcDumpRecordError { + return serializeWithSeen(error, new WeakSet()) +} + +function serializeWithSeen(error: unknown, seen: WeakSet): RpcDumpRecordError { + if (!(error instanceof Error)) + return { name: 'Error', message: String(error) } + + if (seen.has(error)) + return { name: error.name, message: error.message } + seen.add(error) + + const out: RpcDumpRecordError = { name: error.name, message: error.message } + const cause = (error as { cause?: unknown }).cause + if (cause !== undefined) { + out.cause = cause instanceof Error + ? serializeWithSeen(cause, seen) + : cause + } + for (const key of Object.keys(error)) { + if (key === 'name' || key === 'message' || key === 'cause') + continue + out[key] = (error as Record)[key] + } + return out +} + +/** + * Inverse of {@link serializeDumpError}: rebuild a thrown `Error` from + * the plain object stored in a dump record. Preserves `cause`, restores + * the original `name`, and re-attaches any custom own properties. + */ +export function reviveDumpError(stored: RpcDumpRecordError): Error { + const cause = stored.cause instanceof Error + ? stored.cause + : isPlainErrorShape(stored.cause) + ? reviveDumpError(stored.cause) + : stored.cause + const error = cause !== undefined + ? new Error(stored.message, { cause }) + : new Error(stored.message) + error.name = stored.name + for (const key of Object.keys(stored)) { + if (key === 'name' || key === 'message' || key === 'cause') { + continue + } + ;(error as unknown as Record)[key] = stored[key] + } + return error +} + +function isPlainErrorShape(value: unknown): value is RpcDumpRecordError { + return typeof value === 'object' + && value !== null + && typeof (value as { message?: unknown }).message === 'string' + && typeof (value as { name?: unknown }).name === 'string' +} diff --git a/packages/devframe/src/rpc/dumps.test.ts b/packages/devframe/src/rpc/dumps.test.ts index 1284332..d69948b 100644 --- a/packages/devframe/src/rpc/dumps.test.ts +++ b/packages/devframe/src/rpc/dumps.test.ts @@ -111,6 +111,64 @@ describe('dumps', () => { await expect(client.divide(10, 0)).rejects.toThrow('Division by zero') }) + it('should preserve error name, cause, and custom properties', async () => { + const tags = new Map([['a', 1], ['b', 2]]) + const flaky = defineRpcFunction({ + name: 'flaky', + dump: { + inputs: [[]] as [][], + }, + handler: () => { + const err = new TypeError('boom', { cause: new Error('inner') }) as Error & { tags?: unknown } + err.tags = tags + throw err + }, + }) + + const store = await dumpFunctions([flaky]) + const record = Object.entries(store.records) + .filter(([key]) => key.startsWith('flaky---') && !key.endsWith('---fallback')) + .map(([, r]) => r as RpcDumpRecord)[0]! + + expect(record.error?.name).toBe('TypeError') + expect(record.error?.message).toBe('boom') + const cause = record.error?.cause as { name: string, message: string } + expect(cause.name).toBe('Error') + expect(cause.message).toBe('inner') + expect(record.error?.tags).toBe(tags) + + const client = createClientFromDump(store) + await expect(client.flaky()).rejects.toMatchObject({ + name: 'TypeError', + message: 'boom', + cause: { name: 'Error', message: 'inner' }, + tags, + }) + }) + + it('should normalize non-Error throws to { name: "Error", message }', async () => { + const odd = defineRpcFunction({ + name: 'odd', + dump: { + inputs: [[]] as [][], + }, + handler: () => { + // eslint-disable-next-line no-throw-literal + throw 'just a string' + }, + }) + + const store = await dumpFunctions([odd]) + const record = Object.entries(store.records) + .filter(([key]) => key.startsWith('odd---') && !key.endsWith('---fallback')) + .map(([, r]) => r as RpcDumpRecord)[0]! + + expect(record.error).toEqual({ name: 'Error', message: 'just a string' }) + + const client = createClientFromDump(store) + await expect(client.odd()).rejects.toThrow('just a string') + }) + it('should collect dumps from setup result', async () => { const defineWithContext = createDefineWrapperWithContext<{ balance: number }>() diff --git a/packages/devframe/src/rpc/dumps.ts b/packages/devframe/src/rpc/dumps.ts index b4b978e..e4ce8f4 100644 --- a/packages/devframe/src/rpc/dumps.ts +++ b/packages/devframe/src/rpc/dumps.ts @@ -10,6 +10,7 @@ import type { import pLimit from 'p-limit' import { hash } from '../utils/hash' import { logger } from './diagnostics' +import { reviveDumpError, serializeDumpError } from './dump-error' import { validateDefinitions } from './validation' function getDumpRecordKey(functionName: string, args: any[]): string { @@ -166,13 +167,10 @@ export async function dumpFunctions< output, } } - catch (error: any) { + catch (error: unknown) { store.records[recordKey] = { inputs: input, - error: { - message: error.message, - name: error.name, - }, + error: serializeDumpError(error), } } }) @@ -225,9 +223,7 @@ export function createClientFromDump>( const record = await resolveGetter(recordOrGetter) if (record.error) { - const error = new Error(record.error.message) - error.name = record.error.name - throw error + throw reviveDumpError(record.error) } if (typeof record.output === 'function') { diff --git a/packages/devframe/src/rpc/types.ts b/packages/devframe/src/rpc/types.ts index b504a36..d5f5d81 100644 --- a/packages/devframe/src/rpc/types.ts +++ b/packages/devframe/src/rpc/types.ts @@ -98,6 +98,26 @@ export type RpcArgsSchema = readonly GenericSchema[] /** Valibot schema for validating function return value */ export type RpcReturnSchema = GenericSchema +/** + * Serialized representation of a thrown value in a dump record. + * + * Errors are stored as plain objects so they round-trip through both the + * strict-JSON and structured-clone codecs. `message` and `name` are always + * present; `cause` and any own enumerable properties of the original + * `Error` are preserved on a best-effort basis. Non-`Error` throws are + * normalized to `{ name: 'Error', message: String(thrown) }`. + */ +export interface RpcDumpRecordError { + /** Error message (mirrors `Error.message`). */ + message: string + /** Error type name (e.g., "Error", "TypeError"). */ + name: string + /** `Error.cause`, recursively serialized when it is itself an `Error`. */ + cause?: unknown + /** Own enumerable properties of the original error (excluding `message`/`name`/`cause`). */ + [key: string]: unknown +} + /** * Single record in a dump store with pre-computed results. */ @@ -107,12 +127,7 @@ export interface RpcDumpRecord { /** Result (value or lazy function) */ output?: RETURN /** Error if execution failed */ - error?: { - /** Error message */ - message: string - /** Error type name (e.g., "Error", "TypeError") */ - name: string - } + error?: RpcDumpRecordError } /** diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index 516f87f..0acc8e6 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -36,6 +36,8 @@ export interface StaticRpcDumpManifestQueryEntry { records: Record; fallback?: string; serialization?: StaticRpcDumpSerialization; + recordSerializations?: Record; + fallbackSerialization?: StaticRpcDumpSerialization; } export interface StaticRpcDumpManifestStaticEntry { type: 'static'; diff --git a/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts index be2dfe7..0e3c6d5 100644 --- a/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/rpc.snapshot.d.ts @@ -23,6 +23,7 @@ export { RpcDumpCollectionOptions } export { RpcDumpDefinition } export { RpcDumpGetter } export { RpcDumpRecord } +export { RpcDumpRecordError } export { RpcDumpStore } export { RpcFunctionAgentOptions } export { RpcFunctionDefinition } From e46ae58e20960b86b91d5b682b18b919fc597984 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Wed, 13 May 2026 10:46:32 +0900 Subject: [PATCH 4/4] refactor: drop per-record serialization overrides from static dump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serializeDumpError` already flattens the common rich-error shape (`message`, `name`, recursive `cause`) into a JSON-safe object, so `jsonSerializable: true` functions that throw ordinary errors round-trip through strict JSON without any per-record promotion. The edge case — an error with non-JSON own properties (e.g. a `Map` attached to the thrown error) — now surfaces as `DF0020` at build time, consistent with the rest of the `jsonSerializable: true` contract. --- packages/devframe/src/client/static-rpc.ts | 15 +-- .../src/node/__tests__/static-dump.test.ts | 113 +++++++++--------- packages/devframe/src/node/static-dump.ts | 28 +---- .../tsnapi/devframe/node.snapshot.d.ts | 2 - 4 files changed, 58 insertions(+), 100 deletions(-) diff --git a/packages/devframe/src/client/static-rpc.ts b/packages/devframe/src/client/static-rpc.ts index 7e326e4..f46f501 100644 --- a/packages/devframe/src/client/static-rpc.ts +++ b/packages/devframe/src/client/static-rpc.ts @@ -18,15 +18,6 @@ export interface StaticRpcManifestQueryEntry { fallback?: string /** Encoder used when each record/fallback file was written. Default: `'json'`. */ serialization?: StaticRpcSerialization - /** - * Per-record encoder override. When a record file was written with a - * different serializer than {@link serialization} (e.g. an error-bearing - * record promoted to `'structured-clone'` for a `jsonSerializable: true` - * function), the override is recorded here. - */ - recordSerializations?: Record - /** Encoder override for the fallback shard. */ - fallbackSerialization?: StaticRpcSerialization } export type StaticRpcManifestEntry @@ -129,14 +120,12 @@ export function createStaticRpcCaller( const recordPath = entry.records[argsHash] if (recordPath) { - const recordSerialization = entry.recordSerializations?.[argsHash] ?? entry.serialization - const record = await loadQueryRecord(recordPath, recordSerialization) + const record = await loadQueryRecord(recordPath, entry.serialization) return resolveRecordOutput(record) } if (entry.fallback) { - const fallbackSerialization = entry.fallbackSerialization ?? entry.serialization - const fallback = await loadQueryRecord(entry.fallback, fallbackSerialization) + const fallback = await loadQueryRecord(entry.fallback, entry.serialization) return resolveRecordOutput(fallback) } diff --git a/packages/devframe/src/node/__tests__/static-dump.test.ts b/packages/devframe/src/node/__tests__/static-dump.test.ts index 123ac95..66c6b82 100644 --- a/packages/devframe/src/node/__tests__/static-dump.test.ts +++ b/packages/devframe/src/node/__tests__/static-dump.test.ts @@ -218,109 +218,104 @@ describe('collectStaticRpcDump', () => { }) describe('error-bearing records', () => { - it('promotes error-bearing query records to structured-clone for jsonSerializable: true', async () => { + it('writes JSON-safe error shape (message + name + cause) for jsonSerializable: true', async () => { const flaky = defineRpcFunction({ name: 'test:flaky', type: 'query', jsonSerializable: true, - handler: (id: string) => { - if (id === 'bad') - throw new Error('boom') - return { id } + handler: () => { + throw new TypeError('boom', { cause: new Error('inner') }) }, dump: { - inputs: [['good'], ['bad']], + inputs: [[]] as [][], }, }) const result = await collectStaticRpcDump([flaky], {}) const entry = result.manifest['test:flaky'] as { - type: 'query' records: Record serialization: 'json' - recordSerializations?: Record } expect(entry.serialization).toBe('json') - expect(entry.recordSerializations).toBeDefined() - const erroredKeys = Object.keys(entry.recordSerializations!) - expect(erroredKeys).toHaveLength(1) - - const erroredPath = entry.records[erroredKeys[0]!]! - expect(result.files[erroredPath]!.serialization).toBe('structured-clone') + const recordPath = Object.values(entry.records)[0]! + const file = result.files[recordPath]! + expect(file.serialization).toBe('json') - const goodPath = entry.records[Object.keys(entry.records).find(k => k !== erroredKeys[0])!]! - expect(result.files[goodPath]!.serialization).toBe('json') + // serializeDumpError flattens Error.cause into a plain object, so + // strict-JSON encoding succeeds without any per-record promotion. + const wireText = strictJsonStringify(file.data, file.fnName) + const parsed = JSON.parse(wireText) as { + error: { name: string, message: string, cause: { name: string, message: string } } + } + expect(parsed.error.name).toBe('TypeError') + expect(parsed.error.message).toBe('boom') + expect(parsed.error.cause.name).toBe('Error') + expect(parsed.error.cause.message).toBe('inner') }) - it('keeps the entry serialization on error-bearing records when default is structured-clone', async () => { + it('preserves rich error info end-to-end for default (structured-clone) entries', async () => { + const tags = new Map([['a', 1]]) const flaky = defineRpcFunction({ - name: 'test:flaky-sc', + name: 'test:flaky-roundtrip', type: 'query', // default jsonSerializable: false → structured-clone shards - handler: (id: string) => { - if (id === 'bad') - throw new Error('boom') - return { id } + handler: () => { + const err = new TypeError('boom', { cause: new Error('inner') }) as Error & { tags?: unknown } + err.tags = tags + throw err }, dump: { - inputs: [['good'], ['bad']], + inputs: [[]] as [][], }, }) const result = await collectStaticRpcDump([flaky], {}) - const entry = result.manifest['test:flaky-sc'] as { - serialization: 'json' | 'structured-clone' - recordSerializations?: Record + const entry = result.manifest['test:flaky-roundtrip'] as { + records: Record + serialization: 'structured-clone' } - expect(entry.serialization).toBe('structured-clone') - // No overrides — everything is already SC. - expect(entry.recordSerializations).toBeUndefined() + + const recordPath = Object.values(entry.records)[0]! + const file = result.files[recordPath]! + const revived = structuredCloneDeserialize(JSON.parse(structuredCloneStringify(file.data))) as { + error: { name: string, message: string, cause: { message: string }, tags: Map } + } + expect(revived.error.name).toBe('TypeError') + expect(revived.error.message).toBe('boom') + expect(revived.error.cause.message).toBe('inner') + expect(revived.error.tags).toBeInstanceOf(Map) + expect(revived.error.tags.get('a')).toBe(1) }) - it('preserves rich error info (cause + custom props) through a full read round-trip', async () => { - const tags = new Map([['a', 1]]) + it('throws DF0020 when a jsonSerializable: true function attaches non-JSON to an error', async () => { const flaky = defineRpcFunction({ - name: 'test:flaky-roundtrip', + name: 'test:flaky-non-json', type: 'query', jsonSerializable: true, - handler: (id: string) => { - if (id === 'bad') { - const err = new TypeError('boom', { cause: new Error('inner') }) as Error & { tags?: unknown } - err.tags = tags - throw err - } - return { id } + handler: () => { + const err = new Error('boom') as Error & { tags?: unknown } + err.tags = new Map([['a', 1]]) + throw err }, dump: { - inputs: [['bad']], + inputs: [[]] as [][], }, }) const result = await collectStaticRpcDump([flaky], {}) - const entry = result.manifest['test:flaky-roundtrip'] as { - records: Record - recordSerializations?: Record - } - - const recordKey = Object.keys(entry.records)[0]! - const recordPath = entry.records[recordKey]! + const recordPath = Object.values( + (result.manifest['test:flaky-non-json'] as { records: Record }).records, + )[0]! const file = result.files[recordPath]! - // The shard was promoted to structured-clone — verify the Error - // (and its Map property) round-trips losslessly through the wire format. - expect(file.serialization).toBe('structured-clone') - const wireText = structuredCloneStringify(file.data) - const revived = structuredCloneDeserialize(JSON.parse(wireText)) as { - error: { name: string, message: string, cause: { message: string }, tags: Map } - } - expect(revived.error.name).toBe('TypeError') - expect(revived.error.message).toBe('boom') - expect(revived.error.cause.message).toBe('inner') - expect(revived.error.tags).toBeInstanceOf(Map) - expect(revived.error.tags.get('a')).toBe(1) + // Attaching a Map to the thrown Error violates the `jsonSerializable: true` + // contract; the strict serializer surfaces this at build time, same as + // if the function had returned a Map. + expect(() => strictJsonStringify(file.data, file.fnName)) + .toThrowError(/jsonSerializable: true.*is a Map/) }) }) }) diff --git a/packages/devframe/src/node/static-dump.ts b/packages/devframe/src/node/static-dump.ts index 4c6fa9d..27839a2 100644 --- a/packages/devframe/src/node/static-dump.ts +++ b/packages/devframe/src/node/static-dump.ts @@ -19,16 +19,6 @@ export interface StaticRpcDumpManifestQueryEntry { fallback?: string /** Encoder used when each record/fallback file was written. Default: `'json'`. */ serialization?: StaticRpcDumpSerialization - /** - * Per-record encoder override. When a record file is written with a - * different serializer than {@link serialization} (e.g. an error-bearing - * record promoted to `'structured-clone'` for a `jsonSerializable: true` - * function), the override is recorded here so the client picks the - * right decoder. - */ - recordSerializations?: Record - /** Encoder override for the fallback shard, with the same semantics as {@link recordSerializations}. */ - fallbackSerialization?: StaticRpcDumpSerialization } export type StaticRpcDumpManifestValue @@ -125,29 +115,15 @@ export async function collectStaticRpcDump( const key = recordKey.slice(prefix.length) const record = await resolveRecord(recordOrGetter) - // Error-bearing records can contain non-JSON values (e.g. an - // `Error.cause` chain, or a `Map` attached to the thrown error). - // For `jsonSerializable: true` functions, promote just this one - // file to structured-clone so the rich error round-trips losslessly. - const recordSerialization: StaticRpcDumpSerialization - = record.error !== undefined && serialization === 'json' - ? 'structured-clone' - : serialization - if (key === 'fallback') { const path = makeQueryFallbackPath(definition.name) - files[path] = { serialization: recordSerialization, fnName: definition.name, data: record } + files[path] = { serialization, fnName: definition.name, data: record } queryEntry.fallback = path - if (recordSerialization !== serialization) - queryEntry.fallbackSerialization = recordSerialization } else { const path = makeQueryRecordPath(definition.name, key) - files[path] = { serialization: recordSerialization, fnName: definition.name, data: record } + files[path] = { serialization, fnName: definition.name, data: record } queryEntry.records[key] = path - if (recordSerialization !== serialization) { - ;(queryEntry.recordSerializations ??= {})[key] = recordSerialization - } } } diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index 0acc8e6..516f87f 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -36,8 +36,6 @@ export interface StaticRpcDumpManifestQueryEntry { records: Record; fallback?: string; serialization?: StaticRpcDumpSerialization; - recordSerializations?: Record; - fallbackSerialization?: StaticRpcDumpSerialization; } export interface StaticRpcDumpManifestStaticEntry { type: 'static';