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 +15 -0
- package/CONTRIBUTING.md +24 -0
- package/README.md +205 -18
- package/README.mz +186 -12
- package/example/typescript/docs/functions/add.md +1 -1
- package/example/typescript/docs/functions/square.md +1 -1
- package/index.test.mjs +50 -7
- package/leaf-serializer-property.test.mjs +149 -0
- package/leaf-serializer.test.mjs +76 -0
- package/module.mjs +23 -15
- package/package.json +7 -4
- package/parse.mjs +120 -50
- package/stringify.mjs +43 -35
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)
|
package/CONTRIBUTING.md
ADDED
|
@@ -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
|
|
444
|
+
> sendscript@1.1.0 test
|
|
258
445
|
> tap -R silent
|
|
259
446
|
|
|
260
447
|
|
|
261
|
-
> sendscript@1.0
|
|
448
|
+
> sendscript@1.1.0 test
|
|
262
449
|
> tap report text-summary
|
|
263
450
|
|
|
264
451
|
|
|
265
452
|
=============================== Coverage summary ===============================
|
|
266
|
-
Statements : 100% (
|
|
267
|
-
Branches : 100% (
|
|
268
|
-
Functions : 100% (
|
|
269
|
-
Lines : 100% (
|
|
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/
|
|
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/
|
|
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
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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 (
|
|
3
|
+
function instrument (path) {
|
|
8
4
|
function reference (...args) {
|
|
9
|
-
const called = instrument(
|
|
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(
|
|
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
|
-
|
|
33
|
+
path
|
|
39
34
|
})
|
|
40
35
|
|
|
41
36
|
return reference
|
|
42
37
|
}
|
|
43
38
|
|
|
44
|
-
export default function module (schema) {
|
|
45
|
-
if (
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
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
|
|
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
|
|
21
|
-
"docs": "markatzea
|
|
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
|
-
"
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
const isThenable = (value) => (
|
|
7
|
+
value != null && typeof value.then === 'function'
|
|
8
|
+
)
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const isAwaitPromise = Symbol('sendscript-await')
|
|
11
|
+
const undefinedSentinel = Symbol('sendscript-undefined')
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
+
const markAwait = (promise) => {
|
|
20
|
+
promise[isAwaitPromise] = true
|
|
21
|
+
return promise
|
|
22
|
+
}
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 (
|
|
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 === '
|
|
49
|
-
const
|
|
50
|
-
|
|
105
|
+
if (operator === 'leaf') {
|
|
106
|
+
const leafValue = deserialize(rest[0])
|
|
107
|
+
return leafValue === undefined ? undefinedSentinel : leafValue
|
|
108
|
+
}
|
|
51
109
|
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
return JSON.parse(program, reviver)
|
|
81
|
-
})
|
|
82
|
-
}
|
|
158
|
+
const parsed = JSON.parse(program, reviver)
|
|
159
|
+
const result = resolveAwaitedValues(parsed)
|
|
83
160
|
|
|
84
|
-
return
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
19
|
+
// Recursively transform a program tree, encoding SendScript operators and leaf values
|
|
20
|
+
function transformValue (value, leafSerializer) {
|
|
21
|
+
debug(value)
|
|
27
22
|
|
|
28
|
-
|
|
23
|
+
if (value === null) {
|
|
24
|
+
return null
|
|
29
25
|
}
|
|
30
26
|
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
32
|
+
// Encode SendScript operators
|
|
33
|
+
if (value && value[ref]) {
|
|
34
|
+
return ['ref', ...value.path]
|
|
35
|
+
}
|
|
42
36
|
|
|
43
|
-
|
|
37
|
+
if (value && value[call]) {
|
|
38
|
+
return ['call', transformValue(value.ref, leafSerializer), transformValue(value.args, leafSerializer)]
|
|
39
|
+
}
|
|
44
40
|
|
|
45
|
-
|
|
41
|
+
if (value && value[awaitSymbol]) {
|
|
42
|
+
return ['await', transformValue(value.ref, leafSerializer)]
|
|
46
43
|
}
|
|
47
44
|
|
|
48
|
-
//
|
|
49
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
72
|
+
export default function stringify (program, leafSerializer = JSON.stringify) {
|
|
73
|
+
return JSON.stringify(transformValue(program, leafSerializer))
|
|
66
74
|
}
|