From 660f9584ae9c53f27cf6aee4313e72e197e83b32 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 10:01:54 +0000 Subject: [PATCH] fix(rpc): skip unknown meta types when deserializing Client and server can run different versions with different serializer handlers. Previously a meta entry with a handler key the receiver did not know threw and failed the whole payload. Now such entries are skipped, so the value stays in its serialized form while known values, including those nested inside it, are still restored. Skipped entries neither walk nor mutate the payload, and types are still looked up on a null-prototype map, so prototype keys never resolve to a handler. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RZzFyYdaijHa8YrMn --- apps/content/docs/rpc/serializer.mdx | 4 ++++ .../client/src/rpc-json-serializer.test.ts | 21 +++++++++++++++++-- packages/client/src/rpc-json-serializer.ts | 8 ++++--- packages/client/src/rpc-serializer.test.ts | 5 ----- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/apps/content/docs/rpc/serializer.mdx b/apps/content/docs/rpc/serializer.mdx index a18513f75d..ae4567fce8 100644 --- a/apps/content/docs/rpc/serializer.mdx +++ b/apps/content/docs/rpc/serializer.mdx @@ -94,6 +94,10 @@ const link = new RPCLink({ ::: +:::info[Handlers out of sync] +Client and server can run different versions, so one side may receive a handler key it doesn't know. The value is then left in its serialized form instead of failing the whole payload, and values nested inside it are still restored. Configure the same handlers on both sides to get the original types back. +::: + ## BigInt Digit Limit The built-in `bigint` handler only accepts decimal strings of up to 4300 digits, since parsing longer ones can block the event loop. To allow longer values, override it on both sides: diff --git a/packages/client/src/rpc-json-serializer.test.ts b/packages/client/src/rpc-json-serializer.test.ts index 4a733f1d57..18b5823d58 100644 --- a/packages/client/src/rpc-json-serializer.test.ts +++ b/packages/client/src/rpc-json-serializer.test.ts @@ -380,6 +380,24 @@ describe('rpcJsonSerializer: custom handlers', () => { }) }) +describe('rpcJsonSerializer: unknown types', () => { + const serializer = new RPCJsonSerializer({ handlers: { url: undefined } }) + + it('leaves their values in serialized form, still restoring known values inside', () => { + expect(serializer.deserializeJson({ + json: { person: { name: 'dinwwwh', date: '2023-01-01T00:00:00.000Z' }, url: 'https://orpc.dev/' }, + meta: [['date', 'person', 'date'], ['person', 'person'], ['url', 'url']], + })).toEqual({ person: { name: 'dinwwwh', date: new Date('2023-01-01') }, url: 'https://orpc.dev/' }) + }) + + it('skips their entries without walking the path', () => { + expect(serializer.deserializeJson({ + json: { a: { b: { x: '1', y: '2', z: '3' } }, v: '4' }, + meta: [['bigint', 'a', 'b', 'x'], ['unknown', 'a', 'b', 'y'], ['bigint', 'a', 'b', 'z'], ['unknown', 'missing', 'deep'], ['bigint', 'v'], ['unknown', 'v', 'x']], + })).toEqual({ a: { b: { x: 1n, y: '2', z: 3n } }, v: 4n }) + }) +}) + describe('rpcJsonSerializer: deserialize', () => { const serializer = new RPCJsonSerializer() @@ -455,8 +473,7 @@ describe('rpcJsonSerializer: security', () => { }) it.each(['nonexistent', '__proto__', 'constructor', 'prototype', 'toString', 'hasOwnProperty', 'valueOf'])('never resolves the meta type "%s" through the prototype chain', (type) => { - expect(() => serializer.deserializeJson({ json: 1, meta: [[type]] })) - .toThrow(`Invalid RPC serialized data: type "${type}" is not supported.`) + expect(serializer.deserializeJson({ json: 1, meta: [[type]] })).toBe(1) }) it('throws instead of producing garbage for corrupted built-in payloads', () => { diff --git a/packages/client/src/rpc-json-serializer.ts b/packages/client/src/rpc-json-serializer.ts index 9243d1a5f0..5c092b10ae 100644 --- a/packages/client/src/rpc-json-serializer.ts +++ b/packages/client/src/rpc-json-serializer.ts @@ -199,6 +199,9 @@ export interface RPCJsonSerializerOptions { * handlers: { url: undefined } * ``` * + * During deserialization, values whose type has no handler are left in their serialized form, + * so a client and server with different handlers can still communicate. + * * Built-in type keys: `undefined`, `bigint`, `date`, `nan`, `infinity`, `url`, `set`, `map`. */ handlers?: Record | undefined @@ -454,11 +457,10 @@ export class RPCJsonSerializer { let chainDepth = 0 for (const item of meta) { - const type = item[0] - const handler = this.handlers[type] + const handler = this.handlers[item[0]] if (handler === undefined) { - throw invalidSerializedData(`type "${type}" is not supported.`) + continue } const depth = item.length - 1 diff --git a/packages/client/src/rpc-serializer.test.ts b/packages/client/src/rpc-serializer.test.ts index 974ef9036b..fbae71df2a 100644 --- a/packages/client/src/rpc-serializer.test.ts +++ b/packages/client/src/rpc-serializer.test.ts @@ -381,10 +381,5 @@ describe('rpcSerializer', () => { // only a Blob can land there, and the spec rejects it as an array length expect(() => serializer.deserialize(form)).toThrow(RangeError) }) - - it('rejects unknown meta types instead of resolving them on the prototype chain', () => { - expect(() => serializer.deserialize({ json: 1, meta: [['constructor']] })).toThrow() - expect(() => serializer.deserialize({ json: 1, meta: [['__proto__']] })).toThrow() - }) }) })