Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.mz
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions index.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
})

Expand Down
149 changes: 149 additions & 0 deletions leaf-serializer-property.test.mjs
Original file line number Diff line number Diff line change
@@ -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}`)
}
}
))
84 changes: 84 additions & 0 deletions leaf-serializer.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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('fallback to default deserializer when null is passed', async (t) => {
const value = { a: 1, b: 'hello' }
const result = await parse(stringify(value), null)

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()
})
Loading