diff --git a/devframe/packages/devframe/src/rpc/transports/ws-client.ts b/devframe/packages/devframe/src/rpc/transports/ws-client.ts index 1c388f5d..f25b01c8 100644 --- a/devframe/packages/devframe/src/rpc/transports/ws-client.ts +++ b/devframe/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/devframe/packages/devframe/src/rpc/transports/ws-server.ts b/devframe/packages/devframe/src/rpc/transports/ws-server.ts index af8a4656..5c8d3a4f 100644 --- a/devframe/packages/devframe/src/rpc/transports/ws-server.ts +++ b/devframe/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/devframe/packages/devframe/src/rpc/transports/ws.test.ts b/devframe/packages/devframe/src/rpc/transports/ws.test.ts index 03321e06..71f6e37d 100644 --- a/devframe/packages/devframe/src/rpc/transports/ws.test.ts +++ b/devframe/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())) + } + }) })