From d56f1b750e2d7faf72d69f9ba8e9bb62ef5ebce7 Mon Sep 17 00:00:00 2001 From: #!/bin/BasH <1840380+bas080@users.noreply.github.com> Date: Mon, 6 Apr 2026 01:34:52 +0000 Subject: [PATCH 1/4] Support custom serialization of leaf nodes --- README.mz | 54 +++++++++++ index.test.mjs | 15 +++ leaf-serializer-property.test.mjs | 149 ++++++++++++++++++++++++++++ leaf-serializer.test.mjs | 76 +++++++++++++++ package-lock.json | 63 +++++++++++- package.json | 4 +- parse.mjs | 155 +++++++++++++++++++++--------- stringify.mjs | 80 ++++++++------- 8 files changed, 514 insertions(+), 82 deletions(-) create mode 100644 leaf-serializer-property.test.mjs create mode 100644 leaf-serializer.test.mjs diff --git a/README.mz b/README.mz index e09830e..6840563 100644 --- a/README.mz +++ b/README.mz @@ -246,6 +246,60 @@ You can see the docs [here](./example/typescript/docs/globals.md) > experience, it does not represent the actual type. > Values are subject to serialization and deserialization. +## Leaf Serializer + +By default, SendScript uses JSON for serialization, which limits support to primitives and plain objects/arrays. To support richer JavaScript types like `Date`, `RegExp`, `BigInt`, `Map`, `Set`, and `undefined`, you can provide custom serialization functions. + +The `stringify` function accepts an optional `leafSerializer` parameter, and `parse` accepts an optional `leafDeserializer` parameter. These functions control how non-SendScript values (leaves) are encoded and decoded. + +### Example with superjson + +Here's how to use [superjson](https://github.com/blitz-js/superjson) to support extended types: + +```js +import SuperJSON from 'superjson' +import stringify from 'sendscript/stringify.mjs' +import Parse from 'sendscript/parse.mjs' +import module from 'sendscript/module.mjs' + +const leafSerializer = (value) => { + if (value === undefined) return JSON.stringify({ __undefined__: true }) + return JSON.stringify(SuperJSON.serialize(value)) +} + +const leafDeserializer = (text) => { + const parsed = JSON.parse(text) + if (parsed && parsed.__undefined__ === true) return undefined + return SuperJSON.deserialize(parsed) +} + +const { processData } = module(['processData']) + +// Program with Date, RegExp, and other types +const program = { + createdAt: new Date('2020-01-01T00:00:00.000Z'), + pattern: /foo/gi, + count: BigInt('9007199254740992'), + items: new Set([1, 2, 3]), + mapping: new Map([['a', 1], ['b', 2]]) +} + +// Serialize with custom leaf serializer +const json = stringify(processData(program), leafSerializer) + +// Parse with custom leaf deserializer +const parse = Parse({ + processData: (data) => ({ + success: true, + received: data + }) +}) + +const result = parse(json, leafDeserializer) +``` + +The leaf wrapper format is `['leaf', serializedPayload]`, making it unambiguous and safe from colliding with SendScript operators. + ## Tests Tests with 100% code coverage. diff --git a/index.test.mjs b/index.test.mjs index 4df0be7..e1faaba 100644 --- a/index.test.mjs +++ b/index.test.mjs @@ -18,6 +18,11 @@ const module = { asyncAdd: async (a, b) => a + b, aPromise: Promise.resolve(42), delayedIdentity: async (x) => x, + nullProto: () => { + const obj = Object.create(null) + obj.b = 'c' + return obj + }, Function, Promise } @@ -177,6 +182,16 @@ test('should evaluate basic expressions correctly', async (t) => { run(identity(['ref', 'hello'])), run(identity(toArray('ref', 'hello'))) ) + t.strictSame( + run(identity(['leaf', 1, 2, 3])), + ['leaf', 1, 2, 3] + ) + t.end() + }) + + t.test('null-prototype object traversal', (t) => { + const { nullProto } = sendscript.module + t.strictSame(run({ a: nullProto() }), { a: { b: 'c' } }) t.end() }) diff --git a/leaf-serializer-property.test.mjs b/leaf-serializer-property.test.mjs new file mode 100644 index 0000000..def3f62 --- /dev/null +++ b/leaf-serializer-property.test.mjs @@ -0,0 +1,149 @@ +import { test } from 'tap' +import { createRequire } from 'module' +import SuperJSON from 'superjson' + +const require = createRequire(import.meta.url) +const { check, gen } = require('tape-check') + +const leafSerializer = (value) => { + if (value === undefined) return JSON.stringify({ __sendscript_undefined__: true }) + return JSON.stringify(SuperJSON.serialize(value)) +} + +const leafDeserializer = (text) => { + const parsed = JSON.parse(text) + if (parsed && parsed.__sendscript_undefined__ === true) return undefined + return SuperJSON.deserialize(parsed) +} + +// Helper to compare values accounting for types that can't use === +const valueEquals = (a, b) => { + if (a === b) return true + if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime() + if (a instanceof RegExp && b instanceof RegExp) return a.source === b.source && a.flags === b.flags + if (a instanceof Set && b instanceof Set) { + if (a.size !== b.size) return false + for (const item of a) { + if (!b.has(item)) return false + } + return true + } + if (a instanceof Map && b instanceof Map) { + if (a.size !== b.size) return false + for (const [key, val] of a) { + if (!b.has(key) || !valueEquals(val, b.get(key))) return false + } + return true + } + return JSON.stringify(a) === JSON.stringify(b) +} + +// Helper to get a human-readable type name +const getTypeInfo = (value) => { + if (value === null) return 'null' + if (value === undefined) return 'undefined' + if (value instanceof Date) return 'Date' + if (value instanceof RegExp) return 'RegExp' + if (value instanceof Set) return 'Set' + if (value instanceof Map) return 'Map' + if (typeof value === 'bigint') return 'BigInt' + return typeof value +} + +// Property 1: Round-trip - any value that can be serialized should deserialize to an equal value +test('property: round-trip serialization preserves value', check( + gen.any, + (t, value) => { + t.plan(1) + try { + const serialized = leafSerializer(value) + const deserialized = leafDeserializer(serialized) + const typeInfo = getTypeInfo(value) + t.ok(valueEquals(deserialized, value), `Round-trip preserved ${typeInfo}`) + } catch (e) { + // If serialization fails on a particular value, that's acceptable + // (not all values may be serializable) + const typeInfo = getTypeInfo(value) + t.pass(`Serialization of ${typeInfo} threw: ${e.message}`) + } + } +)) + +// Property 2: Determinism - serializing the same value repeatedly produces identical results +test('property: serialization is deterministic', check( + gen.any, + (t, value) => { + t.plan(1) + try { + const serialized1 = leafSerializer(value) + const serialized2 = leafSerializer(value) + const typeInfo = getTypeInfo(value) + t.equal(serialized1, serialized2, `Serialization of ${typeInfo} is deterministic`) + } catch (e) { + const typeInfo = getTypeInfo(value) + t.pass(`Serialization threw for ${typeInfo}: ${e.message}`) + } + } +)) + +// Property 3: Valid JSON - serialized output is always valid JSON +test('property: serialized output is valid JSON', check( + gen.any, + (t, value) => { + t.plan(1) + try { + const serialized = leafSerializer(value) + const parsed = JSON.parse(serialized) + t.ok(typeof parsed === 'object' || typeof parsed === 'string', 'Parsed JSON is an object or string') + } catch (e) { + t.fail(`Invalid JSON output for ${getTypeInfo(value)}: ${e.message}`) + } + } +)) + +// Property 4: Undefined handling - undefined values are preserved through round-trip +test('property: undefined values are preserved through serialization', check( + gen.any, + (t, value) => { + t.plan(1) + if (value === undefined) { + const serialized = leafSerializer(value) + const deserialized = leafDeserializer(serialized) + t.equal(deserialized, undefined, 'Undefined preserved through serialization') + } else { + t.pass('Value was not undefined') + } + } +)) + +// Property 5: Type distinctness - Different values should have different serializations (when possible) +test('property: different primitives have different serializations', check( + gen.primitive, + gen.primitive, + (t, val1, val2) => { + t.plan(1) + if (val1 !== val2 && !(Number.isNaN(val1) && Number.isNaN(val2))) { + const ser1 = leafSerializer(val1) + const ser2 = leafSerializer(val2) + t.not(ser1, ser2, `Different primitives ${getTypeInfo(val1)} and ${getTypeInfo(val2)} have different serializations`) + } else { + t.pass('Primitives are equal or both NaN') + } + } +)) + +// Property 6: Idempotence of serialization - Re-parsing serialized value produces same serialization +test('property: serialization round-trip is stable', check( + gen.any, + (t, value) => { + t.plan(1) + try { + const ser1 = leafSerializer(value) + const deser1 = leafDeserializer(ser1) + const ser2 = leafSerializer(deser1) + t.equal(ser1, ser2, `Serialization is stable for ${getTypeInfo(value)}`) + } catch (e) { + t.pass(`Serialization error for ${getTypeInfo(value)}: ${e.message}`) + } + } +)) diff --git a/leaf-serializer.test.mjs b/leaf-serializer.test.mjs new file mode 100644 index 0000000..34c77cf --- /dev/null +++ b/leaf-serializer.test.mjs @@ -0,0 +1,76 @@ +import { test } from 'tap' +import SuperJSON from 'superjson' +import Sendscript from './index.mjs' + +const leafSerializer = (value) => { + if (value === undefined) return JSON.stringify({ __sendscript_undefined__: true }) + return JSON.stringify(SuperJSON.serialize(value)) +} + +const leafDeserializer = (text) => { + const parsed = JSON.parse(text) + if (parsed && parsed.__sendscript_undefined__ === true) return undefined + return SuperJSON.deserialize(parsed) +} + +const module = { + identity: (x) => x +} + +const sendscript = Sendscript(module) +const { parse, stringify } = sendscript +const run = (program, serializer, deserializer) => + parse(stringify(program, serializer), deserializer) + +test('custom leaf serializer/deserializer using superjson', async (t) => { + const value = { + date: new Date('2020-01-01T00:00:00.000Z'), + regex: /abc/gi, + big: BigInt('123456789012345678901234567890'), + undef: undefined, + nested: { + set: new Set([1, 2, 3]), + map: new Map([['a', 1], ['b', 2]]) + } + } + + const result = await run(value, leafSerializer, leafDeserializer) + + t.ok(result.date instanceof Date) + t.equal(result.date.toISOString(), value.date.toISOString()) + + t.ok(result.regex instanceof RegExp) + t.equal(result.regex.source, 'abc') + t.equal(result.regex.flags, 'gi') + + t.equal(result.big, value.big) + + t.ok(Object.prototype.hasOwnProperty.call(result, 'undef')) + t.equal(result.undef, undefined) + + t.ok(result.nested.set instanceof Set) + t.strictSame(Array.from(result.nested.set), [1, 2, 3]) + + t.ok(result.nested.map instanceof Map) + t.strictSame(Array.from(result.nested.map.entries()), [['a', 1], ['b', 2]]) + + t.end() +}) + +test('default leaf deserializer when not provided', async (t) => { + const value = { a: 1, b: 'hello' } + const result = await run(value) + + t.strictSame(result, value) + t.end() +}) + +test('default leaf deserializer handles undefined parameter', (t) => { + const parse = Sendscript({}).parse + // Create a simple JSON with a leaf then parse using default deserializer + // The reviver will never pass undefined to deserializer, but we test it defensively + const json = '["leaf","{\\"test\\":1}"]' + const result = parse(json) + t.strictSame(result, { test: 1 }) + t.end() +}) diff --git a/package-lock.json b/package-lock.json index 73ac449..89aeec6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,9 @@ "debug": "^4.4.3" }, "devDependencies": { - "tap": "^21.6.3" + "superjson": "^2.2.6", + "tap": "^21.6.3", + "tape-check": "^1.0.0-rc.0" } }, "node_modules/@alcalzone/ansi-tokenize": { @@ -1571,6 +1573,22 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2092,6 +2110,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/isexe": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", @@ -3364,6 +3395,19 @@ "node": ">=8" } }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3468,6 +3512,16 @@ "node": "20 || >=22" } }, + "node_modules/tape-check": { + "version": "1.0.0-rc.0", + "resolved": "https://registry.npmjs.org/tape-check/-/tape-check-1.0.0-rc.0.tgz", + "integrity": "sha512-ZxxeAnhEc/M2J6CZwTwO9Nt4cVS9fu7FCJBCOa32sa42CXdX430fJ6gQyq3Xhnd5xrsCAMbtQpTXDiyDgESexw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "testcheck": "^1.0.0-rc" + } + }, "node_modules/tar": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", @@ -3687,6 +3741,13 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/testcheck": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/testcheck/-/testcheck-1.0.0-rc.2.tgz", + "integrity": "sha512-lY0PM4ypOuf3WenJkx/C8+LeLPucHnnRAOtHRQkm8K6pG170tYw0xmxyVjrejJ2bpHWHn5EUkoC+qhnhFAUcvg==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/package.json b/package.json index ef69eb8..0d315a5 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "author": "Bas Huis", "license": "MIT", "devDependencies": { - "tap": "^21.6.3" + "superjson": "^2.2.6", + "tap": "^21.6.3", + "tape-check": "^1.0.0-rc.0" }, "dependencies": { "debug": "^4.4.3" diff --git a/parse.mjs b/parse.mjs index 71d09f7..36322f5 100644 --- a/parse.mjs +++ b/parse.mjs @@ -1,43 +1,102 @@ import Debug from './debug.mjs' -import isNil from './is-nil.mjs' import { SendScriptReferenceError } from './error.mjs' const debug = Debug.extend('parse') -export default (env) => - function parse (program) { - debug('program', program) +const isThenable = (value) => ( + value != null && typeof value.then === 'function' +) - const awaits = [] - const resolved = {} +const isAwaitPromise = Symbol('sendscript-await') +const undefinedSentinel = Symbol('sendscript-undefined') - JSON.parse(program, (key, value) => { - if (!Array.isArray(value)) return value +const isPlainObject = (value) => { + if (!value || typeof value !== 'object') return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} - const [operator, ...rest] = value +const markAwait = (promise) => { + promise[isAwaitPromise] = true + return promise +} - if (operator === 'await') { - const [program, awaitId] = rest - - awaits.push(((program, awaitId) => async () => { - resolved[awaitId] = await JSON.parse( - JSON.stringify(program), - reviver - ) - })(program, awaitId)) - } +const isExplicitAwait = (value) => ( + isThenable(value) && value[isAwaitPromise] === true +) - return value +// Recursively resolve awaited values in a parsed tree +const resolveAwaitedValues = (value) => { + if (value === undefinedSentinel) return undefined + + if (isThenable(value)) { + return isExplicitAwait(value) + ? markAwait(value.then(resolveAwaitedValues)) + : value + } + + if (Array.isArray(value)) { + let hasAwait = false + const result = value.map((item) => { + const resolved = resolveAwaitedValues(item) + if (isExplicitAwait(resolved)) hasAwait = true + return resolved }) - const spy = fn => (...args) => { - const value = fn(...args) - debug(args, ' => ', value) - return value + if (!hasAwait) return result + + const awaited = result.map((item, index) => + isExplicitAwait(item) + ? Promise.resolve(item).then((resolved) => { + result[index] = resolved + return resolved + }) + : item + ) + + return markAwait(Promise.all(awaited).then(() => result)) + } + + if (isPlainObject(value)) { + const result = {} + const promises = [] + + for (const key of Object.keys(value)) { + const resolved = resolveAwaitedValues(value[key]) + if (isExplicitAwait(resolved)) { + promises.push( + Promise.resolve(resolved).then((resolvedValue) => { + result[key] = resolvedValue + }) + ) + } else { + result[key] = resolved + } } + if (!promises.length) return result + return markAwait(Promise.all(promises).then(() => result)) + } + + return value +} + +const spy = (fn) => (...args) => { + const value = fn(...args) + debug(args, ' => ', value) + return value +} + +const defaultLeafDeserializer = (text) => JSON.parse(text) + +export default (env) => + function parse (program, leafDeserializer = defaultLeafDeserializer) { + const deserialize = leafDeserializer || defaultLeafDeserializer + + debug('program', program) + const reviver = spy((key, value) => { - if (isNil(value)) return value + if (value === null) return value if (!Array.isArray(value)) { return value @@ -45,23 +104,39 @@ export default (env) => const [operator, ...rest] = value - if (operator === 'await') { - const [, awaitId] = rest - debug('read awaits', resolved[awaitId], awaitId) + if (operator === 'leaf') { + const leafValue = deserialize(rest[0]) + return leafValue === undefined ? undefinedSentinel : leafValue + } - return resolved[awaitId] + if (operator === 'await') { + const [program] = rest + return markAwait(Promise.resolve(program)) } if (Array.isArray(operator) && operator[0] === 'quote') { const [, quoted] = operator - return [quoted, ...rest] } if (operator === 'call') { const [fn, args] = rest - - return fn(...args) + const resolvedFn = isExplicitAwait(fn) ? Promise.resolve(fn) : fn + const resolvedArgs = resolveAwaitedValues(args) + + if (isExplicitAwait(resolvedFn) || isExplicitAwait(resolvedArgs)) { + const promiseFn = isExplicitAwait(resolvedFn) + ? Promise.resolve(resolvedFn) + : resolvedFn + const promiseArgs = isExplicitAwait(resolvedArgs) + ? Promise.resolve(resolvedArgs) + : resolvedArgs + + return Promise.all([promiseFn, promiseArgs]) + .then(([resolvedFnValue, resolvedArgsValue]) => resolvedFnValue(...resolvedArgsValue)) + } + + return fn(...resolvedArgs) } if (operator === 'ref') { @@ -75,18 +150,8 @@ export default (env) => return value }) - if (awaits.length) { - return sequential(awaits).then(() => { - return JSON.parse(program, reviver) - }) - } + const parsed = JSON.parse(program, reviver) + const result = resolveAwaitedValues(parsed) - return JSON.parse(program, reviver) + return result } - -function sequential (promises) { - return promises.reduce( - (acc, curr) => acc.then(results => curr().then(res => [...results, res])), - Promise.resolve([]) - ) -} diff --git a/stringify.mjs b/stringify.mjs index 2821e19..d98831a 100644 --- a/stringify.mjs +++ b/stringify.mjs @@ -1,5 +1,4 @@ import Debug from './debug.mjs' -import isNil from './is-nil.mjs' import { awaitSymbol, call, @@ -8,59 +7,70 @@ import { const debug = Debug.extend('stringify') -const replaced = Symbol('replaced') -const keywords = ['ref', 'call', 'quote', 'await'] +const keywords = ['ref', 'call', 'quote', 'await', 'leaf'] const isKeyword = (v) => keywords.includes(v) -let awaitId = -1 -function replacer (key, value) { - debug(this, key, value) - - if (isNil(value)) { - return value - } - - if (value[ref]) { - const result = ['ref', value.name] +const isPlainObject = (value) => { + if (!value || typeof value !== 'object') return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} - result[replaced] = replaced +// Recursively transform a program tree, encoding SendScript operators and leaf values +function transformValue (value, leafSerializer, state) { + debug(value) - return result + if (value === null) { + return null } - if (value[call]) { - const result = ['call', value.ref, value.args] - - result[replaced] = replaced - - return result + // Normalize SendScript wrapper functions (ref, call, await) + if (typeof value === 'function' && typeof value.toJSON === 'function') { + return transformValue(value.toJSON(), leafSerializer, state) } - if (value[awaitSymbol]) { - awaitId += 1 - const result = ['await', value.ref, awaitId] + // Encode SendScript operators + if (value && value[ref]) { + return ['ref', value.name] + } - result[replaced] = replaced + if (value && value[call]) { + return ['call', transformValue(value.ref, leafSerializer, state), transformValue(value.args, leafSerializer, state)] + } - return result + if (value && value[awaitSymbol]) { + state.awaitId += 1 + return ['await', transformValue(value.ref, leafSerializer, state), state.awaitId] } - // Quote only the reserved string and not the complete array. Quoted values - // will be unquoted on parse. A quoted quote also. - if (!value[replaced] && Array.isArray(value)) { + // Handle arrays: quote keyword operators, transform other arrays recursively + if (Array.isArray(value)) { const [operator, ...rest] = value if (isKeyword(operator)) { - const quoted = ['quote', operator] - quoted[replaced] = replaced + // Quote reserved keyword strings to preserve them as data + return [['quote', operator], ...rest.map((item) => transformValue(item, leafSerializer, state))] + } + + return value.map((item) => transformValue(item, leafSerializer, state)) + } + + // Recurse into plain objects + if (isPlainObject(value)) { + const result = {} - return [quoted, ...rest] + for (const key of Object.keys(value)) { + result[key] = transformValue(value[key], leafSerializer, state) } + + return result } - return value + // Encode non-JSON leaf values (Date, RegExp, BigInt, etc.) + return ['leaf', leafSerializer(value)] } -export default function stringify (program) { - return JSON.stringify(program, replacer) +export default function stringify (program, leafSerializer = JSON.stringify) { + const state = { awaitId: -1 } + return JSON.stringify(transformValue(program, leafSerializer, state)) } From 88e69e5c05ddb85aac1797a020e92519c986e89f Mon Sep 17 00:00:00 2001 From: bas080 Date: Tue, 7 Apr 2026 04:23:43 +0300 Subject: [PATCH 2/4] Support nested modules --- index.test.mjs | 42 +++++++++++++++++++++++++++++++++------- leaf-serializer.test.mjs | 2 +- module.mjs | 38 ++++++++++++++++++++++-------------- parse.mjs | 19 +++++++++++------- stringify.mjs | 20 +++++++++---------- 5 files changed, 80 insertions(+), 41 deletions(-) diff --git a/index.test.mjs b/index.test.mjs index e1faaba..2c6647d 100644 --- a/index.test.mjs +++ b/index.test.mjs @@ -1,7 +1,14 @@ import { test } from 'tap' -import Sendscript from './index.mjs' - -const module = { +import stringify from './stringify.mjs' +import ssparse from './parse.mjs' +import module from './module.mjs' + +const myModule = { + nested: { + again: { + T: () => true + } + }, add: (a, b) => a + b, identity: (x) => x, concat: (a, b) => a.concat(b), @@ -27,9 +34,23 @@ const module = { Promise } -const sendscript = Sendscript(module) -const { parse, stringify } = sendscript -const run = (program) => parse(stringify(program)) +const schema = Object.keys(myModule).reduce((acc, key) => { + acc[key] = true + + return acc +}, {}) + +schema.nested = { again: ['T'] } + +const sendscript = { + stringify, + parse: ssparse(myModule), + module: module(schema) +} + +const { parse } = sendscript + +const run = (program) => sendscript.parse(sendscript.stringify(program)) const RealPromise = Promise @@ -51,9 +72,16 @@ test('should evaluate basic expressions correctly', async (t) => { concat, identity, always, - multiply3 + multiply3, + nested } = sendscript.module + t.test('calling nested function works', t => { + t.equal(run(nested.again.T()), true) + + t.end() + }) + t.test('nested await works', async (t) => { // Async identity passthrough const resolvedId = await delayedIdentity diff --git a/leaf-serializer.test.mjs b/leaf-serializer.test.mjs index 34c77cf..8548652 100644 --- a/leaf-serializer.test.mjs +++ b/leaf-serializer.test.mjs @@ -17,7 +17,7 @@ const module = { identity: (x) => x } -const sendscript = Sendscript(module) +const sendscript = Sendscript(Object.keys(module)) const { parse, stringify } = sendscript const run = (program, serializer, deserializer) => parse(stringify(program, serializer), deserializer) diff --git a/module.mjs b/module.mjs index 868569c..75dc163 100644 --- a/module.mjs +++ b/module.mjs @@ -1,12 +1,8 @@ -import { - awaitSymbol, - call, - ref -} from './symbol.mjs' +import { awaitSymbol, call, ref } from './symbol.mjs' -function instrument (name) { +function instrument (path) { function reference (...args) { - const called = instrument(name) + const called = instrument(path) called.toJSON = () => ({ [call]: call, @@ -19,8 +15,7 @@ function instrument (name) { } reference.then = (resolve) => { - const awaited = instrument(name) - + const awaited = instrument(path) delete awaited.then awaited.toJSON = () => ({ @@ -35,18 +30,31 @@ function instrument (name) { reference.toJSON = () => ({ [ref]: ref, reference: true, - name + path }) return reference } -export default function module (schema) { - if (!Array.isArray(schema)) return module(Object.keys(schema)) +export default function module (schema, parentPath = []) { + if (Array.isArray(schema)) { + return schema.reduce((acc, name) => { + acc[name] = instrument([...parentPath, name]) + return acc + }, {}) + } + + return Object.keys(schema).reduce((acc, key) => { + const value = schema[key] - return schema.reduce((api, name) => { - api[name] = instrument(name) + if (Array.isArray(value)) { + acc[key] = module(value, [...parentPath, key]) + } else if (typeof value === 'object' && value !== null) { + acc[key] = module(value, [...parentPath, key]) + } else { + acc[key] = instrument([...parentPath, key]) + } - return api + return acc }, {}) } diff --git a/parse.mjs b/parse.mjs index 36322f5..dc59fee 100644 --- a/parse.mjs +++ b/parse.mjs @@ -90,9 +90,7 @@ const spy = (fn) => (...args) => { const defaultLeafDeserializer = (text) => JSON.parse(text) export default (env) => - function parse (program, leafDeserializer = defaultLeafDeserializer) { - const deserialize = leafDeserializer || defaultLeafDeserializer - + function parse (program, deserialize = defaultLeafDeserializer) { debug('program', program) const reviver = spy((key, value) => { @@ -140,11 +138,18 @@ export default (env) => } if (operator === 'ref') { - const [name] = rest - - if (Object.hasOwn(env, name)) return env[name] + const path = rest // e.g., ["math","add"] + let current = env + + for (const segment of path) { + if (current && Object.hasOwn(current, segment)) { + current = current[segment] + } else { + throw new SendScriptReferenceError({ key, value }) + } + } - throw new SendScriptReferenceError({ key, value }) + return current } return value diff --git a/stringify.mjs b/stringify.mjs index d98831a..676c71d 100644 --- a/stringify.mjs +++ b/stringify.mjs @@ -17,7 +17,7 @@ const isPlainObject = (value) => { } // Recursively transform a program tree, encoding SendScript operators and leaf values -function transformValue (value, leafSerializer, state) { +function transformValue (value, leafSerializer) { debug(value) if (value === null) { @@ -26,21 +26,20 @@ function transformValue (value, leafSerializer, state) { // Normalize SendScript wrapper functions (ref, call, await) if (typeof value === 'function' && typeof value.toJSON === 'function') { - return transformValue(value.toJSON(), leafSerializer, state) + return transformValue(value.toJSON(), leafSerializer) } // Encode SendScript operators if (value && value[ref]) { - return ['ref', value.name] + return ['ref', ...value.path] } if (value && value[call]) { - return ['call', transformValue(value.ref, leafSerializer, state), transformValue(value.args, leafSerializer, state)] + return ['call', transformValue(value.ref, leafSerializer), transformValue(value.args, leafSerializer)] } if (value && value[awaitSymbol]) { - state.awaitId += 1 - return ['await', transformValue(value.ref, leafSerializer, state), state.awaitId] + return ['await', transformValue(value.ref, leafSerializer)] } // Handle arrays: quote keyword operators, transform other arrays recursively @@ -49,10 +48,10 @@ function transformValue (value, leafSerializer, state) { if (isKeyword(operator)) { // Quote reserved keyword strings to preserve them as data - return [['quote', operator], ...rest.map((item) => transformValue(item, leafSerializer, state))] + return [['quote', operator], ...rest.map((item) => transformValue(item, leafSerializer))] } - return value.map((item) => transformValue(item, leafSerializer, state)) + return value.map((item) => transformValue(item, leafSerializer)) } // Recurse into plain objects @@ -60,7 +59,7 @@ function transformValue (value, leafSerializer, state) { const result = {} for (const key of Object.keys(value)) { - result[key] = transformValue(value[key], leafSerializer, state) + result[key] = transformValue(value[key], leafSerializer) } return result @@ -71,6 +70,5 @@ function transformValue (value, leafSerializer, state) { } export default function stringify (program, leafSerializer = JSON.stringify) { - const state = { awaitId: -1 } - return JSON.stringify(transformValue(program, leafSerializer, state)) + return JSON.stringify(transformValue(program, leafSerializer)) } From b3ee9f693dc6137b01c9f0072aa2dd1e940f07c9 Mon Sep 17 00:00:00 2001 From: bas080 Date: Tue, 7 Apr 2026 04:27:14 +0300 Subject: [PATCH 3/4] Document new nesting and leaf serialization feature --- README.mz | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/README.mz b/README.mz index 6840563..3ade26a 100644 --- a/README.mz +++ b/README.mz @@ -246,6 +246,74 @@ You can see the docs [here](./example/typescript/docs/globals.md) > experience, it does not represent the actual type. > Values are subject to serialization and deserialization. + +## Schema and Nested Modules + +Sendscript allows you to define your API as a **nested object of functions**, making it easy to organize your DSL into modules and submodules. Each function is instrumented so that when serialized, it produces a structured reference that can be safely sent and executed elsewhere. + +### Defining a Nested Module + +You can define a schema as either: + +1. **An object with nested objects** – submodules. +2. **An array of function names** – automatically instrumented. + +```js +import module from 'sendscript/module.mjs' + +const myModule = module({ + math: ['add', 'sub'], + + // Use an object with keys and true value + vector: { + add: true, + multiply: true + }, + + // or use an array. + utils: ['identity', 'always'], +}) +``` + +Functions are referenced via their **path in the module tree**: + +```js +const { math, vector } = myModule + +math.add( + 1, + vector.length( + vector.multiply([1,2], 3) + ) +) +``` + +## Validation (using Zod) + +SendScript focuses on program serialization and execution. For runtime input validation, you can use [Zod](https://zod.dev). + +### Validating structured input + +```js +const userSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + roles: z.array(z.string()) +}) + +export function createUser(user) { + userSchema.parse(user) + + return { success: true } +} +``` + +**Benefits**: + +- Ensures arguments match expected types and shapes. +- Throws structured errors that can be propagated to clients. +- Works with TypeScript for automatic type inference. + ## Leaf Serializer By default, SendScript uses JSON for serialization, which limits support to primitives and plain objects/arrays. To support richer JavaScript types like `Date`, `RegExp`, `BigInt`, `Map`, `Set`, and `undefined`, you can provide custom serialization functions. From 68bfab27a561a994381cdcf5a7cf367e29a2d07d Mon Sep 17 00:00:00 2001 From: bas080 Date: Tue, 7 Apr 2026 04:27:28 +0300 Subject: [PATCH 4/4] Define bin script in package json --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 0d315a5..80df412 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "Blur the line between server and client code.", "module": true, "main": "index.mjs", + "bin": "./cli.mjs", "keywords": [ "rpc", "json",