Skip to content
Merged
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
41 changes: 33 additions & 8 deletions src/lib/protocol/serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>());
return this.walkEncode(
value,
context,
new WeakSet<object>(),
new Map<object, unknown>(),
);
}

/**
Expand All @@ -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<object>,
inProgress: WeakSet<object>,
encoded: Map<object, unknown>,
): unknown {
if (value === null || typeof value !== "object") {
if (typeof value === "function") {
Expand Down Expand Up @@ -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<string, unknown> = {};

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;
}

Expand Down
55 changes: 55 additions & 0 deletions src/test/serializer.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = { value: 1 };

circular.self = circular;

expect(() => s.encode(circular)).toThrow(
/Circular references are not supported/,
);
});
});
Loading