diff --git a/src/codecs/array.ts b/src/codecs/array.ts new file mode 100644 index 0000000..d10407c --- /dev/null +++ b/src/codecs/array.ts @@ -0,0 +1,20 @@ +import type { ArgCodec } from '../types.js'; + +export const arrayCodec: ArgCodec = { + tag: Symbol.for('sr:a'), + is: (v): v is unknown[] => Array.isArray(v), + encode: (v, recurse) => v.map(recurse), + decode: (data, recurse) => (data as unknown[]).map(recurse), + writeBack: (original, mutated, reconcile) => { + const minLen = Math.min(original.length, mutated.length); + + for (let i = 0; i < minLen; i++) { + if (!reconcile(original[i], mutated[i])) original[i] = mutated[i]; + } + + if (original.length > mutated.length) original.splice(mutated.length); + + for (let i = original.length; i < mutated.length; i++) + original.push(mutated[i]); + }, +}; diff --git a/src/codecs/bigint.ts b/src/codecs/bigint.ts new file mode 100644 index 0000000..e8db52f --- /dev/null +++ b/src/codecs/bigint.ts @@ -0,0 +1,8 @@ +import type { ArgCodec } from '../types.js'; + +export const bigIntCodec: ArgCodec = { + tag: Symbol.for('sr:bi'), + is: (v): v is bigint => typeof v === 'bigint', + encode: (v) => v.toString(), + decode: (data) => BigInt(data as string), +}; diff --git a/src/codecs/date.ts b/src/codecs/date.ts new file mode 100644 index 0000000..3e6792c --- /dev/null +++ b/src/codecs/date.ts @@ -0,0 +1,11 @@ +import type { ArgCodec } from '../types.js'; + +export const dateCodec: ArgCodec = { + tag: Symbol.for('sr:d'), + is: (v): v is Date => v instanceof Date, + encode: (v) => v.toISOString(), + decode: (data) => new Date(data as string), + writeBack: (original, mutated) => { + original.setTime(mutated.getTime()); + }, +}; diff --git a/src/codecs/map.ts b/src/codecs/map.ts new file mode 100644 index 0000000..3484a9c --- /dev/null +++ b/src/codecs/map.ts @@ -0,0 +1,18 @@ +import type { ArgCodec } from '../types.js'; + +export const mapCodec: ArgCodec> = { + tag: Symbol.for('sr:m'), + is: (v): v is Map => v instanceof Map, + encode: (v, recurse) => + Array.from(v.entries(), (e) => [recurse(e[0]), recurse(e[1])]), + decode: (data, recurse) => { + const entries = data as [unknown, unknown][]; + return new Map( + entries.map((e) => [recurse(e[0]), recurse(e[1])] as [unknown, unknown]) + ); + }, + writeBack: (original, mutated) => { + original.clear(); + for (const [k, v] of mutated) original.set(k, v); + }, +}; diff --git a/src/codecs/object.ts b/src/codecs/object.ts new file mode 100644 index 0000000..d63ea8e --- /dev/null +++ b/src/codecs/object.ts @@ -0,0 +1,37 @@ +import type { ArgCodec } from '../types.js'; + +export const isPlainObject = (v: unknown): v is Record => { + if (v === null || typeof v !== 'object' || Array.isArray(v)) return false; + const proto = Object.getPrototypeOf(v) as unknown; + return proto === Object.prototype || proto === null; +}; + +export const objectCodec: ArgCodec> = { + tag: Symbol.for('sr:obj'), + is: isPlainObject, + // No collision-escape needed: the outer { __sr_enc: 'c', t: 'obj', v: ... } + // sentinel wraps the encoded values, so inner keys are iterated individually + // by decode — any __sr_enc key in the original object is just a normal value. + encode: (v, recurse) => { + const result: Record = {}; + for (const key of Object.keys(v)) { + if (typeof v[key] !== 'function') result[key] = recurse(v[key]); + } + return result; + }, + decode: (data, recurse) => { + const obj = data as Record; + const result: Record = {}; + for (const key of Object.keys(obj)) result[key] = recurse(obj[key]); + return result; + }, + writeBack: (original, mutated, reconcile) => { + for (const key of Object.keys(original)) { + if (!(key in mutated) && typeof original[key] !== 'function') + delete original[key]; + } + for (const key of Object.keys(mutated)) { + if (!reconcile(original[key], mutated[key])) original[key] = mutated[key]; + } + }, +}; diff --git a/src/codecs/set.ts b/src/codecs/set.ts new file mode 100644 index 0000000..b6f0530 --- /dev/null +++ b/src/codecs/set.ts @@ -0,0 +1,12 @@ +import type { ArgCodec } from '../types.js'; + +export const setCodec: ArgCodec> = { + tag: Symbol.for('sr:s'), + is: (v): v is Set => v instanceof Set, + encode: (v, recurse) => Array.from(v, recurse), + decode: (data, recurse) => new Set((data as unknown[]).map(recurse)), + writeBack: (original, mutated) => { + original.clear(); + for (const v of mutated) original.add(v); + }, +}; diff --git a/src/codecs/undefined.ts b/src/codecs/undefined.ts new file mode 100644 index 0000000..e7e94e8 --- /dev/null +++ b/src/codecs/undefined.ts @@ -0,0 +1,8 @@ +import type { ArgCodec } from '../types.js'; + +export const undefinedCodec: ArgCodec = { + tag: Symbol.for('sr:u'), + is: (v): v is undefined => v === undefined, + encode: () => null, + decode: () => undefined, +}; diff --git a/src/index.ts b/src/index.ts index 3e0a4de..2e4c61d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,28 @@ import type { PokuPlugin } from 'poku/plugins'; -import { globalRegistry, setupSharedResourceIPC } from './shared-resources.js'; +import type { SharedResourcesConfig } from './types.js'; +import { + configureCodecs, + globalRegistry, + setupSharedResourceIPC, +} from './shared-resources.js'; -export const sharedResources = (): PokuPlugin => ({ - name: 'shared-resources', - ipc: true, - onTestProcess(child) { - setupSharedResourceIPC(child); - }, - async teardown() { - const entries = Object.values(globalRegistry); +export const sharedResources = (config?: SharedResourcesConfig): PokuPlugin => { + if (config?.codecs && config.codecs.length > 0) + configureCodecs(config.codecs); + return { + name: 'shared-resources', + ipc: true, + onTestProcess(child) { + setupSharedResourceIPC(child); + }, + async teardown() { + const entries = Object.values(globalRegistry); - for (const entry of entries) - if (entry.onDestroy) await entry.onDestroy(entry.state); - }, -}); + for (const entry of entries) + if (entry.onDestroy) await entry.onDestroy(entry.state); + }, + }; +}; export { resource } from './shared-resources.js'; +export type { ArgCodec, SharedResourcesConfig } from './types.js'; diff --git a/src/shared-resources.ts b/src/shared-resources.ts index ffcc4a0..b05e57e 100644 --- a/src/shared-resources.ts +++ b/src/shared-resources.ts @@ -1,5 +1,6 @@ import type { ChildProcess } from 'node:child_process'; import type { + ArgCodec, IPCEventEmitter, IPCMessage, IPCRemoteProcedureCallMessage, @@ -15,6 +16,13 @@ import type { import process from 'node:process'; import { pathToFileURL } from 'node:url'; import { findFileFromStack } from 'poku/plugins'; +import { arrayCodec } from './codecs/array.js'; +import { bigIntCodec } from './codecs/bigint.js'; +import { dateCodec } from './codecs/date.js'; +import { mapCodec } from './codecs/map.js'; +import { isPlainObject, objectCodec } from './codecs/object.js'; +import { setCodec } from './codecs/set.js'; +import { undefinedCodec } from './codecs/undefined.js'; import { ResourceRegistry } from './resource-registry.js'; const isWindows = process.platform === 'win32'; @@ -202,138 +210,100 @@ const remoteProcedureCall = async ( const ENC_TAG = '__sr_enc'; -const isPlainObject = (v: unknown): v is Record => { - if (v === null || typeof v !== 'object' || Array.isArray(v)) return false; - const proto = Object.getPrototypeOf(v) as unknown; - return proto === Object.prototype || proto === null; +// Converts a codec tag (string or symbol) to the string stored in the wire +// format. Symbol tags are globally-registered (Symbol.for), so Symbol.keyFor +// always returns their key string. +const tagToWire = (tag: string | symbol): string => + typeof tag === 'symbol' ? Symbol.keyFor(tag)! : tag; + +// Checked in declaration order: first match wins, so user-registered codecs +// (prepended by configureCodecs) always take precedence over built-ins. +// biome-ignore lint/suspicious/noExplicitAny: stores heterogeneous codec types; is() always guards encode() calls +let argCodecs: ArgCodec[] = [ + undefinedCodec, + bigIntCodec, + dateCodec, + mapCodec, + setCodec, + arrayCodec, + objectCodec, +]; + +/** + * Registers (or merges) custom codecs into the global codec registry. + * New codecs are prepended so they are checked before built-ins, allowing + * subclass overrides. A later codec with the same tag replaces the earlier one. + */ +// biome-ignore lint/suspicious/noExplicitAny: see argCodecs +export const configureCodecs = (codecs: ArgCodec[]): void => { + const incoming = new Map(codecs.map((c) => [c.tag, c])); + argCodecs = [...codecs, ...argCodecs.filter((c) => !incoming.has(c.tag))]; }; -const encodeObjectValues = ( - obj: Record -): Record => { - const result: Record = {}; - for (const key of Object.keys(obj)) result[key] = encodeArg(obj[key]); - return result; -}; - -const encodeObject = (v: Record): unknown => - ENC_TAG in v - ? { [ENC_TAG]: 'esc', v: encodeObjectValues(v) } - : encodeObjectValues(v); - export const encodeArg = (v: unknown): unknown => { - if (v === undefined) return { [ENC_TAG]: 'u' }; - if (typeof v === 'bigint') return { [ENC_TAG]: 'bi', v: v.toString() }; - if (v instanceof Date) return { [ENC_TAG]: 'd', v: v.toISOString() }; - if (v instanceof Map) + for (const codec of argCodecs) + if (codec.is(v)) + return { + [ENC_TAG]: 'c', + t: tagToWire(codec.tag), + v: codec.encode(v, encodeArg), + }; + // Class instances without a registered codec: encode own enumerable data + // properties (functions skipped). The prototype cannot survive a text-based + // IPC round-trip; writeBack reconciles the data back onto the original + // instance on the caller side, preserving its prototype chain. + if (typeof v === 'object' && v !== null) return { - [ENC_TAG]: 'm', - v: Array.from(v.entries(), (e) => [encodeArg(e[0]), encodeArg(e[1])]), + [ENC_TAG]: 'c', + t: tagToWire(objectCodec.tag), + v: objectCodec.encode(v as Record, encodeArg), }; - if (v instanceof Set) return { [ENC_TAG]: 's', v: Array.from(v, encodeArg) }; - if (Array.isArray(v)) return v.map(encodeArg); - if (isPlainObject(v)) return encodeObject(v); return v; }; -const decodeObjectValues = ( - obj: Record -): Record => { - const result: Record = {}; - for (const key of Object.keys(obj)) result[key] = decodeArg(obj[key]); - return result; -}; - const decodeEncoded = (enc: Record): unknown => { - const t = enc[ENC_TAG]; - if (t === 'u') return undefined; - if (t === 'bi') return BigInt(enc.v as string); - if (t === 'd') return new Date(enc.v as string); - if (t === 'm') { - const entries = enc.v as [unknown, unknown][]; - return new Map( - entries.map( - (e) => [decodeArg(e[0]), decodeArg(e[1])] as [unknown, unknown] - ) + if (enc[ENC_TAG] !== 'c') return enc; + const codec = argCodecs.find((c) => + typeof c.tag === 'symbol' ? Symbol.keyFor(c.tag) === enc.t : c.tag === enc.t + ); + if (!codec) + throw new Error( + `No codec registered for tag "${String(enc.t)}". Register it via resource.configure({ codecs }) in the resource file or pass it to sharedResources({ codecs }) for the parent process.` ); - } - if (t === 's') return new Set((enc.v as unknown[]).map(decodeArg)); - if (t === 'esc') return decodeObjectValues(enc.v as Record); - return decodeObjectValues(enc); + return codec.decode(enc.v, decodeArg); }; export const decodeArg = (v: unknown): unknown => { - if (Array.isArray(v)) return v.map(decodeArg); - if (isPlainObject(v)) - return ENC_TAG in v ? decodeEncoded(v) : decodeObjectValues(v); + if (isPlainObject(v)) return ENC_TAG in v ? decodeEncoded(v) : v; return v; }; -const writeBackDate = (original: Date, mutated: Date): void => { - original.setTime(mutated.getTime()); -}; - -const writeBackMap = ( - original: Map, - mutated: Map -): void => { - original.clear(); - for (const [k, v] of mutated) original.set(k, v); -}; - -const writeBackSet = (original: Set, mutated: Set): void => { - original.clear(); - for (const v of mutated) original.add(v); -}; - const tryReconcileInPlace = (original: unknown, mutated: unknown): boolean => { - if (isPlainObject(original) && isPlainObject(mutated)) { - writeBackObject(original, mutated); - return true; - } - if (Array.isArray(original) && Array.isArray(mutated)) { - writeBackArray(original, mutated); - return true; - } - if (original instanceof Map && mutated instanceof Map) { - writeBackMap(original, mutated); - return true; - } - if (original instanceof Set && mutated instanceof Set) { - writeBackSet(original, mutated); - return true; + for (const codec of argCodecs) { + if (codec.writeBack && codec.is(original) && codec.is(mutated)) { + codec.writeBack(original, mutated, tryReconcileInPlace); + return true; + } } - if (original instanceof Date && mutated instanceof Date) { - writeBackDate(original, mutated); + // Class instances without a codec: reconcile own enumerable data properties. + if ( + typeof original === 'object' && + original !== null && + !Array.isArray(original) && + typeof mutated === 'object' && + mutated !== null && + !Array.isArray(mutated) + ) { + objectCodec.writeBack!( + original as Record, + mutated as Record, + tryReconcileInPlace + ); return true; } return false; }; -const writeBackArray = (original: unknown[], mutated: unknown[]): void => { - const minLen = Math.min(original.length, mutated.length); - - for (let i = 0; i < minLen; i++) { - if (!tryReconcileInPlace(original[i], mutated[i])) original[i] = mutated[i]; - } - - if (original.length > mutated.length) original.splice(mutated.length); - - for (let i = original.length; i < mutated.length; i++) - original.push(mutated[i]); -}; - -const writeBackObject = ( - orig: Record, - mut: Record -): void => { - for (const key of Object.keys(orig)) if (!(key in mut)) delete orig[key]; - - for (const key of Object.keys(mut)) { - if (!tryReconcileInPlace(orig[key], mut[key])) orig[key] = mut[key]; - } -}; - export const writeBack = (original: unknown, mutated: unknown): void => { tryReconcileInPlace(original, mutated); }; @@ -530,4 +500,18 @@ const constructSharedResourceWithRPCs = ( export const resource = { create, use, + /** + * Registers custom codecs for the current process (child side). + * Call this at the top level of your resource definition file so it runs + * during module evaluation in both the child and (via `loadModuleResources`) + * the parent process. + * + * Multiple calls merge by tag — a later codec with the same tag replaces the + * earlier one, so resource files can each configure their own codecs safely. + */ + // biome-ignore lint/suspicious/noExplicitAny: see configureCodecs + configure: (config: { codecs?: ArgCodec[] }) => { + if (config.codecs && config.codecs.length > 0) + configureCodecs(config.codecs); + }, } as const; diff --git a/src/types.ts b/src/types.ts index be5a077..fa4dd63 100644 --- a/src/types.ts +++ b/src/types.ts @@ -77,3 +77,68 @@ export type MethodsToRPC = { : (...args: A) => Promise> : T[K]; }; + +/** + * Defines how a custom type (e.g. a class instance or Symbol) is encoded for + * IPC transport and reconstructed on the other side. + * + * Register codecs via `resource.configure({ codecs })` in the resource/test + * file (runs in both child and parent via module evaluation), or pass them to + * `sharedResources({ codecs })` for the parent process only. + */ +// biome-ignore lint/suspicious/noExplicitAny: codec registry stores heterogeneous types; is() always guards encode() calls +export type ArgCodec = { + /** + * Unique identifier used to look up this codec during decoding. + * Use a symbol (e.g. `Symbol.for('mylib:MyType')`) for built-in or library + * codecs — symbols can never be accidentally shadowed by a user codec that + * picks the same string tag. String tags remain supported for user codecs. + */ + tag: string | symbol; + /** Returns `true` when this codec should be used to serialize `v`. */ + is: (v: unknown) => v is T; + /** + * Converts the value to a JSON-serializable form. + * `recurse` re-enters the full `encodeArg` pipeline — use it for container + * types whose contents may themselves need encoding (e.g. Map entries). + * Simple codecs that only deal with primitives can ignore it. + */ + encode: (v: T, recurse: (inner: unknown) => unknown) => unknown; + /** + * Reconstructs the original value from its encoded form. + * `recurse` re-enters the full `decodeArg` pipeline — use it for container + * types that stored recursively-encoded contents. + */ + decode: (encoded: unknown, recurse: (inner: unknown) => unknown) => T; + /** + * Optional. Reconciles `mutated` back onto `original` in place after an IPC + * round-trip, preserving the caller's object reference. + * + * `reconcile` re-enters the full writeBack pipeline — use it when the type + * contains properties or entries that may themselves need in-place + * reconciliation. + * + * When omitted, the default behaviour applies: own enumerable properties are + * copied for plain/class-instance objects; arrays are reconciled element-wise. + */ + writeBack?: ( + original: T, + mutated: T, + reconcile: (orig: unknown, mut: unknown) => boolean + ) => void; +}; + +export type SharedResourcesConfig = { + /** + * Custom codecs for types that cannot be automatically serialized over IPC. + * Common use cases: class instances (full prototype reconstruction), Symbols, + * and any other value that `JSON.stringify` cannot faithfully represent. + * + * Codecs registered here (parent side) must also be registered in child + * processes. The easiest way is to call `resource.configure({ codecs })` at + * the top level of the resource definition file — that module is evaluated + * in both processes. + */ + // biome-ignore lint/suspicious/noExplicitAny: see ArgCodec + codecs?: ArgCodec[]; +}; diff --git a/test/__fixtures__/references/resource.ts b/test/__fixtures__/references/resource.ts index 1fee538..3029841 100644 --- a/test/__fixtures__/references/resource.ts +++ b/test/__fixtures__/references/resource.ts @@ -1,5 +1,31 @@ +import type { ArgCodec } from '../../../src/types.js'; import { resource } from '../../../src/index.js'; +export class Point { + constructor( + public x: number, + public y: number + ) {} + toString() { + return `Point(${this.x}, ${this.y})`; + } +} + +const pointCodec: ArgCodec = { + tag: 'Point', + is: (v): v is Point => v instanceof Point, + encode: (v) => ({ x: v.x, y: v.y }), + decode: (data) => { + const { x, y } = data as { x: number; y: number }; + return new Point(x, y); + }, +}; + +// Called at module-evaluation time — runs in both the child process (direct +// import) and the parent process (via loadModuleResources dynamic import), +// so codecs are available on both sides of the IPC channel automatically. +resource.configure({ codecs: [pointCodec] }); + export const MutatorContext = resource.create(() => { const value = Math.random(); return { @@ -45,6 +71,24 @@ export const NestedMutatorContext = resource.create(() => { }; }); +export const ClassInstanceMutatorContext = resource.create(() => ({ + /** + * Mutates a Point in place and returns whether the parent process received + * it as a real Point instance (true only when the codec is registered). + */ + mutateClassInstance(p: Point): boolean { + p.x += 10; + p.y += 10; + return p instanceof Point; + }, +})); + +export const FunctionPropertyMutatorContext = resource.create(() => ({ + mutateValue(obj: { value: number }) { + obj.value += 1; + }, +})); + export const SpecialTypesMutatorContext = resource.create(() => ({ mutateDate(d: Date) { d.setFullYear(2000); diff --git a/test/__fixtures__/references/unsupported-args.test.ts b/test/__fixtures__/references/unsupported-args.test.ts new file mode 100644 index 0000000..fb8ed3b --- /dev/null +++ b/test/__fixtures__/references/unsupported-args.test.ts @@ -0,0 +1,66 @@ +import { assert, test } from 'poku'; +import { resource } from '../../../src/index.js'; +import { + ClassInstanceMutatorContext, + FunctionPropertyMutatorContext, + Point, +} from './resource.js'; + +test('Codec: class instance fully reconstructed on both sides of IPC', async () => { + const mutator = await resource.use(ClassInstanceMutatorContext); + const p = new Point(1, 2); + + // With the pointCodec registered, the parent process decodes the arg back + // into a real Point instance (not a plain object). The method return value + // confirms reconstruction — `p instanceof Point` in the parent is true. + const parentReceivedRealPoint = await mutator.mutateClassInstance(p); + + assert.strictEqual( + parentReceivedRealPoint, + true, + 'parent should receive a real Point instance via the codec' + ); + assert.strictEqual( + p.x, + 11, + 'x should be incremented by 10 (written back by codec decode)' + ); + assert.strictEqual( + p.y, + 12, + 'y should be incremented by 10 (written back by codec decode)' + ); + assert.ok( + p instanceof Point, + 'prototype chain should be preserved on caller after writeBack' + ); + assert.strictEqual( + p.toString(), + 'Point(11, 12)', + 'prototype methods should still work on the updated instance' + ); +}); + +test('Function property is preserved after IPC call', async () => { + const mutator = await resource.use(FunctionPropertyMutatorContext); + const transform = (x: number) => x * 2; + const obj = { value: 42, transform } as unknown as { value: number }; + + await mutator.mutateValue(obj); + + assert.strictEqual( + (obj as unknown as { value: number }).value, + 43, + 'value should be incremented by the remote method' + ); + assert.strictEqual( + (obj as unknown as { transform: unknown }).transform, + transform, + 'function property should be preserved after IPC write-back' + ); + assert.strictEqual( + (obj as unknown as { transform: (x: number) => number }).transform(5), + 10, + 'preserved function should still work correctly' + ); +}); diff --git a/test/unit/encode-decode.test.ts b/test/unit/encode-decode.test.ts index 5b9771f..5b20038 100644 --- a/test/unit/encode-decode.test.ts +++ b/test/unit/encode-decode.test.ts @@ -1,5 +1,10 @@ +import type { ArgCodec } from '../../src/types.js'; import { assert, test } from 'poku'; -import { decodeArg, encodeArg } from '../../src/shared-resources.js'; +import { + configureCodecs, + decodeArg, + encodeArg, +} from '../../src/shared-resources.js'; const roundtrip = (v: unknown) => decodeArg(encodeArg(v)); @@ -153,7 +158,7 @@ test('encodeArg/decodeArg — plain object passthrough', () => { assert.deepStrictEqual(result, obj, 'Plain object survives roundtrip'); }); -test('encodeArg/decodeArg — object with __sr_enc key (collision escape)', () => { +test('encodeArg/decodeArg — object with __sr_enc key survives roundtrip', () => { const obj = { __sr_enc: 'user-data', other: 42 }; const result = roundtrip(obj) as typeof obj; assert.strictEqual(result.__sr_enc, 'user-data', '__sr_enc key preserved'); @@ -225,3 +230,254 @@ test('encodeArg/decodeArg — deeply nested special types', () => { assert.ok(result.tags instanceof Set, 'Nested Set preserved'); assert.strictEqual(result.tags.has('a'), true, 'Set element preserved'); }); + +test('encodeArg — class instance encoded as own enumerable data properties', () => { + class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + toString() { + return `Point(${this.x}, ${this.y})`; + } + } + const p = new Point(1, 2); + const encoded = encodeArg(p) as Record; + const encodedData = encoded.v as Record; + assert.strictEqual(encodedData.x, 1, 'x own property encoded'); + assert.strictEqual(encodedData.y, 2, 'y own property encoded'); + assert.strictEqual( + Object.hasOwn(encodedData, 'toString'), + false, + 'prototype method should not be an own property of the encoded result' + ); +}); + +test('encodeArg/decodeArg — class instance round-trips own data as a plain object', () => { + class Box { + value: number; + constructor(value: number) { + this.value = value; + } + } + const result = roundtrip(new Box(42)) as Record; + assert.strictEqual( + result.value, + 42, + 'own data property preserved in round-trip' + ); + // Prototype cannot be reconstructed over text-based IPC — documented limitation. + assert.ok( + !(result instanceof Box), + 'decoded result is a plain object (prototype not reconstructed — expected)' + ); +}); + +test('encodeArg — nested class instance encoded as plain data', () => { + class Inner { + id: number; + constructor(id: number) { + this.id = id; + } + } + const encoded = encodeArg({ wrapper: new Inner(5) }) as Record< + string, + unknown + >; + const outerData = encoded.v as Record; + const innerEncoded = outerData.wrapper as Record; + assert.deepStrictEqual( + innerEncoded.v, + { id: 5 }, + 'inner class instance encoded as plain data' + ); +}); + +test('encodeArg — function property omitted from encoded plain object', () => { + const fn = () => 42; + const obj = { value: 1, compute: fn }; + const encoded = encodeArg(obj) as Record; + const encodedData = encoded.v as Record; + assert.strictEqual( + 'compute' in encodedData, + false, + 'Function property should be omitted during encoding' + ); + assert.strictEqual( + encodedData.value, + 1, + 'Regular property should still be encoded' + ); +}); + +test('encodeArg/decodeArg — plain object with function property round-trips without the function', () => { + const fn = () => 99; + const obj = { a: 1, fn } as Record; + const result = roundtrip(obj) as Record; + assert.strictEqual(result.a, 1, 'Regular property preserved in round-trip'); + assert.strictEqual( + 'fn' in result, + false, + 'Function property is absent after encoding round-trip' + ); +}); + +const resetCodecs = () => configureCodecs([]); + +class Color { + r: number; + g: number; + b: number; + constructor(r: number, g: number, b: number) { + this.r = r; + this.g = g; + this.b = b; + } + toHex() { + return `#${[this.r, this.g, this.b].map((n) => n.toString(16).padStart(2, '0')).join('')}`; + } +} + +const colorCodec: ArgCodec = { + tag: 'Color', + is: (v): v is Color => v instanceof Color, + encode: (v) => ({ r: v.r, g: v.g, b: v.b }), + decode: (data) => { + const { r, g, b } = data as { r: number; g: number; b: number }; + return new Color(r, g, b); + }, +}; + +const MY_SYM = Symbol.for('test.mySymbol'); +const symbolCodec: ArgCodec = { + tag: 'MySymbol', + is: (v): v is typeof MY_SYM => v === MY_SYM, + encode: () => null, + decode: () => MY_SYM, +}; + +test('codec — encodeArg wraps value with sentinel tag', () => { + configureCodecs([colorCodec]); + try { + const encoded = encodeArg(new Color(255, 0, 128)) as Record< + string, + unknown + >; + assert.strictEqual(encoded.__sr_enc, 'c', 'sentinel tag should be "c"'); + assert.strictEqual(encoded.t, 'Color', 'codec tag should be present'); + assert.deepStrictEqual( + encoded.v, + { r: 255, g: 0, b: 128 }, + 'encoded value should match' + ); + } finally { + resetCodecs(); + } +}); + +test('codec — roundtrip reconstructs class instance with prototype', () => { + configureCodecs([colorCodec]); + try { + const c = new Color(0, 128, 255); + const result = roundtrip(c); + assert.ok( + result instanceof Color, + 'decoded value should be a Color instance' + ); + assert.strictEqual((result as Color).r, 0, 'r preserved'); + assert.strictEqual((result as Color).g, 128, 'g preserved'); + assert.strictEqual((result as Color).b, 255, 'b preserved'); + assert.strictEqual( + (result as Color).toHex(), + '#0080ff', + 'prototype method should work on reconstructed instance' + ); + } finally { + resetCodecs(); + } +}); + +test('codec — Symbol roundtrip via codec', () => { + configureCodecs([symbolCodec]); + try { + const result = roundtrip(MY_SYM); + assert.strictEqual( + result, + MY_SYM, + 'Symbol should be reconstructed to the same value' + ); + } finally { + resetCodecs(); + } +}); + +test('codec — codec nested inside plain object', () => { + configureCodecs([colorCodec]); + try { + const obj = { bg: new Color(10, 20, 30), label: 'sky' }; + const result = roundtrip(obj) as { bg: Color; label: string }; + assert.ok( + result.bg instanceof Color, + 'nested codec value should be reconstructed' + ); + assert.strictEqual(result.bg.r, 10, 'r preserved inside object'); + assert.strictEqual(result.label, 'sky', 'sibling property preserved'); + } finally { + resetCodecs(); + } +}); + +test('codec — codec inside array', () => { + configureCodecs([colorCodec]); + try { + const arr = [new Color(1, 2, 3), new Color(4, 5, 6)]; + const result = roundtrip(arr) as Color[]; + assert.ok(result[0] instanceof Color, 'first element reconstructed'); + assert.ok(result[1] instanceof Color, 'second element reconstructed'); + assert.strictEqual(result[0].r, 1, 'first Color r preserved'); + assert.strictEqual(result[1].b, 6, 'second Color b preserved'); + } finally { + resetCodecs(); + } +}); + +test('codec — multiple codecs coexist', () => { + configureCodecs([colorCodec, symbolCodec]); + try { + const result1 = roundtrip(new Color(5, 5, 5)); + const result2 = roundtrip(MY_SYM); + assert.ok(result1 instanceof Color, 'Color codec still works'); + assert.strictEqual(result2, MY_SYM, 'Symbol codec still works'); + } finally { + resetCodecs(); + } +}); + +test('codec — later configure call merges by tag without wiping unrelated codecs', () => { + configureCodecs([colorCodec, symbolCodec]); + // Re-register only colorCodec with same tag — symbolCodec should survive + configureCodecs([colorCodec]); + try { + const result = roundtrip(MY_SYM); + assert.strictEqual( + result, + MY_SYM, + 'symbolCodec should still be registered after merge' + ); + } finally { + resetCodecs(); + } +}); + +test('codec — decoding unknown tag throws a descriptive error', () => { + configureCodecs([]); + const orphanEncoded = { __sr_enc: 'c', t: 'OrphanType', v: {} }; + assert.throws( + () => decodeArg(orphanEncoded), + (err: unknown) => + err instanceof Error && (err as Error).message.includes('OrphanType'), + 'Should throw with the unregistered codec tag in the message' + ); +}); diff --git a/test/unit/write-back.test.ts b/test/unit/write-back.test.ts index 0dc9b33..5e31167 100644 --- a/test/unit/write-back.test.ts +++ b/test/unit/write-back.test.ts @@ -1,5 +1,6 @@ +import type { ArgCodec } from '../../src/types.js'; import { assert, test } from 'poku'; -import { writeBack } from '../../src/shared-resources.js'; +import { configureCodecs, writeBack } from '../../src/shared-resources.js'; test('writeBack — array push', () => { const original: number[] = []; @@ -284,3 +285,126 @@ test('writeBack — Set is not treated as plain object (mismatched with array is 'Set should be untouched when mutated is an array' ); }); + +test('writeBack — class instance: updates own enumerable data properties', () => { + class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + } + const p = new Point(1, 2); + writeBack(p, { x: 10, y: 20 }); + assert.strictEqual(p.x, 10, 'x should be updated in-place'); + assert.strictEqual(p.y, 20, 'y should be updated in-place'); + assert.ok(p instanceof Point, 'prototype chain should be preserved'); +}); + +test('writeBack — class instance: adds new own properties from mutated', () => { + class Box { + value: number; + constructor(value: number) { + this.value = value; + } + } + const b = new Box(5); + writeBack(b, { value: 99, extra: 'added' }); + assert.strictEqual(b.value, 99, 'existing property should be updated'); + assert.strictEqual( + (b as unknown as Record).extra, + 'added', + 'new property from mutated should be written back' + ); + assert.ok(b instanceof Box, 'prototype chain should be preserved'); +}); + +test('writeBack — function property not deleted when absent from mutated', () => { + const transform = (x: number) => x * 2; + const original: Record = { value: 42, transform }; + writeBack(original, { value: 100 }); + assert.strictEqual(original.value, 100, 'value should be updated'); + assert.strictEqual( + original.transform, + transform, + 'function property should not be deleted' + ); +}); + +test('writeBack — multiple function properties all preserved', () => { + const fn1 = () => 1; + const fn2 = () => 2; + const original: Record = { count: 0, fn1, fn2 }; + writeBack(original, { count: 5 }); + assert.strictEqual(original.count, 5, 'data property should be updated'); + assert.strictEqual(original.fn1, fn1, 'first function preserved'); + assert.strictEqual(original.fn2, fn2, 'second function preserved'); +}); + +test('writeBack — codec writeBack hook is called for registered types', () => { + class Vector { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + } + let writeBackCalled = false; + const vectorCodec: ArgCodec = { + tag: '__test_vector_wb', + is: (v): v is Vector => v instanceof Vector, + encode: (v) => ({ x: v.x, y: v.y }), + decode: (data) => { + const { x, y } = data as { x: number; y: number }; + return new Vector(x, y); + }, + writeBack: (original, mutated) => { + writeBackCalled = true; + original.x = mutated.x; + original.y = mutated.y; + }, + }; + configureCodecs([vectorCodec]); + const v = new Vector(1, 2); + writeBack(v, new Vector(10, 20)); + assert.strictEqual( + writeBackCalled, + true, + 'codec writeBack should be invoked' + ); + assert.strictEqual(v.x, 10, 'x updated via codec writeBack'); + assert.strictEqual(v.y, 20, 'y updated via codec writeBack'); + assert.ok(v instanceof Vector, 'prototype preserved after codec writeBack'); +}); + +test('writeBack — codec writeBack reconcile callback handles nested values', () => { + class Wrapper { + inner: { value: number }; + constructor(inner: { value: number }) { + this.inner = inner; + } + } + const wrapperCodec: ArgCodec = { + tag: '__test_wrapper_wb', + is: (v): v is Wrapper => v instanceof Wrapper, + encode: (v) => ({ inner: v.inner }), + decode: (data) => new Wrapper((data as Wrapper).inner), + writeBack: (original, mutated, reconcile) => { + // Use reconcile to update the nested plain object in-place + reconcile(original.inner, mutated.inner); + }, + }; + configureCodecs([wrapperCodec]); + const innerRef = { value: 1 }; + const w = new Wrapper(innerRef); + writeBack(w, new Wrapper({ value: 99 })); + assert.strictEqual( + w.inner, + innerRef, + 'inner object reference preserved via reconcile' + ); + assert.strictEqual(innerRef.value, 99, 'inner value updated via reconcile'); + assert.ok(w instanceof Wrapper, 'prototype preserved'); +});