diff --git a/packages/devframe/src/client/static-rpc.ts b/packages/devframe/src/client/static-rpc.ts index 757a050..f46f501 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' @@ -28,10 +30,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 +55,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 } diff --git a/packages/devframe/src/node/__tests__/static-dump.test.ts b/packages/devframe/src/node/__tests__/static-dump.test.ts index 18906b1..66c6b82 100644 --- a/packages/devframe/src/node/__tests__/static-dump.test.ts +++ b/packages/devframe/src/node/__tests__/static-dump.test.ts @@ -216,4 +216,106 @@ describe('collectStaticRpcDump', () => { .toThrowError(/jsonSerializable: true.*is a Map/) }) }) + + describe('error-bearing records', () => { + it('writes JSON-safe error shape (message + name + cause) for jsonSerializable: true', async () => { + const flaky = defineRpcFunction({ + name: 'test:flaky', + type: 'query', + jsonSerializable: true, + handler: () => { + throw new TypeError('boom', { cause: new Error('inner') }) + }, + dump: { + inputs: [[]] as [][], + }, + }) + + const result = await collectStaticRpcDump([flaky], {}) + const entry = result.manifest['test:flaky'] as { + records: Record + serialization: 'json' + } + + expect(entry.serialization).toBe('json') + + const recordPath = Object.values(entry.records)[0]! + const file = result.files[recordPath]! + expect(file.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('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-roundtrip', + type: 'query', + // default jsonSerializable: false → structured-clone shards + handler: () => { + const err = new TypeError('boom', { cause: new Error('inner') }) as Error & { tags?: unknown } + err.tags = tags + throw err + }, + dump: { + inputs: [[]] as [][], + }, + }) + + const result = await collectStaticRpcDump([flaky], {}) + const entry = result.manifest['test:flaky-roundtrip'] as { + records: Record + serialization: 'structured-clone' + } + expect(entry.serialization).toBe('structured-clone') + + 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('throws DF0020 when a jsonSerializable: true function attaches non-JSON to an error', async () => { + const flaky = defineRpcFunction({ + name: 'test:flaky-non-json', + type: 'query', + jsonSerializable: true, + handler: () => { + const err = new Error('boom') as Error & { tags?: unknown } + err.tags = new Map([['a', 1]]) + throw err + }, + dump: { + inputs: [[]] as [][], + }, + }) + + const result = await collectStaticRpcDump([flaky], {}) + const recordPath = Object.values( + (result.manifest['test:flaky-non-json'] as { records: Record }).records, + )[0]! + const file = result.files[recordPath]! + + // 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/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/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/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..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' @@ -52,4 +53,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 HOST = '127.0.0.1' + const PORT = await getPort({ port: 3334, host: HOST }) + 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 }) + + try { + const client = createRpcClient>({}, { + channel: createWsRpcChannel({ url: WS_URL, definitions }), + }) + + await expect(client.$call('explode')).rejects.toThrow(/boom/) + } + finally { + for (const c of wss.clients) c.terminate() + await new Promise(resolve => wss.close(() => resolve())) + } + }) }) 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/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 }