sendscript 1.0.5 → 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,23 @@ 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
+
13
+ #### [v1.0.6](https://github.com/bas080/sendscript/compare/v1.0.5...v1.0.6)
14
+
15
+ > 6 April 2026
16
+
17
+ - Update tap to 21.6.3 [`c1769d9`](https://github.com/bas080/sendscript/commit/c1769d9a24148e63d27b0e5a7456213bec64763e)
18
+ - Add introduction to readme [`a21a494`](https://github.com/bas080/sendscript/commit/a21a494616d5ad8c99f9a2904ef529ea85104a99)
19
+
7
20
  #### [v1.0.5](https://github.com/bas080/sendscript/compare/v1.0.4...v1.0.5)
8
21
 
22
+ > 5 April 2026
23
+
9
24
  - Add publish to npm workflow [`aaaa97d`](https://github.com/bas080/sendscript/commit/aaaa97d1024d6b5ed574a2cd103035949ec8280d)
10
25
 
11
26
  #### [v1.0.4](https://github.com/bas080/sendscript/compare/v1.0.3...v1.0.4)
@@ -0,0 +1,24 @@
1
+ # Contributing
2
+
3
+
4
+ For the README.mz examples to work we need sendscript to be linked.
5
+
6
+ ```bash bash > /dev/null
7
+ set -e
8
+
9
+ npm link
10
+ npm link sendscript
11
+ cd ./example
12
+ npm ci
13
+ npm link sendscript
14
+ ```
15
+
16
+ Generate the README from the mz file.
17
+
18
+ ```bash bash
19
+ markatzea ./README.mz | tee ./README.md
20
+
21
+ npx markdown-toc -i README.md
22
+
23
+ git add *.md ./example
24
+ ```
package/README.md CHANGED
@@ -9,6 +9,7 @@ Write JS code that you can run on servers, browsers or other clients.
9
9
 
10
10
  <!-- toc -->
11
11
 
12
+ - [Introduction](#introduction)
12
13
  - [Socket example](#socket-example)
13
14
  * [Module](#module)
14
15
  * [Server](#server)
@@ -16,6 +17,12 @@ Write JS code that you can run on servers, browsers or other clients.
16
17
  - [Repl](#repl)
17
18
  - [Async/Await](#asyncawait)
18
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)
19
26
  - [Tests](#tests)
20
27
  - [Formatting](#formatting)
21
28
  - [Changelog](#changelog)
@@ -25,6 +32,75 @@ Write JS code that you can run on servers, browsers or other clients.
25
32
 
26
33
  <!-- tocstop -->
27
34
 
35
+ ## Introduction
36
+
37
+ There has been interest in improving APIs by allowing aggregations in a
38
+ single request. Examples include
39
+
40
+ - [JSON-RPC](https://json-rpc.dev/) which allows you to do multiple requests
41
+ but it does not allow you to compose the return value of one endpoint to be the
42
+ input/arguments of another.
43
+
44
+ - [GraphQL](https://graphql.org/) is very cool but also introduces a new languages and the
45
+ tooling that is required to wield it.
46
+
47
+ What SendScript attempts is to allow for very expressive queries and mutations to be performed
48
+ that read and write like ordinary JS. That means that the queries and complete programs
49
+ that are sent to the server from a client can also just run on the server as is. The only
50
+ limitation being the serialization which by default is limited by JSON and could be extended by
51
+ using more advanced (de)serialization libraries.
52
+
53
+ SendScript produces an intermediate JSON representation of the program. Let's see what that looks like.
54
+
55
+ ```js
56
+ import stringify from 'sendscript/stringify.mjs'
57
+ import module from 'sendscript/module.mjs'
58
+
59
+ const { add } = module(['add'])
60
+
61
+ console.log(stringify(add(1,2)))
62
+ ```
63
+ ```json
64
+ ["call",["ref","add"],[["leaf","1"],["leaf","2"]]]
65
+ ```
66
+
67
+ We can then parse that JSON and it will evaluate down to a value.
68
+
69
+ ```js
70
+ import Parse from 'sendscript/parse.mjs'
71
+
72
+ const module = {
73
+ add(a, b) {
74
+ return a + b
75
+ }
76
+ }
77
+
78
+ const parse = Parse(module)
79
+
80
+ const program = '["call",["ref","add"],[1,2]]'
81
+
82
+ console.log(parse(program))
83
+ ```
84
+ ```json
85
+ 3
86
+ ```
87
+
88
+ SendScript does more than a simple function call. It supports function
89
+ composition and even await.
90
+
91
+ This package is nothing more than the absolute core of sendscript. It
92
+ includes:
93
+
94
+ - The `module` function to create stubs to write the programs.
95
+ - `stringify` which takes the program and returns a JSON string.
96
+ - `parse` which takes the `stringify` JSON string and a real module and returns the result.
97
+
98
+ The naming could use more love and there are many things to solve either in the core or around it.
99
+ Things like supporting more complex (de)serializers, errors and maybe mixing client functions with
100
+ sendscript programs. Contact me if I have piqued your interest.
101
+
102
+ ---
103
+
28
104
  SendScript leaves it up to you to choose HTTP, web-sockets or any other
29
105
  method of communication between servers and clients that best fits your
30
106
  needs.
@@ -33,17 +109,6 @@ needs.
33
109
 
34
110
  For this example we'll use [socket.io][socket.io].
35
111
 
36
- ```bash
37
- set -e
38
-
39
- npm link
40
- cd ./example
41
- npm ci
42
- npm link sendscript
43
- ```
44
-
45
- > We use the `--no-save` option because it's only for demonstration purposes.
46
-
47
112
  ### Module
48
113
 
49
114
  We write a simple module.
@@ -234,7 +299,7 @@ npm install --no-save \
234
299
  typedoc \
235
300
  typedoc-plugin-markdown
236
301
 
237
- typedoc --plugin typedoc-plugin-markdown --out ./example/typescript/docs ./example/typescript/math.ts
302
+ npx typedoc --plugin typedoc-plugin-markdown --out ./example/typescript/docs ./example/typescript/math.ts
238
303
  ```
239
304
 
240
305
  You can see the docs [here](./example/typescript/docs/globals.md)
@@ -244,6 +309,128 @@ You can see the docs [here](./example/typescript/docs/globals.md)
244
309
  > experience, it does not represent the actual type.
245
310
  > Values are subject to serialization and deserialization.
246
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
+
247
434
  ## Tests
248
435
 
249
436
  Tests with 100% code coverage.
@@ -254,19 +441,19 @@ npm t -- report text-summary
254
441
  ```
255
442
  ```
256
443
 
257
- > sendscript@1.0.5 test
444
+ > sendscript@1.1.0 test
258
445
  > tap -R silent
259
446
 
260
447
 
261
- > sendscript@1.0.5 test
448
+ > sendscript@1.1.0 test
262
449
  > tap report text-summary
263
450
 
264
451
 
265
452
  =============================== Coverage summary ===============================
266
- Statements : 100% ( 245/245 )
267
- Branches : 100% ( 74/74 )
268
- Functions : 100% ( 18/18 )
269
- Lines : 100% ( 245/245 )
453
+ Statements : 100% ( 328/328 )
454
+ Branches : 100% ( 138/138 )
455
+ Functions : 100% ( 23/23 )
456
+ Lines : 100% ( 328/328 )
270
457
  ================================================================================
271
458
  ```
272
459
 
package/README.mz CHANGED
@@ -9,6 +9,69 @@ Write JS code that you can run on servers, browsers or other clients.
9
9
 
10
10
  <!-- toc -->
11
11
 
12
+ ## Introduction
13
+
14
+ There has been interest in improving APIs by allowing aggregations in a
15
+ single request. Examples include
16
+
17
+ - [JSON-RPC](https://json-rpc.dev/) which allows you to do multiple requests
18
+ but it does not allow you to compose the return value of one endpoint to be the
19
+ input/arguments of another.
20
+
21
+ - [GraphQL](https://graphql.org/) is very cool but also introduces a new languages and the
22
+ tooling that is required to wield it.
23
+
24
+ What SendScript attempts is to allow for very expressive queries and mutations to be performed
25
+ that read and write like ordinary JS. That means that the queries and complete programs
26
+ that are sent to the server from a client can also just run on the server as is. The only
27
+ limitation being the serialization which by default is limited by JSON and could be extended by
28
+ using more advanced (de)serialization libraries.
29
+
30
+ SendScript produces an intermediate JSON representation of the program. Let's see what that looks like.
31
+
32
+ ```js|json node --input-type=module | tee /tmp/sendscript.json
33
+ import stringify from 'sendscript/stringify.mjs'
34
+ import module from 'sendscript/module.mjs'
35
+
36
+ const { add } = module(['add'])
37
+
38
+ console.log(stringify(add(1,2)))
39
+ ```
40
+
41
+ We can then parse that JSON and it will evaluate down to a value.
42
+
43
+ ```js|json node --input-type=module
44
+ import Parse from 'sendscript/parse.mjs'
45
+
46
+ const module = {
47
+ add(a, b) {
48
+ return a + b
49
+ }
50
+ }
51
+
52
+ const parse = Parse(module)
53
+
54
+ const program = '["call",["ref","add"],[1,2]]'
55
+
56
+ console.log(parse(program))
57
+ ```
58
+
59
+ SendScript does more than a simple function call. It supports function
60
+ composition and even await.
61
+
62
+ This package is nothing more than the absolute core of sendscript. It
63
+ includes:
64
+
65
+ - The `module` function to create stubs to write the programs.
66
+ - `stringify` which takes the program and returns a JSON string.
67
+ - `parse` which takes the `stringify` JSON string and a real module and returns the result.
68
+
69
+ The naming could use more love and there are many things to solve either in the core or around it.
70
+ Things like supporting more complex (de)serializers, errors and maybe mixing client functions with
71
+ sendscript programs. Contact me if I have piqued your interest.
72
+
73
+ ---
74
+
12
75
  SendScript leaves it up to you to choose HTTP, web-sockets or any other
13
76
  method of communication between servers and clients that best fits your
14
77
  needs.
@@ -17,17 +80,6 @@ needs.
17
80
 
18
81
  For this example we'll use [socket.io][socket.io].
19
82
 
20
- ```bash bash > /dev/null
21
- set -e
22
-
23
- npm link
24
- cd ./example
25
- npm ci
26
- npm link sendscript
27
- ```
28
-
29
- > We use the `--no-save` option because it's only for demonstration purposes.
30
-
31
83
  ### Module
32
84
 
33
85
  We write a simple module.
@@ -184,7 +236,7 @@ npm install --no-save \
184
236
  typedoc \
185
237
  typedoc-plugin-markdown
186
238
 
187
- typedoc --plugin typedoc-plugin-markdown --out ./example/typescript/docs ./example/typescript/math.ts
239
+ npx typedoc --plugin typedoc-plugin-markdown --out ./example/typescript/docs ./example/typescript/math.ts
188
240
  ```
189
241
 
190
242
  You can see the docs [here](./example/typescript/docs/globals.md)
@@ -194,6 +246,128 @@ You can see the docs [here](./example/typescript/docs/globals.md)
194
246
  > experience, it does not represent the actual type.
195
247
  > Values are subject to serialization and deserialization.
196
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
+
197
371
  ## Tests
198
372
 
199
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/aaaa97d1024d6b5ed574a2cd103035949ec8280d/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/aaaa97d1024d6b5ed574a2cd103035949ec8280d/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.5",
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",
@@ -17,13 +18,15 @@
17
18
  },
18
19
  "scripts": {
19
20
  "test": "tap",
20
- "version": "npm run docs && git add *.md example",
21
- "docs": "markatzea README.mz | tee README.md && npx markdown-toc -i README.md"
21
+ "version": "npm run docs",
22
+ "docs": "markatzea CONTRIBUTING.md"
22
23
  },
23
24
  "author": "Bas Huis",
24
25
  "license": "MIT",
25
26
  "devDependencies": {
26
- "tap": "^21.6.2"
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
  }