From c1ea2d4663147565e7643fb8f043b5667d7fb548 Mon Sep 17 00:00:00 2001 From: kyken Date: Thu, 17 Sep 2026 15:41:39 +0900 Subject: [PATCH 1/3] update --- README.md | 21 ++++ package.json | 1 + src/accessDeep.ts | 142 +++++++++++++++++++++++ src/async.ts | 162 ++++++++++++++++++++++++++ src/bfj.d.ts | 16 +++ src/index.test.ts | 163 ++++++++++++++++++++++++++- src/index.ts | 134 ++++++++++++++++++++++ src/plainer.ts | 281 ++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 919 insertions(+), 1 deletion(-) create mode 100644 src/async.ts create mode 100644 src/bfj.d.ts diff --git a/README.md b/README.md index 9ff5d329..f724577a 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,27 @@ const object = superjson.parse< // object === { date: new Date(0) } ``` +The synchronous `stringify` and `parse` APIs remain available. For asynchronous +JSON processing, use `asyncStringify` and `asyncParse`: + +```js +const jsonString = await superjson.asyncStringify({ date: new Date(0) }); +const object = await superjson.asyncParse(jsonString); +``` + +The asynchronous string APIs use BFJ and are intended for Node.js runtimes. For +asynchronous processing at the transport boundary, use `asyncSerialize` and +`asyncDeserialize`: + +```js +const payload = await superjson.asyncSerialize(value, { yieldRate: 1024 }); +const object = await superjson.asyncDeserialize(payload, { yieldRate: 1024 }); +``` + +The synchronous `serialize` and `deserialize` APIs remain unchanged. The +asynchronous APIs process values in order and yield to the event loop after the +configured number of visited values. + ## Advanced Usage For cases where you want lower level access to the `json` and `meta` data in the output, you can use the `serialize` and `deserialize` functions. diff --git a/package.json b/package.json index e6210940..32fbaafc 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "vitest": "^0.34.6" }, "dependencies": { + "bfj": "^7.1.0", "copy-anything": "^4" } } diff --git a/src/accessDeep.ts b/src/accessDeep.ts index ea986130..1d34deb1 100644 --- a/src/accessDeep.ts +++ b/src/accessDeep.ts @@ -1,5 +1,6 @@ import { isMap, isArray, isPlainObject, isSet } from './is.js'; import { includes } from './util.js'; +import { AsyncYieldController } from './async.js'; const getNthKey = (value: Map | Set, n: number): any => { if (n > value.size) throw new Error('index out of bounds'); @@ -139,3 +140,144 @@ export const setDeep = ( return object; }; + +const getNthKeyAsync = async ( + value: Map | Set, + n: number, + scheduler: AsyncYieldController +): Promise => { + if (n > value.size) throw new Error('index out of bounds'); + const keys = value.keys(); + while (n > 0) { + await scheduler.tick(); + keys.next(); + n--; + } + + await scheduler.tick(); + return keys.next().value; +}; + +export const getDeepAsync = async ( + object: object, + path: (string | number)[], + scheduler: AsyncYieldController +): Promise => { + validatePath(path); + + for (let i = 0; i < path.length; i++) { + await scheduler.tick(); + const key = path[i]; + if (isSet(object)) { + object = await getNthKeyAsync(object, +key, scheduler); + } else if (isMap(object)) { + const row = +key; + const type = +path[++i] === 0 ? 'key' : 'value'; + + const keyOfRow = await getNthKeyAsync(object, row, scheduler); + switch (type) { + case 'key': + object = keyOfRow; + break; + case 'value': + object = object.get(keyOfRow); + break; + } + } else { + object = (object as any)[key]; + } + } + + return object; +}; + +export const setDeepAsync = async ( + object: any, + path: (string | number)[], + mapper: (v: any) => any, + scheduler: AsyncYieldController +): Promise => { + validatePath(path); + + if (path.length === 0) { + return mapper(object); + } + + let parent = object; + + for (let i = 0; i < path.length - 1; i++) { + await scheduler.tick(); + const key = path[i]; + + if (isArray(parent)) { + const index = +key; + parent = parent[index]; + } else if (isPlainObject(parent)) { + parent = parent[key]; + } else if (isSet(parent)) { + const row = +key; + parent = await getNthKeyAsync(parent, row, scheduler); + } else if (isMap(parent)) { + const isEnd = i === path.length - 2; + if (isEnd) { + break; + } + + const row = +key; + const type = +path[++i] === 0 ? 'key' : 'value'; + + const keyOfRow = await getNthKeyAsync(parent, row, scheduler); + switch (type) { + case 'key': + parent = keyOfRow; + break; + case 'value': + parent = parent.get(keyOfRow); + break; + } + } + } + + await scheduler.tick(); + const lastKey = path[path.length - 1]; + + if (isArray(parent)) { + parent[+lastKey] = mapper(parent[+lastKey]); + } else if (isPlainObject(parent)) { + parent[lastKey] = mapper(parent[lastKey]); + } + + if (isSet(parent)) { + const oldValue = await getNthKeyAsync(parent, +lastKey, scheduler); + const newValue = mapper(oldValue); + if (oldValue !== newValue) { + parent.delete(oldValue); + parent.add(newValue); + } + } + + if (isMap(parent)) { + const row = +path[path.length - 2]; + const keyToRow = await getNthKeyAsync(parent, row, scheduler); + + const type = +lastKey === 0 ? 'key' : 'value'; + switch (type) { + case 'key': { + const newKey = mapper(keyToRow); + parent.set(newKey, parent.get(keyToRow)); + + if (newKey !== keyToRow) { + parent.delete(keyToRow); + } + break; + } + + case 'value': { + parent.set(keyToRow, mapper(parent.get(keyToRow))); + break; + } + } + } + + return object; +}; diff --git a/src/async.ts b/src/async.ts new file mode 100644 index 00000000..72078490 --- /dev/null +++ b/src/async.ts @@ -0,0 +1,162 @@ +export interface AsyncOptions { + yieldRate?: number; +} + +const defaultYieldRate = 1024; + +type GlobalWithTimers = typeof globalThis & { + setImmediate?: (callback: () => void) => void; + setTimeout: (callback: () => void, delay: number) => unknown; +}; + +const yieldToEventLoop = () => + new Promise(resolve => { + const globalObject = globalThis as GlobalWithTimers; + if (globalObject.setImmediate) { + globalObject.setImmediate(resolve); + } else { + globalObject.setTimeout(resolve, 0); + } + }); + +export class AsyncYieldController { + private count = 0; + private readonly yieldRate: number; + + constructor(options?: AsyncOptions) { + const yieldRate = options?.yieldRate ?? defaultYieldRate; + if (!Number.isFinite(yieldRate) || yieldRate < 1) { + throw new RangeError('yieldRate must be a finite number greater than 0'); + } + + this.yieldRate = Math.floor(yieldRate); + } + + async tick(): Promise { + this.count++; + if (this.count < this.yieldRate) { + return; + } + + this.count = 0; + await yieldToEventLoop(); + } +} + +const isCopyablePlainObject = (value: any): boolean => + Object.getPrototypeOf(value) === Object.prototype && + Object.prototype.toString.call(value) === '[object Object]'; + +const assignProp = ( + target: any, + key: string | symbol, + value: any, + source: any +) => { + if (Object.prototype.propertyIsEnumerable.call(source, key)) { + target[key] = value; + return; + } + + Object.defineProperty(target, key, { + value, + enumerable: false, + writable: true, + configurable: true, + }); +}; + +const cloneRef = ( + value: any, + clones: Map, + sources: any[], + destinations: any[] +) => { + if (typeof value !== 'object' || value === null) { + return value; + } + + const array = Array.isArray(value); + if (!array && !isCopyablePlainObject(value)) { + return value; + } + + const existing = clones.get(value); + if (existing !== undefined) { + return existing; + } + + const clone = array ? new Array(value.length) : {}; + clones.set(value, clone); + sources.push(value); + destinations.push(clone); + return clone; +}; + +export async function copyAsync( + target: T, + scheduler: AsyncYieldController +): Promise { + if (typeof target !== 'object' || target === null) { + return target; + } + + const clones = new Map(); + const sources: any[] = []; + const destinations: any[] = []; + const result = cloneRef(target, clones, sources, destinations); + + while (sources.length) { + await scheduler.tick(); + const source = sources.pop(); + const destination = destinations.pop(); + + if (Array.isArray(source)) { + for (let index = 0; index < source.length; index++) { + await scheduler.tick(); + if (index in source) { + destination[index] = cloneRef( + source[index], + clones, + sources, + destinations + ); + } + } + continue; + } + + for (const key in source) { + await scheduler.tick(); + if ( + !Object.prototype.hasOwnProperty.call(source, key) || + key === '__proto__' + ) { + continue; + } + + assignProp( + destination, + key, + cloneRef(source[key], clones, sources, destinations), + source + ); + } + + for (const key of Object.getOwnPropertySymbols(source)) { + await scheduler.tick(); + if (!Object.prototype.propertyIsEnumerable.call(source, key)) { + continue; + } + + assignProp( + destination, + key, + cloneRef(source[key], clones, sources, destinations), + source + ); + } + } + + return result; +} \ No newline at end of file diff --git a/src/bfj.d.ts b/src/bfj.d.ts new file mode 100644 index 00000000..ec3360f8 --- /dev/null +++ b/src/bfj.d.ts @@ -0,0 +1,16 @@ +/// + +declare module 'bfj' { + interface Options { + [key: string]: unknown; + } + + function parse( + stream: NodeJS.ReadableStream, + options?: Options + ): Promise; + + function stringify(value: unknown, options?: Options): Promise; + + export { parse, stringify }; +} \ No newline at end of file diff --git a/src/index.test.ts b/src/index.test.ts index 249bbb01..e983fc58 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -3,7 +3,12 @@ import * as fs from 'fs'; -import SuperJSON from './index.js'; +import SuperJSON, { + asyncDeserialize, + asyncParse, + asyncSerialize, + asyncStringify, +} from './index.js'; import { JSONValue, SuperJSONResult, SuperJSONValue } from './types.js'; import { isArray, @@ -1019,6 +1024,162 @@ describe('stringify & parse', () => { }); }); +describe('async stringify & parse', () => { + it('preserves low-level serialize and deserialize behavior', async () => { + const shared = { value: 1 }; + const input = { + date: new Date(0), + set: new Set([shared]), + first: shared, + second: shared, + missing: undefined, + }; + + const expected = SuperJSON.serialize(input); + const serialized = await SuperJSON.asyncSerialize(input); + const deserialized = await SuperJSON.asyncDeserialize( + serialized + ); + + expect(serialized).toEqual(expected); + expect(deserialized).toEqual(input); + expect(deserialized.first).toBe(deserialized.second); + }); + + it('preserves collection references and circular references', async () => { + const shared = { value: 1 }; + const input = { + map: new Map([['shared', shared]]), + set: new Set([shared]), + }; + const expected = SuperJSON.serialize(input); + const serialized = await SuperJSON.asyncSerialize(input); + const deserialized = await SuperJSON.asyncDeserialize( + serialized + ); + + expect(serialized).toEqual(expected); + expect(deserialized.map.get('shared')).toBe( + [...deserialized.set][0] + ); + + const circular: { self?: unknown } = {}; + circular.self = circular; + const circularSerialized = await SuperJSON.asyncSerialize({ circular }); + const circularDeserialized = await SuperJSON.asyncDeserialize<{ + circular: { self?: unknown }; + }>(circularSerialized); + + expect(circularDeserialized.circular.self).toBe( + circularDeserialized.circular + ); + }); + + it('supports in-place asynchronous deserialization', async () => { + const serialized = await asyncSerialize({ date: new Date(0) }); + const deserialized = await asyncDeserialize(serialized, { inPlace: true }); + + expect(deserialized).toBe(serialized.json); + expect((deserialized as { date: Date }).date).toEqual(new Date(0)); + }); + + it('yields to the event loop during serialization and deserialization', async () => { + const input = Object.fromEntries( + Array.from({ length: 100 }, (_, index) => [`value${index}`, index]) + ); + + let serializationDone = false; + const serialization = SuperJSON.asyncSerialize(input, { yieldRate: 1 }); + serialization.then( + () => { + serializationDone = true; + }, + () => { + serializationDone = true; + } + ); + + await new Promise(resolve => setImmediate(resolve)); + expect(serializationDone).toBe(false); + + const serialized = await serialization; + let deserializationDone = false; + const deserialization = SuperJSON.asyncDeserialize(serialized, { + yieldRate: 1, + }); + deserialization.then( + () => { + deserializationDone = true; + }, + () => { + deserializationDone = true; + } + ); + + await new Promise(resolve => setImmediate(resolve)); + expect(deserializationDone).toBe(false); + await deserialization; + }); + + it('preserves SuperJSON transformations', async () => { + const input = { + date: new Date(0), + set: new Set([1, 2]), + missing: undefined, + }; + + const stringified = await SuperJSON.asyncStringify(input); + const parsed = await SuperJSON.asyncParse(stringified); + + expect(JSON.parse(stringified)).toEqual({ + json: { + date: '1970-01-01T00:00:00.000Z', + set: [1, 2], + missing: null, + }, + meta: { + values: { + date: ['Date'], + set: ['set'], + missing: ['undefined'], + }, + v: 1, + }, + }); + expect(parsed.date).toEqual(input.date); + expect(parsed.set).toEqual(input.set); + expect(parsed.missing).toBeUndefined(); + }); + + it('exposes named asynchronous APIs', async () => { + const stringified = await asyncStringify({ value: 1 }); + + await expect(asyncParse(stringified)).resolves.toEqual({ value: 1 }); + }); + + it('rejects invalid JSON', async () => { + await expect(SuperJSON.asyncParse('{')).rejects.toThrow(); + }); + + it('rejects prototype pollution through asynchronous APIs', async () => { + await expect( + SuperJSON.asyncSerialize({ ['__proto__']: 1 } as any) + ).rejects.toThrow(/prototype pollution risk/); + + const payload: SuperJSONResult = { + json: { myValue: 1337 }, + meta: { + referentialEqualities: { + myValue: ['__proto__.x'], + }, + }, + }; + await expect(SuperJSON.asyncDeserialize(payload)).rejects.toThrow( + '__proto__ is not allowed as a property' + ); + }); +}); + describe('allowErrorProps(...) (#91)', () => { it('works with simple prop values', () => { const errorWithAdditionalProps: Error & any = new Error( diff --git a/src/index.ts b/src/index.ts index 9a11ad74..ea4b9fda 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,11 +7,20 @@ import { } from './custom-transformer-registry.js'; import { applyReferentialEqualityAnnotations, + applyReferentialEqualityAnnotationsAsync, applyValueAnnotations, + applyValueAnnotationsAsync, + asyncWalker, generateReferentialEqualityAnnotations, + generateReferentialEqualityAnnotationsAsync, walker, } from './plainer.js'; import { copy } from 'copy-anything'; +import { + AsyncOptions, + AsyncYieldController, + copyAsync, +} from './async.js'; export default class SuperJSON { /** @@ -80,6 +89,81 @@ export default class SuperJSON { return result; } + async asyncSerialize( + object: SuperJSONValue, + options?: AsyncOptions + ): Promise { + const scheduler = new AsyncYieldController(options); + const identities = new Map(); + const output = await asyncWalker( + object, + identities, + this, + this.dedupe, + scheduler + ); + const res: SuperJSONResult = { + json: output.transformedValue, + }; + + if (output.annotations) { + res.meta = { + ...res.meta, + values: output.annotations, + }; + } + + const equalityAnnotations = + await generateReferentialEqualityAnnotationsAsync( + identities, + this.dedupe, + scheduler + ); + if (equalityAnnotations) { + res.meta = { + ...res.meta, + referentialEqualities: equalityAnnotations, + }; + } + + if (res.meta) res.meta.v = 1; + + return res; + } + + async asyncDeserialize( + payload: SuperJSONResult, + options?: { inPlace?: boolean; yieldRate?: number } + ): Promise { + const { json, meta } = payload; + const scheduler = new AsyncYieldController(options); + + let result: T = options?.inPlace + ? json + : ((await copyAsync(json, scheduler)) as any); + + if (meta?.values) { + result = await applyValueAnnotationsAsync( + result, + meta.values, + meta.v ?? 0, + this, + scheduler + ); + } + + if (meta?.referentialEqualities) { + result = await applyReferentialEqualityAnnotationsAsync( + result, + meta.referentialEqualities, + meta.v ?? 0, + scheduler + ); + } + + return result; + } + stringify(object: SuperJSONValue): string { return JSON.stringify(this.serialize(object)); } @@ -88,6 +172,38 @@ export default class SuperJSON { return this.deserialize(JSON.parse(string), { inPlace: true }); } + async asyncStringify( + object: SuperJSONValue, + options?: AsyncOptions + ): Promise { + const bfj = await import('bfj'); + const bfjOptions = options + ? { yieldRate: options.yieldRate } + : undefined; + return bfj.stringify( + await this.asyncSerialize(object, options), + bfjOptions + ); + } + + async asyncParse( + string: string, + options?: AsyncOptions + ): Promise { + const [bfj, { Readable }] = await Promise.all([ + import('bfj'), + import('node:stream'), + ]); + const bfjOptions = options + ? { yieldRate: options.yieldRate } + : undefined; + const payload = await bfj.parse(Readable.from([string]), bfjOptions); + return this.asyncDeserialize(payload as SuperJSONResult, { + inPlace: true, + yieldRate: options?.yieldRate, + }); + } + readonly classRegistry = new ClassRegistry(); registerClass(v: Class, options?: RegisterOptions | string) { this.classRegistry.register(v, options); @@ -121,12 +237,24 @@ export default class SuperJSON { static deserialize = SuperJSON.defaultInstance.deserialize.bind( SuperJSON.defaultInstance ); + static asyncSerialize = SuperJSON.defaultInstance.asyncSerialize.bind( + SuperJSON.defaultInstance + ); + static asyncDeserialize = SuperJSON.defaultInstance.asyncDeserialize.bind( + SuperJSON.defaultInstance + ); static stringify = SuperJSON.defaultInstance.stringify.bind( SuperJSON.defaultInstance ); static parse = SuperJSON.defaultInstance.parse.bind( SuperJSON.defaultInstance ); + static asyncStringify = SuperJSON.defaultInstance.asyncStringify.bind( + SuperJSON.defaultInstance + ); + static asyncParse = SuperJSON.defaultInstance.asyncParse.bind( + SuperJSON.defaultInstance + ); static registerClass = SuperJSON.defaultInstance.registerClass.bind( SuperJSON.defaultInstance ); @@ -146,9 +274,15 @@ export { SuperJSON, SuperJSONResult, SuperJSONValue }; export const serialize = SuperJSON.serialize; export const deserialize = SuperJSON.deserialize; +export const asyncSerialize = SuperJSON.asyncSerialize; +export const asyncDeserialize = SuperJSON.asyncDeserialize; + export const stringify = SuperJSON.stringify; export const parse = SuperJSON.parse; +export const asyncStringify = SuperJSON.asyncStringify; +export const asyncParse = SuperJSON.asyncParse; + export const registerClass = SuperJSON.registerClass; export const registerCustom = SuperJSON.registerCustom; export const registerSymbol = SuperJSON.registerSymbol; diff --git a/src/plainer.ts b/src/plainer.ts index 8e9059f1..bd81e7bc 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -17,6 +17,8 @@ import { import { includes, forEach } from './util.js'; import { parsePath } from './pathstringifier.js'; import { getDeep, setDeep } from './accessDeep.js'; +import { getDeepAsync, setDeepAsync } from './accessDeep.js'; +import { AsyncYieldController } from './async.js'; import SuperJSON from './index.js'; type Tree = InnerNode | Leaf; @@ -61,6 +63,48 @@ function traverse( walker(nodeValue, origin); } +async function traverseAsync( + tree: MinimisedTree, + walker: (v: T, path: string[]) => Promise, + version: number, + scheduler: AsyncYieldController, + origin: string[] = [] +): Promise { + if (!tree) { + return; + } + + await scheduler.tick(); + const legacyPaths = enableLegacyPaths(version); + if (!isArray(tree)) { + for (const [key, subtree] of Object.entries(tree)) { + await traverseAsync( + subtree, + walker, + version, + scheduler, + [...origin, ...parsePath(key, legacyPaths)] + ); + } + return; + } + + const [nodeValue, children] = tree; + if (children) { + for (const [key, child] of Object.entries(children)) { + await traverseAsync( + child, + walker, + version, + scheduler, + [...origin, ...parsePath(key, legacyPaths)] + ); + } + } + + await walker(nodeValue, origin); +} + export function applyValueAnnotations( plain: any, annotations: MinimisedTree, @@ -78,6 +122,30 @@ export function applyValueAnnotations( return plain; } +export async function applyValueAnnotationsAsync( + plain: any, + annotations: MinimisedTree, + version: number, + superJson: SuperJSON, + scheduler: AsyncYieldController +) { + await traverseAsync( + annotations, + async (type, path) => { + plain = await setDeepAsync( + plain, + path, + v => untransformValue(v, type, superJson), + scheduler + ); + }, + version, + scheduler + ); + + return plain; +} + export function applyReferentialEqualityAnnotations( plain: any, annotations: ReferentialEqualityAnnotations, @@ -114,6 +182,57 @@ export function applyReferentialEqualityAnnotations( return plain; } +export async function applyReferentialEqualityAnnotationsAsync( + plain: any, + annotations: ReferentialEqualityAnnotations, + version: number, + scheduler: AsyncYieldController +) { + const legacyPaths = enableLegacyPaths(version); + async function apply(identicalPaths: string[], path: string) { + const object = await getDeepAsync( + plain, + parsePath(path, legacyPaths), + scheduler + ); + + for (const identicalPath of identicalPaths) { + await scheduler.tick(); + plain = await setDeepAsync( + plain, + parsePath(identicalPath, legacyPaths), + () => object, + scheduler + ); + } + } + + if (isArray(annotations)) { + const [root, other] = annotations; + for (const identicalPath of root) { + await scheduler.tick(); + plain = await setDeepAsync( + plain, + parsePath(identicalPath, legacyPaths), + () => plain, + scheduler + ); + } + + if (other) { + for (const [path, identicalPaths] of Object.entries(other)) { + await apply(identicalPaths, path); + } + } + } else { + for (const [path, identicalPaths] of Object.entries(annotations)) { + await apply(identicalPaths, path); + } + } + + return plain; +} + const isDeep = (object: any, superJson: SuperJSON): boolean => isPlainObject(object) || isArray(object) || @@ -185,6 +304,59 @@ export function generateReferentialEqualityAnnotations( } } +export async function generateReferentialEqualityAnnotationsAsync( + identitites: Map, + dedupe: boolean, + scheduler: AsyncYieldController +): Promise { + const result: Record = {}; + let rootEqualityPaths: string[] | undefined = undefined; + + for (const pathsInMap of identitites.values()) { + await scheduler.tick(); + let paths = pathsInMap; + if (paths.length <= 1) { + continue; + } + + if (!dedupe) { + const stringifiedPaths: string[][] = []; + for (const path of paths) { + await scheduler.tick(); + stringifiedPaths.push(path.map(String)); + } + paths = stringifiedPaths.sort((a, b) => a.length - b.length); + } + + const [representativePath, ...identicalPaths] = paths; + if (representativePath.length === 0) { + rootEqualityPaths = []; + for (const path of identicalPaths) { + await scheduler.tick(); + rootEqualityPaths.push(stringifyPath(path.map(String))); + } + } else { + const stringifiedIdenticalPaths: string[] = []; + for (const path of identicalPaths) { + await scheduler.tick(); + stringifiedIdenticalPaths.push(stringifyPath(path.map(String))); + } + result[stringifyPath(representativePath.map(String))] = + stringifiedIdenticalPaths; + } + } + + if (rootEqualityPaths) { + if (isEmptyObject(result)) { + return [rootEqualityPaths]; + } else { + return [rootEqualityPaths, result]; + } + } else { + return isEmptyObject(result) ? undefined : result; + } +} + export const walker = ( object: any, identities: Map, @@ -291,3 +463,112 @@ export const walker = ( return result; }; + +export const asyncWalker = async ( + object: any, + identities: Map, + superJson: SuperJSON, + dedupe: boolean, + scheduler: AsyncYieldController, + path: any[] = [], + objectsInThisPath: any[] = [], + seenObjects = new Map() +): Promise => { + await scheduler.tick(); + const primitive = isPrimitive(object); + + if (!primitive) { + addIdentity(object, path, identities); + + const seen = seenObjects.get(object); + if (seen) { + return dedupe + ? { + transformedValue: null, + } + : seen; + } + } + + if (!isDeep(object, superJson)) { + const transformed = transformValue(object, superJson); + + const result: Result = transformed + ? { + transformedValue: transformed.value, + annotations: [transformed.type], + } + : { + transformedValue: object, + }; + if (!primitive) { + seenObjects.set(object, result); + } + return result; + } + + if (includes(objectsInThisPath, object)) { + return { + transformedValue: null, + }; + } + + const transformationResult = transformValue(object, superJson); + const transformed = transformationResult?.value ?? object; + + const transformedValue: any = isArray(transformed) ? [] : {}; + const innerAnnotations: Record> = {}; + + for (const [index, value] of Object.entries(transformed)) { + await scheduler.tick(); + if ( + index === '__proto__' || + index === 'constructor' || + index === 'prototype' + ) { + throw new Error( + `Detected property ${index}. This is a prototype pollution risk, please remove it from your object.` + ); + } + + const recursiveResult = await asyncWalker( + value, + identities, + superJson, + dedupe, + scheduler, + [...path, index], + [...objectsInThisPath, object], + seenObjects + ); + + transformedValue[index] = recursiveResult.transformedValue; + + if (isArray(recursiveResult.annotations)) { + innerAnnotations[escapeKey(index)] = recursiveResult.annotations; + } else if (isPlainObject(recursiveResult.annotations)) { + for (const [key, tree] of Object.entries(recursiveResult.annotations)) { + innerAnnotations[escapeKey(index) + '.' + key] = tree; + } + } + } + + const result: Result = isEmptyObject(innerAnnotations) + ? { + transformedValue, + annotations: !!transformationResult + ? [transformationResult.type] + : undefined, + } + : { + transformedValue, + annotations: !!transformationResult + ? [transformationResult.type, innerAnnotations] + : innerAnnotations, + }; + if (!primitive) { + seenObjects.set(object, result); + } + + return result; +}; From e408e86d5c082af73180d1cb507c72b448ade87d Mon Sep 17 00:00:00 2001 From: kyken Date: Thu, 17 Sep 2026 16:40:21 +0900 Subject: [PATCH 2/3] fix --- README.md | 42 +++-- package.json | 1 - src/accessDeep.ts | 201 +++++++--------------- src/async.ts | 4 + src/bfj.d.ts | 16 -- src/index.test.ts | 182 ++++++++++++++------ src/index.ts | 45 +---- src/plainer.ts | 414 ++++++++++++++++++---------------------------- 8 files changed, 389 insertions(+), 516 deletions(-) delete mode 100644 src/bfj.d.ts diff --git a/README.md b/README.md index f724577a..8a095d1b 100644 --- a/README.md +++ b/README.md @@ -82,16 +82,7 @@ const object = superjson.parse< // object === { date: new Date(0) } ``` -The synchronous `stringify` and `parse` APIs remain available. For asynchronous -JSON processing, use `asyncStringify` and `asyncParse`: - -```js -const jsonString = await superjson.asyncStringify({ date: new Date(0) }); -const object = await superjson.asyncParse(jsonString); -``` - -The asynchronous string APIs use BFJ and are intended for Node.js runtimes. For -asynchronous processing at the transport boundary, use `asyncSerialize` and +For asynchronous processing at the transport boundary, use `asyncSerialize` and `asyncDeserialize`: ```js @@ -100,8 +91,9 @@ const object = await superjson.asyncDeserialize(payload, { yieldRate: 1024 }); ``` The synchronous `serialize` and `deserialize` APIs remain unchanged. The -asynchronous APIs process values in order and yield to the event loop after the -configured number of visited values. +asynchronous APIs are available in browser and Node.js runtimes and process +values in order, yielding to the event loop after the configured number of +visited values. ## Advanced Usage @@ -231,6 +223,32 @@ Options Returns **`your original value`**. +### asyncSerialize + +Asynchronously serializes a JavaScript value while periodically yielding to the +event loop. + +```js +const payload = await asyncSerialize(object, { yieldRate: 1024 }); +``` + +Options + +- `yieldRate: number` + - Default: `1024` + - Number of visited values between event-loop yields + +### asyncDeserialize + +Asynchronously deserializes the output of `asyncSerialize`. + +```js +const object = await asyncDeserialize(payload, { yieldRate: 1024 }); +``` + +It accepts the same `yieldRate` option as `asyncSerialize` and the `inPlace` +option documented for `deserialize`. + ### stringify Serializes and then stringifies your JavaScript value. diff --git a/package.json b/package.json index 32fbaafc..e6210940 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "vitest": "^0.34.6" }, "dependencies": { - "bfj": "^7.1.0", "copy-anything": "^4" } } diff --git a/src/accessDeep.ts b/src/accessDeep.ts index 1d34deb1..b528f2a0 100644 --- a/src/accessDeep.ts +++ b/src/accessDeep.ts @@ -2,17 +2,6 @@ import { isMap, isArray, isPlainObject, isSet } from './is.js'; import { includes } from './util.js'; import { AsyncYieldController } from './async.js'; -const getNthKey = (value: Map | Set, n: number): any => { - if (n > value.size) throw new Error('index out of bounds'); - const keys = value.keys(); - while (n > 0) { - keys.next(); - n--; - } - - return keys.next().value; -}; - function validatePath(path: (string | number)[]) { if (includes(path, '__proto__')) { throw new Error('__proto__ is not allowed as a property'); @@ -25,18 +14,38 @@ function validatePath(path: (string | number)[]) { } } -export const getDeep = (object: object, path: (string | number)[]): object => { +function* getNthKeyGenerator( + value: Map | Set, + n: number +): Generator { + if (n > value.size) throw new Error('index out of bounds'); + const keys = value.keys(); + while (n > 0) { + yield; + keys.next(); + n--; + } + + yield; + return keys.next().value; +} + +function* getDeepGenerator( + object: object, + path: (string | number)[] +): Generator { validatePath(path); for (let i = 0; i < path.length; i++) { + yield; const key = path[i]; if (isSet(object)) { - object = getNthKey(object, +key); + object = yield* getNthKeyGenerator(object, +key); } else if (isMap(object)) { const row = +key; const type = +path[++i] === 0 ? 'key' : 'value'; - const keyOfRow = getNthKey(object, row); + const keyOfRow = yield* getNthKeyGenerator(object, row); switch (type) { case 'key': object = keyOfRow; @@ -51,13 +60,13 @@ export const getDeep = (object: object, path: (string | number)[]): object => { } return object; -}; +} -export const setDeep = ( +function* setDeepGenerator( object: any, path: (string | number)[], mapper: (v: any) => any -): any => { +): Generator { validatePath(path); if (path.length === 0) { @@ -67,6 +76,7 @@ export const setDeep = ( let parent = object; for (let i = 0; i < path.length - 1; i++) { + yield; const key = path[i]; if (isArray(parent)) { @@ -76,7 +86,7 @@ export const setDeep = ( parent = parent[key]; } else if (isSet(parent)) { const row = +key; - parent = getNthKey(parent, row); + parent = yield* getNthKeyGenerator(parent, row); } else if (isMap(parent)) { const isEnd = i === path.length - 2; if (isEnd) { @@ -86,7 +96,7 @@ export const setDeep = ( const row = +key; const type = +path[++i] === 0 ? 'key' : 'value'; - const keyOfRow = getNthKey(parent, row); + const keyOfRow = yield* getNthKeyGenerator(parent, row); switch (type) { case 'key': parent = keyOfRow; @@ -98,6 +108,7 @@ export const setDeep = ( } } + yield; const lastKey = path[path.length - 1]; if (isArray(parent)) { @@ -107,7 +118,7 @@ export const setDeep = ( } if (isSet(parent)) { - const oldValue = getNthKey(parent, +lastKey); + const oldValue = yield* getNthKeyGenerator(parent, +lastKey); const newValue = mapper(oldValue); if (oldValue !== newValue) { parent.delete(oldValue); @@ -117,7 +128,7 @@ export const setDeep = ( if (isMap(parent)) { const row = +path[path.length - 2]; - const keyToRow = getNthKey(parent, row); + const keyToRow = yield* getNthKeyGenerator(parent, row); const type = +lastKey === 0 ? 'key' : 'value'; switch (type) { @@ -139,23 +150,15 @@ export const setDeep = ( } return object; -}; +} -const getNthKeyAsync = async ( - value: Map | Set, - n: number, - scheduler: AsyncYieldController -): Promise => { - if (n > value.size) throw new Error('index out of bounds'); - const keys = value.keys(); - while (n > 0) { - await scheduler.tick(); - keys.next(); - n--; +export const getDeep = (object: object, path: (string | number)[]): object => { + const iterator = getDeepGenerator(object, path); + let result = iterator.next(); + while (!result.done) { + result = iterator.next(); } - - await scheduler.tick(); - return keys.next().value; + return result.value; }; export const getDeepAsync = async ( @@ -163,32 +166,26 @@ export const getDeepAsync = async ( path: (string | number)[], scheduler: AsyncYieldController ): Promise => { - validatePath(path); - - for (let i = 0; i < path.length; i++) { + const iterator = getDeepGenerator(object, path); + let result = iterator.next(); + while (!result.done) { await scheduler.tick(); - const key = path[i]; - if (isSet(object)) { - object = await getNthKeyAsync(object, +key, scheduler); - } else if (isMap(object)) { - const row = +key; - const type = +path[++i] === 0 ? 'key' : 'value'; - - const keyOfRow = await getNthKeyAsync(object, row, scheduler); - switch (type) { - case 'key': - object = keyOfRow; - break; - case 'value': - object = object.get(keyOfRow); - break; - } - } else { - object = (object as any)[key]; - } + result = iterator.next(); } + return result.value; +}; - return object; +export const setDeep = ( + object: any, + path: (string | number)[], + mapper: (v: any) => any, +): any => { + const iterator = setDeepGenerator(object, path, mapper); + let result = iterator.next(); + while (!result.done) { + result = iterator.next(); + } + return result.value; }; export const setDeepAsync = async ( @@ -197,87 +194,11 @@ export const setDeepAsync = async ( mapper: (v: any) => any, scheduler: AsyncYieldController ): Promise => { - validatePath(path); - - if (path.length === 0) { - return mapper(object); - } - - let parent = object; - - for (let i = 0; i < path.length - 1; i++) { + const iterator = setDeepGenerator(object, path, mapper); + let result = iterator.next(); + while (!result.done) { await scheduler.tick(); - const key = path[i]; - - if (isArray(parent)) { - const index = +key; - parent = parent[index]; - } else if (isPlainObject(parent)) { - parent = parent[key]; - } else if (isSet(parent)) { - const row = +key; - parent = await getNthKeyAsync(parent, row, scheduler); - } else if (isMap(parent)) { - const isEnd = i === path.length - 2; - if (isEnd) { - break; - } - - const row = +key; - const type = +path[++i] === 0 ? 'key' : 'value'; - - const keyOfRow = await getNthKeyAsync(parent, row, scheduler); - switch (type) { - case 'key': - parent = keyOfRow; - break; - case 'value': - parent = parent.get(keyOfRow); - break; - } - } - } - - await scheduler.tick(); - const lastKey = path[path.length - 1]; - - if (isArray(parent)) { - parent[+lastKey] = mapper(parent[+lastKey]); - } else if (isPlainObject(parent)) { - parent[lastKey] = mapper(parent[lastKey]); - } - - if (isSet(parent)) { - const oldValue = await getNthKeyAsync(parent, +lastKey, scheduler); - const newValue = mapper(oldValue); - if (oldValue !== newValue) { - parent.delete(oldValue); - parent.add(newValue); - } - } - - if (isMap(parent)) { - const row = +path[path.length - 2]; - const keyToRow = await getNthKeyAsync(parent, row, scheduler); - - const type = +lastKey === 0 ? 'key' : 'value'; - switch (type) { - case 'key': { - const newKey = mapper(keyToRow); - parent.set(newKey, parent.get(keyToRow)); - - if (newKey !== keyToRow) { - parent.delete(keyToRow); - } - break; - } - - case 'value': { - parent.set(keyToRow, mapper(parent.get(keyToRow))); - break; - } - } + result = iterator.next(); } - - return object; + return result.value; }; diff --git a/src/async.ts b/src/async.ts index 72078490..67fb0e20 100644 --- a/src/async.ts +++ b/src/async.ts @@ -2,6 +2,10 @@ export interface AsyncOptions { yieldRate?: number; } +export interface AsyncDeserializeOptions extends AsyncOptions { + inPlace?: boolean; +} + const defaultYieldRate = 1024; type GlobalWithTimers = typeof globalThis & { diff --git a/src/bfj.d.ts b/src/bfj.d.ts deleted file mode 100644 index ec3360f8..00000000 --- a/src/bfj.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -/// - -declare module 'bfj' { - interface Options { - [key: string]: unknown; - } - - function parse( - stream: NodeJS.ReadableStream, - options?: Options - ): Promise; - - function stringify(value: unknown, options?: Options): Promise; - - export { parse, stringify }; -} \ No newline at end of file diff --git a/src/index.test.ts b/src/index.test.ts index e983fc58..b6eae97d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -5,9 +5,7 @@ import * as fs from 'fs'; import SuperJSON, { asyncDeserialize, - asyncParse, asyncSerialize, - asyncStringify, } from './index.js'; import { JSONValue, SuperJSONResult, SuperJSONValue } from './types.js'; import { @@ -1024,7 +1022,7 @@ describe('stringify & parse', () => { }); }); -describe('async stringify & parse', () => { +describe('async serialize & deserialize', () => { it('preserves low-level serialize and deserialize behavior', async () => { const shared = { value: 1 }; const input = { @@ -1046,6 +1044,83 @@ describe('async stringify & parse', () => { expect(deserialized.first).toBe(deserialized.second); }); + it('preserves registered and special values', async () => { + class User { + constructor(public name: string) {} + } + + class Money { + constructor(public amount: number) {} + } + + const instance = new SuperJSON(); + const symbol = Symbol('registered'); + const error = new Error('failed'); + (error as Error & { code?: string }).code = 'E_FAILED'; + instance.registerClass(User); + instance.registerSymbol(symbol, 'registered'); + instance.registerCustom( + { + isApplicable: (value): value is Money => value instanceof Money, + serialize: value => value.amount, + deserialize: value => new Money(value), + }, + 'money' + ); + instance.allowErrorProps('code'); + + const input = { + date: new Date(0), + regexp: /superjson/gi, + bigint: BigInt(42), + symbol, + typed: new Float64Array([NaN, Infinity, -Infinity]), + user: new User('Ada'), + money: new Money(100), + error, + }; + + const serialized = await instance.asyncSerialize(input, { + yieldRate: 1, + }); + const restored = await instance.asyncDeserialize(serialized, { + yieldRate: 1, + }); + + expect(serialized).toEqual(instance.serialize(input)); + expect(restored.date).toEqual(input.date); + expect(restored.regexp).toEqual(input.regexp); + expect(restored.bigint).toBe(input.bigint); + expect(restored.symbol).toBe(symbol); + expect(restored.typed).toBeInstanceOf(Float64Array); + expect(Number.isNaN(restored.typed[0])).toBe(true); + expect(restored.typed[1]).toBe(Infinity); + expect(restored.typed[2]).toBe(-Infinity); + expect(restored.user).toBeInstanceOf(User); + expect(restored.user.name).toBe('Ada'); + expect(restored.money).toBeInstanceOf(Money); + expect(restored.money.amount).toBe(100); + expect(restored.error).toBeInstanceOf(Error); + expect((restored.error as Error & { code?: string }).code).toBe('E_FAILED'); + }); + + it('restores deduped references asynchronously', async () => { + const instance = new SuperJSON({ dedupe: true }); + const shared = { value: 1 }; + const input = { first: shared, second: shared }; + + const serialized = await instance.asyncSerialize(input, { yieldRate: 1 }); + const restored = await instance.asyncDeserialize(serialized, { + yieldRate: 1, + }); + + expect(serialized.json).toEqual({ + first: { value: 1 }, + second: null, + }); + expect(restored.first).toBe(restored.second); + }); + it('preserves collection references and circular references', async () => { const shared = { value: 1 }; const input = { @@ -1083,6 +1158,25 @@ describe('async stringify & parse', () => { expect((deserialized as { date: Date }).date).toEqual(new Date(0)); }); + it('does not mutate the payload when deserializing out of place', async () => { + const serialized = await asyncSerialize({ date: new Date(0) }); + const deserialized = await asyncDeserialize<{ date: Date }>(serialized); + + expect(deserialized).not.toBe(serialized.json); + expect((serialized.json as { date: string }).date).toBe( + '1970-01-01T00:00:00.000Z' + ); + }); + + it('rejects invalid yield rates', async () => { + await expect( + SuperJSON.asyncSerialize({}, { yieldRate: 0 }) + ).rejects.toThrow('yieldRate'); + await expect( + SuperJSON.asyncDeserialize({ json: {} }, { yieldRate: NaN }) + ).rejects.toThrow('yieldRate'); + }); + it('yields to the event loop during serialization and deserialization', async () => { const input = Object.fromEntries( Array.from({ length: 100 }, (_, index) => [`value${index}`, index]) @@ -1121,63 +1215,41 @@ describe('async stringify & parse', () => { await deserialization; }); - it('preserves SuperJSON transformations', async () => { - const input = { - date: new Date(0), - set: new Set([1, 2]), - missing: undefined, - }; - - const stringified = await SuperJSON.asyncStringify(input); - const parsed = await SuperJSON.asyncParse(stringified); - - expect(JSON.parse(stringified)).toEqual({ - json: { - date: '1970-01-01T00:00:00.000Z', - set: [1, 2], - missing: null, - }, - meta: { - values: { - date: ['Date'], - set: ['set'], - missing: ['undefined'], - }, - v: 1, - }, + it('yields while traversing primitive-heavy arrays', async () => { + const input = Array.from({ length: 100 }, (_, index) => index); + let done = false; + const serialization = SuperJSON.asyncSerialize(input, { yieldRate: 1 }); + serialization.finally(() => { + done = true; }); - expect(parsed.date).toEqual(input.date); - expect(parsed.set).toEqual(input.set); - expect(parsed.missing).toBeUndefined(); - }); - - it('exposes named asynchronous APIs', async () => { - const stringified = await asyncStringify({ value: 1 }); - await expect(asyncParse(stringified)).resolves.toEqual({ value: 1 }); - }); - - it('rejects invalid JSON', async () => { - await expect(SuperJSON.asyncParse('{')).rejects.toThrow(); + await new Promise(resolve => setImmediate(resolve)); + expect(done).toBe(false); + await serialization; }); - it('rejects prototype pollution through asynchronous APIs', async () => { - await expect( - SuperJSON.asyncSerialize({ ['__proto__']: 1 } as any) - ).rejects.toThrow(/prototype pollution risk/); - - const payload: SuperJSONResult = { - json: { myValue: 1337 }, - meta: { - referentialEqualities: { - myValue: ['__proto__.x'], + it.each([ + ['__proto__', '__proto__.x'], + ['prototype', 'prototype.x'], + ['constructor', 'constructor.prototype.x'], + ])( + 'rejects prototype pollution through asynchronous APIs: %s', + async (forbidden, path) => { + await expect( + SuperJSON.asyncSerialize({ [forbidden]: 1 } as any) + ).rejects.toThrow(/prototype pollution risk/); + + const payload: SuperJSONResult = { + json: { myValue: 1337 }, + meta: { + referentialEqualities: { + myValue: [path], + }, }, - }, - }; - await expect(SuperJSON.asyncDeserialize(payload)).rejects.toThrow( - '__proto__ is not allowed as a property' - ); - }); + }; + await expect(SuperJSON.asyncDeserialize(payload)).rejects.toThrow(); + } + ); }); describe('allowErrorProps(...) (#91)', () => { diff --git a/src/index.ts b/src/index.ts index ea4b9fda..5ab95cf3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { import { copy } from 'copy-anything'; import { AsyncOptions, + AsyncDeserializeOptions, AsyncYieldController, copyAsync, } from './async.js'; @@ -133,7 +134,7 @@ export default class SuperJSON { async asyncDeserialize( payload: SuperJSONResult, - options?: { inPlace?: boolean; yieldRate?: number } + options?: AsyncDeserializeOptions ): Promise { const { json, meta } = payload; const scheduler = new AsyncYieldController(options); @@ -172,38 +173,6 @@ export default class SuperJSON { return this.deserialize(JSON.parse(string), { inPlace: true }); } - async asyncStringify( - object: SuperJSONValue, - options?: AsyncOptions - ): Promise { - const bfj = await import('bfj'); - const bfjOptions = options - ? { yieldRate: options.yieldRate } - : undefined; - return bfj.stringify( - await this.asyncSerialize(object, options), - bfjOptions - ); - } - - async asyncParse( - string: string, - options?: AsyncOptions - ): Promise { - const [bfj, { Readable }] = await Promise.all([ - import('bfj'), - import('node:stream'), - ]); - const bfjOptions = options - ? { yieldRate: options.yieldRate } - : undefined; - const payload = await bfj.parse(Readable.from([string]), bfjOptions); - return this.asyncDeserialize(payload as SuperJSONResult, { - inPlace: true, - yieldRate: options?.yieldRate, - }); - } - readonly classRegistry = new ClassRegistry(); registerClass(v: Class, options?: RegisterOptions | string) { this.classRegistry.register(v, options); @@ -249,12 +218,6 @@ export default class SuperJSON { static parse = SuperJSON.defaultInstance.parse.bind( SuperJSON.defaultInstance ); - static asyncStringify = SuperJSON.defaultInstance.asyncStringify.bind( - SuperJSON.defaultInstance - ); - static asyncParse = SuperJSON.defaultInstance.asyncParse.bind( - SuperJSON.defaultInstance - ); static registerClass = SuperJSON.defaultInstance.registerClass.bind( SuperJSON.defaultInstance ); @@ -270,6 +233,7 @@ export default class SuperJSON { } export { SuperJSON, SuperJSONResult, SuperJSONValue }; +export type { AsyncDeserializeOptions, AsyncOptions } from './async.js'; export const serialize = SuperJSON.serialize; export const deserialize = SuperJSON.deserialize; @@ -280,9 +244,6 @@ export const asyncDeserialize = SuperJSON.asyncDeserialize; export const stringify = SuperJSON.stringify; export const parse = SuperJSON.parse; -export const asyncStringify = SuperJSON.asyncStringify; -export const asyncParse = SuperJSON.asyncParse; - export const registerClass = SuperJSON.registerClass; export const registerCustom = SuperJSON.registerCustom; export const registerSymbol = SuperJSON.registerSymbol; diff --git a/src/plainer.ts b/src/plainer.ts index bd81e7bc..2652d772 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -14,7 +14,7 @@ import { TypeAnnotation, untransformValue, } from './transformer.js'; -import { includes, forEach } from './util.js'; +import { includes } from './util.js'; import { parsePath } from './pathstringifier.js'; import { getDeep, setDeep } from './accessDeep.js'; import { getDeepAsync, setDeepAsync } from './accessDeep.js'; @@ -29,38 +29,48 @@ export type MinimisedTree = Tree | Record> | undefined; const enableLegacyPaths = (version: number) => version < 1; -function traverse( +function* traverseGenerator( tree: MinimisedTree, - walker: (v: T, path: string[]) => void, version: number, origin: string[] = [] -): void { +): Generator { if (!tree) { return; } const legacyPaths = enableLegacyPaths(version); if (!isArray(tree)) { - forEach(tree, (subtree, key) => - traverse(subtree, walker, version, [ + for (const [key, subtree] of Object.entries(tree)) { + yield* traverseGenerator(subtree, version, [ ...origin, ...parsePath(key, legacyPaths), - ]) - ); + ]); + } return; } const [nodeValue, children] = tree; if (children) { - forEach(children, (child, key) => { - traverse(child, walker, version, [ + for (const [key, child] of Object.entries(children)) { + yield* traverseGenerator(child, version, [ ...origin, ...parsePath(key, legacyPaths), ]); - }); + } } - walker(nodeValue, origin); + yield [nodeValue, origin]; +} + +function traverse( + tree: MinimisedTree, + walker: (v: T, path: string[]) => void, + version: number, + origin: string[] = [] +): void { + for (const [value, path] of traverseGenerator(tree, version, origin)) { + walker(value, path); + } } async function traverseAsync( @@ -70,39 +80,10 @@ async function traverseAsync( scheduler: AsyncYieldController, origin: string[] = [] ): Promise { - if (!tree) { - return; - } - - await scheduler.tick(); - const legacyPaths = enableLegacyPaths(version); - if (!isArray(tree)) { - for (const [key, subtree] of Object.entries(tree)) { - await traverseAsync( - subtree, - walker, - version, - scheduler, - [...origin, ...parsePath(key, legacyPaths)] - ); - } - return; - } - - const [nodeValue, children] = tree; - if (children) { - for (const [key, child] of Object.entries(children)) { - await traverseAsync( - child, - walker, - version, - scheduler, - [...origin, ...parsePath(key, legacyPaths)] - ); - } + for (const [value, path] of traverseGenerator(tree, version, origin)) { + await scheduler.tick(); + await walker(value, path); } - - await walker(nodeValue, origin); } export function applyValueAnnotations( @@ -146,37 +127,52 @@ export async function applyValueAnnotationsAsync( return plain; } +interface ReferentialEqualityGroup { + sourcePath?: string; + targetPaths: string[]; +} + +function* referentialEqualityGroups( + annotations: ReferentialEqualityAnnotations +): Generator { + if (isArray(annotations)) { + const [root, other] = annotations; + for (const targetPath of root) { + yield { targetPaths: [targetPath] }; + } + + if (other) { + for (const [sourcePath, targetPaths] of Object.entries(other)) { + yield { sourcePath, targetPaths }; + } + } + } else { + for (const [sourcePath, targetPaths] of Object.entries(annotations)) { + yield { sourcePath, targetPaths }; + } + } +} + export function applyReferentialEqualityAnnotations( plain: any, annotations: ReferentialEqualityAnnotations, version: number ) { const legacyPaths = enableLegacyPaths(version); - function apply(identicalPaths: string[], path: string) { - const object = getDeep(plain, parsePath(path, legacyPaths)); - - identicalPaths - .map(path => parsePath(path, legacyPaths)) - .forEach(identicalObjectPath => { - plain = setDeep(plain, identicalObjectPath, () => object); - }); - } - - if (isArray(annotations)) { - const [root, other] = annotations; - root.forEach(identicalPath => { + for (const { sourcePath, targetPaths } of referentialEqualityGroups( + annotations + )) { + const object = sourcePath + ? getDeep(plain, parsePath(sourcePath, legacyPaths)) + : undefined; + + for (const targetPath of targetPaths) { plain = setDeep( plain, - parsePath(identicalPath, legacyPaths), - () => plain + parsePath(targetPath, legacyPaths), + () => (sourcePath ? object : plain) ); - }); - - if (other) { - forEach(other, apply); } - } else { - forEach(annotations, apply); } return plain; @@ -189,45 +185,27 @@ export async function applyReferentialEqualityAnnotationsAsync( scheduler: AsyncYieldController ) { const legacyPaths = enableLegacyPaths(version); - async function apply(identicalPaths: string[], path: string) { - const object = await getDeepAsync( - plain, - parsePath(path, legacyPaths), - scheduler - ); - - for (const identicalPath of identicalPaths) { - await scheduler.tick(); - plain = await setDeepAsync( - plain, - parsePath(identicalPath, legacyPaths), - () => object, - scheduler - ); - } - } - - if (isArray(annotations)) { - const [root, other] = annotations; - for (const identicalPath of root) { + for (const { sourcePath, targetPaths } of referentialEqualityGroups( + annotations + )) { + await scheduler.tick(); + const object = sourcePath + ? await getDeepAsync( + plain, + parsePath(sourcePath, legacyPaths), + scheduler + ) + : undefined; + + for (const targetPath of targetPaths) { await scheduler.tick(); plain = await setDeepAsync( plain, - parsePath(identicalPath, legacyPaths), - () => plain, + parsePath(targetPath, legacyPaths), + () => (sourcePath ? object : plain), scheduler ); } - - if (other) { - for (const [path, identicalPaths] of Object.entries(other)) { - await apply(identicalPaths, path); - } - } - } else { - for (const [path, identicalPaths] of Object.entries(annotations)) { - await apply(identicalPaths, path); - } } return plain; @@ -261,84 +239,44 @@ export type ReferentialEqualityAnnotations = | [string[]] | [string[], Record]; -export function generateReferentialEqualityAnnotations( +function* generateReferentialEqualityAnnotationsGenerator( identitites: Map, dedupe: boolean -): ReferentialEqualityAnnotations | undefined { - const result: Record = {}; - let rootEqualityPaths: string[] | undefined = undefined; - - identitites.forEach(paths => { - if (paths.length <= 1) { - return; - } - - // if we're not deduping, all of these objects continue existing. - // putting the shortest path first makes it easier to parse for humans - // if we're deduping though, only the first entry will still exist, so we can't do this optimisation. - if (!dedupe) { - paths = paths - .map(path => path.map(String)) - .sort((a, b) => a.length - b.length); - } - - const [representativePath, ...identicalPaths] = paths; - - if (representativePath.length === 0) { - rootEqualityPaths = identicalPaths.map(stringifyPath); - } else { - result[stringifyPath(representativePath)] = identicalPaths.map( - stringifyPath - ); - } - }); - - if (rootEqualityPaths) { - if (isEmptyObject(result)) { - return [rootEqualityPaths]; - } else { - return [rootEqualityPaths, result]; - } - } else { - return isEmptyObject(result) ? undefined : result; - } -} - -export async function generateReferentialEqualityAnnotationsAsync( - identitites: Map, - dedupe: boolean, - scheduler: AsyncYieldController -): Promise { +): Generator { const result: Record = {}; let rootEqualityPaths: string[] | undefined = undefined; for (const pathsInMap of identitites.values()) { - await scheduler.tick(); + yield; let paths = pathsInMap; if (paths.length <= 1) { continue; } + // if we're not deduping, all of these objects continue existing. + // putting the shortest path first makes it easier to parse for humans + // if we're deduping though, only the first entry will still exist, so we can't do this optimisation. if (!dedupe) { const stringifiedPaths: string[][] = []; for (const path of paths) { - await scheduler.tick(); + yield; stringifiedPaths.push(path.map(String)); } paths = stringifiedPaths.sort((a, b) => a.length - b.length); } const [representativePath, ...identicalPaths] = paths; + if (representativePath.length === 0) { rootEqualityPaths = []; for (const path of identicalPaths) { - await scheduler.tick(); + yield; rootEqualityPaths.push(stringifyPath(path.map(String))); } } else { const stringifiedIdenticalPaths: string[] = []; for (const path of identicalPaths) { - await scheduler.tick(); + yield; stringifiedIdenticalPaths.push(stringifyPath(path.map(String))); } result[stringifyPath(representativePath.map(String))] = @@ -357,7 +295,39 @@ export async function generateReferentialEqualityAnnotationsAsync( } } -export const walker = ( +export function generateReferentialEqualityAnnotations( + identitites: Map, + dedupe: boolean +): ReferentialEqualityAnnotations | undefined { + const iterator = generateReferentialEqualityAnnotationsGenerator( + identitites, + dedupe + ); + let result = iterator.next(); + while (!result.done) { + result = iterator.next(); + } + return result.value; +} + +export async function generateReferentialEqualityAnnotationsAsync( + identitites: Map, + dedupe: boolean, + scheduler: AsyncYieldController +): Promise { + const iterator = generateReferentialEqualityAnnotationsGenerator( + identitites, + dedupe + ); + let result = iterator.next(); + while (!result.done) { + await scheduler.tick(); + result = iterator.next(); + } + return result.value; +} + +function* walkerGenerator( object: any, identities: Map, superJson: SuperJSON, @@ -365,7 +335,8 @@ export const walker = ( path: any[] = [], objectsInThisPath: any[] = [], seenObjects = new Map() -): Result => { +): Generator { + yield; const primitive = isPrimitive(object); if (!primitive) { @@ -412,7 +383,7 @@ export const walker = ( const transformedValue: any = isArray(transformed) ? [] : {}; const innerAnnotations: Record> = {}; - forEach(transformed, (value, index) => { + for (const [index, value] of Object.entries(transformed)) { if ( index === '__proto__' || index === 'constructor' || @@ -423,7 +394,7 @@ export const walker = ( ); } - const recursiveResult = walker( + const recursiveResult = yield* walkerGenerator( value, identities, superJson, @@ -438,11 +409,11 @@ export const walker = ( if (isArray(recursiveResult.annotations)) { innerAnnotations[escapeKey(index)] = recursiveResult.annotations; } else if (isPlainObject(recursiveResult.annotations)) { - forEach(recursiveResult.annotations, (tree, key) => { + for (const [key, tree] of Object.entries(recursiveResult.annotations)) { innerAnnotations[escapeKey(index) + '.' + key] = tree; - }); + } } - }); + } const result: Result = isEmptyObject(innerAnnotations) ? { @@ -462,6 +433,31 @@ export const walker = ( } return result; +} + +export const walker = ( + object: any, + identities: Map, + superJson: SuperJSON, + dedupe: boolean, + path: any[] = [], + objectsInThisPath: any[] = [], + seenObjects = new Map() +): Result => { + const iterator = walkerGenerator( + object, + identities, + superJson, + dedupe, + path, + objectsInThisPath, + seenObjects + ); + let result = iterator.next(); + while (!result.done) { + result = iterator.next(); + } + return result.value; }; export const asyncWalker = async ( @@ -474,101 +470,19 @@ export const asyncWalker = async ( objectsInThisPath: any[] = [], seenObjects = new Map() ): Promise => { - await scheduler.tick(); - const primitive = isPrimitive(object); - - if (!primitive) { - addIdentity(object, path, identities); - - const seen = seenObjects.get(object); - if (seen) { - return dedupe - ? { - transformedValue: null, - } - : seen; - } - } - - if (!isDeep(object, superJson)) { - const transformed = transformValue(object, superJson); - - const result: Result = transformed - ? { - transformedValue: transformed.value, - annotations: [transformed.type], - } - : { - transformedValue: object, - }; - if (!primitive) { - seenObjects.set(object, result); - } - return result; - } - - if (includes(objectsInThisPath, object)) { - return { - transformedValue: null, - }; - } - - const transformationResult = transformValue(object, superJson); - const transformed = transformationResult?.value ?? object; - - const transformedValue: any = isArray(transformed) ? [] : {}; - const innerAnnotations: Record> = {}; - - for (const [index, value] of Object.entries(transformed)) { + const iterator = walkerGenerator( + object, + identities, + superJson, + dedupe, + path, + objectsInThisPath, + seenObjects + ); + let result = iterator.next(); + while (!result.done) { await scheduler.tick(); - if ( - index === '__proto__' || - index === 'constructor' || - index === 'prototype' - ) { - throw new Error( - `Detected property ${index}. This is a prototype pollution risk, please remove it from your object.` - ); - } - - const recursiveResult = await asyncWalker( - value, - identities, - superJson, - dedupe, - scheduler, - [...path, index], - [...objectsInThisPath, object], - seenObjects - ); - - transformedValue[index] = recursiveResult.transformedValue; - - if (isArray(recursiveResult.annotations)) { - innerAnnotations[escapeKey(index)] = recursiveResult.annotations; - } else if (isPlainObject(recursiveResult.annotations)) { - for (const [key, tree] of Object.entries(recursiveResult.annotations)) { - innerAnnotations[escapeKey(index) + '.' + key] = tree; - } - } + result = iterator.next(); } - - const result: Result = isEmptyObject(innerAnnotations) - ? { - transformedValue, - annotations: !!transformationResult - ? [transformationResult.type] - : undefined, - } - : { - transformedValue, - annotations: !!transformationResult - ? [transformationResult.type, innerAnnotations] - : innerAnnotations, - }; - if (!primitive) { - seenObjects.set(object, result); - } - - return result; + return result.value; }; From 521c3fa0695d225c06184e3b8b94de088ffc1b40 Mon Sep 17 00:00:00 2001 From: kyken Date: Thu, 17 Sep 2026 18:49:35 +0900 Subject: [PATCH 3/3] fix --- README.md | 20 ++++++++--------- package.json | 2 +- src/index.test.ts | 56 ++++++++++++++++++++++++++++------------------- src/index.ts | 12 +++++----- 4 files changed, 50 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 8a095d1b..fa8b0371 100644 --- a/README.md +++ b/README.md @@ -82,12 +82,12 @@ const object = superjson.parse< // object === { date: new Date(0) } ``` -For asynchronous processing at the transport boundary, use `asyncSerialize` and -`asyncDeserialize`: +For asynchronous processing at the transport boundary, use +`serializeAsync` and `deserializeAsync`: ```js -const payload = await superjson.asyncSerialize(value, { yieldRate: 1024 }); -const object = await superjson.asyncDeserialize(payload, { yieldRate: 1024 }); +const payload = await superjson.serializeAsync(value, { yieldRate: 1024 }); +const object = await superjson.deserializeAsync(payload, { yieldRate: 1024 }); ``` The synchronous `serialize` and `deserialize` APIs remain unchanged. The @@ -223,13 +223,13 @@ Options Returns **`your original value`**. -### asyncSerialize +### serializeAsync Asynchronously serializes a JavaScript value while periodically yielding to the event loop. ```js -const payload = await asyncSerialize(object, { yieldRate: 1024 }); +const payload = await serializeAsync(object, { yieldRate: 1024 }); ``` Options @@ -238,15 +238,15 @@ Options - Default: `1024` - Number of visited values between event-loop yields -### asyncDeserialize +### deserializeAsync -Asynchronously deserializes the output of `asyncSerialize`. +Asynchronously deserializes the output of `serializeAsync`. ```js -const object = await asyncDeserialize(payload, { yieldRate: 1024 }); +const object = await deserializeAsync(payload, { yieldRate: 1024 }); ``` -It accepts the same `yieldRate` option as `asyncSerialize` and the `inPlace` +It accepts the same `yieldRate` option as `serializeAsync` and the `inPlace` option documented for `deserialize`. ### stringify diff --git a/package.json b/package.json index e6210940..f7e42acb 100644 --- a/package.json +++ b/package.json @@ -60,4 +60,4 @@ "dependencies": { "copy-anything": "^4" } -} +} \ No newline at end of file diff --git a/src/index.test.ts b/src/index.test.ts index b6eae97d..8351d1d3 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -4,8 +4,8 @@ import * as fs from 'fs'; import SuperJSON, { - asyncDeserialize, - asyncSerialize, + deserializeAsync, + serializeAsync, } from './index.js'; import { JSONValue, SuperJSONResult, SuperJSONValue } from './types.js'; import { @@ -1023,6 +1023,16 @@ describe('stringify & parse', () => { }); describe('async serialize & deserialize', () => { + it('exposes suffix-named async transformer methods', async () => { + const input = { date: new Date(0) }; + const serialized = await SuperJSON.serializeAsync(input); + + expect(serialized).toEqual(await SuperJSON.serializeAsync(input)); + expect(await SuperJSON.deserializeAsync(serialized)).toEqual(input); + expect(await serializeAsync(input)).toEqual(serialized); + expect(await deserializeAsync(serialized)).toEqual(input); + }); + it('preserves low-level serialize and deserialize behavior', async () => { const shared = { value: 1 }; const input = { @@ -1034,8 +1044,8 @@ describe('async serialize & deserialize', () => { }; const expected = SuperJSON.serialize(input); - const serialized = await SuperJSON.asyncSerialize(input); - const deserialized = await SuperJSON.asyncDeserialize( + const serialized = await SuperJSON.serializeAsync(input); + const deserialized = await SuperJSON.deserializeAsync( serialized ); @@ -1080,10 +1090,10 @@ describe('async serialize & deserialize', () => { error, }; - const serialized = await instance.asyncSerialize(input, { + const serialized = await instance.serializeAsync(input, { yieldRate: 1, }); - const restored = await instance.asyncDeserialize(serialized, { + const restored = await instance.deserializeAsync(serialized, { yieldRate: 1, }); @@ -1109,8 +1119,8 @@ describe('async serialize & deserialize', () => { const shared = { value: 1 }; const input = { first: shared, second: shared }; - const serialized = await instance.asyncSerialize(input, { yieldRate: 1 }); - const restored = await instance.asyncDeserialize(serialized, { + const serialized = await instance.serializeAsync(input, { yieldRate: 1 }); + const restored = await instance.deserializeAsync(serialized, { yieldRate: 1, }); @@ -1128,8 +1138,8 @@ describe('async serialize & deserialize', () => { set: new Set([shared]), }; const expected = SuperJSON.serialize(input); - const serialized = await SuperJSON.asyncSerialize(input); - const deserialized = await SuperJSON.asyncDeserialize( + const serialized = await SuperJSON.serializeAsync(input); + const deserialized = await SuperJSON.deserializeAsync( serialized ); @@ -1140,8 +1150,8 @@ describe('async serialize & deserialize', () => { const circular: { self?: unknown } = {}; circular.self = circular; - const circularSerialized = await SuperJSON.asyncSerialize({ circular }); - const circularDeserialized = await SuperJSON.asyncDeserialize<{ + const circularSerialized = await SuperJSON.serializeAsync({ circular }); + const circularDeserialized = await SuperJSON.deserializeAsync<{ circular: { self?: unknown }; }>(circularSerialized); @@ -1151,16 +1161,16 @@ describe('async serialize & deserialize', () => { }); it('supports in-place asynchronous deserialization', async () => { - const serialized = await asyncSerialize({ date: new Date(0) }); - const deserialized = await asyncDeserialize(serialized, { inPlace: true }); + const serialized = await serializeAsync({ date: new Date(0) }); + const deserialized = await deserializeAsync(serialized, { inPlace: true }); expect(deserialized).toBe(serialized.json); expect((deserialized as { date: Date }).date).toEqual(new Date(0)); }); it('does not mutate the payload when deserializing out of place', async () => { - const serialized = await asyncSerialize({ date: new Date(0) }); - const deserialized = await asyncDeserialize<{ date: Date }>(serialized); + const serialized = await serializeAsync({ date: new Date(0) }); + const deserialized = await deserializeAsync<{ date: Date }>(serialized); expect(deserialized).not.toBe(serialized.json); expect((serialized.json as { date: string }).date).toBe( @@ -1170,10 +1180,10 @@ describe('async serialize & deserialize', () => { it('rejects invalid yield rates', async () => { await expect( - SuperJSON.asyncSerialize({}, { yieldRate: 0 }) + SuperJSON.serializeAsync({}, { yieldRate: 0 }) ).rejects.toThrow('yieldRate'); await expect( - SuperJSON.asyncDeserialize({ json: {} }, { yieldRate: NaN }) + SuperJSON.deserializeAsync({ json: {} }, { yieldRate: NaN }) ).rejects.toThrow('yieldRate'); }); @@ -1183,7 +1193,7 @@ describe('async serialize & deserialize', () => { ); let serializationDone = false; - const serialization = SuperJSON.asyncSerialize(input, { yieldRate: 1 }); + const serialization = SuperJSON.serializeAsync(input, { yieldRate: 1 }); serialization.then( () => { serializationDone = true; @@ -1198,7 +1208,7 @@ describe('async serialize & deserialize', () => { const serialized = await serialization; let deserializationDone = false; - const deserialization = SuperJSON.asyncDeserialize(serialized, { + const deserialization = SuperJSON.deserializeAsync(serialized, { yieldRate: 1, }); deserialization.then( @@ -1218,7 +1228,7 @@ describe('async serialize & deserialize', () => { it('yields while traversing primitive-heavy arrays', async () => { const input = Array.from({ length: 100 }, (_, index) => index); let done = false; - const serialization = SuperJSON.asyncSerialize(input, { yieldRate: 1 }); + const serialization = SuperJSON.serializeAsync(input, { yieldRate: 1 }); serialization.finally(() => { done = true; }); @@ -1236,7 +1246,7 @@ describe('async serialize & deserialize', () => { 'rejects prototype pollution through asynchronous APIs: %s', async (forbidden, path) => { await expect( - SuperJSON.asyncSerialize({ [forbidden]: 1 } as any) + SuperJSON.serializeAsync({ [forbidden]: 1 } as any) ).rejects.toThrow(/prototype pollution risk/); const payload: SuperJSONResult = { @@ -1247,7 +1257,7 @@ describe('async serialize & deserialize', () => { }, }, }; - await expect(SuperJSON.asyncDeserialize(payload)).rejects.toThrow(); + await expect(SuperJSON.deserializeAsync(payload)).rejects.toThrow(); } ); }); diff --git a/src/index.ts b/src/index.ts index 5ab95cf3..e0b7ce8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -90,7 +90,7 @@ export default class SuperJSON { return result; } - async asyncSerialize( + async serializeAsync( object: SuperJSONValue, options?: AsyncOptions ): Promise { @@ -132,7 +132,7 @@ export default class SuperJSON { return res; } - async asyncDeserialize( + async deserializeAsync( payload: SuperJSONResult, options?: AsyncDeserializeOptions ): Promise { @@ -206,10 +206,10 @@ export default class SuperJSON { static deserialize = SuperJSON.defaultInstance.deserialize.bind( SuperJSON.defaultInstance ); - static asyncSerialize = SuperJSON.defaultInstance.asyncSerialize.bind( + static serializeAsync = SuperJSON.defaultInstance.serializeAsync.bind( SuperJSON.defaultInstance ); - static asyncDeserialize = SuperJSON.defaultInstance.asyncDeserialize.bind( + static deserializeAsync = SuperJSON.defaultInstance.deserializeAsync.bind( SuperJSON.defaultInstance ); static stringify = SuperJSON.defaultInstance.stringify.bind( @@ -238,8 +238,8 @@ export type { AsyncDeserializeOptions, AsyncOptions } from './async.js'; export const serialize = SuperJSON.serialize; export const deserialize = SuperJSON.deserialize; -export const asyncSerialize = SuperJSON.asyncSerialize; -export const asyncDeserialize = SuperJSON.asyncDeserialize; +export const serializeAsync = SuperJSON.serializeAsync; +export const deserializeAsync = SuperJSON.deserializeAsync; export const stringify = SuperJSON.stringify; export const parse = SuperJSON.parse;