From 1c6da33e7197196ac873f2395f9e4079f08db529 Mon Sep 17 00:00:00 2001 From: lojhan Date: Tue, 17 Mar 2026 16:35:06 -0300 Subject: [PATCH 1/8] feat: support reference mutation through IPC --- src/shared-resources.ts | 55 +++++- src/types.ts | 6 +- .../references/mutation-resource.test.ts | 17 ++ .../references/nested-mutation.test.ts | 43 +++++ .../references/object-mutation.test.ts | 31 ++++ test/__fixtures__/references/resource.ts | 46 +++++ test/integration/shared-resources.test.ts | 10 + test/unit/write-back.test.ts | 174 ++++++++++++++++++ 8 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 test/__fixtures__/references/mutation-resource.test.ts create mode 100644 test/__fixtures__/references/nested-mutation.test.ts create mode 100644 test/__fixtures__/references/object-mutation.test.ts create mode 100644 test/__fixtures__/references/resource.ts create mode 100644 test/unit/write-back.test.ts diff --git a/src/shared-resources.ts b/src/shared-resources.ts index 981b310..aa77170 100644 --- a/src/shared-resources.ts +++ b/src/shared-resources.ts @@ -200,6 +200,54 @@ const remoteProcedureCall = async ( return response.value; }; +const isPlainObject = (v: unknown): v is Record => + v !== null && typeof v === 'object' && !Array.isArray(v); + +const writeBackArray = (original: unknown[], mutated: unknown[]): void => { + const minLen = Math.min(original.length, mutated.length); + + for (let i = 0; i < minLen; i++) { + const origItem = original[i]; + const mutItem = mutated[i]; + + if (isPlainObject(origItem) && isPlainObject(mutItem)) + writeBackObject(origItem, mutItem); + else if (Array.isArray(origItem) && Array.isArray(mutItem)) + writeBackArray(origItem, mutItem); + else 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 (isPlainObject(orig[key]) && isPlainObject(mut[key])) + writeBackObject( + orig[key] as Record, + mut[key] as Record + ); + else if (Array.isArray(orig[key]) && Array.isArray(mut[key])) + writeBackArray(orig[key] as unknown[], mut[key] as unknown[]); + else orig[key] = mut[key]; + } +}; + +export const writeBack = (original: unknown, mutated: unknown): void => { + if (Array.isArray(original) && Array.isArray(mutated)) + writeBackArray(original, mutated); + else if (isPlainObject(original) && isPlainObject(mutated)) + writeBackObject(original, mutated); +}; + export const extractFunctionNames = (obj: Record) => { const seen = new Set(); let current = obj; @@ -335,7 +383,8 @@ export const handleRemoteProcedureCall = async ( try { const method = methodCandidate.bind(entry.state); - const result = await method(...(message.args || [])); + const callArgs = message.args || []; + const result = await method(...callArgs); child.send({ type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, @@ -343,6 +392,7 @@ export const handleRemoteProcedureCall = async ( value: { result, latest: state, + mutatedArgs: callArgs, }, } satisfies IPCResponse); } catch (error) { @@ -367,6 +417,9 @@ const constructSharedResourceWithRPCs = ( return async (...args: unknown[]) => { const rpcResult = await remoteProcedureCall(name, prop, args); + for (let i = 0; i < args.length; i++) + writeBack(args[i], rpcResult.mutatedArgs[i]); + for (const rpcKey of rpcs) { if (rpcKey in rpcResult.latest) { delete rpcResult.latest[rpcKey]; diff --git a/src/types.ts b/src/types.ts index ac6a52f..be5a077 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,7 +58,11 @@ export type IPCResourceResultMessage = { export type IPCRemoteProcedureCallResultMessage = { type: typeof SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT; id: string; - value?: { result: unknown; latest: Record }; + value?: { + result: unknown; + latest: Record; + mutatedArgs: unknown[]; + }; error?: string; }; diff --git a/test/__fixtures__/references/mutation-resource.test.ts b/test/__fixtures__/references/mutation-resource.test.ts new file mode 100644 index 0000000..1b12bf1 --- /dev/null +++ b/test/__fixtures__/references/mutation-resource.test.ts @@ -0,0 +1,17 @@ +import { assert, test, waitForExpectedResult } from 'poku'; +import { resource } from '../../../src/index.js'; +import { MutatorContext } from './resource.js'; + +test('Test second resource only', async () => { + const mutator = await resource.use(MutatorContext); + const value = await mutator.getValue(); + const array: number[] = []; + + await mutator.mutateArray(array); + + assert.strictEqual( + array[0], + value, + 'Array should contain the value returned by getValue()' + ); +}); diff --git a/test/__fixtures__/references/nested-mutation.test.ts b/test/__fixtures__/references/nested-mutation.test.ts new file mode 100644 index 0000000..0dc1865 --- /dev/null +++ b/test/__fixtures__/references/nested-mutation.test.ts @@ -0,0 +1,43 @@ +import { assert, test } from 'poku'; +import { resource } from '../../../src/index.js'; +import { NestedMutatorContext } from './resource.js'; + +test('Deeply nested array mutation via IPC', async () => { + const mutator = await resource.use(NestedMutatorContext); + const value = await mutator.getValue(); + const obj = { nested: { arr: [] as number[] } }; + + await mutator.pushToNestedArray(obj); + + assert.strictEqual( + obj.nested.arr[0], + value, + 'Nested array should contain the value pushed by the remote method' + ); +}); + +test('Array of objects mutation via IPC', async () => { + const mutator = await resource.use(NestedMutatorContext); + const arr = [{ x: 1 }, { x: 2 }, { x: 3 }]; + + await mutator.mutateArrayOfObjects(arr); + + assert.deepStrictEqual( + arr, + [{ x: 2 }, { x: 3 }, { x: 4 }], + 'Each object in the array should have its x property incremented' + ); +}); + +test('Array truncation via IPC', async () => { + const mutator = await resource.use(NestedMutatorContext); + const arr = [10, 20, 30, 40]; + + await mutator.truncateArray(arr); + + assert.deepStrictEqual( + arr, + [10, 20, 30], + 'Array should be truncated to all but the last element' + ); +}); diff --git a/test/__fixtures__/references/object-mutation.test.ts b/test/__fixtures__/references/object-mutation.test.ts new file mode 100644 index 0000000..c1b1f04 --- /dev/null +++ b/test/__fixtures__/references/object-mutation.test.ts @@ -0,0 +1,31 @@ +import { assert, test } from 'poku'; +import { resource } from '../../../src/index.js'; +import { ObjectMutatorContext } from './resource.js'; + +test('Object property mutation via IPC', async () => { + const mutator = await resource.use(ObjectMutatorContext); + const value = await mutator.getValue(); + const obj: Record = {}; + + await mutator.mutateObject(obj); + + assert.strictEqual( + obj.key, + value, + 'Object property should be set by the remote method' + ); +}); + +test('Object property deletion via IPC', async () => { + const mutator = await resource.use(ObjectMutatorContext); + const obj: Record = { toDelete: 'remove-me', keep: 42 }; + + await mutator.deleteKey(obj); + + assert.strictEqual( + 'toDelete' in obj, + false, + 'Deleted property should not exist on the original object' + ); + assert.strictEqual(obj.keep, 42, 'Non-deleted property should remain'); +}); diff --git a/test/__fixtures__/references/resource.ts b/test/__fixtures__/references/resource.ts new file mode 100644 index 0000000..4250ceb --- /dev/null +++ b/test/__fixtures__/references/resource.ts @@ -0,0 +1,46 @@ +import { resource } from '../../../src/index.js'; + +export const MutatorContext = resource.create(() => { + const value = Math.random(); + return { + mutateArray(arr: number[]) { + arr.push(value); + }, + getValue() { + return value; + }, + }; +}); + +export const ObjectMutatorContext = resource.create(() => { + const value = Math.random(); + return { + mutateObject(obj: Record) { + obj.key = value; + }, + deleteKey(obj: Record) { + delete obj.toDelete; + }, + getValue() { + return value; + }, + }; +}); + +export const NestedMutatorContext = resource.create(() => { + const value = Math.random(); + return { + pushToNestedArray(obj: { nested: { arr: number[] } }) { + obj.nested.arr.push(value); + }, + mutateArrayOfObjects(arr: Array<{ x: number }>) { + for (const item of arr) item.x += 1; + }, + truncateArray(arr: number[]) { + arr.splice(arr.length - 1); + }, + getValue() { + return value; + }, + }; +}); diff --git a/test/integration/shared-resources.test.ts b/test/integration/shared-resources.test.ts index 84f05f2..a1c350d 100644 --- a/test/integration/shared-resources.test.ts +++ b/test/integration/shared-resources.test.ts @@ -21,4 +21,14 @@ describe('Shared Resources', async () => { assert.strictEqual(exitCode, 1, 'Exit Code needs to be 1'); }); + + await it('Reference tests', async () => { + const code = await poku('test/__fixtures__/references', { + noExit: true, + plugins: [sharedResources()], + concurrency: 0, + }); + + assert.strictEqual(code, 0, 'Exit Code needs to be 0'); + }); }); diff --git a/test/unit/write-back.test.ts b/test/unit/write-back.test.ts new file mode 100644 index 0000000..67736e0 --- /dev/null +++ b/test/unit/write-back.test.ts @@ -0,0 +1,174 @@ +import { assert, test } from 'poku'; +import { writeBack } from '../../src/shared-resources.js'; + +test('writeBack — array push', () => { + const original: number[] = []; + writeBack(original, [42]); + assert.deepStrictEqual( + original, + [42], + 'Element should be pushed into the original array' + ); +}); + +test('writeBack — array truncation', () => { + const original = [1, 2, 3, 4]; + writeBack(original, [1, 2, 3]); + assert.deepStrictEqual( + original, + [1, 2, 3], + 'Array should be truncated to match mutated length' + ); +}); + +test('writeBack — array emptied', () => { + const original = [1, 2, 3]; + writeBack(original, []); + assert.deepStrictEqual(original, [], 'Array should be emptied'); +}); + +test('writeBack — array element update', () => { + const original = [1, 2, 3]; + writeBack(original, [1, 99, 3]); + assert.deepStrictEqual( + original, + [1, 99, 3], + 'Middle element should be updated in place' + ); +}); + +test('writeBack — preserves original array reference', () => { + const original: number[] = [1]; + const ref = original; + writeBack(original, [1, 2, 3]); + assert.strictEqual( + original, + ref, + 'Original array reference should be preserved' + ); +}); + +test('writeBack — object property set', () => { + const original: Record = {}; + writeBack(original, { key: 42 }); + assert.strictEqual( + original.key, + 42, + 'Property should be set on the original object' + ); +}); + +test('writeBack — object property deletion', () => { + const original: Record = { toDelete: 'x', keep: 1 }; + writeBack(original, { keep: 1 }); + assert.strictEqual( + 'toDelete' in original, + false, + 'Property should be removed' + ); + assert.strictEqual( + original.keep, + 1, + 'Remaining property should still be present' + ); +}); + +test('writeBack — object property update', () => { + const original: Record = { count: 0 }; + writeBack(original, { count: 5 }); + assert.strictEqual(original.count, 5, 'Property should be updated'); +}); + +test('writeBack — preserves original object reference', () => { + const original: Record = { a: 1 }; + const ref = original; + writeBack(original, { a: 2, b: 3 }); + assert.strictEqual( + original, + ref, + 'Original object reference should be preserved' + ); +}); + +test('writeBack — deeply nested array push', () => { + const original = { nested: { arr: [] as number[] } }; + writeBack(original, { nested: { arr: [7] } }); + assert.deepStrictEqual( + original.nested.arr, + [7], + 'Nested array should have the pushed value' + ); +}); + +test('writeBack — deeply nested object property', () => { + const original = { a: { b: { c: 0 } } }; + writeBack(original, { a: { b: { c: 99 } } }); + assert.strictEqual(original.a.b.c, 99, 'Deep property should be updated'); +}); + +test('writeBack — preserves nested object references', () => { + const inner = { arr: [] as number[] }; + const original = { nested: inner }; + writeBack(original, { nested: { arr: [1, 2] } }); + assert.strictEqual( + original.nested, + inner, + 'Nested object reference should be preserved' + ); + assert.deepStrictEqual( + original.nested.arr, + [1, 2], + 'Nested array should be updated in place' + ); +}); + +test('writeBack — array of objects mutation', () => { + const original = [{ x: 1 }, { x: 2 }]; + writeBack(original, [{ x: 10 }, { x: 20 }]); + assert.deepStrictEqual( + original, + [{ x: 10 }, { x: 20 }], + 'Each object element should be updated' + ); +}); + +test('writeBack — preserves references inside array of objects', () => { + const item0 = { x: 1 }; + const original = [item0, { x: 2 }]; + writeBack(original, [{ x: 99 }, { x: 2 }]); + assert.strictEqual( + original[0], + item0, + 'Object reference inside array should be preserved' + ); + assert.strictEqual( + item0.x, + 99, + 'The referenced object should have its property updated' + ); +}); + +test('writeBack — primitive original is a no-op', () => { + const original = 42 as unknown; + assert.doesNotThrow( + () => writeBack(original, 99), + 'Should not throw for primitive original' + ); +}); + +test('writeBack — null original is a no-op', () => { + assert.doesNotThrow( + () => writeBack(null, { key: 1 }), + 'Should not throw for null original' + ); +}); + +test('writeBack — mismatched types (array vs object) is a no-op', () => { + const original: number[] = [1, 2]; + writeBack(original, { key: 1 }); + assert.deepStrictEqual( + original, + [1, 2], + 'Mismatched type should leave original unchanged' + ); +}); From 5a1daf2633063dc3f210c836f69d2e7f280c4524 Mon Sep 17 00:00:00 2001 From: lojhan Date: Tue, 17 Mar 2026 17:12:54 -0300 Subject: [PATCH 2/8] feat: add mutation capabilities to map, set, date, bigint --- src/shared-resources.ts | 143 +++++++++--- test/__fixtures__/references/resource.ts | 27 +++ .../references/special-types.test.ts | 86 +++++++ test/unit/encode-decode.test.ts | 216 ++++++++++++++++++ test/unit/write-back.test.ts | 112 +++++++++ 5 files changed, 558 insertions(+), 26 deletions(-) create mode 100644 test/__fixtures__/references/special-types.test.ts create mode 100644 test/unit/encode-decode.test.ts diff --git a/src/shared-resources.ts b/src/shared-resources.ts index aa77170..ffcc4a0 100644 --- a/src/shared-resources.ts +++ b/src/shared-resources.ts @@ -182,7 +182,7 @@ const remoteProcedureCall = async ( type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL, name, method, - args, + args: args.map(encodeArg), id: requestId, } satisfies IPCRemoteProcedureCallMessage, validator: (message): message is IPCRemoteProcedureCallResultMessage => @@ -200,21 +200,121 @@ const remoteProcedureCall = async ( return response.value; }; -const isPlainObject = (v: unknown): v is Record => - v !== null && typeof v === 'object' && !Array.isArray(v); +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; +}; + +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) + return { + [ENC_TAG]: 'm', + v: Array.from(v.entries(), (e) => [encodeArg(e[0]), encodeArg(e[1])]), + }; + 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 (t === 's') return new Set((enc.v as unknown[]).map(decodeArg)); + if (t === 'esc') return decodeObjectValues(enc.v as Record); + return decodeObjectValues(enc); +}; + +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); + 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; + } + if (original instanceof Date && mutated instanceof Date) { + writeBackDate(original, mutated); + 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++) { - const origItem = original[i]; - const mutItem = mutated[i]; - - if (isPlainObject(origItem) && isPlainObject(mutItem)) - writeBackObject(origItem, mutItem); - else if (Array.isArray(origItem) && Array.isArray(mutItem)) - writeBackArray(origItem, mutItem); - else original[i] = mutated[i]; + if (!tryReconcileInPlace(original[i], mutated[i])) original[i] = mutated[i]; } if (original.length > mutated.length) original.splice(mutated.length); @@ -230,22 +330,12 @@ const writeBackObject = ( for (const key of Object.keys(orig)) if (!(key in mut)) delete orig[key]; for (const key of Object.keys(mut)) { - if (isPlainObject(orig[key]) && isPlainObject(mut[key])) - writeBackObject( - orig[key] as Record, - mut[key] as Record - ); - else if (Array.isArray(orig[key]) && Array.isArray(mut[key])) - writeBackArray(orig[key] as unknown[], mut[key] as unknown[]); - else orig[key] = mut[key]; + if (!tryReconcileInPlace(orig[key], mut[key])) orig[key] = mut[key]; } }; export const writeBack = (original: unknown, mutated: unknown): void => { - if (Array.isArray(original) && Array.isArray(mutated)) - writeBackArray(original, mutated); - else if (isPlainObject(original) && isPlainObject(mutated)) - writeBackObject(original, mutated); + tryReconcileInPlace(original, mutated); }; export const extractFunctionNames = (obj: Record) => { @@ -383,7 +473,7 @@ export const handleRemoteProcedureCall = async ( try { const method = methodCandidate.bind(entry.state); - const callArgs = message.args || []; + const callArgs = (message.args || []).map(decodeArg); const result = await method(...callArgs); child.send({ @@ -392,7 +482,7 @@ export const handleRemoteProcedureCall = async ( value: { result, latest: state, - mutatedArgs: callArgs, + mutatedArgs: callArgs.map(encodeArg), }, } satisfies IPCResponse); } catch (error) { @@ -416,9 +506,10 @@ const constructSharedResourceWithRPCs = ( if (typeof prop === 'string' && rpcs.includes(prop)) { return async (...args: unknown[]) => { const rpcResult = await remoteProcedureCall(name, prop, args); + const decodedMutatedArgs = rpcResult.mutatedArgs.map(decodeArg); for (let i = 0; i < args.length; i++) - writeBack(args[i], rpcResult.mutatedArgs[i]); + writeBack(args[i], decodedMutatedArgs[i]); for (const rpcKey of rpcs) { if (rpcKey in rpcResult.latest) { diff --git a/test/__fixtures__/references/resource.ts b/test/__fixtures__/references/resource.ts index 4250ceb..1fee538 100644 --- a/test/__fixtures__/references/resource.ts +++ b/test/__fixtures__/references/resource.ts @@ -44,3 +44,30 @@ export const NestedMutatorContext = resource.create(() => { }, }; }); + +export const SpecialTypesMutatorContext = resource.create(() => ({ + mutateDate(d: Date) { + d.setFullYear(2000); + }, + mutateMap(m: Map) { + m.set('added', 99); + m.delete('toRemove'); + }, + mutateSet(s: Set) { + s.add(99); + s.delete(0); + }, + setPropertyToUndefined(obj: Record) { + obj.a = undefined; + }, + pushUndefined(arr: (number | undefined)[]) { + arr.push(undefined); + }, + mutateBigIntArray(arr: bigint[]) { + arr.push(99n); + }, + mutateBigIntMap(m: Map) { + m.set('added', 42n); + m.delete('toRemove'); + }, +})); diff --git a/test/__fixtures__/references/special-types.test.ts b/test/__fixtures__/references/special-types.test.ts new file mode 100644 index 0000000..cdf6f49 --- /dev/null +++ b/test/__fixtures__/references/special-types.test.ts @@ -0,0 +1,86 @@ +import { assert, test } from 'poku'; +import { resource } from '../../../src/index.js'; +import { SpecialTypesMutatorContext } from './resource.js'; + +test('Date mutation via IPC', async () => { + const mutator = await resource.use(SpecialTypesMutatorContext); + const d = new Date('2026-01-15T00:00:00.000Z'); + + await mutator.mutateDate(d); + + assert.strictEqual( + d.getFullYear(), + 2000, + 'Date year should be updated to 2000 in place' + ); +}); + +test('Map mutation via IPC', async () => { + const mutator = await resource.use(SpecialTypesMutatorContext); + const m = new Map([ + ['keep', 1], + ['toRemove', 2], + ]); + + await mutator.mutateMap(m); + + assert.strictEqual(m.get('added'), 99, 'New entry should be present'); + assert.strictEqual( + m.has('toRemove'), + false, + 'Removed entry should be absent' + ); + assert.strictEqual(m.get('keep'), 1, 'Unchanged entry should remain'); +}); + +test('Set mutation via IPC', async () => { + const mutator = await resource.use(SpecialTypesMutatorContext); + const s = new Set([0, 1, 2]); + + await mutator.mutateSet(s); + + assert.strictEqual(s.has(99), true, 'New element should be present'); + assert.strictEqual(s.has(0), false, 'Removed element should be absent'); + assert.strictEqual(s.has(1), true, 'Unchanged element should remain'); +}); + +test('Setting object property to undefined via IPC', async () => { + const mutator = await resource.use(SpecialTypesMutatorContext); + const obj: Record = { a: 42 }; + + await mutator.setPropertyToUndefined(obj); + + assert.strictEqual('a' in obj, true, 'Key should still exist on the object'); + assert.strictEqual(obj.a, undefined, 'Value should be undefined'); +}); + +test('Pushing undefined into array via IPC', async () => { + const mutator = await resource.use(SpecialTypesMutatorContext); + const arr: (number | undefined)[] = [1, 2]; + + await mutator.pushUndefined(arr); + + assert.strictEqual(arr.length, 3, 'Array should have grown by one'); + assert.strictEqual(arr[2], undefined, 'New element should be undefined'); +}); + +test('BigInt array mutation via IPC', async () => { + const mutator = await resource.use(SpecialTypesMutatorContext); + const arr: bigint[] = [1n, 2n]; + + await mutator.mutateBigIntArray(arr); + + assert.strictEqual(arr.length, 3, 'Array should have grown by one'); + assert.strictEqual(arr[2], 99n, 'New BigInt element should be 99n'); +}); + +test('BigInt Map mutation via IPC', async () => { + const mutator = await resource.use(SpecialTypesMutatorContext); + const m = new Map([['keep', 1n], ['toRemove', 2n]]); + + await mutator.mutateBigIntMap(m); + + assert.strictEqual(m.has('toRemove'), false, 'Removed entry should be absent'); + assert.strictEqual(m.get('added'), 42n, 'New BigInt entry should be 42n'); + assert.strictEqual(m.get('keep'), 1n, 'Unchanged entry should remain'); +}); diff --git a/test/unit/encode-decode.test.ts b/test/unit/encode-decode.test.ts new file mode 100644 index 0000000..72a8740 --- /dev/null +++ b/test/unit/encode-decode.test.ts @@ -0,0 +1,216 @@ +import { assert, test } from 'poku'; +import { decodeArg, encodeArg } from '../../src/shared-resources.js'; + +const roundtrip = (v: unknown) => decodeArg(encodeArg(v)); + +test('encodeArg/decodeArg — undefined', () => { + assert.strictEqual( + roundtrip(undefined), + undefined, + 'undefined survives roundtrip' + ); +}); + +test('encodeArg/decodeArg — null', () => { + assert.strictEqual(roundtrip(null), null, 'null survives roundtrip'); +}); + +test('encodeArg/decodeArg — number', () => { + assert.strictEqual(roundtrip(42), 42, 'number survives roundtrip'); +}); + +test('encodeArg/decodeArg — string', () => { + assert.strictEqual(roundtrip('hello'), 'hello', 'string survives roundtrip'); +}); + +test('encodeArg/decodeArg — boolean', () => { + assert.strictEqual(roundtrip(true), true, 'boolean survives roundtrip'); +}); + +test('encodeArg/decodeArg — Date', () => { + const d = new Date('2026-03-17T12:00:00.000Z'); + const result = roundtrip(d); + assert.ok(result instanceof Date, 'Should be a Date instance'); + assert.strictEqual( + (result as Date).getTime(), + d.getTime(), + 'Date time preserved' + ); +}); + +test('encodeArg/decodeArg — Map', () => { + const m = new Map([ + ['a', 1], + ['b', 2], + ]); + const result = roundtrip(m) as Map; + assert.ok(result instanceof Map, 'Should be a Map instance'); + assert.strictEqual(result.get('a'), 1, 'Map entry a preserved'); + assert.strictEqual(result.get('b'), 2, 'Map entry b preserved'); + assert.strictEqual(result.size, 2, 'Map size preserved'); +}); + +test('encodeArg/decodeArg — Set', () => { + const s = new Set([1, 2, 3]); + const result = roundtrip(s) as Set; + assert.ok(result instanceof Set, 'Should be a Set instance'); + assert.strictEqual(result.has(1), true, 'Set element 1 preserved'); + assert.strictEqual(result.has(3), true, 'Set element 3 preserved'); + assert.strictEqual(result.size, 3, 'Set size preserved'); +}); + +test('encodeArg/decodeArg — array with undefined', () => { + const arr = [1, undefined, 3]; + const result = roundtrip(arr) as unknown[]; + assert.strictEqual(result[0], 1, 'First element preserved'); + assert.strictEqual(result[1], undefined, 'undefined element preserved'); + assert.strictEqual(result[2], 3, 'Third element preserved'); + assert.strictEqual(result.length, 3, 'Array length preserved'); +}); + +test('encodeArg/decodeArg — object with undefined value', () => { + const obj = { a: undefined as unknown, b: 1 }; + const result = roundtrip(obj) as Record; + assert.strictEqual( + 'a' in result, + true, + 'Key with undefined value should exist' + ); + assert.strictEqual(result.a, undefined, 'undefined value preserved'); + assert.strictEqual(result.b, 1, 'Other values preserved'); +}); + +test('encodeArg/decodeArg — nested Map inside object', () => { + const m = new Map([['x', 10]]); + const obj = { m, count: 1 }; + const result = roundtrip(obj) as { m: Map; count: number }; + assert.ok(result.m instanceof Map, 'Nested Map is a Map instance'); + assert.strictEqual(result.m.get('x'), 10, 'Nested Map entry preserved'); + assert.strictEqual(result.count, 1, 'Other object property preserved'); +}); + +test('encodeArg/decodeArg — nested Set inside array', () => { + const s = new Set([7, 8]); + const arr = [s, 42]; + const result = roundtrip(arr) as unknown[]; + assert.ok(result[0] instanceof Set, 'Nested Set is a Set instance'); + assert.strictEqual( + (result[0] as Set).has(7), + true, + 'Set element 7 preserved' + ); + assert.strictEqual(result[1], 42, 'Other array element preserved'); +}); + +test('encodeArg/decodeArg — nested Date inside array', () => { + const d = new Date('2026-01-01T00:00:00.000Z'); + const arr = [d, 42]; + const result = roundtrip(arr) as unknown[]; + assert.ok(result[0] instanceof Date, 'Nested Date is a Date instance'); + assert.strictEqual( + (result[0] as Date).getTime(), + d.getTime(), + 'Date value preserved' + ); + assert.strictEqual(result[1], 42, 'Other array element preserved'); +}); + +test('encodeArg/decodeArg — Map with object keys', () => { + const key = { id: 1 }; + const m = new Map([[key, 'value']]); + const result = roundtrip(m) as Map, string>; + assert.strictEqual(result.size, 1, 'Map size preserved'); + const [[rk, rv]] = result.entries(); + assert.deepStrictEqual(rk, key, 'Map object key preserved by value'); + assert.strictEqual(rv, 'value', 'Map value preserved'); +}); + +test('encodeArg/decodeArg — Map with undefined value', () => { + const m = new Map([['a', undefined]]); + const result = roundtrip(m) as Map; + assert.strictEqual(result.has('a'), true, 'Key with undefined value present'); + assert.strictEqual( + result.get('a'), + undefined, + 'undefined Map value preserved' + ); +}); + +test('encodeArg/decodeArg — Set with undefined', () => { + const s = new Set([1, undefined, 3]); + const result = roundtrip(s) as Set; + assert.strictEqual( + result.has(undefined), + true, + 'undefined Set element preserved' + ); + assert.strictEqual(result.size, 3, 'Set size preserved'); +}); + +test('encodeArg/decodeArg — plain object passthrough', () => { + const obj = { a: 1, b: 'hello', c: true }; + const result = roundtrip(obj); + assert.deepStrictEqual(result, obj, 'Plain object survives roundtrip'); +}); + +test('encodeArg/decodeArg — object with __sr_enc key (collision escape)', () => { + 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'); + assert.strictEqual(result.other, 42, 'Other key preserved'); +}); + +test('encodeArg/decodeArg — BigInt', () => { + assert.strictEqual(roundtrip(42n), 42n, 'BigInt survives roundtrip'); + assert.strictEqual(roundtrip(0n), 0n, 'BigInt 0 survives roundtrip'); + assert.strictEqual(roundtrip(-99n), -99n, 'Negative BigInt survives roundtrip'); +}); + +test('encodeArg/decodeArg — BigInt in array', () => { + const result = roundtrip([1n, 2n, 3n]) as bigint[]; + assert.deepStrictEqual(result, [1n, 2n, 3n], 'Array of BigInts survives roundtrip'); +}); + +test('encodeArg/decodeArg — BigInt in object', () => { + const result = roundtrip({ a: 1n, b: 2 }) as { a: bigint; b: number }; + assert.strictEqual(result.a, 1n, 'BigInt property survives roundtrip'); + assert.strictEqual(result.b, 2, 'Non-BigInt property preserved'); +}); + +test('encodeArg/decodeArg — BigInt as Map key and value', () => { + const m = new Map([[1n, 100n], [2n, 200n]]); + const result = roundtrip(m) as Map; + assert.ok(result instanceof Map, 'Should be a Map instance'); + assert.strictEqual(result.get(1n), 100n, 'BigInt key and value preserved'); + assert.strictEqual(result.get(2n), 200n, 'Second BigInt entry preserved'); +}); + +test('encodeArg/decodeArg — BigInt in Set', () => { + const s = new Set([1n, 2n, 3n]); + const result = roundtrip(s) as Set; + assert.ok(result instanceof Set, 'Should be a Set instance'); + assert.strictEqual(result.has(1n), true, 'BigInt Set element preserved'); + assert.strictEqual(result.has(3n), true, 'BigInt Set element preserved'); +}); + +test('encodeArg/decodeArg — deeply nested special types', () => { + const d = new Date('2026-06-01T00:00:00.000Z'); + const m = new Map([['created', d]]); + const obj = { data: m, tags: new Set(['a', 'b']) }; + const result = roundtrip(obj) as { + data: Map; + tags: Set; + }; + assert.ok(result.data instanceof Map, 'Nested Map preserved'); + assert.ok( + result.data.get('created') instanceof Date, + 'Map-nested Date preserved' + ); + assert.strictEqual( + result.data.get('created')?.getTime(), + d.getTime(), + 'Date value correct' + ); + assert.ok(result.tags instanceof Set, 'Nested Set preserved'); + assert.strictEqual(result.tags.has('a'), true, 'Set element preserved'); +}); diff --git a/test/unit/write-back.test.ts b/test/unit/write-back.test.ts index 67736e0..0dc9b33 100644 --- a/test/unit/write-back.test.ts +++ b/test/unit/write-back.test.ts @@ -172,3 +172,115 @@ test('writeBack — mismatched types (array vs object) is a no-op', () => { 'Mismatched type should leave original unchanged' ); }); + +test('writeBack — Map repopulation', () => { + const original = new Map([ + ['a', 1], + ['b', 2], + ]); + const ref = original; + writeBack( + original, + new Map([ + ['b', 99], + ['c', 3], + ]) + ); + assert.strictEqual(original, ref, 'Map reference should be preserved'); + assert.strictEqual(original.has('a'), false, 'Removed key should be gone'); + assert.strictEqual(original.get('b'), 99, 'Updated key should be updated'); + assert.strictEqual(original.get('c'), 3, 'New key should be added'); +}); + +test('writeBack — Set repopulation', () => { + const original = new Set([1, 2, 3]); + const ref = original; + writeBack(original, new Set([2, 3, 4])); + assert.strictEqual(original, ref, 'Set reference should be preserved'); + assert.strictEqual(original.has(1), false, 'Removed element should be gone'); + assert.strictEqual(original.has(4), true, 'New element should be present'); + assert.strictEqual(original.has(2), true, 'Unchanged element should remain'); +}); + +test('writeBack — Date mutation', () => { + const original = new Date('2026-01-01T00:00:00.000Z'); + const ref = original; + writeBack(original, new Date('2000-06-15T00:00:00.000Z')); + assert.strictEqual(original, ref, 'Date reference should be preserved'); + assert.strictEqual(original.getFullYear(), 2000, 'Year should be updated'); + assert.strictEqual( + original.getMonth(), + 5, + 'Month should be updated (0-indexed)' + ); +}); + +test('writeBack — Map nested in object (reference preserved)', () => { + const innerMap = new Map([['x', 1]]); + const original: Record = { m: innerMap }; + writeBack(original, { + m: new Map([ + ['x', 1], + ['y', 2], + ]), + }); + assert.strictEqual( + original.m, + innerMap, + 'Inner Map reference should be preserved' + ); + assert.strictEqual( + innerMap.get('y'), + 2, + 'Inner Map should be updated with new entry' + ); +}); + +test('writeBack — Set nested in array (reference preserved)', () => { + const innerSet = new Set([1, 2]); + const original: unknown[] = [innerSet, 42]; + writeBack(original, [new Set([1, 2, 3]), 42]); + assert.strictEqual( + original[0], + innerSet, + 'Set reference inside array should be preserved' + ); + assert.strictEqual(innerSet.has(3), true, 'Set should have new element'); +}); + +test('writeBack — Date nested in array (reference preserved)', () => { + const innerDate = new Date('2026-01-01T00:00:00.000Z'); + const mutationTarget = new Date('2000-01-01T00:00:00.000Z'); + const original: unknown[] = [innerDate, 42]; + writeBack(original, [mutationTarget, 42]); + assert.strictEqual( + original[0], + innerDate, + 'Date reference inside array should be preserved' + ); + assert.strictEqual( + (original[0] as Date).getTime(), + mutationTarget.getTime(), + 'Date time should be updated' + ); +}); + +test('writeBack — Map is not treated as plain object (mismatched with plain object is no-op)', () => { + const original = new Map([['a', 1]]); + writeBack(original, { a: 2 }); + assert.strictEqual( + original.get('a'), + 1, + 'Map should be untouched when mutated is a plain object' + ); +}); + +test('writeBack — Set is not treated as plain object (mismatched with array is no-op)', () => { + const original = new Set([1, 2]); + writeBack(original, [3, 4]); + assert.strictEqual( + original.has(1), + true, + 'Set should be untouched when mutated is an array' + ); +}); From 2438748ddcc897a9535481d6e4871ecbedac6658 Mon Sep 17 00:00:00 2001 From: lojhan Date: Tue, 17 Mar 2026 17:13:27 -0300 Subject: [PATCH 3/8] test: new tests for mutation resource and special types handling --- .../references/special-types.test.ts | 11 +++++++++-- test/unit/encode-decode.test.ts | 17 ++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/test/__fixtures__/references/special-types.test.ts b/test/__fixtures__/references/special-types.test.ts index cdf6f49..d1c3f0e 100644 --- a/test/__fixtures__/references/special-types.test.ts +++ b/test/__fixtures__/references/special-types.test.ts @@ -76,11 +76,18 @@ test('BigInt array mutation via IPC', async () => { test('BigInt Map mutation via IPC', async () => { const mutator = await resource.use(SpecialTypesMutatorContext); - const m = new Map([['keep', 1n], ['toRemove', 2n]]); + const m = new Map([ + ['keep', 1n], + ['toRemove', 2n], + ]); await mutator.mutateBigIntMap(m); - assert.strictEqual(m.has('toRemove'), false, 'Removed entry should be absent'); + assert.strictEqual( + m.has('toRemove'), + false, + 'Removed entry should be absent' + ); assert.strictEqual(m.get('added'), 42n, 'New BigInt entry should be 42n'); assert.strictEqual(m.get('keep'), 1n, 'Unchanged entry should remain'); }); diff --git a/test/unit/encode-decode.test.ts b/test/unit/encode-decode.test.ts index 72a8740..5b9771f 100644 --- a/test/unit/encode-decode.test.ts +++ b/test/unit/encode-decode.test.ts @@ -163,12 +163,20 @@ test('encodeArg/decodeArg — object with __sr_enc key (collision escape)', () = test('encodeArg/decodeArg — BigInt', () => { assert.strictEqual(roundtrip(42n), 42n, 'BigInt survives roundtrip'); assert.strictEqual(roundtrip(0n), 0n, 'BigInt 0 survives roundtrip'); - assert.strictEqual(roundtrip(-99n), -99n, 'Negative BigInt survives roundtrip'); + assert.strictEqual( + roundtrip(-99n), + -99n, + 'Negative BigInt survives roundtrip' + ); }); test('encodeArg/decodeArg — BigInt in array', () => { const result = roundtrip([1n, 2n, 3n]) as bigint[]; - assert.deepStrictEqual(result, [1n, 2n, 3n], 'Array of BigInts survives roundtrip'); + assert.deepStrictEqual( + result, + [1n, 2n, 3n], + 'Array of BigInts survives roundtrip' + ); }); test('encodeArg/decodeArg — BigInt in object', () => { @@ -178,7 +186,10 @@ test('encodeArg/decodeArg — BigInt in object', () => { }); test('encodeArg/decodeArg — BigInt as Map key and value', () => { - const m = new Map([[1n, 100n], [2n, 200n]]); + const m = new Map([ + [1n, 100n], + [2n, 200n], + ]); const result = roundtrip(m) as Map; assert.ok(result instanceof Map, 'Should be a Map instance'); assert.strictEqual(result.get(1n), 100n, 'BigInt key and value preserved'); From 66324920e02dae2fe8dbf528f378bb107ef4a1b8 Mon Sep 17 00:00:00 2001 From: lojhan Date: Tue, 17 Mar 2026 21:23:17 -0300 Subject: [PATCH 4/8] feat: introduce ArgCodec system with symbol tags for built-in codecs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ArgCodec type with is/encode/decode/writeBack/reconcile hooks - Add SharedResourcesConfig type with optional codecs array - Add configureCodecs() export and resource.configure({ codecs }) API - Extract all built-in type handling into composable codec files: src/codecs/undefined.ts, bigint.ts, date.ts, map.ts, set.ts, array.ts, object.ts - Use Symbol.for() tags for all built-in codecs to prevent shadowing - Simplify shared-resources.ts: encodeArg, decodeArg, tryReconcileInPlace now fully delegate to the codec registry - Support class instances without a codec by encoding own enumerable data properties and reconciling back onto the original instance - Preserve function-valued properties during encoding and writeBack - Remove collision-escape (esc) mechanism — outer sentinel wrap makes it unnecessary - Add tagToWire() helper for symbol-to-string wire conversion --- src/codecs/array.ts | 20 ++ src/codecs/bigint.ts | 8 + src/codecs/date.ts | 11 + src/codecs/map.ts | 18 ++ src/codecs/object.ts | 37 +++ src/codecs/set.ts | 12 + src/codecs/undefined.ts | 8 + src/index.ts | 35 ++- src/shared-resources.ts | 203 +++++++------- src/types.ts | 65 +++++ test/__fixtures__/references/resource.ts | 44 ++++ .../references/unsupported-args.test.ts | 66 +++++ test/unit/encode-decode.test.ts | 249 +++++++++++++++++- test/unit/write-back.test.ts | 116 +++++++- 14 files changed, 767 insertions(+), 125 deletions(-) create mode 100644 src/codecs/array.ts create mode 100644 src/codecs/bigint.ts create mode 100644 src/codecs/date.ts create mode 100644 src/codecs/map.ts create mode 100644 src/codecs/object.ts create mode 100644 src/codecs/set.ts create mode 100644 src/codecs/undefined.ts create mode 100644 test/__fixtures__/references/unsupported-args.test.ts 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..ae18f5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,27 @@ 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?.length) 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..1bd5bae 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,102 @@ 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 +502,17 @@ 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?.length) 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..e73d637 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,243 @@ 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 { + constructor( + public x: number, + public y: number + ) {} + 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 { + constructor(public value: number) {} + } + 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 { + constructor(public id: number) {} + } + 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 { + constructor( + public r: number, + public g: number, + public b: number + ) {} + 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..8086ccb 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,116 @@ 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 { + constructor( + public x: number, + public y: number + ) {} + } + 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 { + constructor(public value: number) {} + } + 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 { + constructor( + public x: number, + public y: number + ) {} + } + 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 { + constructor(public inner: { value: number }) {} + } + 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'); +}); From 41b7749a1c31f73bac00eabf48f20f35303bb5ff Mon Sep 17 00:00:00 2001 From: lojhan Date: Tue, 17 Mar 2026 21:27:16 -0300 Subject: [PATCH 5/8] lint: fix --- .vscode/settings.json | 26 +- biome.jsonc | 118 +-- deno.json | 2 +- package.json | 122 ++- src/codecs/array.ts | 28 +- src/codecs/bigint.ts | 10 +- src/codecs/date.ts | 16 +- src/codecs/map.ts | 30 +- src/codecs/object.ts | 62 +- src/codecs/set.ts | 18 +- src/codecs/undefined.ts | 10 +- src/index.ts | 42 +- src/resource-registry.ts | 50 +- src/shared-resources.ts | 850 ++++++++++---------- src/types.ts | 196 ++--- test/integration/shared-resources.test.ts | 54 +- test/unit/encode-decode.test.ts | 919 +++++++++++----------- test/unit/resource-registry.test.ts | 116 +-- test/unit/send-ipc-message.test.ts | 222 +++--- test/unit/write-back.test.ts | 686 ++++++++-------- tsconfig.json | 56 +- 21 files changed, 1826 insertions(+), 1807 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 3ed792a..2698ff9 100755 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,15 +1,15 @@ { - "deno.enable": false, - "editor.trimAutoWhitespace": true, - "editor.indentSize": 2, - "editor.tabSize": 2, - "editor.formatOnSave": true, - "files.trimTrailingWhitespace": true, - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.codeActionsOnSave": { - "source.fixAll": "explicit" - }, - "[markdown]": { - "files.trimTrailingWhitespace": false - } + "deno.enable": false, + "editor.trimAutoWhitespace": true, + "editor.indentSize": 2, + "editor.tabSize": 2, + "editor.formatOnSave": true, + "files.trimTrailingWhitespace": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll": "explicit" + }, + "[markdown]": { + "files.trimTrailingWhitespace": false + } } diff --git a/biome.jsonc b/biome.jsonc index 423064c..29d7e15 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,61 +1,61 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "files": { - "include": ["**/**"], - "ignore": ["coverage", "lib", "node_modules", "test/__fixtures__"] - }, - "organizeImports": { - "enabled": false - }, - "overrides": [ - { - "include": ["src/index.ts"], - "linter": { - "rules": { - "performance": { - "noBarrelFile": "off" - } - } - } - } - ], - "linter": { - "enabled": true, - "rules": { - "all": true, - "complexity": { - "all": true - }, - "a11y": { - "all": true - }, - "correctness": { - "all": true, - "noNodejsModules": "off" - }, - "nursery": { - "all": true, - "useImportRestrictions": "off", - "noProcessEnv": "off", - "noSecrets": "off", - "useExplicitType": "off" - }, - "performance": { - "all": true - }, - "security": { - "all": true - }, - "suspicious": { - "all": true - }, - "style": { - "all": true, - "noNonNullAssertion": "off", - "useNamingConvention": "off", - "noNamespaceImport": "off", - "useBlockStatements": "off" - } - } - } + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "files": { + "include": ["**/**"], + "ignore": ["coverage", "lib", "node_modules", "test/__fixtures__"] + }, + "organizeImports": { + "enabled": false + }, + "overrides": [ + { + "include": ["src/index.ts"], + "linter": { + "rules": { + "performance": { + "noBarrelFile": "off" + } + } + } + } + ], + "linter": { + "enabled": true, + "rules": { + "all": true, + "complexity": { + "all": true + }, + "a11y": { + "all": true + }, + "correctness": { + "all": true, + "noNodejsModules": "off" + }, + "nursery": { + "all": true, + "useImportRestrictions": "off", + "noProcessEnv": "off", + "noSecrets": "off", + "useExplicitType": "off" + }, + "performance": { + "all": true + }, + "security": { + "all": true + }, + "suspicious": { + "all": true + }, + "style": { + "all": true, + "noNonNullAssertion": "off", + "useNamingConvention": "off", + "noNamespaceImport": "off", + "useBlockStatements": "off" + } + } + } } diff --git a/deno.json b/deno.json index 8726fcf..0449a2e 100644 --- a/deno.json +++ b/deno.json @@ -1,3 +1,3 @@ { - "unstable": ["sloppy-imports", "detect-cjs"] + "unstable": ["sloppy-imports", "detect-cjs"] } diff --git a/package.json b/package.json index 3429a72..4dd53a5 100644 --- a/package.json +++ b/package.json @@ -1,64 +1,62 @@ { - "name": "@pokujs/shared-resources", - "version": "1.0.0", - "private": false, - "description": "🪢 A Poku plugin for shared resources across isolated tests.", - "main": "./lib/index.js", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/pokujs/shared-resources.git" - }, - "homepage": "https://poku.io/docs/documentation/helpers/shared-resources", - "bugs": { - "url": "https://github.com/pokujs/shared-resources/issues" - }, - "author": "https://github.com/wellwelwel", - "funding": { - "type": "github", - "url": "https://github.com/pokujs/shared-resources?sponsor=1" - }, - "files": [ - "lib" - ], - "engines": { - "node": ">=16.x.x", - "bun": ">=1.x.x", - "deno": ">=2.x.x", - "typescript": ">=5.x.x" - }, - "scripts": { - "test": "poku test/integration test/unit", - "test:bun": "bun poku test/integration test/unit", - "test:deno": "deno run -A npm:poku --denoAllow=all test/integration test/unit", - "prebuild": "rm -rf lib", - "build": "tsc", - "lint": "biome lint --error-on-warnings && prettier --check .", - "lint:fix": "biome lint --write && prettier --write ." - }, - "devDependencies": { - "@biomejs/biome": "^1.9.4", - "@ianvs/prettier-plugin-sort-imports": "^4.7.0", - "@types/node": "^25.3.3", - "poku": "^3.0.3-canary.68e71482", - "prettier": "^3.8.1", - "tsx": "^4.21.0", - "typescript": "^5.9.3" - }, - "peerDependencies": { - "poku": "canary" - }, - "publishConfig": { - "access": "public" - }, - "keywords": [ - "🐷", - "poku", - "pokujs", - "testing", - "plugin", - "shared-resources", - "ipc", - "parallel" - ] + "name": "@pokujs/shared-resources", + "version": "1.0.0", + "private": false, + "description": "🪢 A Poku plugin for shared resources across isolated tests.", + "main": "./lib/index.js", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/pokujs/shared-resources.git" + }, + "homepage": "https://poku.io/docs/documentation/helpers/shared-resources", + "bugs": { + "url": "https://github.com/pokujs/shared-resources/issues" + }, + "author": "https://github.com/wellwelwel", + "funding": { + "type": "github", + "url": "https://github.com/pokujs/shared-resources?sponsor=1" + }, + "files": ["lib"], + "engines": { + "node": ">=16.x.x", + "bun": ">=1.x.x", + "deno": ">=2.x.x", + "typescript": ">=5.x.x" + }, + "scripts": { + "test": "poku test/integration test/unit", + "test:bun": "bun poku test/integration test/unit", + "test:deno": "deno run -A npm:poku --denoAllow=all test/integration test/unit", + "prebuild": "rm -rf lib", + "build": "tsc", + "lint": "biome lint --error-on-warnings && prettier --check .", + "lint:fix": "biome lint --write && prettier --write ." + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@ianvs/prettier-plugin-sort-imports": "^4.7.0", + "@types/node": "^25.3.3", + "poku": "^3.0.3-canary.68e71482", + "prettier": "^3.8.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "poku": "canary" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "🐷", + "poku", + "pokujs", + "testing", + "plugin", + "shared-resources", + "ipc", + "parallel" + ] } diff --git a/src/codecs/array.ts b/src/codecs/array.ts index d10407c..798ba45 100644 --- a/src/codecs/array.ts +++ b/src/codecs/array.ts @@ -1,20 +1,20 @@ -import type { ArgCodec } from '../types.js'; +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); + 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]; - } + 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); + if (original.length > mutated.length) original.splice(mutated.length); - for (let i = original.length; i < mutated.length; i++) - original.push(mutated[i]); - }, + 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 index e8db52f..bc5be2c 100644 --- a/src/codecs/bigint.ts +++ b/src/codecs/bigint.ts @@ -1,8 +1,8 @@ -import type { ArgCodec } from '../types.js'; +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), + 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 index 3e6792c..8d43a4c 100644 --- a/src/codecs/date.ts +++ b/src/codecs/date.ts @@ -1,11 +1,11 @@ -import type { ArgCodec } from '../types.js'; +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()); - }, + 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 index 3484a9c..ac5f6f1 100644 --- a/src/codecs/map.ts +++ b/src/codecs/map.ts @@ -1,18 +1,18 @@ -import type { ArgCodec } from '../types.js'; +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); - }, + 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 index d63ea8e..87159a0 100644 --- a/src/codecs/object.ts +++ b/src/codecs/object.ts @@ -1,37 +1,37 @@ -import type { ArgCodec } from '../types.js'; +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; + 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]; - } - }, + 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 index b6f0530..449c26a 100644 --- a/src/codecs/set.ts +++ b/src/codecs/set.ts @@ -1,12 +1,12 @@ -import type { ArgCodec } from '../types.js'; +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); - }, + 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 index e7e94e8..aefd9a0 100644 --- a/src/codecs/undefined.ts +++ b/src/codecs/undefined.ts @@ -1,8 +1,8 @@ -import type { ArgCodec } from '../types.js'; +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, + 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 ae18f5a..4a8aaf7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,27 +1,27 @@ -import type { PokuPlugin } from 'poku/plugins'; -import type { SharedResourcesConfig } from './types.js'; +import type { PokuPlugin } from "poku/plugins"; +import type { SharedResourcesConfig } from "./types.js"; import { - configureCodecs, - globalRegistry, - setupSharedResourceIPC, -} from './shared-resources.js'; + configureCodecs, + globalRegistry, + setupSharedResourceIPC, +} from "./shared-resources.js"; export const sharedResources = (config?: SharedResourcesConfig): PokuPlugin => { - if (config?.codecs?.length) configureCodecs(config.codecs); - return { - name: 'shared-resources', - ipc: true, - onTestProcess(child) { - setupSharedResourceIPC(child); - }, - async teardown() { - const entries = Object.values(globalRegistry); + if (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'; +export { resource } from "./shared-resources.js"; +export type { ArgCodec, SharedResourcesConfig } from "./types.js"; diff --git a/src/resource-registry.ts b/src/resource-registry.ts index 4b55fa4..d78a951 100644 --- a/src/resource-registry.ts +++ b/src/resource-registry.ts @@ -1,34 +1,34 @@ export class ResourceRegistry { - private registry: Record = Object.create(null); - private isRegistering = false; + private registry: Record = Object.create(null); + private isRegistering = false; - register(name: string, entry: T) { - this.registry[name] = entry; - } + register(name: string, entry: T) { + this.registry[name] = entry; + } - get(name: string) { - return this.registry[name]; - } + get(name: string) { + return this.registry[name]; + } - has(name: string) { - return name in this.registry; - } + has(name: string) { + return name in this.registry; + } - clear() { - for (const key in this.registry) - if (Object.prototype.hasOwnProperty.call(this.registry, key)) - delete this.registry[key]; - } + clear() { + for (const key in this.registry) + if (Object.prototype.hasOwnProperty.call(this.registry, key)) + delete this.registry[key]; + } - getIsRegistering() { - return this.isRegistering; - } + getIsRegistering() { + return this.isRegistering; + } - setIsRegistering(value: boolean) { - this.isRegistering = value; - } + setIsRegistering(value: boolean) { + this.isRegistering = value; + } - getRegistry() { - return this.registry; - } + getRegistry() { + return this.registry; + } } diff --git a/src/shared-resources.ts b/src/shared-resources.ts index 1bd5bae..6fcc9fb 100644 --- a/src/shared-resources.ts +++ b/src/shared-resources.ts @@ -1,232 +1,232 @@ -import type { ChildProcess } from 'node:child_process'; +import type { ChildProcess } from "node:child_process"; import type { - ArgCodec, - IPCEventEmitter, - IPCMessage, - IPCRemoteProcedureCallMessage, - IPCRemoteProcedureCallResultMessage, - IPCRequestResourceMessage, - IPCResourceResultMessage, - IPCResponse, - MethodsToRPC, - ResourceContext, - SendIPCMessageOptions, - SharedResourceEntry, -} from './types.js'; -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'; + ArgCodec, + IPCEventEmitter, + IPCMessage, + IPCRemoteProcedureCallMessage, + IPCRemoteProcedureCallResultMessage, + IPCRequestResourceMessage, + IPCResourceResultMessage, + IPCResponse, + MethodsToRPC, + ResourceContext, + SendIPCMessageOptions, + SharedResourceEntry, +} from "./types.js"; +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"; const resourceRegistry = new ResourceRegistry(); const moduleCounters = new Map(); export const SHARED_RESOURCE_MESSAGE_TYPES = { - REQUEST_RESOURCE: 'shared_resources_requestResource', - RESOURCE_RESULT: 'shared_resources_resourceResult', - REMOTE_PROCEDURE_CALL: 'shared_resources_remoteProcedureCall', - REMOTE_PROCEDURE_CALL_RESULT: 'shared_resources_remoteProcedureCallResult', + REQUEST_RESOURCE: "shared_resources_requestResource", + RESOURCE_RESULT: "shared_resources_resourceResult", + REMOTE_PROCEDURE_CALL: "shared_resources_remoteProcedureCall", + REMOTE_PROCEDURE_CALL_RESULT: "shared_resources_remoteProcedureCallResult", } as const; export const globalRegistry = resourceRegistry.getRegistry(); const create = ( - factory: () => T, - options?: { - module?: string; - onDestroy?: (instance: Awaited) => void | Promise; - } + factory: () => T, + options?: { + module?: string; + onDestroy?: (instance: Awaited) => void | Promise; + }, ): ResourceContext> => { - let module: string; - - if (options?.module) module = options.module; - else { - const err = { stack: '' }; - Error.captureStackTrace(err, create); - module = findFileFromStack(err.stack); - } - - const count = (moduleCounters.get(module) ?? 0) + 1; - moduleCounters.set(module, count); - const name = count === 1 ? module : `${module}#${count}`; - - return { - factory, - onDestroy: options?.onDestroy, - name, - module, - } as ResourceContext>; + let module: string; + + if (options?.module) module = options.module; + else { + const err = { stack: "" }; + Error.captureStackTrace(err, create); + module = findFileFromStack(err.stack); + } + + const count = (moduleCounters.get(module) ?? 0) + 1; + moduleCounters.set(module, count); + const name = count === 1 ? module : `${module}#${count}`; + + return { + factory, + onDestroy: options?.onDestroy, + name, + module, + } as ResourceContext>; }; const use = async ( - context: ResourceContext + context: ResourceContext, ): Promise> => { - const { name } = context; - - // Parent Process (Host) - if (!process.send || resourceRegistry.getIsRegistering()) { - const existing = resourceRegistry.get(name); - if (existing) { - return existing.state as MethodsToRPC; - } - - const state = await context.factory(); - resourceRegistry.register(name, { - state, - onDestroy: context.onDestroy as - | ((instance: unknown) => void | Promise) - | undefined, - }); - - return state as MethodsToRPC; - } - - if (!context.module) - throw new Error( - `Resource "${name}" is missing "module". Use createResource() or set module explicitly.` - ); - - return requestResource(name, context.module) as unknown as MethodsToRPC; + const { name } = context; + + // Parent Process (Host) + if (!process.send || resourceRegistry.getIsRegistering()) { + const existing = resourceRegistry.get(name); + if (existing) { + return existing.state as MethodsToRPC; + } + + const state = await context.factory(); + resourceRegistry.register(name, { + state, + onDestroy: context.onDestroy as + | ((instance: unknown) => void | Promise) + | undefined, + }); + + return state as MethodsToRPC; + } + + if (!context.module) + throw new Error( + `Resource "${name}" is missing "module". Use createResource() or set module explicitly.`, + ); + + return requestResource(name, context.module) as unknown as MethodsToRPC; }; export const sendIPCMessage = ( - options: SendIPCMessageOptions + options: SendIPCMessageOptions, ): Promise => { - const { - message, - validator, - timeout, - emitter = process, - sender = process.send?.bind(process), - } = options; - - return new Promise((resolve, reject) => { - if (!sender) { - reject(new Error('IPC sender is not available')); - return; - } - - let timer: NodeJS.Timeout | undefined; - - const handleMessage = (response: unknown) => { - if (validator(response)) { - cleanup(); - resolve(response); - } - }; - - const cleanup = () => { - if (timer) clearTimeout(timer); - emitter.off('message', handleMessage); - }; - - if (typeof timeout === 'number' && timeout > 0) { - timer = setTimeout(() => { - cleanup(); - reject(new Error(`IPC request timed out after ${timeout}ms`)); - }, timeout); - } - - emitter.on('message', handleMessage); - - try { - sender(message); - } catch (error) { - cleanup(); - reject(error); - } - }); + const { + message, + validator, + timeout, + emitter = process, + sender = process.send?.bind(process), + } = options; + + return new Promise((resolve, reject) => { + if (!sender) { + reject(new Error("IPC sender is not available")); + return; + } + + let timer: NodeJS.Timeout | undefined; + + const handleMessage = (response: unknown) => { + if (validator(response)) { + cleanup(); + resolve(response); + } + }; + + const cleanup = () => { + if (timer) clearTimeout(timer); + emitter.off("message", handleMessage); + }; + + if (typeof timeout === "number" && timeout > 0) { + timer = setTimeout(() => { + cleanup(); + reject(new Error(`IPC request timed out after ${timeout}ms`)); + }, timeout); + } + + emitter.on("message", handleMessage); + + try { + sender(message); + } catch (error) { + cleanup(); + reject(error); + } + }); }; const requestResource = async (name: string, module: string) => { - const requestId = `${name}-${Date.now()}-${Math.random()}`; - - const response = await sendIPCMessage({ - message: { - type: SHARED_RESOURCE_MESSAGE_TYPES.REQUEST_RESOURCE, - name, - module, - id: requestId, - }, - validator: (message): message is IPCResourceResultMessage => - typeof message === 'object' && - message !== null && - 'type' in message && - message.type === SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT && - 'id' in message && - message.id === requestId, - }); - - if (response.error || !response.value || !response.rpcs) - throw new Error( - response.error ?? `Invalid response for resource "${name}"` - ); - - return constructSharedResourceWithRPCs( - response.value as Record, - response.rpcs, - name - ); + const requestId = `${name}-${Date.now()}-${Math.random()}`; + + const response = await sendIPCMessage({ + message: { + type: SHARED_RESOURCE_MESSAGE_TYPES.REQUEST_RESOURCE, + name, + module, + id: requestId, + }, + validator: (message): message is IPCResourceResultMessage => + typeof message === "object" && + message !== null && + "type" in message && + message.type === SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT && + "id" in message && + message.id === requestId, + }); + + if (response.error || !response.value || !response.rpcs) + throw new Error( + response.error ?? `Invalid response for resource "${name}"`, + ); + + return constructSharedResourceWithRPCs( + response.value as Record, + response.rpcs, + name, + ); }; const remoteProcedureCall = async ( - name: string, - method: string, - args: unknown[] + name: string, + method: string, + args: unknown[], ) => { - const requestId = `${name}-${method}-${Date.now()}-${Math.random()}`; - - const response = await sendIPCMessage({ - message: { - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL, - name, - method, - args: args.map(encodeArg), - id: requestId, - } satisfies IPCRemoteProcedureCallMessage, - validator: (message): message is IPCRemoteProcedureCallResultMessage => - typeof message === 'object' && - message !== null && - 'id' in message && - message.id === requestId, - }); - - if (response.error || !response.value) - throw new Error( - response.error ?? `Invalid RPC response for "${name}.${method}"` - ); - - return response.value; + const requestId = `${name}-${method}-${Date.now()}-${Math.random()}`; + + const response = await sendIPCMessage({ + message: { + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL, + name, + method, + args: args.map(encodeArg), + id: requestId, + } satisfies IPCRemoteProcedureCallMessage, + validator: (message): message is IPCRemoteProcedureCallResultMessage => + typeof message === "object" && + message !== null && + "id" in message && + message.id === requestId, + }); + + if (response.error || !response.value) + throw new Error( + response.error ?? `Invalid RPC response for "${name}.${method}"`, + ); + + return response.value; }; -const ENC_TAG = '__sr_enc'; +const ENC_TAG = "__sr_enc"; // 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; + 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, + undefinedCodec, + bigIntCodec, + dateCodec, + mapCodec, + setCodec, + arrayCodec, + objectCodec, ]; /** @@ -236,283 +236,283 @@ let argCodecs: ArgCodec[] = [ */ // 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 incoming = new Map(codecs.map((c) => [c.tag, c])); + argCodecs = [...codecs, ...argCodecs.filter((c) => !incoming.has(c.tag))]; }; export const encodeArg = (v: unknown): unknown => { - 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]: 'c', - t: tagToWire(objectCodec.tag), - v: objectCodec.encode(v as Record, encodeArg), - }; - return v; + 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]: "c", + t: tagToWire(objectCodec.tag), + v: objectCodec.encode(v as Record, encodeArg), + }; + return v; }; const decodeEncoded = (enc: Record): 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.' - ); - return codec.decode(enc.v, decodeArg); + 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.`, + ); + return codec.decode(enc.v, decodeArg); }; export const decodeArg = (v: unknown): unknown => { - if (isPlainObject(v)) return ENC_TAG in v ? decodeEncoded(v) : v; - return v; + if (isPlainObject(v)) return ENC_TAG in v ? decodeEncoded(v) : v; + return v; }; const tryReconcileInPlace = (original: unknown, mutated: unknown): boolean => { - for (const codec of argCodecs) { - if (codec.writeBack && codec.is(original) && codec.is(mutated)) { - codec.writeBack(original, mutated, tryReconcileInPlace); - return true; - } - } - // 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; + for (const codec of argCodecs) { + if (codec.writeBack && codec.is(original) && codec.is(mutated)) { + codec.writeBack(original, mutated, tryReconcileInPlace); + return true; + } + } + // 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; }; export const writeBack = (original: unknown, mutated: unknown): void => { - tryReconcileInPlace(original, mutated); + tryReconcileInPlace(original, mutated); }; export const extractFunctionNames = (obj: Record) => { - const seen = new Set(); - let current = obj; + const seen = new Set(); + let current = obj; - while ( - current !== Object.prototype && - Object.getPrototypeOf(current) !== null - ) { - for (const key of Object.getOwnPropertyNames(current)) { - if (typeof obj[key] !== 'function' || key === 'constructor') continue; + while ( + current !== Object.prototype && + Object.getPrototypeOf(current) !== null + ) { + for (const key of Object.getOwnPropertyNames(current)) { + if (typeof obj[key] !== "function" || key === "constructor") continue; - seen.add(key); - } + seen.add(key); + } - current = Object.getPrototypeOf(current); - } + current = Object.getPrototypeOf(current); + } - return Array.from(seen); + return Array.from(seen); }; export const setupSharedResourceIPC = ( - child: IPCEventEmitter | ChildProcess, - registry: Record = globalRegistry + child: IPCEventEmitter | ChildProcess, + registry: Record = globalRegistry, ): void => { - child.on('message', async (message: IPCMessage) => { - if (message.type === SHARED_RESOURCE_MESSAGE_TYPES.REQUEST_RESOURCE) - await handleRequestResource(message, registry, child); - else if ( - message.type === SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL - ) - await handleRemoteProcedureCall(message, registry, child); - }); + child.on("message", async (message: IPCMessage) => { + if (message.type === SHARED_RESOURCE_MESSAGE_TYPES.REQUEST_RESOURCE) + await handleRequestResource(message, registry, child); + else if ( + message.type === SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL + ) + await handleRemoteProcedureCall(message, registry, child); + }); }; const loadModuleResources = async (module: string) => { - resourceRegistry.setIsRegistering(true); - - try { - const modulePath = isWindows ? pathToFileURL(module).href : module; - const mod: Record = await import(modulePath); - - for (const key in mod) { - if (!Object.prototype.hasOwnProperty.call(mod, key)) continue; - - const exported = mod[key]; - - if ( - exported && - typeof exported === 'object' && - 'factory' in exported && - typeof exported.factory === 'function' - ) - await use(exported as ResourceContext); - } - } finally { - resourceRegistry.setIsRegistering(false); - } + resourceRegistry.setIsRegistering(true); + + try { + const modulePath = isWindows ? pathToFileURL(module).href : module; + const mod: Record = await import(modulePath); + + for (const key in mod) { + if (!Object.prototype.hasOwnProperty.call(mod, key)) continue; + + const exported = mod[key]; + + if ( + exported && + typeof exported === "object" && + "factory" in exported && + typeof exported.factory === "function" + ) + await use(exported as ResourceContext); + } + } finally { + resourceRegistry.setIsRegistering(false); + } }; export const handleRequestResource = async ( - message: IPCRequestResourceMessage, - registry: Record, - child: IPCEventEmitter | ChildProcess + message: IPCRequestResourceMessage, + registry: Record, + child: IPCEventEmitter | ChildProcess, ) => { - try { - if (!registry[message.name]) await loadModuleResources(message.module); - - const entry = registry[message.name]; - if (!entry) { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, - name: message.name, - id: message.id, - error: `Resource "${message.name}" not found in module "${message.module}"`, - } satisfies IPCResponse); - return; - } - - const rpcs = extractFunctionNames(entry.state as Record); - - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, - name: message.name, - value: entry.state, - rpcs, - id: message.id, - } satisfies IPCResourceResultMessage); - } catch (error) { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, - name: message.name, - id: message.id, - error: error instanceof Error ? error.message : String(error), - } satisfies IPCResponse); - } + try { + if (!registry[message.name]) await loadModuleResources(message.module); + + const entry = registry[message.name]; + if (!entry) { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, + name: message.name, + id: message.id, + error: `Resource "${message.name}" not found in module "${message.module}"`, + } satisfies IPCResponse); + return; + } + + const rpcs = extractFunctionNames(entry.state as Record); + + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, + name: message.name, + value: entry.state, + rpcs, + id: message.id, + } satisfies IPCResourceResultMessage); + } catch (error) { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, + name: message.name, + id: message.id, + error: error instanceof Error ? error.message : String(error), + } satisfies IPCResponse); + } }; export const handleRemoteProcedureCall = async ( - message: IPCRemoteProcedureCallMessage, - registry: Record, - child: IPCEventEmitter | ChildProcess + message: IPCRemoteProcedureCallMessage, + registry: Record, + child: IPCEventEmitter | ChildProcess, ) => { - const entry = registry[message.name]; - if (!entry) { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, - id: message.id, - error: `Resource "${message.name}" not found`, - } satisfies IPCResponse); - return; - } - - const state = entry.state as Record; - - if (!message.method) { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, - id: message.id, - error: 'Method name is missing', - } satisfies IPCResponse); - return; - } - - const methodCandidate = state[message.method]; - if (typeof methodCandidate !== 'function') { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, - id: message.id, - error: `Method "${message.method}" not found on resource "${message.name}"`, - } satisfies IPCResponse); - return; - } - - try { - const method = methodCandidate.bind(entry.state); - const callArgs = (message.args || []).map(decodeArg); - const result = await method(...callArgs); - - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, - id: message.id, - value: { - result, - latest: state, - mutatedArgs: callArgs.map(encodeArg), - }, - } satisfies IPCResponse); - } catch (error) { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, - id: message.id, - error: error instanceof Error ? error.message : String(error), - } satisfies IPCResponse); - } + const entry = registry[message.name]; + if (!entry) { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, + id: message.id, + error: `Resource "${message.name}" not found`, + } satisfies IPCResponse); + return; + } + + const state = entry.state as Record; + + if (!message.method) { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, + id: message.id, + error: "Method name is missing", + } satisfies IPCResponse); + return; + } + + const methodCandidate = state[message.method]; + if (typeof methodCandidate !== "function") { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, + id: message.id, + error: `Method "${message.method}" not found on resource "${message.name}"`, + } satisfies IPCResponse); + return; + } + + try { + const method = methodCandidate.bind(entry.state); + const callArgs = (message.args || []).map(decodeArg); + const result = await method(...callArgs); + + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, + id: message.id, + value: { + result, + latest: state, + mutatedArgs: callArgs.map(encodeArg), + }, + } satisfies IPCResponse); + } catch (error) { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, + id: message.id, + error: error instanceof Error ? error.message : String(error), + } satisfies IPCResponse); + } }; const constructSharedResourceWithRPCs = ( - target: Record, - rpcs: string[], - name: string + target: Record, + rpcs: string[], + name: string, ) => { - if (rpcs.length === 0) return target; - - return new Proxy(target, { - get(target, prop, receiver) { - if (typeof prop === 'string' && rpcs.includes(prop)) { - return async (...args: unknown[]) => { - const rpcResult = await remoteProcedureCall(name, prop, args); - const decodedMutatedArgs = rpcResult.mutatedArgs.map(decodeArg); - - for (let i = 0; i < args.length; i++) - writeBack(args[i], decodedMutatedArgs[i]); - - for (const rpcKey of rpcs) { - if (rpcKey in rpcResult.latest) { - delete rpcResult.latest[rpcKey]; - } - } - - Object.assign(target, rpcResult.latest); - return rpcResult.result; - }; - } - - return Reflect.get(target, prop, receiver); - }, - }); + if (rpcs.length === 0) return target; + + return new Proxy(target, { + get(target, prop, receiver) { + if (typeof prop === "string" && rpcs.includes(prop)) { + return async (...args: unknown[]) => { + const rpcResult = await remoteProcedureCall(name, prop, args); + const decodedMutatedArgs = rpcResult.mutatedArgs.map(decodeArg); + + for (let i = 0; i < args.length; i++) + writeBack(args[i], decodedMutatedArgs[i]); + + for (const rpcKey of rpcs) { + if (rpcKey in rpcResult.latest) { + delete rpcResult.latest[rpcKey]; + } + } + + Object.assign(target, rpcResult.latest); + return rpcResult.result; + }; + } + + return Reflect.get(target, prop, receiver); + }, + }); }; 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?.length) configureCodecs(config.codecs); - }, + 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?.length > 0) configureCodecs(config.codecs); + }, } as const; diff --git a/src/types.ts b/src/types.ts index fa4dd63..f6ac08c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,81 +1,81 @@ -import type { ChildProcess, ChildProcessEventMap } from 'node:child_process'; -import type EventEmitter from 'node:events'; -import type { InternalEventEmitter } from 'node:events'; -import type { SHARED_RESOURCE_MESSAGE_TYPES } from './shared-resources.js'; +import type { ChildProcess, ChildProcessEventMap } from "node:child_process"; +import type EventEmitter from "node:events"; +import type { InternalEventEmitter } from "node:events"; +import type { SHARED_RESOURCE_MESSAGE_TYPES } from "./shared-resources.js"; export type IPCEventEmitter = InternalEventEmitter & { - send: (message: unknown, ...args: unknown[]) => boolean; + send: (message: unknown, ...args: unknown[]) => boolean; }; export type ResourceContext = { - name: string; - module?: string; - factory: () => T | Promise; - onDestroy?: (instance: T) => void | Promise; + name: string; + module?: string; + factory: () => T | Promise; + onDestroy?: (instance: T) => void | Promise; }; export type SharedResourceEntry = { - state: T; - onDestroy?: (instance: T) => void | Promise; + state: T; + onDestroy?: (instance: T) => void | Promise; }; export type IPCRequestResourceMessage = { - type: typeof SHARED_RESOURCE_MESSAGE_TYPES.REQUEST_RESOURCE; - name: string; - module: string; - id: string; + type: typeof SHARED_RESOURCE_MESSAGE_TYPES.REQUEST_RESOURCE; + name: string; + module: string; + id: string; }; export type IPCRemoteProcedureCallMessage = { - type: typeof SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL; - name: string; - id: string; - method: string; - args: unknown[]; + type: typeof SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL; + name: string; + id: string; + method: string; + args: unknown[]; }; export type IPCMessage = - | IPCRequestResourceMessage - | IPCRemoteProcedureCallMessage; + | IPCRequestResourceMessage + | IPCRemoteProcedureCallMessage; export type SendIPCMessageOptions = { - message: { id: string; [key: string]: unknown }; - validator: (response: unknown) => response is TResponse; - timeout?: number; - emitter?: EventEmitter | IPCEventEmitter | ChildProcess; - sender?: (message: unknown) => void; + message: { id: string; [key: string]: unknown }; + validator: (response: unknown) => response is TResponse; + timeout?: number; + emitter?: EventEmitter | IPCEventEmitter | ChildProcess; + sender?: (message: unknown) => void; }; export type IPCResourceResultMessage = { - type: typeof SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT; - name: string; - id: string; - value?: unknown; - rpcs?: string[]; - error?: string; + type: typeof SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT; + name: string; + id: string; + value?: unknown; + rpcs?: string[]; + error?: string; }; export type IPCRemoteProcedureCallResultMessage = { - type: typeof SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT; - id: string; - value?: { - result: unknown; - latest: Record; - mutatedArgs: unknown[]; - }; - error?: string; + type: typeof SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT; + id: string; + value?: { + result: unknown; + latest: Record; + mutatedArgs: unknown[]; + }; + error?: string; }; export type IPCResponse = - | IPCResourceResultMessage - | IPCRemoteProcedureCallResultMessage; + | IPCResourceResultMessage + | IPCRemoteProcedureCallResultMessage; export type MethodsToRPC = { - [K in keyof T]: T[K] extends (...args: infer A) => infer R - ? R extends Promise - ? T[K] - : (...args: A) => Promise> - : T[K]; + [K in keyof T]: T[K] extends (...args: infer A) => infer R + ? R extends Promise + ? T[K] + : (...args: A) => Promise> + : T[K]; }; /** @@ -88,57 +88,57 @@ export type MethodsToRPC = { */ // 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; + /** + * 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[]; + /** + * 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/integration/shared-resources.test.ts b/test/integration/shared-resources.test.ts index a1c350d..d963729 100644 --- a/test/integration/shared-resources.test.ts +++ b/test/integration/shared-resources.test.ts @@ -1,34 +1,34 @@ -import { assert, describe, it, poku } from 'poku'; -import { inspectPoku } from 'poku/plugins'; -import { sharedResources } from '../../src/index.js'; +import { assert, describe, it, poku } from "poku"; +import { inspectPoku } from "poku/plugins"; +import { sharedResources } from "../../src/index.js"; -describe('Shared Resources', async () => { - await it('Parallel tests', async () => { - const code = await poku('test/__fixtures__/parallel', { - noExit: true, - plugins: [sharedResources()], - concurrency: 0, - }); +describe("Shared Resources", async () => { + await it("Parallel tests", async () => { + const code = await poku("test/__fixtures__/parallel", { + noExit: true, + plugins: [sharedResources()], + concurrency: 0, + }); - assert.strictEqual(code, 0, 'Exit Code needs to be 0'); - }); + assert.strictEqual(code, 0, "Exit Code needs to be 0"); + }); - await it('Error tests', async () => { - const { exitCode } = await inspectPoku({ - command: - '--config="test/__fixtures__/error/poku.config.ts" test/__fixtures__/error', - }); + await it("Error tests", async () => { + const { exitCode } = await inspectPoku({ + command: + '--config="test/__fixtures__/error/poku.config.ts" test/__fixtures__/error', + }); - assert.strictEqual(exitCode, 1, 'Exit Code needs to be 1'); - }); + assert.strictEqual(exitCode, 1, "Exit Code needs to be 1"); + }); - await it('Reference tests', async () => { - const code = await poku('test/__fixtures__/references', { - noExit: true, - plugins: [sharedResources()], - concurrency: 0, - }); + await it("Reference tests", async () => { + const code = await poku("test/__fixtures__/references", { + noExit: true, + plugins: [sharedResources()], + concurrency: 0, + }); - assert.strictEqual(code, 0, 'Exit Code needs to be 0'); - }); + assert.strictEqual(code, 0, "Exit Code needs to be 0"); + }); }); diff --git a/test/unit/encode-decode.test.ts b/test/unit/encode-decode.test.ts index e73d637..6d576db 100644 --- a/test/unit/encode-decode.test.ts +++ b/test/unit/encode-decode.test.ts @@ -1,472 +1,483 @@ -import type { ArgCodec } from '../../src/types.js'; -import { assert, test } from 'poku'; +import type { ArgCodec } from "../../src/types.js"; +import { assert, test } from "poku"; import { - configureCodecs, - decodeArg, - encodeArg, -} from '../../src/shared-resources.js'; + configureCodecs, + decodeArg, + encodeArg, +} from "../../src/shared-resources.js"; const roundtrip = (v: unknown) => decodeArg(encodeArg(v)); -test('encodeArg/decodeArg — undefined', () => { - assert.strictEqual( - roundtrip(undefined), - undefined, - 'undefined survives roundtrip' - ); -}); - -test('encodeArg/decodeArg — null', () => { - assert.strictEqual(roundtrip(null), null, 'null survives roundtrip'); -}); - -test('encodeArg/decodeArg — number', () => { - assert.strictEqual(roundtrip(42), 42, 'number survives roundtrip'); -}); - -test('encodeArg/decodeArg — string', () => { - assert.strictEqual(roundtrip('hello'), 'hello', 'string survives roundtrip'); -}); - -test('encodeArg/decodeArg — boolean', () => { - assert.strictEqual(roundtrip(true), true, 'boolean survives roundtrip'); -}); - -test('encodeArg/decodeArg — Date', () => { - const d = new Date('2026-03-17T12:00:00.000Z'); - const result = roundtrip(d); - assert.ok(result instanceof Date, 'Should be a Date instance'); - assert.strictEqual( - (result as Date).getTime(), - d.getTime(), - 'Date time preserved' - ); -}); - -test('encodeArg/decodeArg — Map', () => { - const m = new Map([ - ['a', 1], - ['b', 2], - ]); - const result = roundtrip(m) as Map; - assert.ok(result instanceof Map, 'Should be a Map instance'); - assert.strictEqual(result.get('a'), 1, 'Map entry a preserved'); - assert.strictEqual(result.get('b'), 2, 'Map entry b preserved'); - assert.strictEqual(result.size, 2, 'Map size preserved'); -}); - -test('encodeArg/decodeArg — Set', () => { - const s = new Set([1, 2, 3]); - const result = roundtrip(s) as Set; - assert.ok(result instanceof Set, 'Should be a Set instance'); - assert.strictEqual(result.has(1), true, 'Set element 1 preserved'); - assert.strictEqual(result.has(3), true, 'Set element 3 preserved'); - assert.strictEqual(result.size, 3, 'Set size preserved'); -}); - -test('encodeArg/decodeArg — array with undefined', () => { - const arr = [1, undefined, 3]; - const result = roundtrip(arr) as unknown[]; - assert.strictEqual(result[0], 1, 'First element preserved'); - assert.strictEqual(result[1], undefined, 'undefined element preserved'); - assert.strictEqual(result[2], 3, 'Third element preserved'); - assert.strictEqual(result.length, 3, 'Array length preserved'); -}); - -test('encodeArg/decodeArg — object with undefined value', () => { - const obj = { a: undefined as unknown, b: 1 }; - const result = roundtrip(obj) as Record; - assert.strictEqual( - 'a' in result, - true, - 'Key with undefined value should exist' - ); - assert.strictEqual(result.a, undefined, 'undefined value preserved'); - assert.strictEqual(result.b, 1, 'Other values preserved'); -}); - -test('encodeArg/decodeArg — nested Map inside object', () => { - const m = new Map([['x', 10]]); - const obj = { m, count: 1 }; - const result = roundtrip(obj) as { m: Map; count: number }; - assert.ok(result.m instanceof Map, 'Nested Map is a Map instance'); - assert.strictEqual(result.m.get('x'), 10, 'Nested Map entry preserved'); - assert.strictEqual(result.count, 1, 'Other object property preserved'); -}); - -test('encodeArg/decodeArg — nested Set inside array', () => { - const s = new Set([7, 8]); - const arr = [s, 42]; - const result = roundtrip(arr) as unknown[]; - assert.ok(result[0] instanceof Set, 'Nested Set is a Set instance'); - assert.strictEqual( - (result[0] as Set).has(7), - true, - 'Set element 7 preserved' - ); - assert.strictEqual(result[1], 42, 'Other array element preserved'); -}); - -test('encodeArg/decodeArg — nested Date inside array', () => { - const d = new Date('2026-01-01T00:00:00.000Z'); - const arr = [d, 42]; - const result = roundtrip(arr) as unknown[]; - assert.ok(result[0] instanceof Date, 'Nested Date is a Date instance'); - assert.strictEqual( - (result[0] as Date).getTime(), - d.getTime(), - 'Date value preserved' - ); - assert.strictEqual(result[1], 42, 'Other array element preserved'); -}); - -test('encodeArg/decodeArg — Map with object keys', () => { - const key = { id: 1 }; - const m = new Map([[key, 'value']]); - const result = roundtrip(m) as Map, string>; - assert.strictEqual(result.size, 1, 'Map size preserved'); - const [[rk, rv]] = result.entries(); - assert.deepStrictEqual(rk, key, 'Map object key preserved by value'); - assert.strictEqual(rv, 'value', 'Map value preserved'); -}); - -test('encodeArg/decodeArg — Map with undefined value', () => { - const m = new Map([['a', undefined]]); - const result = roundtrip(m) as Map; - assert.strictEqual(result.has('a'), true, 'Key with undefined value present'); - assert.strictEqual( - result.get('a'), - undefined, - 'undefined Map value preserved' - ); -}); - -test('encodeArg/decodeArg — Set with undefined', () => { - const s = new Set([1, undefined, 3]); - const result = roundtrip(s) as Set; - assert.strictEqual( - result.has(undefined), - true, - 'undefined Set element preserved' - ); - assert.strictEqual(result.size, 3, 'Set size preserved'); -}); - -test('encodeArg/decodeArg — plain object passthrough', () => { - const obj = { a: 1, b: 'hello', c: true }; - const result = roundtrip(obj); - assert.deepStrictEqual(result, obj, 'Plain object survives roundtrip'); -}); - -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'); - assert.strictEqual(result.other, 42, 'Other key preserved'); -}); - -test('encodeArg/decodeArg — BigInt', () => { - assert.strictEqual(roundtrip(42n), 42n, 'BigInt survives roundtrip'); - assert.strictEqual(roundtrip(0n), 0n, 'BigInt 0 survives roundtrip'); - assert.strictEqual( - roundtrip(-99n), - -99n, - 'Negative BigInt survives roundtrip' - ); -}); - -test('encodeArg/decodeArg — BigInt in array', () => { - const result = roundtrip([1n, 2n, 3n]) as bigint[]; - assert.deepStrictEqual( - result, - [1n, 2n, 3n], - 'Array of BigInts survives roundtrip' - ); -}); - -test('encodeArg/decodeArg — BigInt in object', () => { - const result = roundtrip({ a: 1n, b: 2 }) as { a: bigint; b: number }; - assert.strictEqual(result.a, 1n, 'BigInt property survives roundtrip'); - assert.strictEqual(result.b, 2, 'Non-BigInt property preserved'); -}); - -test('encodeArg/decodeArg — BigInt as Map key and value', () => { - const m = new Map([ - [1n, 100n], - [2n, 200n], - ]); - const result = roundtrip(m) as Map; - assert.ok(result instanceof Map, 'Should be a Map instance'); - assert.strictEqual(result.get(1n), 100n, 'BigInt key and value preserved'); - assert.strictEqual(result.get(2n), 200n, 'Second BigInt entry preserved'); -}); - -test('encodeArg/decodeArg — BigInt in Set', () => { - const s = new Set([1n, 2n, 3n]); - const result = roundtrip(s) as Set; - assert.ok(result instanceof Set, 'Should be a Set instance'); - assert.strictEqual(result.has(1n), true, 'BigInt Set element preserved'); - assert.strictEqual(result.has(3n), true, 'BigInt Set element preserved'); -}); - -test('encodeArg/decodeArg — deeply nested special types', () => { - const d = new Date('2026-06-01T00:00:00.000Z'); - const m = new Map([['created', d]]); - const obj = { data: m, tags: new Set(['a', 'b']) }; - const result = roundtrip(obj) as { - data: Map; - tags: Set; - }; - assert.ok(result.data instanceof Map, 'Nested Map preserved'); - assert.ok( - result.data.get('created') instanceof Date, - 'Map-nested Date preserved' - ); - assert.strictEqual( - result.data.get('created')?.getTime(), - d.getTime(), - 'Date value correct' - ); - 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 { - constructor( - public x: number, - public y: number - ) {} - 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 { - constructor(public value: number) {} - } - 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 { - constructor(public id: number) {} - } - 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' - ); +test("encodeArg/decodeArg — undefined", () => { + assert.strictEqual( + roundtrip(undefined), + undefined, + "undefined survives roundtrip", + ); +}); + +test("encodeArg/decodeArg — null", () => { + assert.strictEqual(roundtrip(null), null, "null survives roundtrip"); +}); + +test("encodeArg/decodeArg — number", () => { + assert.strictEqual(roundtrip(42), 42, "number survives roundtrip"); +}); + +test("encodeArg/decodeArg — string", () => { + assert.strictEqual(roundtrip("hello"), "hello", "string survives roundtrip"); +}); + +test("encodeArg/decodeArg — boolean", () => { + assert.strictEqual(roundtrip(true), true, "boolean survives roundtrip"); +}); + +test("encodeArg/decodeArg — Date", () => { + const d = new Date("2026-03-17T12:00:00.000Z"); + const result = roundtrip(d); + assert.ok(result instanceof Date, "Should be a Date instance"); + assert.strictEqual( + (result as Date).getTime(), + d.getTime(), + "Date time preserved", + ); +}); + +test("encodeArg/decodeArg — Map", () => { + const m = new Map([ + ["a", 1], + ["b", 2], + ]); + const result = roundtrip(m) as Map; + assert.ok(result instanceof Map, "Should be a Map instance"); + assert.strictEqual(result.get("a"), 1, "Map entry a preserved"); + assert.strictEqual(result.get("b"), 2, "Map entry b preserved"); + assert.strictEqual(result.size, 2, "Map size preserved"); +}); + +test("encodeArg/decodeArg — Set", () => { + const s = new Set([1, 2, 3]); + const result = roundtrip(s) as Set; + assert.ok(result instanceof Set, "Should be a Set instance"); + assert.strictEqual(result.has(1), true, "Set element 1 preserved"); + assert.strictEqual(result.has(3), true, "Set element 3 preserved"); + assert.strictEqual(result.size, 3, "Set size preserved"); +}); + +test("encodeArg/decodeArg — array with undefined", () => { + const arr = [1, undefined, 3]; + const result = roundtrip(arr) as unknown[]; + assert.strictEqual(result[0], 1, "First element preserved"); + assert.strictEqual(result[1], undefined, "undefined element preserved"); + assert.strictEqual(result[2], 3, "Third element preserved"); + assert.strictEqual(result.length, 3, "Array length preserved"); +}); + +test("encodeArg/decodeArg — object with undefined value", () => { + const obj = { a: undefined as unknown, b: 1 }; + const result = roundtrip(obj) as Record; + assert.strictEqual( + "a" in result, + true, + "Key with undefined value should exist", + ); + assert.strictEqual(result.a, undefined, "undefined value preserved"); + assert.strictEqual(result.b, 1, "Other values preserved"); +}); + +test("encodeArg/decodeArg — nested Map inside object", () => { + const m = new Map([["x", 10]]); + const obj = { m, count: 1 }; + const result = roundtrip(obj) as { m: Map; count: number }; + assert.ok(result.m instanceof Map, "Nested Map is a Map instance"); + assert.strictEqual(result.m.get("x"), 10, "Nested Map entry preserved"); + assert.strictEqual(result.count, 1, "Other object property preserved"); +}); + +test("encodeArg/decodeArg — nested Set inside array", () => { + const s = new Set([7, 8]); + const arr = [s, 42]; + const result = roundtrip(arr) as unknown[]; + assert.ok(result[0] instanceof Set, "Nested Set is a Set instance"); + assert.strictEqual( + (result[0] as Set).has(7), + true, + "Set element 7 preserved", + ); + assert.strictEqual(result[1], 42, "Other array element preserved"); +}); + +test("encodeArg/decodeArg — nested Date inside array", () => { + const d = new Date("2026-01-01T00:00:00.000Z"); + const arr = [d, 42]; + const result = roundtrip(arr) as unknown[]; + assert.ok(result[0] instanceof Date, "Nested Date is a Date instance"); + assert.strictEqual( + (result[0] as Date).getTime(), + d.getTime(), + "Date value preserved", + ); + assert.strictEqual(result[1], 42, "Other array element preserved"); +}); + +test("encodeArg/decodeArg — Map with object keys", () => { + const key = { id: 1 }; + const m = new Map([[key, "value"]]); + const result = roundtrip(m) as Map, string>; + assert.strictEqual(result.size, 1, "Map size preserved"); + const [[rk, rv]] = result.entries(); + assert.deepStrictEqual(rk, key, "Map object key preserved by value"); + assert.strictEqual(rv, "value", "Map value preserved"); +}); + +test("encodeArg/decodeArg — Map with undefined value", () => { + const m = new Map([["a", undefined]]); + const result = roundtrip(m) as Map; + assert.strictEqual(result.has("a"), true, "Key with undefined value present"); + assert.strictEqual( + result.get("a"), + undefined, + "undefined Map value preserved", + ); +}); + +test("encodeArg/decodeArg — Set with undefined", () => { + const s = new Set([1, undefined, 3]); + const result = roundtrip(s) as Set; + assert.strictEqual( + result.has(undefined), + true, + "undefined Set element preserved", + ); + assert.strictEqual(result.size, 3, "Set size preserved"); +}); + +test("encodeArg/decodeArg — plain object passthrough", () => { + const obj = { a: 1, b: "hello", c: true }; + const result = roundtrip(obj); + assert.deepStrictEqual(result, obj, "Plain object survives roundtrip"); +}); + +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"); + assert.strictEqual(result.other, 42, "Other key preserved"); +}); + +test("encodeArg/decodeArg — BigInt", () => { + assert.strictEqual(roundtrip(42n), 42n, "BigInt survives roundtrip"); + assert.strictEqual(roundtrip(0n), 0n, "BigInt 0 survives roundtrip"); + assert.strictEqual( + roundtrip(-99n), + -99n, + "Negative BigInt survives roundtrip", + ); +}); + +test("encodeArg/decodeArg — BigInt in array", () => { + const result = roundtrip([1n, 2n, 3n]) as bigint[]; + assert.deepStrictEqual( + result, + [1n, 2n, 3n], + "Array of BigInts survives roundtrip", + ); +}); + +test("encodeArg/decodeArg — BigInt in object", () => { + const result = roundtrip({ a: 1n, b: 2 }) as { a: bigint; b: number }; + assert.strictEqual(result.a, 1n, "BigInt property survives roundtrip"); + assert.strictEqual(result.b, 2, "Non-BigInt property preserved"); +}); + +test("encodeArg/decodeArg — BigInt as Map key and value", () => { + const m = new Map([ + [1n, 100n], + [2n, 200n], + ]); + const result = roundtrip(m) as Map; + assert.ok(result instanceof Map, "Should be a Map instance"); + assert.strictEqual(result.get(1n), 100n, "BigInt key and value preserved"); + assert.strictEqual(result.get(2n), 200n, "Second BigInt entry preserved"); +}); + +test("encodeArg/decodeArg — BigInt in Set", () => { + const s = new Set([1n, 2n, 3n]); + const result = roundtrip(s) as Set; + assert.ok(result instanceof Set, "Should be a Set instance"); + assert.strictEqual(result.has(1n), true, "BigInt Set element preserved"); + assert.strictEqual(result.has(3n), true, "BigInt Set element preserved"); +}); + +test("encodeArg/decodeArg — deeply nested special types", () => { + const d = new Date("2026-06-01T00:00:00.000Z"); + const m = new Map([["created", d]]); + const obj = { data: m, tags: new Set(["a", "b"]) }; + const result = roundtrip(obj) as { + data: Map; + tags: Set; + }; + assert.ok(result.data instanceof Map, "Nested Map preserved"); + assert.ok( + result.data.get("created") instanceof Date, + "Map-nested Date preserved", + ); + assert.strictEqual( + result.data.get("created")?.getTime(), + d.getTime(), + "Date value correct", + ); + 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 { - constructor( - public r: number, - public g: number, - public b: number - ) {} - toHex() { - return `#${[this.r, this.g, this.b].map((n) => n.toString(16).padStart(2, '0')).join('')}`; - } + 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); - }, + 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 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, + 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' - ); +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/resource-registry.test.ts b/test/unit/resource-registry.test.ts index 351ccb0..8b1a66e 100644 --- a/test/unit/resource-registry.test.ts +++ b/test/unit/resource-registry.test.ts @@ -1,66 +1,66 @@ -import { assert, test } from 'poku'; -import { ResourceRegistry } from '../../src/resource-registry.js'; +import { assert, test } from "poku"; +import { ResourceRegistry } from "../../src/resource-registry.js"; -test('ResourceRegistry', () => { - const registry = new ResourceRegistry(); +test("ResourceRegistry", () => { + const registry = new ResourceRegistry(); - test('should register and retrieve a resource', () => { - registry.register('test-resource', 'test-value'); - assert.strictEqual( - registry.get('test-resource'), - 'test-value', - 'Resource retrieved correctly' - ); - }); + test("should register and retrieve a resource", () => { + registry.register("test-resource", "test-value"); + assert.strictEqual( + registry.get("test-resource"), + "test-value", + "Resource retrieved correctly", + ); + }); - test('should check if a resource exists', () => { - assert.strictEqual(registry.has('test-resource'), true, 'Resource exists'); - assert.strictEqual( - registry.has('non-existent'), - false, - 'Resource does not exist' - ); - }); + test("should check if a resource exists", () => { + assert.strictEqual(registry.has("test-resource"), true, "Resource exists"); + assert.strictEqual( + registry.has("non-existent"), + false, + "Resource does not exist", + ); + }); - test('should manage isRegistering state', () => { - assert.strictEqual( - registry.getIsRegistering(), - false, - 'Initial state is false' - ); - registry.setIsRegistering(true); - assert.strictEqual( - registry.getIsRegistering(), - true, - 'State updated to true' - ); - registry.setIsRegistering(false); - assert.strictEqual( - registry.getIsRegistering(), - false, - 'State updated to false' - ); - }); + test("should manage isRegistering state", () => { + assert.strictEqual( + registry.getIsRegistering(), + false, + "Initial state is false", + ); + registry.setIsRegistering(true); + assert.strictEqual( + registry.getIsRegistering(), + true, + "State updated to true", + ); + registry.setIsRegistering(false); + assert.strictEqual( + registry.getIsRegistering(), + false, + "State updated to false", + ); + }); - test('should return the entire registry', () => { - const reg = registry.getRegistry(); - const expected = Object.create(null); - expected['test-resource'] = 'test-value'; + test("should return the entire registry", () => { + const reg = registry.getRegistry(); + const expected = Object.create(null); + expected["test-resource"] = "test-value"; - assert.deepStrictEqual(reg, expected, 'Registry object returned'); - }); + assert.deepStrictEqual(reg, expected, "Registry object returned"); + }); - test('should clear the registry', () => { - registry.clear(); - assert.strictEqual( - registry.has('test-resource'), - false, - 'Registry cleared' - ); - assert.deepStrictEqual( - registry.getRegistry(), - Object.create(null), - 'Registry object is empty' - ); - }); + test("should clear the registry", () => { + registry.clear(); + assert.strictEqual( + registry.has("test-resource"), + false, + "Registry cleared", + ); + assert.deepStrictEqual( + registry.getRegistry(), + Object.create(null), + "Registry object is empty", + ); + }); }); diff --git a/test/unit/send-ipc-message.test.ts b/test/unit/send-ipc-message.test.ts index 2867e96..6529b29 100644 --- a/test/unit/send-ipc-message.test.ts +++ b/test/unit/send-ipc-message.test.ts @@ -1,129 +1,129 @@ -import { EventEmitter } from 'node:events'; -import { assert, test } from 'poku'; -import { sendIPCMessage } from '../../src/shared-resources.js'; +import { EventEmitter } from "node:events"; +import { assert, test } from "poku"; +import { sendIPCMessage } from "../../src/shared-resources.js"; const TIMED_OUT_PATTERN = /timed out/; type MockMessage = { id: string; success?: boolean }; -test('sendIPCMessage', async () => { - await test('should resolve when a valid response is received', async () => { - const emitter = new EventEmitter(); - const sender = (msg: unknown) => { - const m = msg as MockMessage; - // Simulate response after a short delay - setTimeout(() => { - emitter.emit('message', { id: m.id, success: true }); - }, 10); - }; +test("sendIPCMessage", async () => { + await test("should resolve when a valid response is received", async () => { + const emitter = new EventEmitter(); + const sender = (msg: unknown) => { + const m = msg as MockMessage; + // Simulate response after a short delay + setTimeout(() => { + emitter.emit("message", { id: m.id, success: true }); + }, 10); + }; - const result = await sendIPCMessage({ - message: { id: '123' }, - validator: (msg): msg is MockMessage => (msg as MockMessage).id === '123', - emitter, - sender, - }); + const result = await sendIPCMessage({ + message: { id: "123" }, + validator: (msg): msg is MockMessage => (msg as MockMessage).id === "123", + emitter, + sender, + }); - assert.deepStrictEqual( - result, - { id: '123', success: true }, - 'Resolved with correct response' - ); - }); + assert.deepStrictEqual( + result, + { id: "123", success: true }, + "Resolved with correct response", + ); + }); - await test('should ignore invalid responses', async () => { - const emitter = new EventEmitter(); - const sender = (msg: unknown) => { - const m = msg as MockMessage; - setTimeout(() => { - emitter.emit('message', { id: 'other', success: false }); // Should be ignored - setTimeout(() => { - emitter.emit('message', { id: m.id, success: true }); // Should be accepted - }, 10); - }, 10); - }; + await test("should ignore invalid responses", async () => { + const emitter = new EventEmitter(); + const sender = (msg: unknown) => { + const m = msg as MockMessage; + setTimeout(() => { + emitter.emit("message", { id: "other", success: false }); // Should be ignored + setTimeout(() => { + emitter.emit("message", { id: m.id, success: true }); // Should be accepted + }, 10); + }, 10); + }; - const result = await sendIPCMessage({ - message: { id: '456' }, - validator: (msg): msg is MockMessage => (msg as MockMessage).id === '456', - emitter, - sender, - }); + const result = await sendIPCMessage({ + message: { id: "456" }, + validator: (msg): msg is MockMessage => (msg as MockMessage).id === "456", + emitter, + sender, + }); - assert.deepStrictEqual( - result, - { id: '456', success: true }, - 'Resolved with correct response after ignoring invalid one' - ); - }); + assert.deepStrictEqual( + result, + { id: "456", success: true }, + "Resolved with correct response after ignoring invalid one", + ); + }); - await test('should reject on timeout', async () => { - const emitter = new EventEmitter(); - const sender = () => undefined; + await test("should reject on timeout", async () => { + const emitter = new EventEmitter(); + const sender = () => undefined; - try { - await sendIPCMessage({ - message: { id: '789' }, - validator: (msg): msg is MockMessage => - (msg as MockMessage).id === '789', - timeout: 50, - emitter, - sender, - }); - assert.fail('Should have rejected'); - } catch (error) { - assert.match( - (error as Error).message, - TIMED_OUT_PATTERN, - 'Rejected with timeout error' - ); - } - }); + try { + await sendIPCMessage({ + message: { id: "789" }, + validator: (msg): msg is MockMessage => + (msg as MockMessage).id === "789", + timeout: 50, + emitter, + sender, + }); + assert.fail("Should have rejected"); + } catch (error) { + assert.match( + (error as Error).message, + TIMED_OUT_PATTERN, + "Rejected with timeout error", + ); + } + }); - await test('should reject if sender throws', async () => { - const emitter = new EventEmitter(); - const sender = () => { - throw new Error('Send failed'); - }; + await test("should reject if sender throws", async () => { + const emitter = new EventEmitter(); + const sender = () => { + throw new Error("Send failed"); + }; - try { - await sendIPCMessage({ - message: { id: 'abc' }, - validator: (msg): msg is MockMessage => true, - emitter, - sender, - }); - assert.fail('Should have rejected'); - } catch (error) { - assert.strictEqual( - (error as Error).message, - 'Send failed', - 'Rejected with sender error' - ); - } - }); + try { + await sendIPCMessage({ + message: { id: "abc" }, + validator: (msg): msg is MockMessage => true, + emitter, + sender, + }); + assert.fail("Should have rejected"); + } catch (error) { + assert.strictEqual( + (error as Error).message, + "Send failed", + "Rejected with sender error", + ); + } + }); - await test('should cleanup listeners', async () => { - const emitter = new EventEmitter(); - const sender = (msg: unknown) => { - const m = msg as MockMessage; - setTimeout(() => { - emitter.emit('message', { id: m.id }); - }, 10); - }; + await test("should cleanup listeners", async () => { + const emitter = new EventEmitter(); + const sender = (msg: unknown) => { + const m = msg as MockMessage; + setTimeout(() => { + emitter.emit("message", { id: m.id }); + }, 10); + }; - await sendIPCMessage({ - message: { id: 'cleanup' }, - validator: (msg): msg is MockMessage => - (msg as MockMessage).id === 'cleanup', - emitter, - sender, - }); + await sendIPCMessage({ + message: { id: "cleanup" }, + validator: (msg): msg is MockMessage => + (msg as MockMessage).id === "cleanup", + emitter, + sender, + }); - assert.strictEqual( - emitter.listenerCount('message'), - 0, - 'Listener removed after success' - ); - }); + assert.strictEqual( + emitter.listenerCount("message"), + 0, + "Listener removed after success", + ); + }); }); diff --git a/test/unit/write-back.test.ts b/test/unit/write-back.test.ts index 8086ccb..01b5ebc 100644 --- a/test/unit/write-back.test.ts +++ b/test/unit/write-back.test.ts @@ -1,400 +1,410 @@ -import type { ArgCodec } from '../../src/types.js'; -import { assert, test } from 'poku'; -import { configureCodecs, writeBack } from '../../src/shared-resources.js'; +import type { ArgCodec } from "../../src/types.js"; +import { assert, test } from "poku"; +import { configureCodecs, writeBack } from "../../src/shared-resources.js"; -test('writeBack — array push', () => { - const original: number[] = []; - writeBack(original, [42]); - assert.deepStrictEqual( - original, - [42], - 'Element should be pushed into the original array' - ); +test("writeBack — array push", () => { + const original: number[] = []; + writeBack(original, [42]); + assert.deepStrictEqual( + original, + [42], + "Element should be pushed into the original array", + ); }); -test('writeBack — array truncation', () => { - const original = [1, 2, 3, 4]; - writeBack(original, [1, 2, 3]); - assert.deepStrictEqual( - original, - [1, 2, 3], - 'Array should be truncated to match mutated length' - ); +test("writeBack — array truncation", () => { + const original = [1, 2, 3, 4]; + writeBack(original, [1, 2, 3]); + assert.deepStrictEqual( + original, + [1, 2, 3], + "Array should be truncated to match mutated length", + ); }); -test('writeBack — array emptied', () => { - const original = [1, 2, 3]; - writeBack(original, []); - assert.deepStrictEqual(original, [], 'Array should be emptied'); +test("writeBack — array emptied", () => { + const original = [1, 2, 3]; + writeBack(original, []); + assert.deepStrictEqual(original, [], "Array should be emptied"); }); -test('writeBack — array element update', () => { - const original = [1, 2, 3]; - writeBack(original, [1, 99, 3]); - assert.deepStrictEqual( - original, - [1, 99, 3], - 'Middle element should be updated in place' - ); +test("writeBack — array element update", () => { + const original = [1, 2, 3]; + writeBack(original, [1, 99, 3]); + assert.deepStrictEqual( + original, + [1, 99, 3], + "Middle element should be updated in place", + ); }); -test('writeBack — preserves original array reference', () => { - const original: number[] = [1]; - const ref = original; - writeBack(original, [1, 2, 3]); - assert.strictEqual( - original, - ref, - 'Original array reference should be preserved' - ); +test("writeBack — preserves original array reference", () => { + const original: number[] = [1]; + const ref = original; + writeBack(original, [1, 2, 3]); + assert.strictEqual( + original, + ref, + "Original array reference should be preserved", + ); }); -test('writeBack — object property set', () => { - const original: Record = {}; - writeBack(original, { key: 42 }); - assert.strictEqual( - original.key, - 42, - 'Property should be set on the original object' - ); +test("writeBack — object property set", () => { + const original: Record = {}; + writeBack(original, { key: 42 }); + assert.strictEqual( + original.key, + 42, + "Property should be set on the original object", + ); }); -test('writeBack — object property deletion', () => { - const original: Record = { toDelete: 'x', keep: 1 }; - writeBack(original, { keep: 1 }); - assert.strictEqual( - 'toDelete' in original, - false, - 'Property should be removed' - ); - assert.strictEqual( - original.keep, - 1, - 'Remaining property should still be present' - ); +test("writeBack — object property deletion", () => { + const original: Record = { toDelete: "x", keep: 1 }; + writeBack(original, { keep: 1 }); + assert.strictEqual( + "toDelete" in original, + false, + "Property should be removed", + ); + assert.strictEqual( + original.keep, + 1, + "Remaining property should still be present", + ); }); -test('writeBack — object property update', () => { - const original: Record = { count: 0 }; - writeBack(original, { count: 5 }); - assert.strictEqual(original.count, 5, 'Property should be updated'); +test("writeBack — object property update", () => { + const original: Record = { count: 0 }; + writeBack(original, { count: 5 }); + assert.strictEqual(original.count, 5, "Property should be updated"); }); -test('writeBack — preserves original object reference', () => { - const original: Record = { a: 1 }; - const ref = original; - writeBack(original, { a: 2, b: 3 }); - assert.strictEqual( - original, - ref, - 'Original object reference should be preserved' - ); +test("writeBack — preserves original object reference", () => { + const original: Record = { a: 1 }; + const ref = original; + writeBack(original, { a: 2, b: 3 }); + assert.strictEqual( + original, + ref, + "Original object reference should be preserved", + ); }); -test('writeBack — deeply nested array push', () => { - const original = { nested: { arr: [] as number[] } }; - writeBack(original, { nested: { arr: [7] } }); - assert.deepStrictEqual( - original.nested.arr, - [7], - 'Nested array should have the pushed value' - ); +test("writeBack — deeply nested array push", () => { + const original = { nested: { arr: [] as number[] } }; + writeBack(original, { nested: { arr: [7] } }); + assert.deepStrictEqual( + original.nested.arr, + [7], + "Nested array should have the pushed value", + ); }); -test('writeBack — deeply nested object property', () => { - const original = { a: { b: { c: 0 } } }; - writeBack(original, { a: { b: { c: 99 } } }); - assert.strictEqual(original.a.b.c, 99, 'Deep property should be updated'); +test("writeBack — deeply nested object property", () => { + const original = { a: { b: { c: 0 } } }; + writeBack(original, { a: { b: { c: 99 } } }); + assert.strictEqual(original.a.b.c, 99, "Deep property should be updated"); }); -test('writeBack — preserves nested object references', () => { - const inner = { arr: [] as number[] }; - const original = { nested: inner }; - writeBack(original, { nested: { arr: [1, 2] } }); - assert.strictEqual( - original.nested, - inner, - 'Nested object reference should be preserved' - ); - assert.deepStrictEqual( - original.nested.arr, - [1, 2], - 'Nested array should be updated in place' - ); +test("writeBack — preserves nested object references", () => { + const inner = { arr: [] as number[] }; + const original = { nested: inner }; + writeBack(original, { nested: { arr: [1, 2] } }); + assert.strictEqual( + original.nested, + inner, + "Nested object reference should be preserved", + ); + assert.deepStrictEqual( + original.nested.arr, + [1, 2], + "Nested array should be updated in place", + ); }); -test('writeBack — array of objects mutation', () => { - const original = [{ x: 1 }, { x: 2 }]; - writeBack(original, [{ x: 10 }, { x: 20 }]); - assert.deepStrictEqual( - original, - [{ x: 10 }, { x: 20 }], - 'Each object element should be updated' - ); +test("writeBack — array of objects mutation", () => { + const original = [{ x: 1 }, { x: 2 }]; + writeBack(original, [{ x: 10 }, { x: 20 }]); + assert.deepStrictEqual( + original, + [{ x: 10 }, { x: 20 }], + "Each object element should be updated", + ); }); -test('writeBack — preserves references inside array of objects', () => { - const item0 = { x: 1 }; - const original = [item0, { x: 2 }]; - writeBack(original, [{ x: 99 }, { x: 2 }]); - assert.strictEqual( - original[0], - item0, - 'Object reference inside array should be preserved' - ); - assert.strictEqual( - item0.x, - 99, - 'The referenced object should have its property updated' - ); +test("writeBack — preserves references inside array of objects", () => { + const item0 = { x: 1 }; + const original = [item0, { x: 2 }]; + writeBack(original, [{ x: 99 }, { x: 2 }]); + assert.strictEqual( + original[0], + item0, + "Object reference inside array should be preserved", + ); + assert.strictEqual( + item0.x, + 99, + "The referenced object should have its property updated", + ); }); -test('writeBack — primitive original is a no-op', () => { - const original = 42 as unknown; - assert.doesNotThrow( - () => writeBack(original, 99), - 'Should not throw for primitive original' - ); +test("writeBack — primitive original is a no-op", () => { + const original = 42 as unknown; + assert.doesNotThrow( + () => writeBack(original, 99), + "Should not throw for primitive original", + ); }); -test('writeBack — null original is a no-op', () => { - assert.doesNotThrow( - () => writeBack(null, { key: 1 }), - 'Should not throw for null original' - ); +test("writeBack — null original is a no-op", () => { + assert.doesNotThrow( + () => writeBack(null, { key: 1 }), + "Should not throw for null original", + ); }); -test('writeBack — mismatched types (array vs object) is a no-op', () => { - const original: number[] = [1, 2]; - writeBack(original, { key: 1 }); - assert.deepStrictEqual( - original, - [1, 2], - 'Mismatched type should leave original unchanged' - ); +test("writeBack — mismatched types (array vs object) is a no-op", () => { + const original: number[] = [1, 2]; + writeBack(original, { key: 1 }); + assert.deepStrictEqual( + original, + [1, 2], + "Mismatched type should leave original unchanged", + ); }); -test('writeBack — Map repopulation', () => { - const original = new Map([ - ['a', 1], - ['b', 2], - ]); - const ref = original; - writeBack( - original, - new Map([ - ['b', 99], - ['c', 3], - ]) - ); - assert.strictEqual(original, ref, 'Map reference should be preserved'); - assert.strictEqual(original.has('a'), false, 'Removed key should be gone'); - assert.strictEqual(original.get('b'), 99, 'Updated key should be updated'); - assert.strictEqual(original.get('c'), 3, 'New key should be added'); +test("writeBack — Map repopulation", () => { + const original = new Map([ + ["a", 1], + ["b", 2], + ]); + const ref = original; + writeBack( + original, + new Map([ + ["b", 99], + ["c", 3], + ]), + ); + assert.strictEqual(original, ref, "Map reference should be preserved"); + assert.strictEqual(original.has("a"), false, "Removed key should be gone"); + assert.strictEqual(original.get("b"), 99, "Updated key should be updated"); + assert.strictEqual(original.get("c"), 3, "New key should be added"); }); -test('writeBack — Set repopulation', () => { - const original = new Set([1, 2, 3]); - const ref = original; - writeBack(original, new Set([2, 3, 4])); - assert.strictEqual(original, ref, 'Set reference should be preserved'); - assert.strictEqual(original.has(1), false, 'Removed element should be gone'); - assert.strictEqual(original.has(4), true, 'New element should be present'); - assert.strictEqual(original.has(2), true, 'Unchanged element should remain'); +test("writeBack — Set repopulation", () => { + const original = new Set([1, 2, 3]); + const ref = original; + writeBack(original, new Set([2, 3, 4])); + assert.strictEqual(original, ref, "Set reference should be preserved"); + assert.strictEqual(original.has(1), false, "Removed element should be gone"); + assert.strictEqual(original.has(4), true, "New element should be present"); + assert.strictEqual(original.has(2), true, "Unchanged element should remain"); }); -test('writeBack — Date mutation', () => { - const original = new Date('2026-01-01T00:00:00.000Z'); - const ref = original; - writeBack(original, new Date('2000-06-15T00:00:00.000Z')); - assert.strictEqual(original, ref, 'Date reference should be preserved'); - assert.strictEqual(original.getFullYear(), 2000, 'Year should be updated'); - assert.strictEqual( - original.getMonth(), - 5, - 'Month should be updated (0-indexed)' - ); +test("writeBack — Date mutation", () => { + const original = new Date("2026-01-01T00:00:00.000Z"); + const ref = original; + writeBack(original, new Date("2000-06-15T00:00:00.000Z")); + assert.strictEqual(original, ref, "Date reference should be preserved"); + assert.strictEqual(original.getFullYear(), 2000, "Year should be updated"); + assert.strictEqual( + original.getMonth(), + 5, + "Month should be updated (0-indexed)", + ); }); -test('writeBack — Map nested in object (reference preserved)', () => { - const innerMap = new Map([['x', 1]]); - const original: Record = { m: innerMap }; - writeBack(original, { - m: new Map([ - ['x', 1], - ['y', 2], - ]), - }); - assert.strictEqual( - original.m, - innerMap, - 'Inner Map reference should be preserved' - ); - assert.strictEqual( - innerMap.get('y'), - 2, - 'Inner Map should be updated with new entry' - ); +test("writeBack — Map nested in object (reference preserved)", () => { + const innerMap = new Map([["x", 1]]); + const original: Record = { m: innerMap }; + writeBack(original, { + m: new Map([ + ["x", 1], + ["y", 2], + ]), + }); + assert.strictEqual( + original.m, + innerMap, + "Inner Map reference should be preserved", + ); + assert.strictEqual( + innerMap.get("y"), + 2, + "Inner Map should be updated with new entry", + ); }); -test('writeBack — Set nested in array (reference preserved)', () => { - const innerSet = new Set([1, 2]); - const original: unknown[] = [innerSet, 42]; - writeBack(original, [new Set([1, 2, 3]), 42]); - assert.strictEqual( - original[0], - innerSet, - 'Set reference inside array should be preserved' - ); - assert.strictEqual(innerSet.has(3), true, 'Set should have new element'); +test("writeBack — Set nested in array (reference preserved)", () => { + const innerSet = new Set([1, 2]); + const original: unknown[] = [innerSet, 42]; + writeBack(original, [new Set([1, 2, 3]), 42]); + assert.strictEqual( + original[0], + innerSet, + "Set reference inside array should be preserved", + ); + assert.strictEqual(innerSet.has(3), true, "Set should have new element"); }); -test('writeBack — Date nested in array (reference preserved)', () => { - const innerDate = new Date('2026-01-01T00:00:00.000Z'); - const mutationTarget = new Date('2000-01-01T00:00:00.000Z'); - const original: unknown[] = [innerDate, 42]; - writeBack(original, [mutationTarget, 42]); - assert.strictEqual( - original[0], - innerDate, - 'Date reference inside array should be preserved' - ); - assert.strictEqual( - (original[0] as Date).getTime(), - mutationTarget.getTime(), - 'Date time should be updated' - ); +test("writeBack — Date nested in array (reference preserved)", () => { + const innerDate = new Date("2026-01-01T00:00:00.000Z"); + const mutationTarget = new Date("2000-01-01T00:00:00.000Z"); + const original: unknown[] = [innerDate, 42]; + writeBack(original, [mutationTarget, 42]); + assert.strictEqual( + original[0], + innerDate, + "Date reference inside array should be preserved", + ); + assert.strictEqual( + (original[0] as Date).getTime(), + mutationTarget.getTime(), + "Date time should be updated", + ); }); -test('writeBack — Map is not treated as plain object (mismatched with plain object is no-op)', () => { - const original = new Map([['a', 1]]); - writeBack(original, { a: 2 }); - assert.strictEqual( - original.get('a'), - 1, - 'Map should be untouched when mutated is a plain object' - ); +test("writeBack — Map is not treated as plain object (mismatched with plain object is no-op)", () => { + const original = new Map([["a", 1]]); + writeBack(original, { a: 2 }); + assert.strictEqual( + original.get("a"), + 1, + "Map should be untouched when mutated is a plain object", + ); }); -test('writeBack — Set is not treated as plain object (mismatched with array is no-op)', () => { - const original = new Set([1, 2]); - writeBack(original, [3, 4]); - assert.strictEqual( - original.has(1), - true, - 'Set should be untouched when mutated is an array' - ); +test("writeBack — Set is not treated as plain object (mismatched with array is no-op)", () => { + const original = new Set([1, 2]); + writeBack(original, [3, 4]); + assert.strictEqual( + original.has(1), + true, + "Set should be untouched when mutated is an array", + ); }); -test('writeBack — class instance: updates own enumerable data properties', () => { - class Point { - constructor( - public x: number, - public y: number - ) {} - } - 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: 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 { - constructor(public value: number) {} - } - 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 — 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 — 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 — 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 { - constructor( - public x: number, - public y: number - ) {} - } - 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 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 { - constructor(public inner: { value: number }) {} - } - 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'); +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"); }); diff --git a/tsconfig.json b/tsconfig.json index 1a926f8..c60b9ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,30 +1,30 @@ { - "include": ["src"], - "exclude": ["test", "src/@types"], - "compilerOptions": { - "target": "ES2021", - "lib": ["ES2021"], - "module": "Node16", - "moduleResolution": "Node16", - "outDir": "lib", - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "allowJs": false, - "strict": true, - "alwaysStrict": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "noUnusedLocals": true, - "noUnusedParameters": false, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "noImplicitAny": true, - "removeComments": false, - "sourceMap": false, - "esModuleInterop": true, - "noEmitOnError": true, - "declaration": true, - "declarationDir": "lib", - "allowSyntheticDefaultImports": true - } + "include": ["src"], + "exclude": ["test", "src/@types"], + "compilerOptions": { + "target": "ES2021", + "lib": ["ES2021"], + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "lib", + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "allowJs": false, + "strict": true, + "alwaysStrict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "noUnusedParameters": false, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "noImplicitAny": true, + "removeComments": false, + "sourceMap": false, + "esModuleInterop": true, + "noEmitOnError": true, + "declaration": true, + "declarationDir": "lib", + "allowSyntheticDefaultImports": true + } } From a9af31fe7d87974aa82d0c11e86ce877a8624d61 Mon Sep 17 00:00:00 2001 From: lojhan Date: Tue, 17 Mar 2026 21:29:03 -0300 Subject: [PATCH 6/8] lint: fix format, template literals, parameter properties, explicit length checks --- src/index.ts | 42 +- src/shared-resources.ts | 848 +++++++++++++++-------------- test/unit/encode-decode.test.ts | 930 ++++++++++++++++---------------- test/unit/write-back.test.ts | 696 ++++++++++++------------ 4 files changed, 1257 insertions(+), 1259 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4a8aaf7..e806f3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,27 +1,27 @@ -import type { PokuPlugin } from "poku/plugins"; -import type { SharedResourcesConfig } from "./types.js"; +import type { PokuPlugin } from 'poku/plugins'; +import type { SharedResourcesConfig } from './types.js'; import { - configureCodecs, - globalRegistry, - setupSharedResourceIPC, -} from "./shared-resources.js"; + configureCodecs, + globalRegistry, + setupSharedResourceIPC, +} from './shared-resources.js'; export const sharedResources = (config?: SharedResourcesConfig): PokuPlugin => { - if (config?.codecs?.length > 0) configureCodecs(config.codecs); - return { - name: "shared-resources", - ipc: true, - onTestProcess(child) { - setupSharedResourceIPC(child); - }, - async teardown() { - const entries = Object.values(globalRegistry); + if (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"; +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 6fcc9fb..038836e 100644 --- a/src/shared-resources.ts +++ b/src/shared-resources.ts @@ -1,232 +1,232 @@ -import type { ChildProcess } from "node:child_process"; +import type { ChildProcess } from 'node:child_process'; import type { - ArgCodec, - IPCEventEmitter, - IPCMessage, - IPCRemoteProcedureCallMessage, - IPCRemoteProcedureCallResultMessage, - IPCRequestResourceMessage, - IPCResourceResultMessage, - IPCResponse, - MethodsToRPC, - ResourceContext, - SendIPCMessageOptions, - SharedResourceEntry, -} from "./types.js"; -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"; + ArgCodec, + IPCEventEmitter, + IPCMessage, + IPCRemoteProcedureCallMessage, + IPCRemoteProcedureCallResultMessage, + IPCRequestResourceMessage, + IPCResourceResultMessage, + IPCResponse, + MethodsToRPC, + ResourceContext, + SendIPCMessageOptions, + SharedResourceEntry, +} from './types.js'; +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'; const resourceRegistry = new ResourceRegistry(); const moduleCounters = new Map(); export const SHARED_RESOURCE_MESSAGE_TYPES = { - REQUEST_RESOURCE: "shared_resources_requestResource", - RESOURCE_RESULT: "shared_resources_resourceResult", - REMOTE_PROCEDURE_CALL: "shared_resources_remoteProcedureCall", - REMOTE_PROCEDURE_CALL_RESULT: "shared_resources_remoteProcedureCallResult", + REQUEST_RESOURCE: 'shared_resources_requestResource', + RESOURCE_RESULT: 'shared_resources_resourceResult', + REMOTE_PROCEDURE_CALL: 'shared_resources_remoteProcedureCall', + REMOTE_PROCEDURE_CALL_RESULT: 'shared_resources_remoteProcedureCallResult', } as const; export const globalRegistry = resourceRegistry.getRegistry(); const create = ( - factory: () => T, - options?: { - module?: string; - onDestroy?: (instance: Awaited) => void | Promise; - }, + factory: () => T, + options?: { + module?: string; + onDestroy?: (instance: Awaited) => void | Promise; + } ): ResourceContext> => { - let module: string; - - if (options?.module) module = options.module; - else { - const err = { stack: "" }; - Error.captureStackTrace(err, create); - module = findFileFromStack(err.stack); - } - - const count = (moduleCounters.get(module) ?? 0) + 1; - moduleCounters.set(module, count); - const name = count === 1 ? module : `${module}#${count}`; - - return { - factory, - onDestroy: options?.onDestroy, - name, - module, - } as ResourceContext>; + let module: string; + + if (options?.module) module = options.module; + else { + const err = { stack: '' }; + Error.captureStackTrace(err, create); + module = findFileFromStack(err.stack); + } + + const count = (moduleCounters.get(module) ?? 0) + 1; + moduleCounters.set(module, count); + const name = count === 1 ? module : `${module}#${count}`; + + return { + factory, + onDestroy: options?.onDestroy, + name, + module, + } as ResourceContext>; }; const use = async ( - context: ResourceContext, + context: ResourceContext ): Promise> => { - const { name } = context; - - // Parent Process (Host) - if (!process.send || resourceRegistry.getIsRegistering()) { - const existing = resourceRegistry.get(name); - if (existing) { - return existing.state as MethodsToRPC; - } - - const state = await context.factory(); - resourceRegistry.register(name, { - state, - onDestroy: context.onDestroy as - | ((instance: unknown) => void | Promise) - | undefined, - }); - - return state as MethodsToRPC; - } - - if (!context.module) - throw new Error( - `Resource "${name}" is missing "module". Use createResource() or set module explicitly.`, - ); - - return requestResource(name, context.module) as unknown as MethodsToRPC; + const { name } = context; + + // Parent Process (Host) + if (!process.send || resourceRegistry.getIsRegistering()) { + const existing = resourceRegistry.get(name); + if (existing) { + return existing.state as MethodsToRPC; + } + + const state = await context.factory(); + resourceRegistry.register(name, { + state, + onDestroy: context.onDestroy as + | ((instance: unknown) => void | Promise) + | undefined, + }); + + return state as MethodsToRPC; + } + + if (!context.module) + throw new Error( + `Resource "${name}" is missing "module". Use createResource() or set module explicitly.` + ); + + return requestResource(name, context.module) as unknown as MethodsToRPC; }; export const sendIPCMessage = ( - options: SendIPCMessageOptions, + options: SendIPCMessageOptions ): Promise => { - const { - message, - validator, - timeout, - emitter = process, - sender = process.send?.bind(process), - } = options; - - return new Promise((resolve, reject) => { - if (!sender) { - reject(new Error("IPC sender is not available")); - return; - } - - let timer: NodeJS.Timeout | undefined; - - const handleMessage = (response: unknown) => { - if (validator(response)) { - cleanup(); - resolve(response); - } - }; - - const cleanup = () => { - if (timer) clearTimeout(timer); - emitter.off("message", handleMessage); - }; - - if (typeof timeout === "number" && timeout > 0) { - timer = setTimeout(() => { - cleanup(); - reject(new Error(`IPC request timed out after ${timeout}ms`)); - }, timeout); - } - - emitter.on("message", handleMessage); - - try { - sender(message); - } catch (error) { - cleanup(); - reject(error); - } - }); + const { + message, + validator, + timeout, + emitter = process, + sender = process.send?.bind(process), + } = options; + + return new Promise((resolve, reject) => { + if (!sender) { + reject(new Error('IPC sender is not available')); + return; + } + + let timer: NodeJS.Timeout | undefined; + + const handleMessage = (response: unknown) => { + if (validator(response)) { + cleanup(); + resolve(response); + } + }; + + const cleanup = () => { + if (timer) clearTimeout(timer); + emitter.off('message', handleMessage); + }; + + if (typeof timeout === 'number' && timeout > 0) { + timer = setTimeout(() => { + cleanup(); + reject(new Error(`IPC request timed out after ${timeout}ms`)); + }, timeout); + } + + emitter.on('message', handleMessage); + + try { + sender(message); + } catch (error) { + cleanup(); + reject(error); + } + }); }; const requestResource = async (name: string, module: string) => { - const requestId = `${name}-${Date.now()}-${Math.random()}`; - - const response = await sendIPCMessage({ - message: { - type: SHARED_RESOURCE_MESSAGE_TYPES.REQUEST_RESOURCE, - name, - module, - id: requestId, - }, - validator: (message): message is IPCResourceResultMessage => - typeof message === "object" && - message !== null && - "type" in message && - message.type === SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT && - "id" in message && - message.id === requestId, - }); - - if (response.error || !response.value || !response.rpcs) - throw new Error( - response.error ?? `Invalid response for resource "${name}"`, - ); - - return constructSharedResourceWithRPCs( - response.value as Record, - response.rpcs, - name, - ); + const requestId = `${name}-${Date.now()}-${Math.random()}`; + + const response = await sendIPCMessage({ + message: { + type: SHARED_RESOURCE_MESSAGE_TYPES.REQUEST_RESOURCE, + name, + module, + id: requestId, + }, + validator: (message): message is IPCResourceResultMessage => + typeof message === 'object' && + message !== null && + 'type' in message && + message.type === SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT && + 'id' in message && + message.id === requestId, + }); + + if (response.error || !response.value || !response.rpcs) + throw new Error( + response.error ?? `Invalid response for resource "${name}"` + ); + + return constructSharedResourceWithRPCs( + response.value as Record, + response.rpcs, + name + ); }; const remoteProcedureCall = async ( - name: string, - method: string, - args: unknown[], + name: string, + method: string, + args: unknown[] ) => { - const requestId = `${name}-${method}-${Date.now()}-${Math.random()}`; - - const response = await sendIPCMessage({ - message: { - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL, - name, - method, - args: args.map(encodeArg), - id: requestId, - } satisfies IPCRemoteProcedureCallMessage, - validator: (message): message is IPCRemoteProcedureCallResultMessage => - typeof message === "object" && - message !== null && - "id" in message && - message.id === requestId, - }); - - if (response.error || !response.value) - throw new Error( - response.error ?? `Invalid RPC response for "${name}.${method}"`, - ); - - return response.value; + const requestId = `${name}-${method}-${Date.now()}-${Math.random()}`; + + const response = await sendIPCMessage({ + message: { + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL, + name, + method, + args: args.map(encodeArg), + id: requestId, + } satisfies IPCRemoteProcedureCallMessage, + validator: (message): message is IPCRemoteProcedureCallResultMessage => + typeof message === 'object' && + message !== null && + 'id' in message && + message.id === requestId, + }); + + if (response.error || !response.value) + throw new Error( + response.error ?? `Invalid RPC response for "${name}.${method}"` + ); + + return response.value; }; -const ENC_TAG = "__sr_enc"; +const ENC_TAG = '__sr_enc'; // 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; + 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, + undefinedCodec, + bigIntCodec, + dateCodec, + mapCodec, + setCodec, + arrayCodec, + objectCodec, ]; /** @@ -236,283 +236,281 @@ let argCodecs: ArgCodec[] = [ */ // 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 incoming = new Map(codecs.map((c) => [c.tag, c])); + argCodecs = [...codecs, ...argCodecs.filter((c) => !incoming.has(c.tag))]; }; export const encodeArg = (v: unknown): unknown => { - 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]: "c", - t: tagToWire(objectCodec.tag), - v: objectCodec.encode(v as Record, encodeArg), - }; - return v; + 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]: 'c', + t: tagToWire(objectCodec.tag), + v: objectCodec.encode(v as Record, encodeArg), + }; + return v; }; const decodeEncoded = (enc: Record): 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.`, - ); - return codec.decode(enc.v, decodeArg); + 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.` + ); + return codec.decode(enc.v, decodeArg); }; export const decodeArg = (v: unknown): unknown => { - if (isPlainObject(v)) return ENC_TAG in v ? decodeEncoded(v) : v; - return v; + if (isPlainObject(v)) return ENC_TAG in v ? decodeEncoded(v) : v; + return v; }; const tryReconcileInPlace = (original: unknown, mutated: unknown): boolean => { - for (const codec of argCodecs) { - if (codec.writeBack && codec.is(original) && codec.is(mutated)) { - codec.writeBack(original, mutated, tryReconcileInPlace); - return true; - } - } - // 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; + for (const codec of argCodecs) { + if (codec.writeBack && codec.is(original) && codec.is(mutated)) { + codec.writeBack(original, mutated, tryReconcileInPlace); + return true; + } + } + // 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; }; export const writeBack = (original: unknown, mutated: unknown): void => { - tryReconcileInPlace(original, mutated); + tryReconcileInPlace(original, mutated); }; export const extractFunctionNames = (obj: Record) => { - const seen = new Set(); - let current = obj; + const seen = new Set(); + let current = obj; - while ( - current !== Object.prototype && - Object.getPrototypeOf(current) !== null - ) { - for (const key of Object.getOwnPropertyNames(current)) { - if (typeof obj[key] !== "function" || key === "constructor") continue; + while ( + current !== Object.prototype && + Object.getPrototypeOf(current) !== null + ) { + for (const key of Object.getOwnPropertyNames(current)) { + if (typeof obj[key] !== 'function' || key === 'constructor') continue; - seen.add(key); - } + seen.add(key); + } - current = Object.getPrototypeOf(current); - } + current = Object.getPrototypeOf(current); + } - return Array.from(seen); + return Array.from(seen); }; export const setupSharedResourceIPC = ( - child: IPCEventEmitter | ChildProcess, - registry: Record = globalRegistry, + child: IPCEventEmitter | ChildProcess, + registry: Record = globalRegistry ): void => { - child.on("message", async (message: IPCMessage) => { - if (message.type === SHARED_RESOURCE_MESSAGE_TYPES.REQUEST_RESOURCE) - await handleRequestResource(message, registry, child); - else if ( - message.type === SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL - ) - await handleRemoteProcedureCall(message, registry, child); - }); + child.on('message', async (message: IPCMessage) => { + if (message.type === SHARED_RESOURCE_MESSAGE_TYPES.REQUEST_RESOURCE) + await handleRequestResource(message, registry, child); + else if ( + message.type === SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL + ) + await handleRemoteProcedureCall(message, registry, child); + }); }; const loadModuleResources = async (module: string) => { - resourceRegistry.setIsRegistering(true); - - try { - const modulePath = isWindows ? pathToFileURL(module).href : module; - const mod: Record = await import(modulePath); - - for (const key in mod) { - if (!Object.prototype.hasOwnProperty.call(mod, key)) continue; - - const exported = mod[key]; - - if ( - exported && - typeof exported === "object" && - "factory" in exported && - typeof exported.factory === "function" - ) - await use(exported as ResourceContext); - } - } finally { - resourceRegistry.setIsRegistering(false); - } + resourceRegistry.setIsRegistering(true); + + try { + const modulePath = isWindows ? pathToFileURL(module).href : module; + const mod: Record = await import(modulePath); + + for (const key in mod) { + if (!Object.prototype.hasOwnProperty.call(mod, key)) continue; + + const exported = mod[key]; + + if ( + exported && + typeof exported === 'object' && + 'factory' in exported && + typeof exported.factory === 'function' + ) + await use(exported as ResourceContext); + } + } finally { + resourceRegistry.setIsRegistering(false); + } }; export const handleRequestResource = async ( - message: IPCRequestResourceMessage, - registry: Record, - child: IPCEventEmitter | ChildProcess, + message: IPCRequestResourceMessage, + registry: Record, + child: IPCEventEmitter | ChildProcess ) => { - try { - if (!registry[message.name]) await loadModuleResources(message.module); - - const entry = registry[message.name]; - if (!entry) { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, - name: message.name, - id: message.id, - error: `Resource "${message.name}" not found in module "${message.module}"`, - } satisfies IPCResponse); - return; - } - - const rpcs = extractFunctionNames(entry.state as Record); - - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, - name: message.name, - value: entry.state, - rpcs, - id: message.id, - } satisfies IPCResourceResultMessage); - } catch (error) { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, - name: message.name, - id: message.id, - error: error instanceof Error ? error.message : String(error), - } satisfies IPCResponse); - } + try { + if (!registry[message.name]) await loadModuleResources(message.module); + + const entry = registry[message.name]; + if (!entry) { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, + name: message.name, + id: message.id, + error: `Resource "${message.name}" not found in module "${message.module}"`, + } satisfies IPCResponse); + return; + } + + const rpcs = extractFunctionNames(entry.state as Record); + + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, + name: message.name, + value: entry.state, + rpcs, + id: message.id, + } satisfies IPCResourceResultMessage); + } catch (error) { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, + name: message.name, + id: message.id, + error: error instanceof Error ? error.message : String(error), + } satisfies IPCResponse); + } }; export const handleRemoteProcedureCall = async ( - message: IPCRemoteProcedureCallMessage, - registry: Record, - child: IPCEventEmitter | ChildProcess, + message: IPCRemoteProcedureCallMessage, + registry: Record, + child: IPCEventEmitter | ChildProcess ) => { - const entry = registry[message.name]; - if (!entry) { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, - id: message.id, - error: `Resource "${message.name}" not found`, - } satisfies IPCResponse); - return; - } - - const state = entry.state as Record; - - if (!message.method) { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, - id: message.id, - error: "Method name is missing", - } satisfies IPCResponse); - return; - } - - const methodCandidate = state[message.method]; - if (typeof methodCandidate !== "function") { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, - id: message.id, - error: `Method "${message.method}" not found on resource "${message.name}"`, - } satisfies IPCResponse); - return; - } - - try { - const method = methodCandidate.bind(entry.state); - const callArgs = (message.args || []).map(decodeArg); - const result = await method(...callArgs); - - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, - id: message.id, - value: { - result, - latest: state, - mutatedArgs: callArgs.map(encodeArg), - }, - } satisfies IPCResponse); - } catch (error) { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, - id: message.id, - error: error instanceof Error ? error.message : String(error), - } satisfies IPCResponse); - } + const entry = registry[message.name]; + if (!entry) { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, + id: message.id, + error: `Resource "${message.name}" not found`, + } satisfies IPCResponse); + return; + } + + const state = entry.state as Record; + + if (!message.method) { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, + id: message.id, + error: 'Method name is missing', + } satisfies IPCResponse); + return; + } + + const methodCandidate = state[message.method]; + if (typeof methodCandidate !== 'function') { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, + id: message.id, + error: `Method "${message.method}" not found on resource "${message.name}"`, + } satisfies IPCResponse); + return; + } + + try { + const method = methodCandidate.bind(entry.state); + const callArgs = (message.args || []).map(decodeArg); + const result = await method(...callArgs); + + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, + id: message.id, + value: { + result, + latest: state, + mutatedArgs: callArgs.map(encodeArg), + }, + } satisfies IPCResponse); + } catch (error) { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, + id: message.id, + error: error instanceof Error ? error.message : String(error), + } satisfies IPCResponse); + } }; const constructSharedResourceWithRPCs = ( - target: Record, - rpcs: string[], - name: string, + target: Record, + rpcs: string[], + name: string ) => { - if (rpcs.length === 0) return target; - - return new Proxy(target, { - get(target, prop, receiver) { - if (typeof prop === "string" && rpcs.includes(prop)) { - return async (...args: unknown[]) => { - const rpcResult = await remoteProcedureCall(name, prop, args); - const decodedMutatedArgs = rpcResult.mutatedArgs.map(decodeArg); - - for (let i = 0; i < args.length; i++) - writeBack(args[i], decodedMutatedArgs[i]); - - for (const rpcKey of rpcs) { - if (rpcKey in rpcResult.latest) { - delete rpcResult.latest[rpcKey]; - } - } - - Object.assign(target, rpcResult.latest); - return rpcResult.result; - }; - } - - return Reflect.get(target, prop, receiver); - }, - }); + if (rpcs.length === 0) return target; + + return new Proxy(target, { + get(target, prop, receiver) { + if (typeof prop === 'string' && rpcs.includes(prop)) { + return async (...args: unknown[]) => { + const rpcResult = await remoteProcedureCall(name, prop, args); + const decodedMutatedArgs = rpcResult.mutatedArgs.map(decodeArg); + + for (let i = 0; i < args.length; i++) + writeBack(args[i], decodedMutatedArgs[i]); + + for (const rpcKey of rpcs) { + if (rpcKey in rpcResult.latest) { + delete rpcResult.latest[rpcKey]; + } + } + + Object.assign(target, rpcResult.latest); + return rpcResult.result; + }; + } + + return Reflect.get(target, prop, receiver); + }, + }); }; 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?.length > 0) configureCodecs(config.codecs); - }, + 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?.length > 0) configureCodecs(config.codecs); + }, } as const; diff --git a/test/unit/encode-decode.test.ts b/test/unit/encode-decode.test.ts index 6d576db..5b20038 100644 --- a/test/unit/encode-decode.test.ts +++ b/test/unit/encode-decode.test.ts @@ -1,483 +1,483 @@ -import type { ArgCodec } from "../../src/types.js"; -import { assert, test } from "poku"; +import type { ArgCodec } from '../../src/types.js'; +import { assert, test } from 'poku'; import { - configureCodecs, - decodeArg, - encodeArg, -} from "../../src/shared-resources.js"; + configureCodecs, + decodeArg, + encodeArg, +} from '../../src/shared-resources.js'; const roundtrip = (v: unknown) => decodeArg(encodeArg(v)); -test("encodeArg/decodeArg — undefined", () => { - assert.strictEqual( - roundtrip(undefined), - undefined, - "undefined survives roundtrip", - ); -}); - -test("encodeArg/decodeArg — null", () => { - assert.strictEqual(roundtrip(null), null, "null survives roundtrip"); -}); - -test("encodeArg/decodeArg — number", () => { - assert.strictEqual(roundtrip(42), 42, "number survives roundtrip"); -}); - -test("encodeArg/decodeArg — string", () => { - assert.strictEqual(roundtrip("hello"), "hello", "string survives roundtrip"); -}); - -test("encodeArg/decodeArg — boolean", () => { - assert.strictEqual(roundtrip(true), true, "boolean survives roundtrip"); -}); - -test("encodeArg/decodeArg — Date", () => { - const d = new Date("2026-03-17T12:00:00.000Z"); - const result = roundtrip(d); - assert.ok(result instanceof Date, "Should be a Date instance"); - assert.strictEqual( - (result as Date).getTime(), - d.getTime(), - "Date time preserved", - ); -}); - -test("encodeArg/decodeArg — Map", () => { - const m = new Map([ - ["a", 1], - ["b", 2], - ]); - const result = roundtrip(m) as Map; - assert.ok(result instanceof Map, "Should be a Map instance"); - assert.strictEqual(result.get("a"), 1, "Map entry a preserved"); - assert.strictEqual(result.get("b"), 2, "Map entry b preserved"); - assert.strictEqual(result.size, 2, "Map size preserved"); -}); - -test("encodeArg/decodeArg — Set", () => { - const s = new Set([1, 2, 3]); - const result = roundtrip(s) as Set; - assert.ok(result instanceof Set, "Should be a Set instance"); - assert.strictEqual(result.has(1), true, "Set element 1 preserved"); - assert.strictEqual(result.has(3), true, "Set element 3 preserved"); - assert.strictEqual(result.size, 3, "Set size preserved"); -}); - -test("encodeArg/decodeArg — array with undefined", () => { - const arr = [1, undefined, 3]; - const result = roundtrip(arr) as unknown[]; - assert.strictEqual(result[0], 1, "First element preserved"); - assert.strictEqual(result[1], undefined, "undefined element preserved"); - assert.strictEqual(result[2], 3, "Third element preserved"); - assert.strictEqual(result.length, 3, "Array length preserved"); -}); - -test("encodeArg/decodeArg — object with undefined value", () => { - const obj = { a: undefined as unknown, b: 1 }; - const result = roundtrip(obj) as Record; - assert.strictEqual( - "a" in result, - true, - "Key with undefined value should exist", - ); - assert.strictEqual(result.a, undefined, "undefined value preserved"); - assert.strictEqual(result.b, 1, "Other values preserved"); -}); - -test("encodeArg/decodeArg — nested Map inside object", () => { - const m = new Map([["x", 10]]); - const obj = { m, count: 1 }; - const result = roundtrip(obj) as { m: Map; count: number }; - assert.ok(result.m instanceof Map, "Nested Map is a Map instance"); - assert.strictEqual(result.m.get("x"), 10, "Nested Map entry preserved"); - assert.strictEqual(result.count, 1, "Other object property preserved"); -}); - -test("encodeArg/decodeArg — nested Set inside array", () => { - const s = new Set([7, 8]); - const arr = [s, 42]; - const result = roundtrip(arr) as unknown[]; - assert.ok(result[0] instanceof Set, "Nested Set is a Set instance"); - assert.strictEqual( - (result[0] as Set).has(7), - true, - "Set element 7 preserved", - ); - assert.strictEqual(result[1], 42, "Other array element preserved"); -}); - -test("encodeArg/decodeArg — nested Date inside array", () => { - const d = new Date("2026-01-01T00:00:00.000Z"); - const arr = [d, 42]; - const result = roundtrip(arr) as unknown[]; - assert.ok(result[0] instanceof Date, "Nested Date is a Date instance"); - assert.strictEqual( - (result[0] as Date).getTime(), - d.getTime(), - "Date value preserved", - ); - assert.strictEqual(result[1], 42, "Other array element preserved"); -}); - -test("encodeArg/decodeArg — Map with object keys", () => { - const key = { id: 1 }; - const m = new Map([[key, "value"]]); - const result = roundtrip(m) as Map, string>; - assert.strictEqual(result.size, 1, "Map size preserved"); - const [[rk, rv]] = result.entries(); - assert.deepStrictEqual(rk, key, "Map object key preserved by value"); - assert.strictEqual(rv, "value", "Map value preserved"); -}); - -test("encodeArg/decodeArg — Map with undefined value", () => { - const m = new Map([["a", undefined]]); - const result = roundtrip(m) as Map; - assert.strictEqual(result.has("a"), true, "Key with undefined value present"); - assert.strictEqual( - result.get("a"), - undefined, - "undefined Map value preserved", - ); -}); - -test("encodeArg/decodeArg — Set with undefined", () => { - const s = new Set([1, undefined, 3]); - const result = roundtrip(s) as Set; - assert.strictEqual( - result.has(undefined), - true, - "undefined Set element preserved", - ); - assert.strictEqual(result.size, 3, "Set size preserved"); -}); - -test("encodeArg/decodeArg — plain object passthrough", () => { - const obj = { a: 1, b: "hello", c: true }; - const result = roundtrip(obj); - assert.deepStrictEqual(result, obj, "Plain object survives roundtrip"); -}); - -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"); - assert.strictEqual(result.other, 42, "Other key preserved"); -}); - -test("encodeArg/decodeArg — BigInt", () => { - assert.strictEqual(roundtrip(42n), 42n, "BigInt survives roundtrip"); - assert.strictEqual(roundtrip(0n), 0n, "BigInt 0 survives roundtrip"); - assert.strictEqual( - roundtrip(-99n), - -99n, - "Negative BigInt survives roundtrip", - ); -}); - -test("encodeArg/decodeArg — BigInt in array", () => { - const result = roundtrip([1n, 2n, 3n]) as bigint[]; - assert.deepStrictEqual( - result, - [1n, 2n, 3n], - "Array of BigInts survives roundtrip", - ); -}); - -test("encodeArg/decodeArg — BigInt in object", () => { - const result = roundtrip({ a: 1n, b: 2 }) as { a: bigint; b: number }; - assert.strictEqual(result.a, 1n, "BigInt property survives roundtrip"); - assert.strictEqual(result.b, 2, "Non-BigInt property preserved"); -}); - -test("encodeArg/decodeArg — BigInt as Map key and value", () => { - const m = new Map([ - [1n, 100n], - [2n, 200n], - ]); - const result = roundtrip(m) as Map; - assert.ok(result instanceof Map, "Should be a Map instance"); - assert.strictEqual(result.get(1n), 100n, "BigInt key and value preserved"); - assert.strictEqual(result.get(2n), 200n, "Second BigInt entry preserved"); -}); - -test("encodeArg/decodeArg — BigInt in Set", () => { - const s = new Set([1n, 2n, 3n]); - const result = roundtrip(s) as Set; - assert.ok(result instanceof Set, "Should be a Set instance"); - assert.strictEqual(result.has(1n), true, "BigInt Set element preserved"); - assert.strictEqual(result.has(3n), true, "BigInt Set element preserved"); -}); - -test("encodeArg/decodeArg — deeply nested special types", () => { - const d = new Date("2026-06-01T00:00:00.000Z"); - const m = new Map([["created", d]]); - const obj = { data: m, tags: new Set(["a", "b"]) }; - const result = roundtrip(obj) as { - data: Map; - tags: Set; - }; - assert.ok(result.data instanceof Map, "Nested Map preserved"); - assert.ok( - result.data.get("created") instanceof Date, - "Map-nested Date preserved", - ); - assert.strictEqual( - result.data.get("created")?.getTime(), - d.getTime(), - "Date value correct", - ); - 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", - ); +test('encodeArg/decodeArg — undefined', () => { + assert.strictEqual( + roundtrip(undefined), + undefined, + 'undefined survives roundtrip' + ); +}); + +test('encodeArg/decodeArg — null', () => { + assert.strictEqual(roundtrip(null), null, 'null survives roundtrip'); +}); + +test('encodeArg/decodeArg — number', () => { + assert.strictEqual(roundtrip(42), 42, 'number survives roundtrip'); +}); + +test('encodeArg/decodeArg — string', () => { + assert.strictEqual(roundtrip('hello'), 'hello', 'string survives roundtrip'); +}); + +test('encodeArg/decodeArg — boolean', () => { + assert.strictEqual(roundtrip(true), true, 'boolean survives roundtrip'); +}); + +test('encodeArg/decodeArg — Date', () => { + const d = new Date('2026-03-17T12:00:00.000Z'); + const result = roundtrip(d); + assert.ok(result instanceof Date, 'Should be a Date instance'); + assert.strictEqual( + (result as Date).getTime(), + d.getTime(), + 'Date time preserved' + ); +}); + +test('encodeArg/decodeArg — Map', () => { + const m = new Map([ + ['a', 1], + ['b', 2], + ]); + const result = roundtrip(m) as Map; + assert.ok(result instanceof Map, 'Should be a Map instance'); + assert.strictEqual(result.get('a'), 1, 'Map entry a preserved'); + assert.strictEqual(result.get('b'), 2, 'Map entry b preserved'); + assert.strictEqual(result.size, 2, 'Map size preserved'); +}); + +test('encodeArg/decodeArg — Set', () => { + const s = new Set([1, 2, 3]); + const result = roundtrip(s) as Set; + assert.ok(result instanceof Set, 'Should be a Set instance'); + assert.strictEqual(result.has(1), true, 'Set element 1 preserved'); + assert.strictEqual(result.has(3), true, 'Set element 3 preserved'); + assert.strictEqual(result.size, 3, 'Set size preserved'); +}); + +test('encodeArg/decodeArg — array with undefined', () => { + const arr = [1, undefined, 3]; + const result = roundtrip(arr) as unknown[]; + assert.strictEqual(result[0], 1, 'First element preserved'); + assert.strictEqual(result[1], undefined, 'undefined element preserved'); + assert.strictEqual(result[2], 3, 'Third element preserved'); + assert.strictEqual(result.length, 3, 'Array length preserved'); +}); + +test('encodeArg/decodeArg — object with undefined value', () => { + const obj = { a: undefined as unknown, b: 1 }; + const result = roundtrip(obj) as Record; + assert.strictEqual( + 'a' in result, + true, + 'Key with undefined value should exist' + ); + assert.strictEqual(result.a, undefined, 'undefined value preserved'); + assert.strictEqual(result.b, 1, 'Other values preserved'); +}); + +test('encodeArg/decodeArg — nested Map inside object', () => { + const m = new Map([['x', 10]]); + const obj = { m, count: 1 }; + const result = roundtrip(obj) as { m: Map; count: number }; + assert.ok(result.m instanceof Map, 'Nested Map is a Map instance'); + assert.strictEqual(result.m.get('x'), 10, 'Nested Map entry preserved'); + assert.strictEqual(result.count, 1, 'Other object property preserved'); +}); + +test('encodeArg/decodeArg — nested Set inside array', () => { + const s = new Set([7, 8]); + const arr = [s, 42]; + const result = roundtrip(arr) as unknown[]; + assert.ok(result[0] instanceof Set, 'Nested Set is a Set instance'); + assert.strictEqual( + (result[0] as Set).has(7), + true, + 'Set element 7 preserved' + ); + assert.strictEqual(result[1], 42, 'Other array element preserved'); +}); + +test('encodeArg/decodeArg — nested Date inside array', () => { + const d = new Date('2026-01-01T00:00:00.000Z'); + const arr = [d, 42]; + const result = roundtrip(arr) as unknown[]; + assert.ok(result[0] instanceof Date, 'Nested Date is a Date instance'); + assert.strictEqual( + (result[0] as Date).getTime(), + d.getTime(), + 'Date value preserved' + ); + assert.strictEqual(result[1], 42, 'Other array element preserved'); +}); + +test('encodeArg/decodeArg — Map with object keys', () => { + const key = { id: 1 }; + const m = new Map([[key, 'value']]); + const result = roundtrip(m) as Map, string>; + assert.strictEqual(result.size, 1, 'Map size preserved'); + const [[rk, rv]] = result.entries(); + assert.deepStrictEqual(rk, key, 'Map object key preserved by value'); + assert.strictEqual(rv, 'value', 'Map value preserved'); +}); + +test('encodeArg/decodeArg — Map with undefined value', () => { + const m = new Map([['a', undefined]]); + const result = roundtrip(m) as Map; + assert.strictEqual(result.has('a'), true, 'Key with undefined value present'); + assert.strictEqual( + result.get('a'), + undefined, + 'undefined Map value preserved' + ); +}); + +test('encodeArg/decodeArg — Set with undefined', () => { + const s = new Set([1, undefined, 3]); + const result = roundtrip(s) as Set; + assert.strictEqual( + result.has(undefined), + true, + 'undefined Set element preserved' + ); + assert.strictEqual(result.size, 3, 'Set size preserved'); +}); + +test('encodeArg/decodeArg — plain object passthrough', () => { + const obj = { a: 1, b: 'hello', c: true }; + const result = roundtrip(obj); + assert.deepStrictEqual(result, obj, 'Plain object survives roundtrip'); +}); + +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'); + assert.strictEqual(result.other, 42, 'Other key preserved'); +}); + +test('encodeArg/decodeArg — BigInt', () => { + assert.strictEqual(roundtrip(42n), 42n, 'BigInt survives roundtrip'); + assert.strictEqual(roundtrip(0n), 0n, 'BigInt 0 survives roundtrip'); + assert.strictEqual( + roundtrip(-99n), + -99n, + 'Negative BigInt survives roundtrip' + ); +}); + +test('encodeArg/decodeArg — BigInt in array', () => { + const result = roundtrip([1n, 2n, 3n]) as bigint[]; + assert.deepStrictEqual( + result, + [1n, 2n, 3n], + 'Array of BigInts survives roundtrip' + ); +}); + +test('encodeArg/decodeArg — BigInt in object', () => { + const result = roundtrip({ a: 1n, b: 2 }) as { a: bigint; b: number }; + assert.strictEqual(result.a, 1n, 'BigInt property survives roundtrip'); + assert.strictEqual(result.b, 2, 'Non-BigInt property preserved'); +}); + +test('encodeArg/decodeArg — BigInt as Map key and value', () => { + const m = new Map([ + [1n, 100n], + [2n, 200n], + ]); + const result = roundtrip(m) as Map; + assert.ok(result instanceof Map, 'Should be a Map instance'); + assert.strictEqual(result.get(1n), 100n, 'BigInt key and value preserved'); + assert.strictEqual(result.get(2n), 200n, 'Second BigInt entry preserved'); +}); + +test('encodeArg/decodeArg — BigInt in Set', () => { + const s = new Set([1n, 2n, 3n]); + const result = roundtrip(s) as Set; + assert.ok(result instanceof Set, 'Should be a Set instance'); + assert.strictEqual(result.has(1n), true, 'BigInt Set element preserved'); + assert.strictEqual(result.has(3n), true, 'BigInt Set element preserved'); +}); + +test('encodeArg/decodeArg — deeply nested special types', () => { + const d = new Date('2026-06-01T00:00:00.000Z'); + const m = new Map([['created', d]]); + const obj = { data: m, tags: new Set(['a', 'b']) }; + const result = roundtrip(obj) as { + data: Map; + tags: Set; + }; + assert.ok(result.data instanceof Map, 'Nested Map preserved'); + assert.ok( + result.data.get('created') instanceof Date, + 'Map-nested Date preserved' + ); + assert.strictEqual( + result.data.get('created')?.getTime(), + d.getTime(), + 'Date value correct' + ); + 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("")}`; - } + 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); - }, + 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 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, + 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", - ); +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 01b5ebc..5e31167 100644 --- a/test/unit/write-back.test.ts +++ b/test/unit/write-back.test.ts @@ -1,410 +1,410 @@ -import type { ArgCodec } from "../../src/types.js"; -import { assert, test } from "poku"; -import { configureCodecs, writeBack } from "../../src/shared-resources.js"; +import type { ArgCodec } from '../../src/types.js'; +import { assert, test } from 'poku'; +import { configureCodecs, writeBack } from '../../src/shared-resources.js'; -test("writeBack — array push", () => { - const original: number[] = []; - writeBack(original, [42]); - assert.deepStrictEqual( - original, - [42], - "Element should be pushed into the original array", - ); +test('writeBack — array push', () => { + const original: number[] = []; + writeBack(original, [42]); + assert.deepStrictEqual( + original, + [42], + 'Element should be pushed into the original array' + ); }); -test("writeBack — array truncation", () => { - const original = [1, 2, 3, 4]; - writeBack(original, [1, 2, 3]); - assert.deepStrictEqual( - original, - [1, 2, 3], - "Array should be truncated to match mutated length", - ); +test('writeBack — array truncation', () => { + const original = [1, 2, 3, 4]; + writeBack(original, [1, 2, 3]); + assert.deepStrictEqual( + original, + [1, 2, 3], + 'Array should be truncated to match mutated length' + ); }); -test("writeBack — array emptied", () => { - const original = [1, 2, 3]; - writeBack(original, []); - assert.deepStrictEqual(original, [], "Array should be emptied"); +test('writeBack — array emptied', () => { + const original = [1, 2, 3]; + writeBack(original, []); + assert.deepStrictEqual(original, [], 'Array should be emptied'); }); -test("writeBack — array element update", () => { - const original = [1, 2, 3]; - writeBack(original, [1, 99, 3]); - assert.deepStrictEqual( - original, - [1, 99, 3], - "Middle element should be updated in place", - ); +test('writeBack — array element update', () => { + const original = [1, 2, 3]; + writeBack(original, [1, 99, 3]); + assert.deepStrictEqual( + original, + [1, 99, 3], + 'Middle element should be updated in place' + ); }); -test("writeBack — preserves original array reference", () => { - const original: number[] = [1]; - const ref = original; - writeBack(original, [1, 2, 3]); - assert.strictEqual( - original, - ref, - "Original array reference should be preserved", - ); +test('writeBack — preserves original array reference', () => { + const original: number[] = [1]; + const ref = original; + writeBack(original, [1, 2, 3]); + assert.strictEqual( + original, + ref, + 'Original array reference should be preserved' + ); }); -test("writeBack — object property set", () => { - const original: Record = {}; - writeBack(original, { key: 42 }); - assert.strictEqual( - original.key, - 42, - "Property should be set on the original object", - ); +test('writeBack — object property set', () => { + const original: Record = {}; + writeBack(original, { key: 42 }); + assert.strictEqual( + original.key, + 42, + 'Property should be set on the original object' + ); }); -test("writeBack — object property deletion", () => { - const original: Record = { toDelete: "x", keep: 1 }; - writeBack(original, { keep: 1 }); - assert.strictEqual( - "toDelete" in original, - false, - "Property should be removed", - ); - assert.strictEqual( - original.keep, - 1, - "Remaining property should still be present", - ); +test('writeBack — object property deletion', () => { + const original: Record = { toDelete: 'x', keep: 1 }; + writeBack(original, { keep: 1 }); + assert.strictEqual( + 'toDelete' in original, + false, + 'Property should be removed' + ); + assert.strictEqual( + original.keep, + 1, + 'Remaining property should still be present' + ); }); -test("writeBack — object property update", () => { - const original: Record = { count: 0 }; - writeBack(original, { count: 5 }); - assert.strictEqual(original.count, 5, "Property should be updated"); +test('writeBack — object property update', () => { + const original: Record = { count: 0 }; + writeBack(original, { count: 5 }); + assert.strictEqual(original.count, 5, 'Property should be updated'); }); -test("writeBack — preserves original object reference", () => { - const original: Record = { a: 1 }; - const ref = original; - writeBack(original, { a: 2, b: 3 }); - assert.strictEqual( - original, - ref, - "Original object reference should be preserved", - ); +test('writeBack — preserves original object reference', () => { + const original: Record = { a: 1 }; + const ref = original; + writeBack(original, { a: 2, b: 3 }); + assert.strictEqual( + original, + ref, + 'Original object reference should be preserved' + ); }); -test("writeBack — deeply nested array push", () => { - const original = { nested: { arr: [] as number[] } }; - writeBack(original, { nested: { arr: [7] } }); - assert.deepStrictEqual( - original.nested.arr, - [7], - "Nested array should have the pushed value", - ); +test('writeBack — deeply nested array push', () => { + const original = { nested: { arr: [] as number[] } }; + writeBack(original, { nested: { arr: [7] } }); + assert.deepStrictEqual( + original.nested.arr, + [7], + 'Nested array should have the pushed value' + ); }); -test("writeBack — deeply nested object property", () => { - const original = { a: { b: { c: 0 } } }; - writeBack(original, { a: { b: { c: 99 } } }); - assert.strictEqual(original.a.b.c, 99, "Deep property should be updated"); +test('writeBack — deeply nested object property', () => { + const original = { a: { b: { c: 0 } } }; + writeBack(original, { a: { b: { c: 99 } } }); + assert.strictEqual(original.a.b.c, 99, 'Deep property should be updated'); }); -test("writeBack — preserves nested object references", () => { - const inner = { arr: [] as number[] }; - const original = { nested: inner }; - writeBack(original, { nested: { arr: [1, 2] } }); - assert.strictEqual( - original.nested, - inner, - "Nested object reference should be preserved", - ); - assert.deepStrictEqual( - original.nested.arr, - [1, 2], - "Nested array should be updated in place", - ); +test('writeBack — preserves nested object references', () => { + const inner = { arr: [] as number[] }; + const original = { nested: inner }; + writeBack(original, { nested: { arr: [1, 2] } }); + assert.strictEqual( + original.nested, + inner, + 'Nested object reference should be preserved' + ); + assert.deepStrictEqual( + original.nested.arr, + [1, 2], + 'Nested array should be updated in place' + ); }); -test("writeBack — array of objects mutation", () => { - const original = [{ x: 1 }, { x: 2 }]; - writeBack(original, [{ x: 10 }, { x: 20 }]); - assert.deepStrictEqual( - original, - [{ x: 10 }, { x: 20 }], - "Each object element should be updated", - ); +test('writeBack — array of objects mutation', () => { + const original = [{ x: 1 }, { x: 2 }]; + writeBack(original, [{ x: 10 }, { x: 20 }]); + assert.deepStrictEqual( + original, + [{ x: 10 }, { x: 20 }], + 'Each object element should be updated' + ); }); -test("writeBack — preserves references inside array of objects", () => { - const item0 = { x: 1 }; - const original = [item0, { x: 2 }]; - writeBack(original, [{ x: 99 }, { x: 2 }]); - assert.strictEqual( - original[0], - item0, - "Object reference inside array should be preserved", - ); - assert.strictEqual( - item0.x, - 99, - "The referenced object should have its property updated", - ); +test('writeBack — preserves references inside array of objects', () => { + const item0 = { x: 1 }; + const original = [item0, { x: 2 }]; + writeBack(original, [{ x: 99 }, { x: 2 }]); + assert.strictEqual( + original[0], + item0, + 'Object reference inside array should be preserved' + ); + assert.strictEqual( + item0.x, + 99, + 'The referenced object should have its property updated' + ); }); -test("writeBack — primitive original is a no-op", () => { - const original = 42 as unknown; - assert.doesNotThrow( - () => writeBack(original, 99), - "Should not throw for primitive original", - ); +test('writeBack — primitive original is a no-op', () => { + const original = 42 as unknown; + assert.doesNotThrow( + () => writeBack(original, 99), + 'Should not throw for primitive original' + ); }); -test("writeBack — null original is a no-op", () => { - assert.doesNotThrow( - () => writeBack(null, { key: 1 }), - "Should not throw for null original", - ); +test('writeBack — null original is a no-op', () => { + assert.doesNotThrow( + () => writeBack(null, { key: 1 }), + 'Should not throw for null original' + ); }); -test("writeBack — mismatched types (array vs object) is a no-op", () => { - const original: number[] = [1, 2]; - writeBack(original, { key: 1 }); - assert.deepStrictEqual( - original, - [1, 2], - "Mismatched type should leave original unchanged", - ); +test('writeBack — mismatched types (array vs object) is a no-op', () => { + const original: number[] = [1, 2]; + writeBack(original, { key: 1 }); + assert.deepStrictEqual( + original, + [1, 2], + 'Mismatched type should leave original unchanged' + ); }); -test("writeBack — Map repopulation", () => { - const original = new Map([ - ["a", 1], - ["b", 2], - ]); - const ref = original; - writeBack( - original, - new Map([ - ["b", 99], - ["c", 3], - ]), - ); - assert.strictEqual(original, ref, "Map reference should be preserved"); - assert.strictEqual(original.has("a"), false, "Removed key should be gone"); - assert.strictEqual(original.get("b"), 99, "Updated key should be updated"); - assert.strictEqual(original.get("c"), 3, "New key should be added"); +test('writeBack — Map repopulation', () => { + const original = new Map([ + ['a', 1], + ['b', 2], + ]); + const ref = original; + writeBack( + original, + new Map([ + ['b', 99], + ['c', 3], + ]) + ); + assert.strictEqual(original, ref, 'Map reference should be preserved'); + assert.strictEqual(original.has('a'), false, 'Removed key should be gone'); + assert.strictEqual(original.get('b'), 99, 'Updated key should be updated'); + assert.strictEqual(original.get('c'), 3, 'New key should be added'); }); -test("writeBack — Set repopulation", () => { - const original = new Set([1, 2, 3]); - const ref = original; - writeBack(original, new Set([2, 3, 4])); - assert.strictEqual(original, ref, "Set reference should be preserved"); - assert.strictEqual(original.has(1), false, "Removed element should be gone"); - assert.strictEqual(original.has(4), true, "New element should be present"); - assert.strictEqual(original.has(2), true, "Unchanged element should remain"); +test('writeBack — Set repopulation', () => { + const original = new Set([1, 2, 3]); + const ref = original; + writeBack(original, new Set([2, 3, 4])); + assert.strictEqual(original, ref, 'Set reference should be preserved'); + assert.strictEqual(original.has(1), false, 'Removed element should be gone'); + assert.strictEqual(original.has(4), true, 'New element should be present'); + assert.strictEqual(original.has(2), true, 'Unchanged element should remain'); }); -test("writeBack — Date mutation", () => { - const original = new Date("2026-01-01T00:00:00.000Z"); - const ref = original; - writeBack(original, new Date("2000-06-15T00:00:00.000Z")); - assert.strictEqual(original, ref, "Date reference should be preserved"); - assert.strictEqual(original.getFullYear(), 2000, "Year should be updated"); - assert.strictEqual( - original.getMonth(), - 5, - "Month should be updated (0-indexed)", - ); +test('writeBack — Date mutation', () => { + const original = new Date('2026-01-01T00:00:00.000Z'); + const ref = original; + writeBack(original, new Date('2000-06-15T00:00:00.000Z')); + assert.strictEqual(original, ref, 'Date reference should be preserved'); + assert.strictEqual(original.getFullYear(), 2000, 'Year should be updated'); + assert.strictEqual( + original.getMonth(), + 5, + 'Month should be updated (0-indexed)' + ); }); -test("writeBack — Map nested in object (reference preserved)", () => { - const innerMap = new Map([["x", 1]]); - const original: Record = { m: innerMap }; - writeBack(original, { - m: new Map([ - ["x", 1], - ["y", 2], - ]), - }); - assert.strictEqual( - original.m, - innerMap, - "Inner Map reference should be preserved", - ); - assert.strictEqual( - innerMap.get("y"), - 2, - "Inner Map should be updated with new entry", - ); +test('writeBack — Map nested in object (reference preserved)', () => { + const innerMap = new Map([['x', 1]]); + const original: Record = { m: innerMap }; + writeBack(original, { + m: new Map([ + ['x', 1], + ['y', 2], + ]), + }); + assert.strictEqual( + original.m, + innerMap, + 'Inner Map reference should be preserved' + ); + assert.strictEqual( + innerMap.get('y'), + 2, + 'Inner Map should be updated with new entry' + ); }); -test("writeBack — Set nested in array (reference preserved)", () => { - const innerSet = new Set([1, 2]); - const original: unknown[] = [innerSet, 42]; - writeBack(original, [new Set([1, 2, 3]), 42]); - assert.strictEqual( - original[0], - innerSet, - "Set reference inside array should be preserved", - ); - assert.strictEqual(innerSet.has(3), true, "Set should have new element"); +test('writeBack — Set nested in array (reference preserved)', () => { + const innerSet = new Set([1, 2]); + const original: unknown[] = [innerSet, 42]; + writeBack(original, [new Set([1, 2, 3]), 42]); + assert.strictEqual( + original[0], + innerSet, + 'Set reference inside array should be preserved' + ); + assert.strictEqual(innerSet.has(3), true, 'Set should have new element'); }); -test("writeBack — Date nested in array (reference preserved)", () => { - const innerDate = new Date("2026-01-01T00:00:00.000Z"); - const mutationTarget = new Date("2000-01-01T00:00:00.000Z"); - const original: unknown[] = [innerDate, 42]; - writeBack(original, [mutationTarget, 42]); - assert.strictEqual( - original[0], - innerDate, - "Date reference inside array should be preserved", - ); - assert.strictEqual( - (original[0] as Date).getTime(), - mutationTarget.getTime(), - "Date time should be updated", - ); +test('writeBack — Date nested in array (reference preserved)', () => { + const innerDate = new Date('2026-01-01T00:00:00.000Z'); + const mutationTarget = new Date('2000-01-01T00:00:00.000Z'); + const original: unknown[] = [innerDate, 42]; + writeBack(original, [mutationTarget, 42]); + assert.strictEqual( + original[0], + innerDate, + 'Date reference inside array should be preserved' + ); + assert.strictEqual( + (original[0] as Date).getTime(), + mutationTarget.getTime(), + 'Date time should be updated' + ); }); -test("writeBack — Map is not treated as plain object (mismatched with plain object is no-op)", () => { - const original = new Map([["a", 1]]); - writeBack(original, { a: 2 }); - assert.strictEqual( - original.get("a"), - 1, - "Map should be untouched when mutated is a plain object", - ); +test('writeBack — Map is not treated as plain object (mismatched with plain object is no-op)', () => { + const original = new Map([['a', 1]]); + writeBack(original, { a: 2 }); + assert.strictEqual( + original.get('a'), + 1, + 'Map should be untouched when mutated is a plain object' + ); }); -test("writeBack — Set is not treated as plain object (mismatched with array is no-op)", () => { - const original = new Set([1, 2]); - writeBack(original, [3, 4]); - assert.strictEqual( - original.has(1), - true, - "Set should be untouched when mutated is an array", - ); +test('writeBack — Set is not treated as plain object (mismatched with array is no-op)', () => { + const original = new Set([1, 2]); + writeBack(original, [3, 4]); + assert.strictEqual( + original.has(1), + true, + '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: 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 — 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 — 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 — 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 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"); +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'); }); From eef22289beeb07de9d054164ac5f4d8b2466c7ad Mon Sep 17 00:00:00 2001 From: lojhan Date: Tue, 17 Mar 2026 21:32:39 -0300 Subject: [PATCH 7/8] lint:fix --- .vscode/settings.json | 26 +- biome.jsonc | 118 +-- deno.json | 2 +- package.json | 122 +-- src/codecs/array.ts | 28 +- src/codecs/bigint.ts | 10 +- src/codecs/date.ts | 16 +- src/codecs/map.ts | 30 +- src/codecs/object.ts | 62 +- src/codecs/set.ts | 18 +- src/codecs/undefined.ts | 10 +- src/index.ts | 42 +- src/resource-registry.ts | 50 +- src/shared-resources.ts | 848 ++++++++++---------- src/types.ts | 196 ++--- test/integration/shared-resources.test.ts | 54 +- test/unit/encode-decode.test.ts | 930 +++++++++++----------- test/unit/resource-registry.test.ts | 116 +-- test/unit/send-ipc-message.test.ts | 222 +++--- test/unit/write-back.test.ts | 696 ++++++++-------- tsconfig.json | 56 +- 21 files changed, 1826 insertions(+), 1826 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 2698ff9..3ed792a 100755 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,15 +1,15 @@ { - "deno.enable": false, - "editor.trimAutoWhitespace": true, - "editor.indentSize": 2, - "editor.tabSize": 2, - "editor.formatOnSave": true, - "files.trimTrailingWhitespace": true, - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.codeActionsOnSave": { - "source.fixAll": "explicit" - }, - "[markdown]": { - "files.trimTrailingWhitespace": false - } + "deno.enable": false, + "editor.trimAutoWhitespace": true, + "editor.indentSize": 2, + "editor.tabSize": 2, + "editor.formatOnSave": true, + "files.trimTrailingWhitespace": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll": "explicit" + }, + "[markdown]": { + "files.trimTrailingWhitespace": false + } } diff --git a/biome.jsonc b/biome.jsonc index 29d7e15..423064c 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,61 +1,61 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "files": { - "include": ["**/**"], - "ignore": ["coverage", "lib", "node_modules", "test/__fixtures__"] - }, - "organizeImports": { - "enabled": false - }, - "overrides": [ - { - "include": ["src/index.ts"], - "linter": { - "rules": { - "performance": { - "noBarrelFile": "off" - } - } - } - } - ], - "linter": { - "enabled": true, - "rules": { - "all": true, - "complexity": { - "all": true - }, - "a11y": { - "all": true - }, - "correctness": { - "all": true, - "noNodejsModules": "off" - }, - "nursery": { - "all": true, - "useImportRestrictions": "off", - "noProcessEnv": "off", - "noSecrets": "off", - "useExplicitType": "off" - }, - "performance": { - "all": true - }, - "security": { - "all": true - }, - "suspicious": { - "all": true - }, - "style": { - "all": true, - "noNonNullAssertion": "off", - "useNamingConvention": "off", - "noNamespaceImport": "off", - "useBlockStatements": "off" - } - } - } + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "files": { + "include": ["**/**"], + "ignore": ["coverage", "lib", "node_modules", "test/__fixtures__"] + }, + "organizeImports": { + "enabled": false + }, + "overrides": [ + { + "include": ["src/index.ts"], + "linter": { + "rules": { + "performance": { + "noBarrelFile": "off" + } + } + } + } + ], + "linter": { + "enabled": true, + "rules": { + "all": true, + "complexity": { + "all": true + }, + "a11y": { + "all": true + }, + "correctness": { + "all": true, + "noNodejsModules": "off" + }, + "nursery": { + "all": true, + "useImportRestrictions": "off", + "noProcessEnv": "off", + "noSecrets": "off", + "useExplicitType": "off" + }, + "performance": { + "all": true + }, + "security": { + "all": true + }, + "suspicious": { + "all": true + }, + "style": { + "all": true, + "noNonNullAssertion": "off", + "useNamingConvention": "off", + "noNamespaceImport": "off", + "useBlockStatements": "off" + } + } + } } diff --git a/deno.json b/deno.json index 0449a2e..8726fcf 100644 --- a/deno.json +++ b/deno.json @@ -1,3 +1,3 @@ { - "unstable": ["sloppy-imports", "detect-cjs"] + "unstable": ["sloppy-imports", "detect-cjs"] } diff --git a/package.json b/package.json index 4dd53a5..3429a72 100644 --- a/package.json +++ b/package.json @@ -1,62 +1,64 @@ { - "name": "@pokujs/shared-resources", - "version": "1.0.0", - "private": false, - "description": "🪢 A Poku plugin for shared resources across isolated tests.", - "main": "./lib/index.js", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/pokujs/shared-resources.git" - }, - "homepage": "https://poku.io/docs/documentation/helpers/shared-resources", - "bugs": { - "url": "https://github.com/pokujs/shared-resources/issues" - }, - "author": "https://github.com/wellwelwel", - "funding": { - "type": "github", - "url": "https://github.com/pokujs/shared-resources?sponsor=1" - }, - "files": ["lib"], - "engines": { - "node": ">=16.x.x", - "bun": ">=1.x.x", - "deno": ">=2.x.x", - "typescript": ">=5.x.x" - }, - "scripts": { - "test": "poku test/integration test/unit", - "test:bun": "bun poku test/integration test/unit", - "test:deno": "deno run -A npm:poku --denoAllow=all test/integration test/unit", - "prebuild": "rm -rf lib", - "build": "tsc", - "lint": "biome lint --error-on-warnings && prettier --check .", - "lint:fix": "biome lint --write && prettier --write ." - }, - "devDependencies": { - "@biomejs/biome": "^1.9.4", - "@ianvs/prettier-plugin-sort-imports": "^4.7.0", - "@types/node": "^25.3.3", - "poku": "^3.0.3-canary.68e71482", - "prettier": "^3.8.1", - "tsx": "^4.21.0", - "typescript": "^5.9.3" - }, - "peerDependencies": { - "poku": "canary" - }, - "publishConfig": { - "access": "public" - }, - "keywords": [ - "🐷", - "poku", - "pokujs", - "testing", - "plugin", - "shared-resources", - "ipc", - "parallel" - ] + "name": "@pokujs/shared-resources", + "version": "1.0.0", + "private": false, + "description": "🪢 A Poku plugin for shared resources across isolated tests.", + "main": "./lib/index.js", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/pokujs/shared-resources.git" + }, + "homepage": "https://poku.io/docs/documentation/helpers/shared-resources", + "bugs": { + "url": "https://github.com/pokujs/shared-resources/issues" + }, + "author": "https://github.com/wellwelwel", + "funding": { + "type": "github", + "url": "https://github.com/pokujs/shared-resources?sponsor=1" + }, + "files": [ + "lib" + ], + "engines": { + "node": ">=16.x.x", + "bun": ">=1.x.x", + "deno": ">=2.x.x", + "typescript": ">=5.x.x" + }, + "scripts": { + "test": "poku test/integration test/unit", + "test:bun": "bun poku test/integration test/unit", + "test:deno": "deno run -A npm:poku --denoAllow=all test/integration test/unit", + "prebuild": "rm -rf lib", + "build": "tsc", + "lint": "biome lint --error-on-warnings && prettier --check .", + "lint:fix": "biome lint --write && prettier --write ." + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@ianvs/prettier-plugin-sort-imports": "^4.7.0", + "@types/node": "^25.3.3", + "poku": "^3.0.3-canary.68e71482", + "prettier": "^3.8.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "poku": "canary" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "🐷", + "poku", + "pokujs", + "testing", + "plugin", + "shared-resources", + "ipc", + "parallel" + ] } diff --git a/src/codecs/array.ts b/src/codecs/array.ts index 798ba45..d10407c 100644 --- a/src/codecs/array.ts +++ b/src/codecs/array.ts @@ -1,20 +1,20 @@ -import type { ArgCodec } from "../types.js"; +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); + 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]; - } + 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); + if (original.length > mutated.length) original.splice(mutated.length); - for (let i = original.length; i < mutated.length; i++) - original.push(mutated[i]); - }, + 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 index bc5be2c..e8db52f 100644 --- a/src/codecs/bigint.ts +++ b/src/codecs/bigint.ts @@ -1,8 +1,8 @@ -import type { ArgCodec } from "../types.js"; +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), + 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 index 8d43a4c..3e6792c 100644 --- a/src/codecs/date.ts +++ b/src/codecs/date.ts @@ -1,11 +1,11 @@ -import type { ArgCodec } from "../types.js"; +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()); - }, + 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 index ac5f6f1..3484a9c 100644 --- a/src/codecs/map.ts +++ b/src/codecs/map.ts @@ -1,18 +1,18 @@ -import type { ArgCodec } from "../types.js"; +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); - }, + 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 index 87159a0..d63ea8e 100644 --- a/src/codecs/object.ts +++ b/src/codecs/object.ts @@ -1,37 +1,37 @@ -import type { ArgCodec } from "../types.js"; +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; + 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]; - } - }, + 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 index 449c26a..b6f0530 100644 --- a/src/codecs/set.ts +++ b/src/codecs/set.ts @@ -1,12 +1,12 @@ -import type { ArgCodec } from "../types.js"; +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); - }, + 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 index aefd9a0..e7e94e8 100644 --- a/src/codecs/undefined.ts +++ b/src/codecs/undefined.ts @@ -1,8 +1,8 @@ -import type { ArgCodec } from "../types.js"; +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, + 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 4a8aaf7..e806f3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,27 +1,27 @@ -import type { PokuPlugin } from "poku/plugins"; -import type { SharedResourcesConfig } from "./types.js"; +import type { PokuPlugin } from 'poku/plugins'; +import type { SharedResourcesConfig } from './types.js'; import { - configureCodecs, - globalRegistry, - setupSharedResourceIPC, -} from "./shared-resources.js"; + configureCodecs, + globalRegistry, + setupSharedResourceIPC, +} from './shared-resources.js'; export const sharedResources = (config?: SharedResourcesConfig): PokuPlugin => { - if (config?.codecs?.length > 0) configureCodecs(config.codecs); - return { - name: "shared-resources", - ipc: true, - onTestProcess(child) { - setupSharedResourceIPC(child); - }, - async teardown() { - const entries = Object.values(globalRegistry); + if (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"; +export { resource } from './shared-resources.js'; +export type { ArgCodec, SharedResourcesConfig } from './types.js'; diff --git a/src/resource-registry.ts b/src/resource-registry.ts index d78a951..4b55fa4 100644 --- a/src/resource-registry.ts +++ b/src/resource-registry.ts @@ -1,34 +1,34 @@ export class ResourceRegistry { - private registry: Record = Object.create(null); - private isRegistering = false; + private registry: Record = Object.create(null); + private isRegistering = false; - register(name: string, entry: T) { - this.registry[name] = entry; - } + register(name: string, entry: T) { + this.registry[name] = entry; + } - get(name: string) { - return this.registry[name]; - } + get(name: string) { + return this.registry[name]; + } - has(name: string) { - return name in this.registry; - } + has(name: string) { + return name in this.registry; + } - clear() { - for (const key in this.registry) - if (Object.prototype.hasOwnProperty.call(this.registry, key)) - delete this.registry[key]; - } + clear() { + for (const key in this.registry) + if (Object.prototype.hasOwnProperty.call(this.registry, key)) + delete this.registry[key]; + } - getIsRegistering() { - return this.isRegistering; - } + getIsRegistering() { + return this.isRegistering; + } - setIsRegistering(value: boolean) { - this.isRegistering = value; - } + setIsRegistering(value: boolean) { + this.isRegistering = value; + } - getRegistry() { - return this.registry; - } + getRegistry() { + return this.registry; + } } diff --git a/src/shared-resources.ts b/src/shared-resources.ts index 6fcc9fb..038836e 100644 --- a/src/shared-resources.ts +++ b/src/shared-resources.ts @@ -1,232 +1,232 @@ -import type { ChildProcess } from "node:child_process"; +import type { ChildProcess } from 'node:child_process'; import type { - ArgCodec, - IPCEventEmitter, - IPCMessage, - IPCRemoteProcedureCallMessage, - IPCRemoteProcedureCallResultMessage, - IPCRequestResourceMessage, - IPCResourceResultMessage, - IPCResponse, - MethodsToRPC, - ResourceContext, - SendIPCMessageOptions, - SharedResourceEntry, -} from "./types.js"; -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"; + ArgCodec, + IPCEventEmitter, + IPCMessage, + IPCRemoteProcedureCallMessage, + IPCRemoteProcedureCallResultMessage, + IPCRequestResourceMessage, + IPCResourceResultMessage, + IPCResponse, + MethodsToRPC, + ResourceContext, + SendIPCMessageOptions, + SharedResourceEntry, +} from './types.js'; +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'; const resourceRegistry = new ResourceRegistry(); const moduleCounters = new Map(); export const SHARED_RESOURCE_MESSAGE_TYPES = { - REQUEST_RESOURCE: "shared_resources_requestResource", - RESOURCE_RESULT: "shared_resources_resourceResult", - REMOTE_PROCEDURE_CALL: "shared_resources_remoteProcedureCall", - REMOTE_PROCEDURE_CALL_RESULT: "shared_resources_remoteProcedureCallResult", + REQUEST_RESOURCE: 'shared_resources_requestResource', + RESOURCE_RESULT: 'shared_resources_resourceResult', + REMOTE_PROCEDURE_CALL: 'shared_resources_remoteProcedureCall', + REMOTE_PROCEDURE_CALL_RESULT: 'shared_resources_remoteProcedureCallResult', } as const; export const globalRegistry = resourceRegistry.getRegistry(); const create = ( - factory: () => T, - options?: { - module?: string; - onDestroy?: (instance: Awaited) => void | Promise; - }, + factory: () => T, + options?: { + module?: string; + onDestroy?: (instance: Awaited) => void | Promise; + } ): ResourceContext> => { - let module: string; - - if (options?.module) module = options.module; - else { - const err = { stack: "" }; - Error.captureStackTrace(err, create); - module = findFileFromStack(err.stack); - } - - const count = (moduleCounters.get(module) ?? 0) + 1; - moduleCounters.set(module, count); - const name = count === 1 ? module : `${module}#${count}`; - - return { - factory, - onDestroy: options?.onDestroy, - name, - module, - } as ResourceContext>; + let module: string; + + if (options?.module) module = options.module; + else { + const err = { stack: '' }; + Error.captureStackTrace(err, create); + module = findFileFromStack(err.stack); + } + + const count = (moduleCounters.get(module) ?? 0) + 1; + moduleCounters.set(module, count); + const name = count === 1 ? module : `${module}#${count}`; + + return { + factory, + onDestroy: options?.onDestroy, + name, + module, + } as ResourceContext>; }; const use = async ( - context: ResourceContext, + context: ResourceContext ): Promise> => { - const { name } = context; - - // Parent Process (Host) - if (!process.send || resourceRegistry.getIsRegistering()) { - const existing = resourceRegistry.get(name); - if (existing) { - return existing.state as MethodsToRPC; - } - - const state = await context.factory(); - resourceRegistry.register(name, { - state, - onDestroy: context.onDestroy as - | ((instance: unknown) => void | Promise) - | undefined, - }); - - return state as MethodsToRPC; - } - - if (!context.module) - throw new Error( - `Resource "${name}" is missing "module". Use createResource() or set module explicitly.`, - ); - - return requestResource(name, context.module) as unknown as MethodsToRPC; + const { name } = context; + + // Parent Process (Host) + if (!process.send || resourceRegistry.getIsRegistering()) { + const existing = resourceRegistry.get(name); + if (existing) { + return existing.state as MethodsToRPC; + } + + const state = await context.factory(); + resourceRegistry.register(name, { + state, + onDestroy: context.onDestroy as + | ((instance: unknown) => void | Promise) + | undefined, + }); + + return state as MethodsToRPC; + } + + if (!context.module) + throw new Error( + `Resource "${name}" is missing "module". Use createResource() or set module explicitly.` + ); + + return requestResource(name, context.module) as unknown as MethodsToRPC; }; export const sendIPCMessage = ( - options: SendIPCMessageOptions, + options: SendIPCMessageOptions ): Promise => { - const { - message, - validator, - timeout, - emitter = process, - sender = process.send?.bind(process), - } = options; - - return new Promise((resolve, reject) => { - if (!sender) { - reject(new Error("IPC sender is not available")); - return; - } - - let timer: NodeJS.Timeout | undefined; - - const handleMessage = (response: unknown) => { - if (validator(response)) { - cleanup(); - resolve(response); - } - }; - - const cleanup = () => { - if (timer) clearTimeout(timer); - emitter.off("message", handleMessage); - }; - - if (typeof timeout === "number" && timeout > 0) { - timer = setTimeout(() => { - cleanup(); - reject(new Error(`IPC request timed out after ${timeout}ms`)); - }, timeout); - } - - emitter.on("message", handleMessage); - - try { - sender(message); - } catch (error) { - cleanup(); - reject(error); - } - }); + const { + message, + validator, + timeout, + emitter = process, + sender = process.send?.bind(process), + } = options; + + return new Promise((resolve, reject) => { + if (!sender) { + reject(new Error('IPC sender is not available')); + return; + } + + let timer: NodeJS.Timeout | undefined; + + const handleMessage = (response: unknown) => { + if (validator(response)) { + cleanup(); + resolve(response); + } + }; + + const cleanup = () => { + if (timer) clearTimeout(timer); + emitter.off('message', handleMessage); + }; + + if (typeof timeout === 'number' && timeout > 0) { + timer = setTimeout(() => { + cleanup(); + reject(new Error(`IPC request timed out after ${timeout}ms`)); + }, timeout); + } + + emitter.on('message', handleMessage); + + try { + sender(message); + } catch (error) { + cleanup(); + reject(error); + } + }); }; const requestResource = async (name: string, module: string) => { - const requestId = `${name}-${Date.now()}-${Math.random()}`; - - const response = await sendIPCMessage({ - message: { - type: SHARED_RESOURCE_MESSAGE_TYPES.REQUEST_RESOURCE, - name, - module, - id: requestId, - }, - validator: (message): message is IPCResourceResultMessage => - typeof message === "object" && - message !== null && - "type" in message && - message.type === SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT && - "id" in message && - message.id === requestId, - }); - - if (response.error || !response.value || !response.rpcs) - throw new Error( - response.error ?? `Invalid response for resource "${name}"`, - ); - - return constructSharedResourceWithRPCs( - response.value as Record, - response.rpcs, - name, - ); + const requestId = `${name}-${Date.now()}-${Math.random()}`; + + const response = await sendIPCMessage({ + message: { + type: SHARED_RESOURCE_MESSAGE_TYPES.REQUEST_RESOURCE, + name, + module, + id: requestId, + }, + validator: (message): message is IPCResourceResultMessage => + typeof message === 'object' && + message !== null && + 'type' in message && + message.type === SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT && + 'id' in message && + message.id === requestId, + }); + + if (response.error || !response.value || !response.rpcs) + throw new Error( + response.error ?? `Invalid response for resource "${name}"` + ); + + return constructSharedResourceWithRPCs( + response.value as Record, + response.rpcs, + name + ); }; const remoteProcedureCall = async ( - name: string, - method: string, - args: unknown[], + name: string, + method: string, + args: unknown[] ) => { - const requestId = `${name}-${method}-${Date.now()}-${Math.random()}`; - - const response = await sendIPCMessage({ - message: { - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL, - name, - method, - args: args.map(encodeArg), - id: requestId, - } satisfies IPCRemoteProcedureCallMessage, - validator: (message): message is IPCRemoteProcedureCallResultMessage => - typeof message === "object" && - message !== null && - "id" in message && - message.id === requestId, - }); - - if (response.error || !response.value) - throw new Error( - response.error ?? `Invalid RPC response for "${name}.${method}"`, - ); - - return response.value; + const requestId = `${name}-${method}-${Date.now()}-${Math.random()}`; + + const response = await sendIPCMessage({ + message: { + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL, + name, + method, + args: args.map(encodeArg), + id: requestId, + } satisfies IPCRemoteProcedureCallMessage, + validator: (message): message is IPCRemoteProcedureCallResultMessage => + typeof message === 'object' && + message !== null && + 'id' in message && + message.id === requestId, + }); + + if (response.error || !response.value) + throw new Error( + response.error ?? `Invalid RPC response for "${name}.${method}"` + ); + + return response.value; }; -const ENC_TAG = "__sr_enc"; +const ENC_TAG = '__sr_enc'; // 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; + 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, + undefinedCodec, + bigIntCodec, + dateCodec, + mapCodec, + setCodec, + arrayCodec, + objectCodec, ]; /** @@ -236,283 +236,281 @@ let argCodecs: ArgCodec[] = [ */ // 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 incoming = new Map(codecs.map((c) => [c.tag, c])); + argCodecs = [...codecs, ...argCodecs.filter((c) => !incoming.has(c.tag))]; }; export const encodeArg = (v: unknown): unknown => { - 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]: "c", - t: tagToWire(objectCodec.tag), - v: objectCodec.encode(v as Record, encodeArg), - }; - return v; + 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]: 'c', + t: tagToWire(objectCodec.tag), + v: objectCodec.encode(v as Record, encodeArg), + }; + return v; }; const decodeEncoded = (enc: Record): 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.`, - ); - return codec.decode(enc.v, decodeArg); + 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.` + ); + return codec.decode(enc.v, decodeArg); }; export const decodeArg = (v: unknown): unknown => { - if (isPlainObject(v)) return ENC_TAG in v ? decodeEncoded(v) : v; - return v; + if (isPlainObject(v)) return ENC_TAG in v ? decodeEncoded(v) : v; + return v; }; const tryReconcileInPlace = (original: unknown, mutated: unknown): boolean => { - for (const codec of argCodecs) { - if (codec.writeBack && codec.is(original) && codec.is(mutated)) { - codec.writeBack(original, mutated, tryReconcileInPlace); - return true; - } - } - // 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; + for (const codec of argCodecs) { + if (codec.writeBack && codec.is(original) && codec.is(mutated)) { + codec.writeBack(original, mutated, tryReconcileInPlace); + return true; + } + } + // 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; }; export const writeBack = (original: unknown, mutated: unknown): void => { - tryReconcileInPlace(original, mutated); + tryReconcileInPlace(original, mutated); }; export const extractFunctionNames = (obj: Record) => { - const seen = new Set(); - let current = obj; + const seen = new Set(); + let current = obj; - while ( - current !== Object.prototype && - Object.getPrototypeOf(current) !== null - ) { - for (const key of Object.getOwnPropertyNames(current)) { - if (typeof obj[key] !== "function" || key === "constructor") continue; + while ( + current !== Object.prototype && + Object.getPrototypeOf(current) !== null + ) { + for (const key of Object.getOwnPropertyNames(current)) { + if (typeof obj[key] !== 'function' || key === 'constructor') continue; - seen.add(key); - } + seen.add(key); + } - current = Object.getPrototypeOf(current); - } + current = Object.getPrototypeOf(current); + } - return Array.from(seen); + return Array.from(seen); }; export const setupSharedResourceIPC = ( - child: IPCEventEmitter | ChildProcess, - registry: Record = globalRegistry, + child: IPCEventEmitter | ChildProcess, + registry: Record = globalRegistry ): void => { - child.on("message", async (message: IPCMessage) => { - if (message.type === SHARED_RESOURCE_MESSAGE_TYPES.REQUEST_RESOURCE) - await handleRequestResource(message, registry, child); - else if ( - message.type === SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL - ) - await handleRemoteProcedureCall(message, registry, child); - }); + child.on('message', async (message: IPCMessage) => { + if (message.type === SHARED_RESOURCE_MESSAGE_TYPES.REQUEST_RESOURCE) + await handleRequestResource(message, registry, child); + else if ( + message.type === SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL + ) + await handleRemoteProcedureCall(message, registry, child); + }); }; const loadModuleResources = async (module: string) => { - resourceRegistry.setIsRegistering(true); - - try { - const modulePath = isWindows ? pathToFileURL(module).href : module; - const mod: Record = await import(modulePath); - - for (const key in mod) { - if (!Object.prototype.hasOwnProperty.call(mod, key)) continue; - - const exported = mod[key]; - - if ( - exported && - typeof exported === "object" && - "factory" in exported && - typeof exported.factory === "function" - ) - await use(exported as ResourceContext); - } - } finally { - resourceRegistry.setIsRegistering(false); - } + resourceRegistry.setIsRegistering(true); + + try { + const modulePath = isWindows ? pathToFileURL(module).href : module; + const mod: Record = await import(modulePath); + + for (const key in mod) { + if (!Object.prototype.hasOwnProperty.call(mod, key)) continue; + + const exported = mod[key]; + + if ( + exported && + typeof exported === 'object' && + 'factory' in exported && + typeof exported.factory === 'function' + ) + await use(exported as ResourceContext); + } + } finally { + resourceRegistry.setIsRegistering(false); + } }; export const handleRequestResource = async ( - message: IPCRequestResourceMessage, - registry: Record, - child: IPCEventEmitter | ChildProcess, + message: IPCRequestResourceMessage, + registry: Record, + child: IPCEventEmitter | ChildProcess ) => { - try { - if (!registry[message.name]) await loadModuleResources(message.module); - - const entry = registry[message.name]; - if (!entry) { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, - name: message.name, - id: message.id, - error: `Resource "${message.name}" not found in module "${message.module}"`, - } satisfies IPCResponse); - return; - } - - const rpcs = extractFunctionNames(entry.state as Record); - - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, - name: message.name, - value: entry.state, - rpcs, - id: message.id, - } satisfies IPCResourceResultMessage); - } catch (error) { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, - name: message.name, - id: message.id, - error: error instanceof Error ? error.message : String(error), - } satisfies IPCResponse); - } + try { + if (!registry[message.name]) await loadModuleResources(message.module); + + const entry = registry[message.name]; + if (!entry) { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, + name: message.name, + id: message.id, + error: `Resource "${message.name}" not found in module "${message.module}"`, + } satisfies IPCResponse); + return; + } + + const rpcs = extractFunctionNames(entry.state as Record); + + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, + name: message.name, + value: entry.state, + rpcs, + id: message.id, + } satisfies IPCResourceResultMessage); + } catch (error) { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT, + name: message.name, + id: message.id, + error: error instanceof Error ? error.message : String(error), + } satisfies IPCResponse); + } }; export const handleRemoteProcedureCall = async ( - message: IPCRemoteProcedureCallMessage, - registry: Record, - child: IPCEventEmitter | ChildProcess, + message: IPCRemoteProcedureCallMessage, + registry: Record, + child: IPCEventEmitter | ChildProcess ) => { - const entry = registry[message.name]; - if (!entry) { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, - id: message.id, - error: `Resource "${message.name}" not found`, - } satisfies IPCResponse); - return; - } - - const state = entry.state as Record; - - if (!message.method) { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, - id: message.id, - error: "Method name is missing", - } satisfies IPCResponse); - return; - } - - const methodCandidate = state[message.method]; - if (typeof methodCandidate !== "function") { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, - id: message.id, - error: `Method "${message.method}" not found on resource "${message.name}"`, - } satisfies IPCResponse); - return; - } - - try { - const method = methodCandidate.bind(entry.state); - const callArgs = (message.args || []).map(decodeArg); - const result = await method(...callArgs); - - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, - id: message.id, - value: { - result, - latest: state, - mutatedArgs: callArgs.map(encodeArg), - }, - } satisfies IPCResponse); - } catch (error) { - child.send({ - type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, - id: message.id, - error: error instanceof Error ? error.message : String(error), - } satisfies IPCResponse); - } + const entry = registry[message.name]; + if (!entry) { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, + id: message.id, + error: `Resource "${message.name}" not found`, + } satisfies IPCResponse); + return; + } + + const state = entry.state as Record; + + if (!message.method) { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, + id: message.id, + error: 'Method name is missing', + } satisfies IPCResponse); + return; + } + + const methodCandidate = state[message.method]; + if (typeof methodCandidate !== 'function') { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, + id: message.id, + error: `Method "${message.method}" not found on resource "${message.name}"`, + } satisfies IPCResponse); + return; + } + + try { + const method = methodCandidate.bind(entry.state); + const callArgs = (message.args || []).map(decodeArg); + const result = await method(...callArgs); + + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, + id: message.id, + value: { + result, + latest: state, + mutatedArgs: callArgs.map(encodeArg), + }, + } satisfies IPCResponse); + } catch (error) { + child.send({ + type: SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT, + id: message.id, + error: error instanceof Error ? error.message : String(error), + } satisfies IPCResponse); + } }; const constructSharedResourceWithRPCs = ( - target: Record, - rpcs: string[], - name: string, + target: Record, + rpcs: string[], + name: string ) => { - if (rpcs.length === 0) return target; - - return new Proxy(target, { - get(target, prop, receiver) { - if (typeof prop === "string" && rpcs.includes(prop)) { - return async (...args: unknown[]) => { - const rpcResult = await remoteProcedureCall(name, prop, args); - const decodedMutatedArgs = rpcResult.mutatedArgs.map(decodeArg); - - for (let i = 0; i < args.length; i++) - writeBack(args[i], decodedMutatedArgs[i]); - - for (const rpcKey of rpcs) { - if (rpcKey in rpcResult.latest) { - delete rpcResult.latest[rpcKey]; - } - } - - Object.assign(target, rpcResult.latest); - return rpcResult.result; - }; - } - - return Reflect.get(target, prop, receiver); - }, - }); + if (rpcs.length === 0) return target; + + return new Proxy(target, { + get(target, prop, receiver) { + if (typeof prop === 'string' && rpcs.includes(prop)) { + return async (...args: unknown[]) => { + const rpcResult = await remoteProcedureCall(name, prop, args); + const decodedMutatedArgs = rpcResult.mutatedArgs.map(decodeArg); + + for (let i = 0; i < args.length; i++) + writeBack(args[i], decodedMutatedArgs[i]); + + for (const rpcKey of rpcs) { + if (rpcKey in rpcResult.latest) { + delete rpcResult.latest[rpcKey]; + } + } + + Object.assign(target, rpcResult.latest); + return rpcResult.result; + }; + } + + return Reflect.get(target, prop, receiver); + }, + }); }; 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?.length > 0) configureCodecs(config.codecs); - }, + 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?.length > 0) configureCodecs(config.codecs); + }, } as const; diff --git a/src/types.ts b/src/types.ts index f6ac08c..fa4dd63 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,81 +1,81 @@ -import type { ChildProcess, ChildProcessEventMap } from "node:child_process"; -import type EventEmitter from "node:events"; -import type { InternalEventEmitter } from "node:events"; -import type { SHARED_RESOURCE_MESSAGE_TYPES } from "./shared-resources.js"; +import type { ChildProcess, ChildProcessEventMap } from 'node:child_process'; +import type EventEmitter from 'node:events'; +import type { InternalEventEmitter } from 'node:events'; +import type { SHARED_RESOURCE_MESSAGE_TYPES } from './shared-resources.js'; export type IPCEventEmitter = InternalEventEmitter & { - send: (message: unknown, ...args: unknown[]) => boolean; + send: (message: unknown, ...args: unknown[]) => boolean; }; export type ResourceContext = { - name: string; - module?: string; - factory: () => T | Promise; - onDestroy?: (instance: T) => void | Promise; + name: string; + module?: string; + factory: () => T | Promise; + onDestroy?: (instance: T) => void | Promise; }; export type SharedResourceEntry = { - state: T; - onDestroy?: (instance: T) => void | Promise; + state: T; + onDestroy?: (instance: T) => void | Promise; }; export type IPCRequestResourceMessage = { - type: typeof SHARED_RESOURCE_MESSAGE_TYPES.REQUEST_RESOURCE; - name: string; - module: string; - id: string; + type: typeof SHARED_RESOURCE_MESSAGE_TYPES.REQUEST_RESOURCE; + name: string; + module: string; + id: string; }; export type IPCRemoteProcedureCallMessage = { - type: typeof SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL; - name: string; - id: string; - method: string; - args: unknown[]; + type: typeof SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL; + name: string; + id: string; + method: string; + args: unknown[]; }; export type IPCMessage = - | IPCRequestResourceMessage - | IPCRemoteProcedureCallMessage; + | IPCRequestResourceMessage + | IPCRemoteProcedureCallMessage; export type SendIPCMessageOptions = { - message: { id: string; [key: string]: unknown }; - validator: (response: unknown) => response is TResponse; - timeout?: number; - emitter?: EventEmitter | IPCEventEmitter | ChildProcess; - sender?: (message: unknown) => void; + message: { id: string; [key: string]: unknown }; + validator: (response: unknown) => response is TResponse; + timeout?: number; + emitter?: EventEmitter | IPCEventEmitter | ChildProcess; + sender?: (message: unknown) => void; }; export type IPCResourceResultMessage = { - type: typeof SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT; - name: string; - id: string; - value?: unknown; - rpcs?: string[]; - error?: string; + type: typeof SHARED_RESOURCE_MESSAGE_TYPES.RESOURCE_RESULT; + name: string; + id: string; + value?: unknown; + rpcs?: string[]; + error?: string; }; export type IPCRemoteProcedureCallResultMessage = { - type: typeof SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT; - id: string; - value?: { - result: unknown; - latest: Record; - mutatedArgs: unknown[]; - }; - error?: string; + type: typeof SHARED_RESOURCE_MESSAGE_TYPES.REMOTE_PROCEDURE_CALL_RESULT; + id: string; + value?: { + result: unknown; + latest: Record; + mutatedArgs: unknown[]; + }; + error?: string; }; export type IPCResponse = - | IPCResourceResultMessage - | IPCRemoteProcedureCallResultMessage; + | IPCResourceResultMessage + | IPCRemoteProcedureCallResultMessage; export type MethodsToRPC = { - [K in keyof T]: T[K] extends (...args: infer A) => infer R - ? R extends Promise - ? T[K] - : (...args: A) => Promise> - : T[K]; + [K in keyof T]: T[K] extends (...args: infer A) => infer R + ? R extends Promise + ? T[K] + : (...args: A) => Promise> + : T[K]; }; /** @@ -88,57 +88,57 @@ export type MethodsToRPC = { */ // 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; + /** + * 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[]; + /** + * 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/integration/shared-resources.test.ts b/test/integration/shared-resources.test.ts index d963729..a1c350d 100644 --- a/test/integration/shared-resources.test.ts +++ b/test/integration/shared-resources.test.ts @@ -1,34 +1,34 @@ -import { assert, describe, it, poku } from "poku"; -import { inspectPoku } from "poku/plugins"; -import { sharedResources } from "../../src/index.js"; +import { assert, describe, it, poku } from 'poku'; +import { inspectPoku } from 'poku/plugins'; +import { sharedResources } from '../../src/index.js'; -describe("Shared Resources", async () => { - await it("Parallel tests", async () => { - const code = await poku("test/__fixtures__/parallel", { - noExit: true, - plugins: [sharedResources()], - concurrency: 0, - }); +describe('Shared Resources', async () => { + await it('Parallel tests', async () => { + const code = await poku('test/__fixtures__/parallel', { + noExit: true, + plugins: [sharedResources()], + concurrency: 0, + }); - assert.strictEqual(code, 0, "Exit Code needs to be 0"); - }); + assert.strictEqual(code, 0, 'Exit Code needs to be 0'); + }); - await it("Error tests", async () => { - const { exitCode } = await inspectPoku({ - command: - '--config="test/__fixtures__/error/poku.config.ts" test/__fixtures__/error', - }); + await it('Error tests', async () => { + const { exitCode } = await inspectPoku({ + command: + '--config="test/__fixtures__/error/poku.config.ts" test/__fixtures__/error', + }); - assert.strictEqual(exitCode, 1, "Exit Code needs to be 1"); - }); + assert.strictEqual(exitCode, 1, 'Exit Code needs to be 1'); + }); - await it("Reference tests", async () => { - const code = await poku("test/__fixtures__/references", { - noExit: true, - plugins: [sharedResources()], - concurrency: 0, - }); + await it('Reference tests', async () => { + const code = await poku('test/__fixtures__/references', { + noExit: true, + plugins: [sharedResources()], + concurrency: 0, + }); - assert.strictEqual(code, 0, "Exit Code needs to be 0"); - }); + assert.strictEqual(code, 0, 'Exit Code needs to be 0'); + }); }); diff --git a/test/unit/encode-decode.test.ts b/test/unit/encode-decode.test.ts index 6d576db..5b20038 100644 --- a/test/unit/encode-decode.test.ts +++ b/test/unit/encode-decode.test.ts @@ -1,483 +1,483 @@ -import type { ArgCodec } from "../../src/types.js"; -import { assert, test } from "poku"; +import type { ArgCodec } from '../../src/types.js'; +import { assert, test } from 'poku'; import { - configureCodecs, - decodeArg, - encodeArg, -} from "../../src/shared-resources.js"; + configureCodecs, + decodeArg, + encodeArg, +} from '../../src/shared-resources.js'; const roundtrip = (v: unknown) => decodeArg(encodeArg(v)); -test("encodeArg/decodeArg — undefined", () => { - assert.strictEqual( - roundtrip(undefined), - undefined, - "undefined survives roundtrip", - ); -}); - -test("encodeArg/decodeArg — null", () => { - assert.strictEqual(roundtrip(null), null, "null survives roundtrip"); -}); - -test("encodeArg/decodeArg — number", () => { - assert.strictEqual(roundtrip(42), 42, "number survives roundtrip"); -}); - -test("encodeArg/decodeArg — string", () => { - assert.strictEqual(roundtrip("hello"), "hello", "string survives roundtrip"); -}); - -test("encodeArg/decodeArg — boolean", () => { - assert.strictEqual(roundtrip(true), true, "boolean survives roundtrip"); -}); - -test("encodeArg/decodeArg — Date", () => { - const d = new Date("2026-03-17T12:00:00.000Z"); - const result = roundtrip(d); - assert.ok(result instanceof Date, "Should be a Date instance"); - assert.strictEqual( - (result as Date).getTime(), - d.getTime(), - "Date time preserved", - ); -}); - -test("encodeArg/decodeArg — Map", () => { - const m = new Map([ - ["a", 1], - ["b", 2], - ]); - const result = roundtrip(m) as Map; - assert.ok(result instanceof Map, "Should be a Map instance"); - assert.strictEqual(result.get("a"), 1, "Map entry a preserved"); - assert.strictEqual(result.get("b"), 2, "Map entry b preserved"); - assert.strictEqual(result.size, 2, "Map size preserved"); -}); - -test("encodeArg/decodeArg — Set", () => { - const s = new Set([1, 2, 3]); - const result = roundtrip(s) as Set; - assert.ok(result instanceof Set, "Should be a Set instance"); - assert.strictEqual(result.has(1), true, "Set element 1 preserved"); - assert.strictEqual(result.has(3), true, "Set element 3 preserved"); - assert.strictEqual(result.size, 3, "Set size preserved"); -}); - -test("encodeArg/decodeArg — array with undefined", () => { - const arr = [1, undefined, 3]; - const result = roundtrip(arr) as unknown[]; - assert.strictEqual(result[0], 1, "First element preserved"); - assert.strictEqual(result[1], undefined, "undefined element preserved"); - assert.strictEqual(result[2], 3, "Third element preserved"); - assert.strictEqual(result.length, 3, "Array length preserved"); -}); - -test("encodeArg/decodeArg — object with undefined value", () => { - const obj = { a: undefined as unknown, b: 1 }; - const result = roundtrip(obj) as Record; - assert.strictEqual( - "a" in result, - true, - "Key with undefined value should exist", - ); - assert.strictEqual(result.a, undefined, "undefined value preserved"); - assert.strictEqual(result.b, 1, "Other values preserved"); -}); - -test("encodeArg/decodeArg — nested Map inside object", () => { - const m = new Map([["x", 10]]); - const obj = { m, count: 1 }; - const result = roundtrip(obj) as { m: Map; count: number }; - assert.ok(result.m instanceof Map, "Nested Map is a Map instance"); - assert.strictEqual(result.m.get("x"), 10, "Nested Map entry preserved"); - assert.strictEqual(result.count, 1, "Other object property preserved"); -}); - -test("encodeArg/decodeArg — nested Set inside array", () => { - const s = new Set([7, 8]); - const arr = [s, 42]; - const result = roundtrip(arr) as unknown[]; - assert.ok(result[0] instanceof Set, "Nested Set is a Set instance"); - assert.strictEqual( - (result[0] as Set).has(7), - true, - "Set element 7 preserved", - ); - assert.strictEqual(result[1], 42, "Other array element preserved"); -}); - -test("encodeArg/decodeArg — nested Date inside array", () => { - const d = new Date("2026-01-01T00:00:00.000Z"); - const arr = [d, 42]; - const result = roundtrip(arr) as unknown[]; - assert.ok(result[0] instanceof Date, "Nested Date is a Date instance"); - assert.strictEqual( - (result[0] as Date).getTime(), - d.getTime(), - "Date value preserved", - ); - assert.strictEqual(result[1], 42, "Other array element preserved"); -}); - -test("encodeArg/decodeArg — Map with object keys", () => { - const key = { id: 1 }; - const m = new Map([[key, "value"]]); - const result = roundtrip(m) as Map, string>; - assert.strictEqual(result.size, 1, "Map size preserved"); - const [[rk, rv]] = result.entries(); - assert.deepStrictEqual(rk, key, "Map object key preserved by value"); - assert.strictEqual(rv, "value", "Map value preserved"); -}); - -test("encodeArg/decodeArg — Map with undefined value", () => { - const m = new Map([["a", undefined]]); - const result = roundtrip(m) as Map; - assert.strictEqual(result.has("a"), true, "Key with undefined value present"); - assert.strictEqual( - result.get("a"), - undefined, - "undefined Map value preserved", - ); -}); - -test("encodeArg/decodeArg — Set with undefined", () => { - const s = new Set([1, undefined, 3]); - const result = roundtrip(s) as Set; - assert.strictEqual( - result.has(undefined), - true, - "undefined Set element preserved", - ); - assert.strictEqual(result.size, 3, "Set size preserved"); -}); - -test("encodeArg/decodeArg — plain object passthrough", () => { - const obj = { a: 1, b: "hello", c: true }; - const result = roundtrip(obj); - assert.deepStrictEqual(result, obj, "Plain object survives roundtrip"); -}); - -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"); - assert.strictEqual(result.other, 42, "Other key preserved"); -}); - -test("encodeArg/decodeArg — BigInt", () => { - assert.strictEqual(roundtrip(42n), 42n, "BigInt survives roundtrip"); - assert.strictEqual(roundtrip(0n), 0n, "BigInt 0 survives roundtrip"); - assert.strictEqual( - roundtrip(-99n), - -99n, - "Negative BigInt survives roundtrip", - ); -}); - -test("encodeArg/decodeArg — BigInt in array", () => { - const result = roundtrip([1n, 2n, 3n]) as bigint[]; - assert.deepStrictEqual( - result, - [1n, 2n, 3n], - "Array of BigInts survives roundtrip", - ); -}); - -test("encodeArg/decodeArg — BigInt in object", () => { - const result = roundtrip({ a: 1n, b: 2 }) as { a: bigint; b: number }; - assert.strictEqual(result.a, 1n, "BigInt property survives roundtrip"); - assert.strictEqual(result.b, 2, "Non-BigInt property preserved"); -}); - -test("encodeArg/decodeArg — BigInt as Map key and value", () => { - const m = new Map([ - [1n, 100n], - [2n, 200n], - ]); - const result = roundtrip(m) as Map; - assert.ok(result instanceof Map, "Should be a Map instance"); - assert.strictEqual(result.get(1n), 100n, "BigInt key and value preserved"); - assert.strictEqual(result.get(2n), 200n, "Second BigInt entry preserved"); -}); - -test("encodeArg/decodeArg — BigInt in Set", () => { - const s = new Set([1n, 2n, 3n]); - const result = roundtrip(s) as Set; - assert.ok(result instanceof Set, "Should be a Set instance"); - assert.strictEqual(result.has(1n), true, "BigInt Set element preserved"); - assert.strictEqual(result.has(3n), true, "BigInt Set element preserved"); -}); - -test("encodeArg/decodeArg — deeply nested special types", () => { - const d = new Date("2026-06-01T00:00:00.000Z"); - const m = new Map([["created", d]]); - const obj = { data: m, tags: new Set(["a", "b"]) }; - const result = roundtrip(obj) as { - data: Map; - tags: Set; - }; - assert.ok(result.data instanceof Map, "Nested Map preserved"); - assert.ok( - result.data.get("created") instanceof Date, - "Map-nested Date preserved", - ); - assert.strictEqual( - result.data.get("created")?.getTime(), - d.getTime(), - "Date value correct", - ); - 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", - ); +test('encodeArg/decodeArg — undefined', () => { + assert.strictEqual( + roundtrip(undefined), + undefined, + 'undefined survives roundtrip' + ); +}); + +test('encodeArg/decodeArg — null', () => { + assert.strictEqual(roundtrip(null), null, 'null survives roundtrip'); +}); + +test('encodeArg/decodeArg — number', () => { + assert.strictEqual(roundtrip(42), 42, 'number survives roundtrip'); +}); + +test('encodeArg/decodeArg — string', () => { + assert.strictEqual(roundtrip('hello'), 'hello', 'string survives roundtrip'); +}); + +test('encodeArg/decodeArg — boolean', () => { + assert.strictEqual(roundtrip(true), true, 'boolean survives roundtrip'); +}); + +test('encodeArg/decodeArg — Date', () => { + const d = new Date('2026-03-17T12:00:00.000Z'); + const result = roundtrip(d); + assert.ok(result instanceof Date, 'Should be a Date instance'); + assert.strictEqual( + (result as Date).getTime(), + d.getTime(), + 'Date time preserved' + ); +}); + +test('encodeArg/decodeArg — Map', () => { + const m = new Map([ + ['a', 1], + ['b', 2], + ]); + const result = roundtrip(m) as Map; + assert.ok(result instanceof Map, 'Should be a Map instance'); + assert.strictEqual(result.get('a'), 1, 'Map entry a preserved'); + assert.strictEqual(result.get('b'), 2, 'Map entry b preserved'); + assert.strictEqual(result.size, 2, 'Map size preserved'); +}); + +test('encodeArg/decodeArg — Set', () => { + const s = new Set([1, 2, 3]); + const result = roundtrip(s) as Set; + assert.ok(result instanceof Set, 'Should be a Set instance'); + assert.strictEqual(result.has(1), true, 'Set element 1 preserved'); + assert.strictEqual(result.has(3), true, 'Set element 3 preserved'); + assert.strictEqual(result.size, 3, 'Set size preserved'); +}); + +test('encodeArg/decodeArg — array with undefined', () => { + const arr = [1, undefined, 3]; + const result = roundtrip(arr) as unknown[]; + assert.strictEqual(result[0], 1, 'First element preserved'); + assert.strictEqual(result[1], undefined, 'undefined element preserved'); + assert.strictEqual(result[2], 3, 'Third element preserved'); + assert.strictEqual(result.length, 3, 'Array length preserved'); +}); + +test('encodeArg/decodeArg — object with undefined value', () => { + const obj = { a: undefined as unknown, b: 1 }; + const result = roundtrip(obj) as Record; + assert.strictEqual( + 'a' in result, + true, + 'Key with undefined value should exist' + ); + assert.strictEqual(result.a, undefined, 'undefined value preserved'); + assert.strictEqual(result.b, 1, 'Other values preserved'); +}); + +test('encodeArg/decodeArg — nested Map inside object', () => { + const m = new Map([['x', 10]]); + const obj = { m, count: 1 }; + const result = roundtrip(obj) as { m: Map; count: number }; + assert.ok(result.m instanceof Map, 'Nested Map is a Map instance'); + assert.strictEqual(result.m.get('x'), 10, 'Nested Map entry preserved'); + assert.strictEqual(result.count, 1, 'Other object property preserved'); +}); + +test('encodeArg/decodeArg — nested Set inside array', () => { + const s = new Set([7, 8]); + const arr = [s, 42]; + const result = roundtrip(arr) as unknown[]; + assert.ok(result[0] instanceof Set, 'Nested Set is a Set instance'); + assert.strictEqual( + (result[0] as Set).has(7), + true, + 'Set element 7 preserved' + ); + assert.strictEqual(result[1], 42, 'Other array element preserved'); +}); + +test('encodeArg/decodeArg — nested Date inside array', () => { + const d = new Date('2026-01-01T00:00:00.000Z'); + const arr = [d, 42]; + const result = roundtrip(arr) as unknown[]; + assert.ok(result[0] instanceof Date, 'Nested Date is a Date instance'); + assert.strictEqual( + (result[0] as Date).getTime(), + d.getTime(), + 'Date value preserved' + ); + assert.strictEqual(result[1], 42, 'Other array element preserved'); +}); + +test('encodeArg/decodeArg — Map with object keys', () => { + const key = { id: 1 }; + const m = new Map([[key, 'value']]); + const result = roundtrip(m) as Map, string>; + assert.strictEqual(result.size, 1, 'Map size preserved'); + const [[rk, rv]] = result.entries(); + assert.deepStrictEqual(rk, key, 'Map object key preserved by value'); + assert.strictEqual(rv, 'value', 'Map value preserved'); +}); + +test('encodeArg/decodeArg — Map with undefined value', () => { + const m = new Map([['a', undefined]]); + const result = roundtrip(m) as Map; + assert.strictEqual(result.has('a'), true, 'Key with undefined value present'); + assert.strictEqual( + result.get('a'), + undefined, + 'undefined Map value preserved' + ); +}); + +test('encodeArg/decodeArg — Set with undefined', () => { + const s = new Set([1, undefined, 3]); + const result = roundtrip(s) as Set; + assert.strictEqual( + result.has(undefined), + true, + 'undefined Set element preserved' + ); + assert.strictEqual(result.size, 3, 'Set size preserved'); +}); + +test('encodeArg/decodeArg — plain object passthrough', () => { + const obj = { a: 1, b: 'hello', c: true }; + const result = roundtrip(obj); + assert.deepStrictEqual(result, obj, 'Plain object survives roundtrip'); +}); + +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'); + assert.strictEqual(result.other, 42, 'Other key preserved'); +}); + +test('encodeArg/decodeArg — BigInt', () => { + assert.strictEqual(roundtrip(42n), 42n, 'BigInt survives roundtrip'); + assert.strictEqual(roundtrip(0n), 0n, 'BigInt 0 survives roundtrip'); + assert.strictEqual( + roundtrip(-99n), + -99n, + 'Negative BigInt survives roundtrip' + ); +}); + +test('encodeArg/decodeArg — BigInt in array', () => { + const result = roundtrip([1n, 2n, 3n]) as bigint[]; + assert.deepStrictEqual( + result, + [1n, 2n, 3n], + 'Array of BigInts survives roundtrip' + ); +}); + +test('encodeArg/decodeArg — BigInt in object', () => { + const result = roundtrip({ a: 1n, b: 2 }) as { a: bigint; b: number }; + assert.strictEqual(result.a, 1n, 'BigInt property survives roundtrip'); + assert.strictEqual(result.b, 2, 'Non-BigInt property preserved'); +}); + +test('encodeArg/decodeArg — BigInt as Map key and value', () => { + const m = new Map([ + [1n, 100n], + [2n, 200n], + ]); + const result = roundtrip(m) as Map; + assert.ok(result instanceof Map, 'Should be a Map instance'); + assert.strictEqual(result.get(1n), 100n, 'BigInt key and value preserved'); + assert.strictEqual(result.get(2n), 200n, 'Second BigInt entry preserved'); +}); + +test('encodeArg/decodeArg — BigInt in Set', () => { + const s = new Set([1n, 2n, 3n]); + const result = roundtrip(s) as Set; + assert.ok(result instanceof Set, 'Should be a Set instance'); + assert.strictEqual(result.has(1n), true, 'BigInt Set element preserved'); + assert.strictEqual(result.has(3n), true, 'BigInt Set element preserved'); +}); + +test('encodeArg/decodeArg — deeply nested special types', () => { + const d = new Date('2026-06-01T00:00:00.000Z'); + const m = new Map([['created', d]]); + const obj = { data: m, tags: new Set(['a', 'b']) }; + const result = roundtrip(obj) as { + data: Map; + tags: Set; + }; + assert.ok(result.data instanceof Map, 'Nested Map preserved'); + assert.ok( + result.data.get('created') instanceof Date, + 'Map-nested Date preserved' + ); + assert.strictEqual( + result.data.get('created')?.getTime(), + d.getTime(), + 'Date value correct' + ); + 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("")}`; - } + 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); - }, + 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 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, + 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", - ); +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/resource-registry.test.ts b/test/unit/resource-registry.test.ts index 8b1a66e..351ccb0 100644 --- a/test/unit/resource-registry.test.ts +++ b/test/unit/resource-registry.test.ts @@ -1,66 +1,66 @@ -import { assert, test } from "poku"; -import { ResourceRegistry } from "../../src/resource-registry.js"; +import { assert, test } from 'poku'; +import { ResourceRegistry } from '../../src/resource-registry.js'; -test("ResourceRegistry", () => { - const registry = new ResourceRegistry(); +test('ResourceRegistry', () => { + const registry = new ResourceRegistry(); - test("should register and retrieve a resource", () => { - registry.register("test-resource", "test-value"); - assert.strictEqual( - registry.get("test-resource"), - "test-value", - "Resource retrieved correctly", - ); - }); + test('should register and retrieve a resource', () => { + registry.register('test-resource', 'test-value'); + assert.strictEqual( + registry.get('test-resource'), + 'test-value', + 'Resource retrieved correctly' + ); + }); - test("should check if a resource exists", () => { - assert.strictEqual(registry.has("test-resource"), true, "Resource exists"); - assert.strictEqual( - registry.has("non-existent"), - false, - "Resource does not exist", - ); - }); + test('should check if a resource exists', () => { + assert.strictEqual(registry.has('test-resource'), true, 'Resource exists'); + assert.strictEqual( + registry.has('non-existent'), + false, + 'Resource does not exist' + ); + }); - test("should manage isRegistering state", () => { - assert.strictEqual( - registry.getIsRegistering(), - false, - "Initial state is false", - ); - registry.setIsRegistering(true); - assert.strictEqual( - registry.getIsRegistering(), - true, - "State updated to true", - ); - registry.setIsRegistering(false); - assert.strictEqual( - registry.getIsRegistering(), - false, - "State updated to false", - ); - }); + test('should manage isRegistering state', () => { + assert.strictEqual( + registry.getIsRegistering(), + false, + 'Initial state is false' + ); + registry.setIsRegistering(true); + assert.strictEqual( + registry.getIsRegistering(), + true, + 'State updated to true' + ); + registry.setIsRegistering(false); + assert.strictEqual( + registry.getIsRegistering(), + false, + 'State updated to false' + ); + }); - test("should return the entire registry", () => { - const reg = registry.getRegistry(); - const expected = Object.create(null); - expected["test-resource"] = "test-value"; + test('should return the entire registry', () => { + const reg = registry.getRegistry(); + const expected = Object.create(null); + expected['test-resource'] = 'test-value'; - assert.deepStrictEqual(reg, expected, "Registry object returned"); - }); + assert.deepStrictEqual(reg, expected, 'Registry object returned'); + }); - test("should clear the registry", () => { - registry.clear(); - assert.strictEqual( - registry.has("test-resource"), - false, - "Registry cleared", - ); - assert.deepStrictEqual( - registry.getRegistry(), - Object.create(null), - "Registry object is empty", - ); - }); + test('should clear the registry', () => { + registry.clear(); + assert.strictEqual( + registry.has('test-resource'), + false, + 'Registry cleared' + ); + assert.deepStrictEqual( + registry.getRegistry(), + Object.create(null), + 'Registry object is empty' + ); + }); }); diff --git a/test/unit/send-ipc-message.test.ts b/test/unit/send-ipc-message.test.ts index 6529b29..2867e96 100644 --- a/test/unit/send-ipc-message.test.ts +++ b/test/unit/send-ipc-message.test.ts @@ -1,129 +1,129 @@ -import { EventEmitter } from "node:events"; -import { assert, test } from "poku"; -import { sendIPCMessage } from "../../src/shared-resources.js"; +import { EventEmitter } from 'node:events'; +import { assert, test } from 'poku'; +import { sendIPCMessage } from '../../src/shared-resources.js'; const TIMED_OUT_PATTERN = /timed out/; type MockMessage = { id: string; success?: boolean }; -test("sendIPCMessage", async () => { - await test("should resolve when a valid response is received", async () => { - const emitter = new EventEmitter(); - const sender = (msg: unknown) => { - const m = msg as MockMessage; - // Simulate response after a short delay - setTimeout(() => { - emitter.emit("message", { id: m.id, success: true }); - }, 10); - }; +test('sendIPCMessage', async () => { + await test('should resolve when a valid response is received', async () => { + const emitter = new EventEmitter(); + const sender = (msg: unknown) => { + const m = msg as MockMessage; + // Simulate response after a short delay + setTimeout(() => { + emitter.emit('message', { id: m.id, success: true }); + }, 10); + }; - const result = await sendIPCMessage({ - message: { id: "123" }, - validator: (msg): msg is MockMessage => (msg as MockMessage).id === "123", - emitter, - sender, - }); + const result = await sendIPCMessage({ + message: { id: '123' }, + validator: (msg): msg is MockMessage => (msg as MockMessage).id === '123', + emitter, + sender, + }); - assert.deepStrictEqual( - result, - { id: "123", success: true }, - "Resolved with correct response", - ); - }); + assert.deepStrictEqual( + result, + { id: '123', success: true }, + 'Resolved with correct response' + ); + }); - await test("should ignore invalid responses", async () => { - const emitter = new EventEmitter(); - const sender = (msg: unknown) => { - const m = msg as MockMessage; - setTimeout(() => { - emitter.emit("message", { id: "other", success: false }); // Should be ignored - setTimeout(() => { - emitter.emit("message", { id: m.id, success: true }); // Should be accepted - }, 10); - }, 10); - }; + await test('should ignore invalid responses', async () => { + const emitter = new EventEmitter(); + const sender = (msg: unknown) => { + const m = msg as MockMessage; + setTimeout(() => { + emitter.emit('message', { id: 'other', success: false }); // Should be ignored + setTimeout(() => { + emitter.emit('message', { id: m.id, success: true }); // Should be accepted + }, 10); + }, 10); + }; - const result = await sendIPCMessage({ - message: { id: "456" }, - validator: (msg): msg is MockMessage => (msg as MockMessage).id === "456", - emitter, - sender, - }); + const result = await sendIPCMessage({ + message: { id: '456' }, + validator: (msg): msg is MockMessage => (msg as MockMessage).id === '456', + emitter, + sender, + }); - assert.deepStrictEqual( - result, - { id: "456", success: true }, - "Resolved with correct response after ignoring invalid one", - ); - }); + assert.deepStrictEqual( + result, + { id: '456', success: true }, + 'Resolved with correct response after ignoring invalid one' + ); + }); - await test("should reject on timeout", async () => { - const emitter = new EventEmitter(); - const sender = () => undefined; + await test('should reject on timeout', async () => { + const emitter = new EventEmitter(); + const sender = () => undefined; - try { - await sendIPCMessage({ - message: { id: "789" }, - validator: (msg): msg is MockMessage => - (msg as MockMessage).id === "789", - timeout: 50, - emitter, - sender, - }); - assert.fail("Should have rejected"); - } catch (error) { - assert.match( - (error as Error).message, - TIMED_OUT_PATTERN, - "Rejected with timeout error", - ); - } - }); + try { + await sendIPCMessage({ + message: { id: '789' }, + validator: (msg): msg is MockMessage => + (msg as MockMessage).id === '789', + timeout: 50, + emitter, + sender, + }); + assert.fail('Should have rejected'); + } catch (error) { + assert.match( + (error as Error).message, + TIMED_OUT_PATTERN, + 'Rejected with timeout error' + ); + } + }); - await test("should reject if sender throws", async () => { - const emitter = new EventEmitter(); - const sender = () => { - throw new Error("Send failed"); - }; + await test('should reject if sender throws', async () => { + const emitter = new EventEmitter(); + const sender = () => { + throw new Error('Send failed'); + }; - try { - await sendIPCMessage({ - message: { id: "abc" }, - validator: (msg): msg is MockMessage => true, - emitter, - sender, - }); - assert.fail("Should have rejected"); - } catch (error) { - assert.strictEqual( - (error as Error).message, - "Send failed", - "Rejected with sender error", - ); - } - }); + try { + await sendIPCMessage({ + message: { id: 'abc' }, + validator: (msg): msg is MockMessage => true, + emitter, + sender, + }); + assert.fail('Should have rejected'); + } catch (error) { + assert.strictEqual( + (error as Error).message, + 'Send failed', + 'Rejected with sender error' + ); + } + }); - await test("should cleanup listeners", async () => { - const emitter = new EventEmitter(); - const sender = (msg: unknown) => { - const m = msg as MockMessage; - setTimeout(() => { - emitter.emit("message", { id: m.id }); - }, 10); - }; + await test('should cleanup listeners', async () => { + const emitter = new EventEmitter(); + const sender = (msg: unknown) => { + const m = msg as MockMessage; + setTimeout(() => { + emitter.emit('message', { id: m.id }); + }, 10); + }; - await sendIPCMessage({ - message: { id: "cleanup" }, - validator: (msg): msg is MockMessage => - (msg as MockMessage).id === "cleanup", - emitter, - sender, - }); + await sendIPCMessage({ + message: { id: 'cleanup' }, + validator: (msg): msg is MockMessage => + (msg as MockMessage).id === 'cleanup', + emitter, + sender, + }); - assert.strictEqual( - emitter.listenerCount("message"), - 0, - "Listener removed after success", - ); - }); + assert.strictEqual( + emitter.listenerCount('message'), + 0, + 'Listener removed after success' + ); + }); }); diff --git a/test/unit/write-back.test.ts b/test/unit/write-back.test.ts index 01b5ebc..5e31167 100644 --- a/test/unit/write-back.test.ts +++ b/test/unit/write-back.test.ts @@ -1,410 +1,410 @@ -import type { ArgCodec } from "../../src/types.js"; -import { assert, test } from "poku"; -import { configureCodecs, writeBack } from "../../src/shared-resources.js"; +import type { ArgCodec } from '../../src/types.js'; +import { assert, test } from 'poku'; +import { configureCodecs, writeBack } from '../../src/shared-resources.js'; -test("writeBack — array push", () => { - const original: number[] = []; - writeBack(original, [42]); - assert.deepStrictEqual( - original, - [42], - "Element should be pushed into the original array", - ); +test('writeBack — array push', () => { + const original: number[] = []; + writeBack(original, [42]); + assert.deepStrictEqual( + original, + [42], + 'Element should be pushed into the original array' + ); }); -test("writeBack — array truncation", () => { - const original = [1, 2, 3, 4]; - writeBack(original, [1, 2, 3]); - assert.deepStrictEqual( - original, - [1, 2, 3], - "Array should be truncated to match mutated length", - ); +test('writeBack — array truncation', () => { + const original = [1, 2, 3, 4]; + writeBack(original, [1, 2, 3]); + assert.deepStrictEqual( + original, + [1, 2, 3], + 'Array should be truncated to match mutated length' + ); }); -test("writeBack — array emptied", () => { - const original = [1, 2, 3]; - writeBack(original, []); - assert.deepStrictEqual(original, [], "Array should be emptied"); +test('writeBack — array emptied', () => { + const original = [1, 2, 3]; + writeBack(original, []); + assert.deepStrictEqual(original, [], 'Array should be emptied'); }); -test("writeBack — array element update", () => { - const original = [1, 2, 3]; - writeBack(original, [1, 99, 3]); - assert.deepStrictEqual( - original, - [1, 99, 3], - "Middle element should be updated in place", - ); +test('writeBack — array element update', () => { + const original = [1, 2, 3]; + writeBack(original, [1, 99, 3]); + assert.deepStrictEqual( + original, + [1, 99, 3], + 'Middle element should be updated in place' + ); }); -test("writeBack — preserves original array reference", () => { - const original: number[] = [1]; - const ref = original; - writeBack(original, [1, 2, 3]); - assert.strictEqual( - original, - ref, - "Original array reference should be preserved", - ); +test('writeBack — preserves original array reference', () => { + const original: number[] = [1]; + const ref = original; + writeBack(original, [1, 2, 3]); + assert.strictEqual( + original, + ref, + 'Original array reference should be preserved' + ); }); -test("writeBack — object property set", () => { - const original: Record = {}; - writeBack(original, { key: 42 }); - assert.strictEqual( - original.key, - 42, - "Property should be set on the original object", - ); +test('writeBack — object property set', () => { + const original: Record = {}; + writeBack(original, { key: 42 }); + assert.strictEqual( + original.key, + 42, + 'Property should be set on the original object' + ); }); -test("writeBack — object property deletion", () => { - const original: Record = { toDelete: "x", keep: 1 }; - writeBack(original, { keep: 1 }); - assert.strictEqual( - "toDelete" in original, - false, - "Property should be removed", - ); - assert.strictEqual( - original.keep, - 1, - "Remaining property should still be present", - ); +test('writeBack — object property deletion', () => { + const original: Record = { toDelete: 'x', keep: 1 }; + writeBack(original, { keep: 1 }); + assert.strictEqual( + 'toDelete' in original, + false, + 'Property should be removed' + ); + assert.strictEqual( + original.keep, + 1, + 'Remaining property should still be present' + ); }); -test("writeBack — object property update", () => { - const original: Record = { count: 0 }; - writeBack(original, { count: 5 }); - assert.strictEqual(original.count, 5, "Property should be updated"); +test('writeBack — object property update', () => { + const original: Record = { count: 0 }; + writeBack(original, { count: 5 }); + assert.strictEqual(original.count, 5, 'Property should be updated'); }); -test("writeBack — preserves original object reference", () => { - const original: Record = { a: 1 }; - const ref = original; - writeBack(original, { a: 2, b: 3 }); - assert.strictEqual( - original, - ref, - "Original object reference should be preserved", - ); +test('writeBack — preserves original object reference', () => { + const original: Record = { a: 1 }; + const ref = original; + writeBack(original, { a: 2, b: 3 }); + assert.strictEqual( + original, + ref, + 'Original object reference should be preserved' + ); }); -test("writeBack — deeply nested array push", () => { - const original = { nested: { arr: [] as number[] } }; - writeBack(original, { nested: { arr: [7] } }); - assert.deepStrictEqual( - original.nested.arr, - [7], - "Nested array should have the pushed value", - ); +test('writeBack — deeply nested array push', () => { + const original = { nested: { arr: [] as number[] } }; + writeBack(original, { nested: { arr: [7] } }); + assert.deepStrictEqual( + original.nested.arr, + [7], + 'Nested array should have the pushed value' + ); }); -test("writeBack — deeply nested object property", () => { - const original = { a: { b: { c: 0 } } }; - writeBack(original, { a: { b: { c: 99 } } }); - assert.strictEqual(original.a.b.c, 99, "Deep property should be updated"); +test('writeBack — deeply nested object property', () => { + const original = { a: { b: { c: 0 } } }; + writeBack(original, { a: { b: { c: 99 } } }); + assert.strictEqual(original.a.b.c, 99, 'Deep property should be updated'); }); -test("writeBack — preserves nested object references", () => { - const inner = { arr: [] as number[] }; - const original = { nested: inner }; - writeBack(original, { nested: { arr: [1, 2] } }); - assert.strictEqual( - original.nested, - inner, - "Nested object reference should be preserved", - ); - assert.deepStrictEqual( - original.nested.arr, - [1, 2], - "Nested array should be updated in place", - ); +test('writeBack — preserves nested object references', () => { + const inner = { arr: [] as number[] }; + const original = { nested: inner }; + writeBack(original, { nested: { arr: [1, 2] } }); + assert.strictEqual( + original.nested, + inner, + 'Nested object reference should be preserved' + ); + assert.deepStrictEqual( + original.nested.arr, + [1, 2], + 'Nested array should be updated in place' + ); }); -test("writeBack — array of objects mutation", () => { - const original = [{ x: 1 }, { x: 2 }]; - writeBack(original, [{ x: 10 }, { x: 20 }]); - assert.deepStrictEqual( - original, - [{ x: 10 }, { x: 20 }], - "Each object element should be updated", - ); +test('writeBack — array of objects mutation', () => { + const original = [{ x: 1 }, { x: 2 }]; + writeBack(original, [{ x: 10 }, { x: 20 }]); + assert.deepStrictEqual( + original, + [{ x: 10 }, { x: 20 }], + 'Each object element should be updated' + ); }); -test("writeBack — preserves references inside array of objects", () => { - const item0 = { x: 1 }; - const original = [item0, { x: 2 }]; - writeBack(original, [{ x: 99 }, { x: 2 }]); - assert.strictEqual( - original[0], - item0, - "Object reference inside array should be preserved", - ); - assert.strictEqual( - item0.x, - 99, - "The referenced object should have its property updated", - ); +test('writeBack — preserves references inside array of objects', () => { + const item0 = { x: 1 }; + const original = [item0, { x: 2 }]; + writeBack(original, [{ x: 99 }, { x: 2 }]); + assert.strictEqual( + original[0], + item0, + 'Object reference inside array should be preserved' + ); + assert.strictEqual( + item0.x, + 99, + 'The referenced object should have its property updated' + ); }); -test("writeBack — primitive original is a no-op", () => { - const original = 42 as unknown; - assert.doesNotThrow( - () => writeBack(original, 99), - "Should not throw for primitive original", - ); +test('writeBack — primitive original is a no-op', () => { + const original = 42 as unknown; + assert.doesNotThrow( + () => writeBack(original, 99), + 'Should not throw for primitive original' + ); }); -test("writeBack — null original is a no-op", () => { - assert.doesNotThrow( - () => writeBack(null, { key: 1 }), - "Should not throw for null original", - ); +test('writeBack — null original is a no-op', () => { + assert.doesNotThrow( + () => writeBack(null, { key: 1 }), + 'Should not throw for null original' + ); }); -test("writeBack — mismatched types (array vs object) is a no-op", () => { - const original: number[] = [1, 2]; - writeBack(original, { key: 1 }); - assert.deepStrictEqual( - original, - [1, 2], - "Mismatched type should leave original unchanged", - ); +test('writeBack — mismatched types (array vs object) is a no-op', () => { + const original: number[] = [1, 2]; + writeBack(original, { key: 1 }); + assert.deepStrictEqual( + original, + [1, 2], + 'Mismatched type should leave original unchanged' + ); }); -test("writeBack — Map repopulation", () => { - const original = new Map([ - ["a", 1], - ["b", 2], - ]); - const ref = original; - writeBack( - original, - new Map([ - ["b", 99], - ["c", 3], - ]), - ); - assert.strictEqual(original, ref, "Map reference should be preserved"); - assert.strictEqual(original.has("a"), false, "Removed key should be gone"); - assert.strictEqual(original.get("b"), 99, "Updated key should be updated"); - assert.strictEqual(original.get("c"), 3, "New key should be added"); +test('writeBack — Map repopulation', () => { + const original = new Map([ + ['a', 1], + ['b', 2], + ]); + const ref = original; + writeBack( + original, + new Map([ + ['b', 99], + ['c', 3], + ]) + ); + assert.strictEqual(original, ref, 'Map reference should be preserved'); + assert.strictEqual(original.has('a'), false, 'Removed key should be gone'); + assert.strictEqual(original.get('b'), 99, 'Updated key should be updated'); + assert.strictEqual(original.get('c'), 3, 'New key should be added'); }); -test("writeBack — Set repopulation", () => { - const original = new Set([1, 2, 3]); - const ref = original; - writeBack(original, new Set([2, 3, 4])); - assert.strictEqual(original, ref, "Set reference should be preserved"); - assert.strictEqual(original.has(1), false, "Removed element should be gone"); - assert.strictEqual(original.has(4), true, "New element should be present"); - assert.strictEqual(original.has(2), true, "Unchanged element should remain"); +test('writeBack — Set repopulation', () => { + const original = new Set([1, 2, 3]); + const ref = original; + writeBack(original, new Set([2, 3, 4])); + assert.strictEqual(original, ref, 'Set reference should be preserved'); + assert.strictEqual(original.has(1), false, 'Removed element should be gone'); + assert.strictEqual(original.has(4), true, 'New element should be present'); + assert.strictEqual(original.has(2), true, 'Unchanged element should remain'); }); -test("writeBack — Date mutation", () => { - const original = new Date("2026-01-01T00:00:00.000Z"); - const ref = original; - writeBack(original, new Date("2000-06-15T00:00:00.000Z")); - assert.strictEqual(original, ref, "Date reference should be preserved"); - assert.strictEqual(original.getFullYear(), 2000, "Year should be updated"); - assert.strictEqual( - original.getMonth(), - 5, - "Month should be updated (0-indexed)", - ); +test('writeBack — Date mutation', () => { + const original = new Date('2026-01-01T00:00:00.000Z'); + const ref = original; + writeBack(original, new Date('2000-06-15T00:00:00.000Z')); + assert.strictEqual(original, ref, 'Date reference should be preserved'); + assert.strictEqual(original.getFullYear(), 2000, 'Year should be updated'); + assert.strictEqual( + original.getMonth(), + 5, + 'Month should be updated (0-indexed)' + ); }); -test("writeBack — Map nested in object (reference preserved)", () => { - const innerMap = new Map([["x", 1]]); - const original: Record = { m: innerMap }; - writeBack(original, { - m: new Map([ - ["x", 1], - ["y", 2], - ]), - }); - assert.strictEqual( - original.m, - innerMap, - "Inner Map reference should be preserved", - ); - assert.strictEqual( - innerMap.get("y"), - 2, - "Inner Map should be updated with new entry", - ); +test('writeBack — Map nested in object (reference preserved)', () => { + const innerMap = new Map([['x', 1]]); + const original: Record = { m: innerMap }; + writeBack(original, { + m: new Map([ + ['x', 1], + ['y', 2], + ]), + }); + assert.strictEqual( + original.m, + innerMap, + 'Inner Map reference should be preserved' + ); + assert.strictEqual( + innerMap.get('y'), + 2, + 'Inner Map should be updated with new entry' + ); }); -test("writeBack — Set nested in array (reference preserved)", () => { - const innerSet = new Set([1, 2]); - const original: unknown[] = [innerSet, 42]; - writeBack(original, [new Set([1, 2, 3]), 42]); - assert.strictEqual( - original[0], - innerSet, - "Set reference inside array should be preserved", - ); - assert.strictEqual(innerSet.has(3), true, "Set should have new element"); +test('writeBack — Set nested in array (reference preserved)', () => { + const innerSet = new Set([1, 2]); + const original: unknown[] = [innerSet, 42]; + writeBack(original, [new Set([1, 2, 3]), 42]); + assert.strictEqual( + original[0], + innerSet, + 'Set reference inside array should be preserved' + ); + assert.strictEqual(innerSet.has(3), true, 'Set should have new element'); }); -test("writeBack — Date nested in array (reference preserved)", () => { - const innerDate = new Date("2026-01-01T00:00:00.000Z"); - const mutationTarget = new Date("2000-01-01T00:00:00.000Z"); - const original: unknown[] = [innerDate, 42]; - writeBack(original, [mutationTarget, 42]); - assert.strictEqual( - original[0], - innerDate, - "Date reference inside array should be preserved", - ); - assert.strictEqual( - (original[0] as Date).getTime(), - mutationTarget.getTime(), - "Date time should be updated", - ); +test('writeBack — Date nested in array (reference preserved)', () => { + const innerDate = new Date('2026-01-01T00:00:00.000Z'); + const mutationTarget = new Date('2000-01-01T00:00:00.000Z'); + const original: unknown[] = [innerDate, 42]; + writeBack(original, [mutationTarget, 42]); + assert.strictEqual( + original[0], + innerDate, + 'Date reference inside array should be preserved' + ); + assert.strictEqual( + (original[0] as Date).getTime(), + mutationTarget.getTime(), + 'Date time should be updated' + ); }); -test("writeBack — Map is not treated as plain object (mismatched with plain object is no-op)", () => { - const original = new Map([["a", 1]]); - writeBack(original, { a: 2 }); - assert.strictEqual( - original.get("a"), - 1, - "Map should be untouched when mutated is a plain object", - ); +test('writeBack — Map is not treated as plain object (mismatched with plain object is no-op)', () => { + const original = new Map([['a', 1]]); + writeBack(original, { a: 2 }); + assert.strictEqual( + original.get('a'), + 1, + 'Map should be untouched when mutated is a plain object' + ); }); -test("writeBack — Set is not treated as plain object (mismatched with array is no-op)", () => { - const original = new Set([1, 2]); - writeBack(original, [3, 4]); - assert.strictEqual( - original.has(1), - true, - "Set should be untouched when mutated is an array", - ); +test('writeBack — Set is not treated as plain object (mismatched with array is no-op)', () => { + const original = new Set([1, 2]); + writeBack(original, [3, 4]); + assert.strictEqual( + original.has(1), + true, + '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: 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 — 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 — 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 — 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 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"); +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'); }); diff --git a/tsconfig.json b/tsconfig.json index c60b9ac..1a926f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,30 +1,30 @@ { - "include": ["src"], - "exclude": ["test", "src/@types"], - "compilerOptions": { - "target": "ES2021", - "lib": ["ES2021"], - "module": "Node16", - "moduleResolution": "Node16", - "outDir": "lib", - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "allowJs": false, - "strict": true, - "alwaysStrict": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "noUnusedLocals": true, - "noUnusedParameters": false, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "noImplicitAny": true, - "removeComments": false, - "sourceMap": false, - "esModuleInterop": true, - "noEmitOnError": true, - "declaration": true, - "declarationDir": "lib", - "allowSyntheticDefaultImports": true - } + "include": ["src"], + "exclude": ["test", "src/@types"], + "compilerOptions": { + "target": "ES2021", + "lib": ["ES2021"], + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "lib", + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "allowJs": false, + "strict": true, + "alwaysStrict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "noUnusedParameters": false, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "noImplicitAny": true, + "removeComments": false, + "sourceMap": false, + "esModuleInterop": true, + "noEmitOnError": true, + "declaration": true, + "declarationDir": "lib", + "allowSyntheticDefaultImports": true + } } From d5e27b662f976d69464774777f8ed54c53b83d0a Mon Sep 17 00:00:00 2001 From: lojhan Date: Tue, 17 Mar 2026 21:34:06 -0300 Subject: [PATCH 8/8] fix: broken build --- src/index.ts | 3 ++- src/shared-resources.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index e806f3b..2e4c61d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,8 @@ import { } from './shared-resources.js'; export const sharedResources = (config?: SharedResourcesConfig): PokuPlugin => { - if (config?.codecs?.length > 0) configureCodecs(config.codecs); + if (config?.codecs && config.codecs.length > 0) + configureCodecs(config.codecs); return { name: 'shared-resources', ipc: true, diff --git a/src/shared-resources.ts b/src/shared-resources.ts index 038836e..b05e57e 100644 --- a/src/shared-resources.ts +++ b/src/shared-resources.ts @@ -511,6 +511,7 @@ export const resource = { */ // biome-ignore lint/suspicious/noExplicitAny: see configureCodecs configure: (config: { codecs?: ArgCodec[] }) => { - if (config.codecs?.length > 0) configureCodecs(config.codecs); + if (config.codecs && config.codecs.length > 0) + configureCodecs(config.codecs); }, } as const;