From 4112bef4ceff5d4ecb5d277c04eddc2b9e36beee Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 31 Jul 2026 17:54:24 +0300 Subject: [PATCH] fix(serializer): encode shared object refs in DAGs Duplicate already-encoded subtrees on revisit instead of treating shared references as circular graphs. --- src/lib/protocol/serializer.ts | 41 +++++++++++++++++++----- src/test/serializer.unit.spec.ts | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 src/test/serializer.unit.spec.ts diff --git a/src/lib/protocol/serializer.ts b/src/lib/protocol/serializer.ts index a0651fa..f1619b3 100644 --- a/src/lib/protocol/serializer.ts +++ b/src/lib/protocol/serializer.ts @@ -41,7 +41,12 @@ export class Serializer { * @returns Value safe to send via `postMessage` */ encode(value: unknown, context: EncodeContext = {}): unknown { - return this.walkEncode(value, context, new WeakSet()); + return this.walkEncode( + value, + context, + new WeakSet(), + new Map(), + ); } /** @@ -56,15 +61,18 @@ export class Serializer { /** * Recursive encode walk. Throws on circular plain-object graphs. + * Shared (non-circular) refs are duplicated on the wire. * @param value - Current node * @param context - Encode hooks - * @param seen - Cycle detection set + * @param inProgress - Objects currently being walked (cycle detection) + * @param encoded - Finished wire copies keyed by source object * @returns Encoded node */ private walkEncode( value: unknown, context: EncodeContext, - seen: WeakSet, + inProgress: WeakSet, + encoded: Map, ): unknown { if (value === null || typeof value !== "object") { if (typeof value === "function") { @@ -123,29 +131,46 @@ export class Serializer { if (ref) return ref; } - if (seen.has(value)) { + if (inProgress.has(value)) { throw new Error( "Circular references are not supported when encoding values for remote calls", ); } + if (encoded.has(value)) { + return structuredClone(encoded.get(value)); + } + if (Array.isArray(value)) { - seen.add(value); + inProgress.add(value); + + const result = value.map((item) => this.walkEncode( + item, + context, + inProgress, + encoded, + )); + + inProgress.delete(value); + encoded.set(value, result); - return value.map((item) => this.walkEncode(item, context, seen)); + return result; } if (!isPlainObject(value)) { return value; } - seen.add(value); + inProgress.add(value); const out: Record = {}; for (const [key, item] of Object.entries(value)) { - out[key] = this.walkEncode(item, context, seen); + out[key] = this.walkEncode(item, context, inProgress, encoded); } + inProgress.delete(value); + encoded.set(value, out); + return out; } diff --git a/src/test/serializer.unit.spec.ts b/src/test/serializer.unit.spec.ts new file mode 100644 index 0000000..66e0017 --- /dev/null +++ b/src/test/serializer.unit.spec.ts @@ -0,0 +1,55 @@ +import { + describe, expect, it, +} from "vitest"; + +import { Serializer } from "../lib/protocol/serializer.js"; + +describe("Serializer", () => { + it("encodes shared (non-circular) object references in a DAG", () => { + const s = new Serializer(); + const buyData = { + avgPrice: 100, + location: "x" as const, + maxPrice: 100, + minPrice: 100, + ordersCount: 1, + xOrdersCount: 1, + }; + const sellData = { + avgPrice: 110, + location: "x" as const, + maxPrice: 110, + minPrice: 110, + ordersCount: 1, + xOrdersCount: 1, + }; + + // Nested summary + top-level fields point at the same objects. + const result = { + items: [{ + arbitrage: { + buy: buyData, + profit: 1, + sell: sellData, + }, + buy: buyData, + sell: sellData, + typeID: 1, + }], + staleSpread: [], + }; + + expect(() => s.encode(result)).not.toThrow(); + }); + + it("rejects true circular references", () => { + const s = new Serializer(); + const circular: Record = { value: 1 }; + + circular.self = circular; + + expect(() => s.encode(circular)).toThrow( + /Circular references are not supported/, + ); + }); +});