sendscript 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,8 +4,16 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ #### [v1.1.0](https://github.com/bas080/sendscript/compare/v1.0.6...v1.1.0)
8
+
9
+ - Support custom serialization of leaf nodes [`d56f1b7`](https://github.com/bas080/sendscript/commit/d56f1b750e2d7faf72d69f9ba8e9bb62ef5ebce7)
10
+ - Support nested modules [`88e69e5`](https://github.com/bas080/sendscript/commit/88e69e5c05ddb85aac1797a020e92519c986e89f)
11
+ - Document new nesting and leaf serialization feature [`b3ee9f6`](https://github.com/bas080/sendscript/commit/b3ee9f693dc6137b01c9f0072aa2dd1e940f07c9)
12
+
7
13
  #### [v1.0.6](https://github.com/bas080/sendscript/compare/v1.0.5...v1.0.6)
8
14
 
15
+ > 6 April 2026
16
+
9
17
  - Update tap to 21.6.3 [`c1769d9`](https://github.com/bas080/sendscript/commit/c1769d9a24148e63d27b0e5a7456213bec64763e)
10
18
  - Add introduction to readme [`a21a494`](https://github.com/bas080/sendscript/commit/a21a494616d5ad8c99f9a2904ef529ea85104a99)
11
19
 
package/README.md CHANGED
@@ -17,6 +17,12 @@ Write JS code that you can run on servers, browsers or other clients.
17
17
  - [Repl](#repl)
18
18
  - [Async/Await](#asyncawait)
19
19
  - [TypeScript](#typescript)
20
+ - [Schema and Nested Modules](#schema-and-nested-modules)
21
+ * [Defining a Nested Module](#defining-a-nested-module)
22
+ - [Validation (using Zod)](#validation-using-zod)
23
+ * [Validating structured input](#validating-structured-input)
24
+ - [Leaf Serializer](#leaf-serializer)
25
+ * [Example with superjson](#example-with-superjson)
20
26
  - [Tests](#tests)
21
27
  - [Formatting](#formatting)
22
28
  - [Changelog](#changelog)
@@ -55,7 +61,7 @@ const { add } = module(['add'])
55
61
  console.log(stringify(add(1,2)))
56
62
  ```
57
63
  ```json
58
- ["call",["ref","add"],[1,2]]
64
+ ["call",["ref","add"],[["leaf","1"],["leaf","2"]]]
59
65
  ```
60
66
 
61
67
  We can then parse that JSON and it will evaluate down to a value.
@@ -303,6 +309,128 @@ You can see the docs [here](./example/typescript/docs/globals.md)
303
309
  > experience, it does not represent the actual type.
304
310
  > Values are subject to serialization and deserialization.
305
311
 
312
+
313
+ ## Schema and Nested Modules
314
+
315
+ 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.
316
+
317
+ ### Defining a Nested Module
318
+
319
+ You can define a schema as either:
320
+
321
+ 1. **An object with nested objects** – submodules.
322
+ 2. **An array of function names** – automatically instrumented.
323
+
324
+ ```js
325
+ import module from 'sendscript/module.mjs'
326
+
327
+ const myModule = module({
328
+ math: ['add', 'sub'],
329
+
330
+ // Use an object with keys and true value
331
+ vector: {
332
+ add: true,
333
+ multiply: true
334
+ },
335
+
336
+ // or use an array.
337
+ utils: ['identity', 'always'],
338
+ })
339
+ ```
340
+
341
+ Functions are referenced via their **path in the module tree**:
342
+
343
+ ```js
344
+ const { math, vector } = myModule
345
+
346
+ math.add(
347
+ 1,
348
+ vector.length(
349
+ vector.multiply([1,2], 3)
350
+ )
351
+ )
352
+ ```
353
+
354
+ ## Validation (using Zod)
355
+
356
+ SendScript focuses on program serialization and execution. For runtime input validation, you can use [Zod](https://zod.dev).
357
+
358
+ ### Validating structured input
359
+
360
+ ```js
361
+ const userSchema = z.object({
362
+ id: z.string().uuid(),
363
+ name: z.string(),
364
+ roles: z.array(z.string())
365
+ })
366
+
367
+ export function createUser(user) {
368
+ userSchema.parse(user)
369
+
370
+ return { success: true }
371
+ }
372
+ ```
373
+
374
+ **Benefits**:
375
+
376
+ - Ensures arguments match expected types and shapes.
377
+ - Throws structured errors that can be propagated to clients.
378
+ - Works with TypeScript for automatic type inference.
379
+
380
+ ## Leaf Serializer
381
+
382
+ 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.
383
+
384
+ 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.
385
+
386
+ ### Example with superjson
387
+
388
+ Here's how to use [superjson](https://github.com/blitz-js/superjson) to support extended types:
389
+
390
+ ```js
391
+ import SuperJSON from 'superjson'
392
+ import stringify from 'sendscript/stringify.mjs'
393
+ import Parse from 'sendscript/parse.mjs'
394
+ import module from 'sendscript/module.mjs'
395
+
396
+ const leafSerializer = (value) => {
397
+ if (value === undefined) return JSON.stringify({ __undefined__: true })
398
+ return JSON.stringify(SuperJSON.serialize(value))
399
+ }
400
+
401
+ const leafDeserializer = (text) => {
402
+ const parsed = JSON.parse(text)
403
+ if (parsed && parsed.__undefined__ === true) return undefined
404
+ return SuperJSON.deserialize(parsed)
405
+ }
406
+
407
+ const { processData } = module(['processData'])
408
+
409
+ // Program with Date, RegExp, and other types
410
+ const program = {
411
+ createdAt: new Date('2020-01-01T00:00:00.000Z'),
412
+ pattern: /foo/gi,
413
+ count: BigInt('9007199254740992'),
414
+ items: new Set([1, 2, 3]),
415
+ mapping: new Map([['a', 1], ['b', 2]])
416
+ }
417
+
418
+ // Serialize with custom leaf serializer
419
+ const json = stringify(processData(program), leafSerializer)
420
+
421
+ // Parse with custom leaf deserializer
422
+ const parse = Parse({
423
+ processData: (data) => ({
424
+ success: true,
425
+ received: data
426
+ })
427
+ })
428
+
429
+ const result = parse(json, leafDeserializer)
430
+ ```
431
+
432
+ The leaf wrapper format is `['leaf', serializedPayload]`, making it unambiguous and safe from colliding with SendScript operators.
433
+
306
434
  ## Tests
307
435
 
308
436
  Tests with 100% code coverage.
@@ -313,19 +441,19 @@ npm t -- report text-summary
313
441
  ```
314
442
  ```
315
443
 
316
- > sendscript@1.0.6 test
444
+ > sendscript@1.1.0 test
317
445
  > tap -R silent
318
446
 
319
447
 
320
- > sendscript@1.0.6 test
448
+ > sendscript@1.1.0 test
321
449
  > tap report text-summary
322
450
 
323
451
 
324
452
  =============================== Coverage summary ===============================
325
- Statements : 100% ( 245/245 )
326
- Branches : 100% ( 74/74 )
327
- Functions : 100% ( 18/18 )
328
- Lines : 100% ( 245/245 )
453
+ Statements : 100% ( 328/328 )
454
+ Branches : 100% ( 138/138 )
455
+ Functions : 100% ( 23/23 )
456
+ Lines : 100% ( 328/328 )
329
457
  ================================================================================
330
458
  ```
331
459
 
package/README.mz CHANGED
@@ -246,6 +246,128 @@ You can see the docs [here](./example/typescript/docs/globals.md)
246
246
  > experience, it does not represent the actual type.
247
247
  > Values are subject to serialization and deserialization.
248
248
 
249
+
250
+ ## Schema and Nested Modules
251
+
252
+ 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.
253
+
254
+ ### Defining a Nested Module
255
+
256
+ You can define a schema as either:
257
+
258
+ 1. **An object with nested objects** – submodules.
259
+ 2. **An array of function names** – automatically instrumented.
260
+
261
+ ```js
262
+ import module from 'sendscript/module.mjs'
263
+
264
+ const myModule = module({
265
+ math: ['add', 'sub'],
266
+
267
+ // Use an object with keys and true value
268
+ vector: {
269
+ add: true,
270
+ multiply: true
271
+ },
272
+
273
+ // or use an array.
274
+ utils: ['identity', 'always'],
275
+ })
276
+ ```
277
+
278
+ Functions are referenced via their **path in the module tree**:
279
+
280
+ ```js
281
+ const { math, vector } = myModule
282
+
283
+ math.add(
284
+ 1,
285
+ vector.length(
286
+ vector.multiply([1,2], 3)
287
+ )
288
+ )
289
+ ```
290
+
291
+ ## Validation (using Zod)
292
+
293
+ SendScript focuses on program serialization and execution. For runtime input validation, you can use [Zod](https://zod.dev).
294
+
295
+ ### Validating structured input
296
+
297
+ ```js
298
+ const userSchema = z.object({
299
+ id: z.string().uuid(),
300
+ name: z.string(),
301
+ roles: z.array(z.string())
302
+ })
303
+
304
+ export function createUser(user) {
305
+ userSchema.parse(user)
306
+
307
+ return { success: true }
308
+ }
309
+ ```
310
+
311
+ **Benefits**:
312
+
313
+ - Ensures arguments match expected types and shapes.
314
+ - Throws structured errors that can be propagated to clients.
315
+ - Works with TypeScript for automatic type inference.
316
+
317
+ ## Leaf Serializer
318
+
319
+ 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.
320
+
321
+ 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.
322
+
323
+ ### Example with superjson
324
+
325
+ Here's how to use [superjson](https://github.com/blitz-js/superjson) to support extended types:
326
+
327
+ ```js
328
+ import SuperJSON from 'superjson'
329
+ import stringify from 'sendscript/stringify.mjs'
330
+ import Parse from 'sendscript/parse.mjs'
331
+ import module from 'sendscript/module.mjs'
332
+
333
+ const leafSerializer = (value) => {
334
+ if (value === undefined) return JSON.stringify({ __undefined__: true })
335
+ return JSON.stringify(SuperJSON.serialize(value))
336
+ }
337
+
338
+ const leafDeserializer = (text) => {
339
+ const parsed = JSON.parse(text)
340
+ if (parsed && parsed.__undefined__ === true) return undefined
341
+ return SuperJSON.deserialize(parsed)
342
+ }
343
+
344
+ const { processData } = module(['processData'])
345
+
346
+ // Program with Date, RegExp, and other types
347
+ const program = {
348
+ createdAt: new Date('2020-01-01T00:00:00.000Z'),
349
+ pattern: /foo/gi,
350
+ count: BigInt('9007199254740992'),
351
+ items: new Set([1, 2, 3]),
352
+ mapping: new Map([['a', 1], ['b', 2]])
353
+ }
354
+
355
+ // Serialize with custom leaf serializer
356
+ const json = stringify(processData(program), leafSerializer)
357
+
358
+ // Parse with custom leaf deserializer
359
+ const parse = Parse({
360
+ processData: (data) => ({
361
+ success: true,
362
+ received: data
363
+ })
364
+ })
365
+
366
+ const result = parse(json, leafDeserializer)
367
+ ```
368
+
369
+ The leaf wrapper format is `['leaf', serializedPayload]`, making it unambiguous and safe from colliding with SendScript operators.
370
+
249
371
  ## Tests
250
372
 
251
373
  Tests with 100% code coverage.
@@ -8,7 +8,7 @@
8
8
 
9
9
  > **add**(`a`, `b`): `number`
10
10
 
11
- Defined in: [math.ts:1](https://github.com/bas080/sendscript/blob/c1769d9a24148e63d27b0e5a7456213bec64763e/example/typescript/math.ts#L1)
11
+ Defined in: [math.ts:1](https://github.com/bas080/sendscript/blob/68bfab27a561a994381cdcf5a7cf367e29a2d07d/example/typescript/math.ts#L1)
12
12
 
13
13
  ## Parameters
14
14
 
@@ -8,7 +8,7 @@
8
8
 
9
9
  > **square**(`a`): `number`
10
10
 
11
- Defined in: [math.ts:2](https://github.com/bas080/sendscript/blob/c1769d9a24148e63d27b0e5a7456213bec64763e/example/typescript/math.ts#L2)
11
+ Defined in: [math.ts:2](https://github.com/bas080/sendscript/blob/68bfab27a561a994381cdcf5a7cf367e29a2d07d/example/typescript/math.ts#L2)
12
12
 
13
13
  ## Parameters
14
14
 
package/index.test.mjs CHANGED
@@ -1,7 +1,14 @@
1
1
  import { test } from 'tap'
2
- import Sendscript from './index.mjs'
3
-
4
- const module = {
2
+ import stringify from './stringify.mjs'
3
+ import ssparse from './parse.mjs'
4
+ import module from './module.mjs'
5
+
6
+ const myModule = {
7
+ nested: {
8
+ again: {
9
+ T: () => true
10
+ }
11
+ },
5
12
  add: (a, b) => a + b,
6
13
  identity: (x) => x,
7
14
  concat: (a, b) => a.concat(b),
@@ -18,13 +25,32 @@ const module = {
18
25
  asyncAdd: async (a, b) => a + b,
19
26
  aPromise: Promise.resolve(42),
20
27
  delayedIdentity: async (x) => x,
28
+ nullProto: () => {
29
+ const obj = Object.create(null)
30
+ obj.b = 'c'
31
+ return obj
32
+ },
21
33
  Function,
22
34
  Promise
23
35
  }
24
36
 
25
- const sendscript = Sendscript(module)
26
- const { parse, stringify } = sendscript
27
- const run = (program) => parse(stringify(program))
37
+ const schema = Object.keys(myModule).reduce((acc, key) => {
38
+ acc[key] = true
39
+
40
+ return acc
41
+ }, {})
42
+
43
+ schema.nested = { again: ['T'] }
44
+
45
+ const sendscript = {
46
+ stringify,
47
+ parse: ssparse(myModule),
48
+ module: module(schema)
49
+ }
50
+
51
+ const { parse } = sendscript
52
+
53
+ const run = (program) => sendscript.parse(sendscript.stringify(program))
28
54
 
29
55
  const RealPromise = Promise
30
56
 
@@ -46,9 +72,16 @@ test('should evaluate basic expressions correctly', async (t) => {
46
72
  concat,
47
73
  identity,
48
74
  always,
49
- multiply3
75
+ multiply3,
76
+ nested
50
77
  } = sendscript.module
51
78
 
79
+ t.test('calling nested function works', t => {
80
+ t.equal(run(nested.again.T()), true)
81
+
82
+ t.end()
83
+ })
84
+
52
85
  t.test('nested await works', async (t) => {
53
86
  // Async identity passthrough
54
87
  const resolvedId = await delayedIdentity
@@ -177,6 +210,16 @@ test('should evaluate basic expressions correctly', async (t) => {
177
210
  run(identity(['ref', 'hello'])),
178
211
  run(identity(toArray('ref', 'hello')))
179
212
  )
213
+ t.strictSame(
214
+ run(identity(['leaf', 1, 2, 3])),
215
+ ['leaf', 1, 2, 3]
216
+ )
217
+ t.end()
218
+ })
219
+
220
+ t.test('null-prototype object traversal', (t) => {
221
+ const { nullProto } = sendscript.module
222
+ t.strictSame(run({ a: nullProto() }), { a: { b: 'c' } })
180
223
  t.end()
181
224
  })
182
225
 
@@ -0,0 +1,149 @@
1
+ import { test } from 'tap'
2
+ import { createRequire } from 'module'
3
+ import SuperJSON from 'superjson'
4
+
5
+ const require = createRequire(import.meta.url)
6
+ const { check, gen } = require('tape-check')
7
+
8
+ const leafSerializer = (value) => {
9
+ if (value === undefined) return JSON.stringify({ __sendscript_undefined__: true })
10
+ return JSON.stringify(SuperJSON.serialize(value))
11
+ }
12
+
13
+ const leafDeserializer = (text) => {
14
+ const parsed = JSON.parse(text)
15
+ if (parsed && parsed.__sendscript_undefined__ === true) return undefined
16
+ return SuperJSON.deserialize(parsed)
17
+ }
18
+
19
+ // Helper to compare values accounting for types that can't use ===
20
+ const valueEquals = (a, b) => {
21
+ if (a === b) return true
22
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
23
+ if (a instanceof RegExp && b instanceof RegExp) return a.source === b.source && a.flags === b.flags
24
+ if (a instanceof Set && b instanceof Set) {
25
+ if (a.size !== b.size) return false
26
+ for (const item of a) {
27
+ if (!b.has(item)) return false
28
+ }
29
+ return true
30
+ }
31
+ if (a instanceof Map && b instanceof Map) {
32
+ if (a.size !== b.size) return false
33
+ for (const [key, val] of a) {
34
+ if (!b.has(key) || !valueEquals(val, b.get(key))) return false
35
+ }
36
+ return true
37
+ }
38
+ return JSON.stringify(a) === JSON.stringify(b)
39
+ }
40
+
41
+ // Helper to get a human-readable type name
42
+ const getTypeInfo = (value) => {
43
+ if (value === null) return 'null'
44
+ if (value === undefined) return 'undefined'
45
+ if (value instanceof Date) return 'Date'
46
+ if (value instanceof RegExp) return 'RegExp'
47
+ if (value instanceof Set) return 'Set'
48
+ if (value instanceof Map) return 'Map'
49
+ if (typeof value === 'bigint') return 'BigInt'
50
+ return typeof value
51
+ }
52
+
53
+ // Property 1: Round-trip - any value that can be serialized should deserialize to an equal value
54
+ test('property: round-trip serialization preserves value', check(
55
+ gen.any,
56
+ (t, value) => {
57
+ t.plan(1)
58
+ try {
59
+ const serialized = leafSerializer(value)
60
+ const deserialized = leafDeserializer(serialized)
61
+ const typeInfo = getTypeInfo(value)
62
+ t.ok(valueEquals(deserialized, value), `Round-trip preserved ${typeInfo}`)
63
+ } catch (e) {
64
+ // If serialization fails on a particular value, that's acceptable
65
+ // (not all values may be serializable)
66
+ const typeInfo = getTypeInfo(value)
67
+ t.pass(`Serialization of ${typeInfo} threw: ${e.message}`)
68
+ }
69
+ }
70
+ ))
71
+
72
+ // Property 2: Determinism - serializing the same value repeatedly produces identical results
73
+ test('property: serialization is deterministic', check(
74
+ gen.any,
75
+ (t, value) => {
76
+ t.plan(1)
77
+ try {
78
+ const serialized1 = leafSerializer(value)
79
+ const serialized2 = leafSerializer(value)
80
+ const typeInfo = getTypeInfo(value)
81
+ t.equal(serialized1, serialized2, `Serialization of ${typeInfo} is deterministic`)
82
+ } catch (e) {
83
+ const typeInfo = getTypeInfo(value)
84
+ t.pass(`Serialization threw for ${typeInfo}: ${e.message}`)
85
+ }
86
+ }
87
+ ))
88
+
89
+ // Property 3: Valid JSON - serialized output is always valid JSON
90
+ test('property: serialized output is valid JSON', check(
91
+ gen.any,
92
+ (t, value) => {
93
+ t.plan(1)
94
+ try {
95
+ const serialized = leafSerializer(value)
96
+ const parsed = JSON.parse(serialized)
97
+ t.ok(typeof parsed === 'object' || typeof parsed === 'string', 'Parsed JSON is an object or string')
98
+ } catch (e) {
99
+ t.fail(`Invalid JSON output for ${getTypeInfo(value)}: ${e.message}`)
100
+ }
101
+ }
102
+ ))
103
+
104
+ // Property 4: Undefined handling - undefined values are preserved through round-trip
105
+ test('property: undefined values are preserved through serialization', check(
106
+ gen.any,
107
+ (t, value) => {
108
+ t.plan(1)
109
+ if (value === undefined) {
110
+ const serialized = leafSerializer(value)
111
+ const deserialized = leafDeserializer(serialized)
112
+ t.equal(deserialized, undefined, 'Undefined preserved through serialization')
113
+ } else {
114
+ t.pass('Value was not undefined')
115
+ }
116
+ }
117
+ ))
118
+
119
+ // Property 5: Type distinctness - Different values should have different serializations (when possible)
120
+ test('property: different primitives have different serializations', check(
121
+ gen.primitive,
122
+ gen.primitive,
123
+ (t, val1, val2) => {
124
+ t.plan(1)
125
+ if (val1 !== val2 && !(Number.isNaN(val1) && Number.isNaN(val2))) {
126
+ const ser1 = leafSerializer(val1)
127
+ const ser2 = leafSerializer(val2)
128
+ t.not(ser1, ser2, `Different primitives ${getTypeInfo(val1)} and ${getTypeInfo(val2)} have different serializations`)
129
+ } else {
130
+ t.pass('Primitives are equal or both NaN')
131
+ }
132
+ }
133
+ ))
134
+
135
+ // Property 6: Idempotence of serialization - Re-parsing serialized value produces same serialization
136
+ test('property: serialization round-trip is stable', check(
137
+ gen.any,
138
+ (t, value) => {
139
+ t.plan(1)
140
+ try {
141
+ const ser1 = leafSerializer(value)
142
+ const deser1 = leafDeserializer(ser1)
143
+ const ser2 = leafSerializer(deser1)
144
+ t.equal(ser1, ser2, `Serialization is stable for ${getTypeInfo(value)}`)
145
+ } catch (e) {
146
+ t.pass(`Serialization error for ${getTypeInfo(value)}: ${e.message}`)
147
+ }
148
+ }
149
+ ))
@@ -0,0 +1,76 @@
1
+ import { test } from 'tap'
2
+ import SuperJSON from 'superjson'
3
+ import Sendscript from './index.mjs'
4
+
5
+ const leafSerializer = (value) => {
6
+ if (value === undefined) return JSON.stringify({ __sendscript_undefined__: true })
7
+ return JSON.stringify(SuperJSON.serialize(value))
8
+ }
9
+
10
+ const leafDeserializer = (text) => {
11
+ const parsed = JSON.parse(text)
12
+ if (parsed && parsed.__sendscript_undefined__ === true) return undefined
13
+ return SuperJSON.deserialize(parsed)
14
+ }
15
+
16
+ const module = {
17
+ identity: (x) => x
18
+ }
19
+
20
+ const sendscript = Sendscript(Object.keys(module))
21
+ const { parse, stringify } = sendscript
22
+ const run = (program, serializer, deserializer) =>
23
+ parse(stringify(program, serializer), deserializer)
24
+
25
+ test('custom leaf serializer/deserializer using superjson', async (t) => {
26
+ const value = {
27
+ date: new Date('2020-01-01T00:00:00.000Z'),
28
+ regex: /abc/gi,
29
+ big: BigInt('123456789012345678901234567890'),
30
+ undef: undefined,
31
+ nested: {
32
+ set: new Set([1, 2, 3]),
33
+ map: new Map([['a', 1], ['b', 2]])
34
+ }
35
+ }
36
+
37
+ const result = await run(value, leafSerializer, leafDeserializer)
38
+
39
+ t.ok(result.date instanceof Date)
40
+ t.equal(result.date.toISOString(), value.date.toISOString())
41
+
42
+ t.ok(result.regex instanceof RegExp)
43
+ t.equal(result.regex.source, 'abc')
44
+ t.equal(result.regex.flags, 'gi')
45
+
46
+ t.equal(result.big, value.big)
47
+
48
+ t.ok(Object.prototype.hasOwnProperty.call(result, 'undef'))
49
+ t.equal(result.undef, undefined)
50
+
51
+ t.ok(result.nested.set instanceof Set)
52
+ t.strictSame(Array.from(result.nested.set), [1, 2, 3])
53
+
54
+ t.ok(result.nested.map instanceof Map)
55
+ t.strictSame(Array.from(result.nested.map.entries()), [['a', 1], ['b', 2]])
56
+
57
+ t.end()
58
+ })
59
+
60
+ test('default leaf deserializer when not provided', async (t) => {
61
+ const value = { a: 1, b: 'hello' }
62
+ const result = await run(value)
63
+
64
+ t.strictSame(result, value)
65
+ t.end()
66
+ })
67
+
68
+ test('default leaf deserializer handles undefined parameter', (t) => {
69
+ const parse = Sendscript({}).parse
70
+ // Create a simple JSON with a leaf then parse using default deserializer
71
+ // The reviver will never pass undefined to deserializer, but we test it defensively
72
+ const json = '["leaf","{\\"test\\":1}"]'
73
+ const result = parse(json)
74
+ t.strictSame(result, { test: 1 })
75
+ t.end()
76
+ })
package/module.mjs CHANGED
@@ -1,12 +1,8 @@
1
- import {
2
- awaitSymbol,
3
- call,
4
- ref
5
- } from './symbol.mjs'
1
+ import { awaitSymbol, call, ref } from './symbol.mjs'
6
2
 
7
- function instrument (name) {
3
+ function instrument (path) {
8
4
  function reference (...args) {
9
- const called = instrument(name)
5
+ const called = instrument(path)
10
6
 
11
7
  called.toJSON = () => ({
12
8
  [call]: call,
@@ -19,8 +15,7 @@ function instrument (name) {
19
15
  }
20
16
 
21
17
  reference.then = (resolve) => {
22
- const awaited = instrument(name)
23
-
18
+ const awaited = instrument(path)
24
19
  delete awaited.then
25
20
 
26
21
  awaited.toJSON = () => ({
@@ -35,18 +30,31 @@ function instrument (name) {
35
30
  reference.toJSON = () => ({
36
31
  [ref]: ref,
37
32
  reference: true,
38
- name
33
+ path
39
34
  })
40
35
 
41
36
  return reference
42
37
  }
43
38
 
44
- export default function module (schema) {
45
- if (!Array.isArray(schema)) return module(Object.keys(schema))
39
+ export default function module (schema, parentPath = []) {
40
+ if (Array.isArray(schema)) {
41
+ return schema.reduce((acc, name) => {
42
+ acc[name] = instrument([...parentPath, name])
43
+ return acc
44
+ }, {})
45
+ }
46
+
47
+ return Object.keys(schema).reduce((acc, key) => {
48
+ const value = schema[key]
46
49
 
47
- return schema.reduce((api, name) => {
48
- api[name] = instrument(name)
50
+ if (Array.isArray(value)) {
51
+ acc[key] = module(value, [...parentPath, key])
52
+ } else if (typeof value === 'object' && value !== null) {
53
+ acc[key] = module(value, [...parentPath, key])
54
+ } else {
55
+ acc[key] = instrument([...parentPath, key])
56
+ }
49
57
 
50
- return api
58
+ return acc
51
59
  }, {})
52
60
  }
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "sendscript",
3
- "version": "1.0.6",
3
+ "version": "1.1.0",
4
4
  "description": "Blur the line between server and client code.",
5
5
  "module": true,
6
6
  "main": "index.mjs",
7
+ "bin": "./cli.mjs",
7
8
  "keywords": [
8
9
  "rpc",
9
10
  "json",
@@ -23,7 +24,9 @@
23
24
  "author": "Bas Huis",
24
25
  "license": "MIT",
25
26
  "devDependencies": {
26
- "tap": "^21.6.3"
27
+ "superjson": "^2.2.6",
28
+ "tap": "^21.6.3",
29
+ "tape-check": "^1.0.0-rc.0"
27
30
  },
28
31
  "dependencies": {
29
32
  "debug": "^4.4.3"
package/parse.mjs CHANGED
@@ -1,43 +1,100 @@
1
1
  import Debug from './debug.mjs'
2
- import isNil from './is-nil.mjs'
3
2
  import { SendScriptReferenceError } from './error.mjs'
4
3
 
5
4
  const debug = Debug.extend('parse')
6
5
 
7
- export default (env) =>
8
- function parse (program) {
9
- debug('program', program)
6
+ const isThenable = (value) => (
7
+ value != null && typeof value.then === 'function'
8
+ )
10
9
 
11
- const awaits = []
12
- const resolved = {}
10
+ const isAwaitPromise = Symbol('sendscript-await')
11
+ const undefinedSentinel = Symbol('sendscript-undefined')
13
12
 
14
- JSON.parse(program, (key, value) => {
15
- if (!Array.isArray(value)) return value
13
+ const isPlainObject = (value) => {
14
+ if (!value || typeof value !== 'object') return false
15
+ const proto = Object.getPrototypeOf(value)
16
+ return proto === Object.prototype || proto === null
17
+ }
16
18
 
17
- const [operator, ...rest] = value
19
+ const markAwait = (promise) => {
20
+ promise[isAwaitPromise] = true
21
+ return promise
22
+ }
18
23
 
19
- if (operator === 'await') {
20
- const [program, awaitId] = rest
21
-
22
- awaits.push(((program, awaitId) => async () => {
23
- resolved[awaitId] = await JSON.parse(
24
- JSON.stringify(program),
25
- reviver
26
- )
27
- })(program, awaitId))
28
- }
24
+ const isExplicitAwait = (value) => (
25
+ isThenable(value) && value[isAwaitPromise] === true
26
+ )
29
27
 
30
- return value
28
+ // Recursively resolve awaited values in a parsed tree
29
+ const resolveAwaitedValues = (value) => {
30
+ if (value === undefinedSentinel) return undefined
31
+
32
+ if (isThenable(value)) {
33
+ return isExplicitAwait(value)
34
+ ? markAwait(value.then(resolveAwaitedValues))
35
+ : value
36
+ }
37
+
38
+ if (Array.isArray(value)) {
39
+ let hasAwait = false
40
+ const result = value.map((item) => {
41
+ const resolved = resolveAwaitedValues(item)
42
+ if (isExplicitAwait(resolved)) hasAwait = true
43
+ return resolved
31
44
  })
32
45
 
33
- const spy = fn => (...args) => {
34
- const value = fn(...args)
35
- debug(args, ' => ', value)
36
- return value
46
+ if (!hasAwait) return result
47
+
48
+ const awaited = result.map((item, index) =>
49
+ isExplicitAwait(item)
50
+ ? Promise.resolve(item).then((resolved) => {
51
+ result[index] = resolved
52
+ return resolved
53
+ })
54
+ : item
55
+ )
56
+
57
+ return markAwait(Promise.all(awaited).then(() => result))
58
+ }
59
+
60
+ if (isPlainObject(value)) {
61
+ const result = {}
62
+ const promises = []
63
+
64
+ for (const key of Object.keys(value)) {
65
+ const resolved = resolveAwaitedValues(value[key])
66
+ if (isExplicitAwait(resolved)) {
67
+ promises.push(
68
+ Promise.resolve(resolved).then((resolvedValue) => {
69
+ result[key] = resolvedValue
70
+ })
71
+ )
72
+ } else {
73
+ result[key] = resolved
74
+ }
37
75
  }
38
76
 
77
+ if (!promises.length) return result
78
+ return markAwait(Promise.all(promises).then(() => result))
79
+ }
80
+
81
+ return value
82
+ }
83
+
84
+ const spy = (fn) => (...args) => {
85
+ const value = fn(...args)
86
+ debug(args, ' => ', value)
87
+ return value
88
+ }
89
+
90
+ const defaultLeafDeserializer = (text) => JSON.parse(text)
91
+
92
+ export default (env) =>
93
+ function parse (program, deserialize = defaultLeafDeserializer) {
94
+ debug('program', program)
95
+
39
96
  const reviver = spy((key, value) => {
40
- if (isNil(value)) return value
97
+ if (value === null) return value
41
98
 
42
99
  if (!Array.isArray(value)) {
43
100
  return value
@@ -45,48 +102,61 @@ export default (env) =>
45
102
 
46
103
  const [operator, ...rest] = value
47
104
 
48
- if (operator === 'await') {
49
- const [, awaitId] = rest
50
- debug('read awaits', resolved[awaitId], awaitId)
105
+ if (operator === 'leaf') {
106
+ const leafValue = deserialize(rest[0])
107
+ return leafValue === undefined ? undefinedSentinel : leafValue
108
+ }
51
109
 
52
- return resolved[awaitId]
110
+ if (operator === 'await') {
111
+ const [program] = rest
112
+ return markAwait(Promise.resolve(program))
53
113
  }
54
114
 
55
115
  if (Array.isArray(operator) && operator[0] === 'quote') {
56
116
  const [, quoted] = operator
57
-
58
117
  return [quoted, ...rest]
59
118
  }
60
119
 
61
120
  if (operator === 'call') {
62
121
  const [fn, args] = rest
63
-
64
- return fn(...args)
122
+ const resolvedFn = isExplicitAwait(fn) ? Promise.resolve(fn) : fn
123
+ const resolvedArgs = resolveAwaitedValues(args)
124
+
125
+ if (isExplicitAwait(resolvedFn) || isExplicitAwait(resolvedArgs)) {
126
+ const promiseFn = isExplicitAwait(resolvedFn)
127
+ ? Promise.resolve(resolvedFn)
128
+ : resolvedFn
129
+ const promiseArgs = isExplicitAwait(resolvedArgs)
130
+ ? Promise.resolve(resolvedArgs)
131
+ : resolvedArgs
132
+
133
+ return Promise.all([promiseFn, promiseArgs])
134
+ .then(([resolvedFnValue, resolvedArgsValue]) => resolvedFnValue(...resolvedArgsValue))
135
+ }
136
+
137
+ return fn(...resolvedArgs)
65
138
  }
66
139
 
67
140
  if (operator === 'ref') {
68
- const [name] = rest
69
-
70
- if (Object.hasOwn(env, name)) return env[name]
71
-
72
- throw new SendScriptReferenceError({ key, value })
141
+ const path = rest // e.g., ["math","add"]
142
+ let current = env
143
+
144
+ for (const segment of path) {
145
+ if (current && Object.hasOwn(current, segment)) {
146
+ current = current[segment]
147
+ } else {
148
+ throw new SendScriptReferenceError({ key, value })
149
+ }
150
+ }
151
+
152
+ return current
73
153
  }
74
154
 
75
155
  return value
76
156
  })
77
157
 
78
- if (awaits.length) {
79
- return sequential(awaits).then(() => {
80
- return JSON.parse(program, reviver)
81
- })
82
- }
158
+ const parsed = JSON.parse(program, reviver)
159
+ const result = resolveAwaitedValues(parsed)
83
160
 
84
- return JSON.parse(program, reviver)
161
+ return result
85
162
  }
86
-
87
- function sequential (promises) {
88
- return promises.reduce(
89
- (acc, curr) => acc.then(results => curr().then(res => [...results, res])),
90
- Promise.resolve([])
91
- )
92
- }
package/stringify.mjs CHANGED
@@ -1,5 +1,4 @@
1
1
  import Debug from './debug.mjs'
2
- import isNil from './is-nil.mjs'
3
2
  import {
4
3
  awaitSymbol,
5
4
  call,
@@ -8,59 +7,68 @@ import {
8
7
 
9
8
  const debug = Debug.extend('stringify')
10
9
 
11
- const replaced = Symbol('replaced')
12
- const keywords = ['ref', 'call', 'quote', 'await']
10
+ const keywords = ['ref', 'call', 'quote', 'await', 'leaf']
13
11
  const isKeyword = (v) => keywords.includes(v)
14
- let awaitId = -1
15
12
 
16
- function replacer (key, value) {
17
- debug(this, key, value)
18
-
19
- if (isNil(value)) {
20
- return value
21
- }
22
-
23
- if (value[ref]) {
24
- const result = ['ref', value.name]
13
+ const isPlainObject = (value) => {
14
+ if (!value || typeof value !== 'object') return false
15
+ const proto = Object.getPrototypeOf(value)
16
+ return proto === Object.prototype || proto === null
17
+ }
25
18
 
26
- result[replaced] = replaced
19
+ // Recursively transform a program tree, encoding SendScript operators and leaf values
20
+ function transformValue (value, leafSerializer) {
21
+ debug(value)
27
22
 
28
- return result
23
+ if (value === null) {
24
+ return null
29
25
  }
30
26
 
31
- if (value[call]) {
32
- const result = ['call', value.ref, value.args]
33
-
34
- result[replaced] = replaced
35
-
36
- return result
27
+ // Normalize SendScript wrapper functions (ref, call, await)
28
+ if (typeof value === 'function' && typeof value.toJSON === 'function') {
29
+ return transformValue(value.toJSON(), leafSerializer)
37
30
  }
38
31
 
39
- if (value[awaitSymbol]) {
40
- awaitId += 1
41
- const result = ['await', value.ref, awaitId]
32
+ // Encode SendScript operators
33
+ if (value && value[ref]) {
34
+ return ['ref', ...value.path]
35
+ }
42
36
 
43
- result[replaced] = replaced
37
+ if (value && value[call]) {
38
+ return ['call', transformValue(value.ref, leafSerializer), transformValue(value.args, leafSerializer)]
39
+ }
44
40
 
45
- return result
41
+ if (value && value[awaitSymbol]) {
42
+ return ['await', transformValue(value.ref, leafSerializer)]
46
43
  }
47
44
 
48
- // Quote only the reserved string and not the complete array. Quoted values
49
- // will be unquoted on parse. A quoted quote also.
50
- if (!value[replaced] && Array.isArray(value)) {
45
+ // Handle arrays: quote keyword operators, transform other arrays recursively
46
+ if (Array.isArray(value)) {
51
47
  const [operator, ...rest] = value
52
48
 
53
49
  if (isKeyword(operator)) {
54
- const quoted = ['quote', operator]
55
- quoted[replaced] = replaced
50
+ // Quote reserved keyword strings to preserve them as data
51
+ return [['quote', operator], ...rest.map((item) => transformValue(item, leafSerializer))]
52
+ }
53
+
54
+ return value.map((item) => transformValue(item, leafSerializer))
55
+ }
56
+
57
+ // Recurse into plain objects
58
+ if (isPlainObject(value)) {
59
+ const result = {}
56
60
 
57
- return [quoted, ...rest]
61
+ for (const key of Object.keys(value)) {
62
+ result[key] = transformValue(value[key], leafSerializer)
58
63
  }
64
+
65
+ return result
59
66
  }
60
67
 
61
- return value
68
+ // Encode non-JSON leaf values (Date, RegExp, BigInt, etc.)
69
+ return ['leaf', leafSerializer(value)]
62
70
  }
63
71
 
64
- export default function stringify (program) {
65
- return JSON.stringify(program, replacer)
72
+ export default function stringify (program, leafSerializer = JSON.stringify) {
73
+ return JSON.stringify(transformValue(program, leafSerializer))
66
74
  }