Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion devframe/packages/devframe/src/rpc/transports/ws-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`
Expand Down
7 changes: 6 additions & 1 deletion devframe/packages/devframe/src/rpc/transports/ws-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`
Expand Down
34 changes: 34 additions & 0 deletions devframe/packages/devframe/src/rpc/transports/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { jsonSerializable?: boolean }>([
['explode', { jsonSerializable: true }],
])

const server = createRpcServer<Record<string, never>, typeof serverFunctions>(serverFunctions)
const { wss } = attachWsRpcTransport(server, { port: PORT, host: HOST, definitions: definitions as any })

try {
const client = createRpcClient<typeof serverFunctions, Record<string, never>>({}, {
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<void>(resolve => wss.close(() => resolve()))
}
})
})