sendscript 1.0.6 → 2.0.1
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/.github/workflows/publish.yml +2 -2
- package/.github/workflows/release.yml +45 -0
- package/CHANGELOG.md +22 -0
- package/README.md +161 -45
- package/README.mz +142 -21
- package/example/client.socket.io.mjs +5 -5
- package/example/server.socket.io.mjs +2 -1
- package/example/typescript/client.ts +6 -6
- package/example/typescript/docs/functions/add.md +1 -1
- package/example/typescript/docs/functions/square.md +1 -1
- package/example/typescript/math.client.ts +3 -7
- package/index.test.mjs +126 -11
- package/leaf-serializer-property.test.mjs +149 -0
- package/leaf-serializer.test.mjs +71 -0
- package/package.json +8 -3
- package/parse.mjs +147 -49
- package/references.mjs +59 -0
- package/references.test.mjs +8 -0
- package/stringify.mjs +47 -35
- package/index.mjs +0 -11
- package/module.mjs +0 -52
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import module from 'sendscript/module.mjs'
|
|
2
1
|
import type * as mathTypes from './math.ts'
|
|
2
|
+
import Stringify from 'sendscript/stringify.mjs'
|
|
3
|
+
import references from 'sendscript/references.mjs'
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
'add',
|
|
6
|
-
'square'
|
|
7
|
-
]) as typeof mathTypes
|
|
8
|
-
|
|
9
|
-
export default math
|
|
5
|
+
export default references(['add', 'square']) as typeof mathTypes
|
package/index.test.mjs
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { test } from 'tap'
|
|
2
|
-
import
|
|
2
|
+
import Stringify from './stringify.mjs'
|
|
3
|
+
import references from './references.mjs'
|
|
4
|
+
import Parse from './parse.mjs'
|
|
3
5
|
|
|
4
|
-
const
|
|
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,12 +25,23 @@ const module = {
|
|
|
18
25
|
asyncAdd: async (a, b) => a + b,
|
|
19
26
|
aPromise: Promise.resolve(42),
|
|
20
27
|
delayedIdentity: async (x) => x,
|
|
21
|
-
|
|
28
|
+
nullProto: () => {
|
|
29
|
+
const obj = Object.create(null)
|
|
30
|
+
obj.b = 'c'
|
|
31
|
+
return obj
|
|
32
|
+
},
|
|
22
33
|
Promise
|
|
23
34
|
}
|
|
24
35
|
|
|
25
|
-
const
|
|
26
|
-
|
|
36
|
+
const schema = Object.keys(myModule)
|
|
37
|
+
schema.push(['nested', [
|
|
38
|
+
['again', ['T']]
|
|
39
|
+
]])
|
|
40
|
+
|
|
41
|
+
const api = references(schema)
|
|
42
|
+
const stringify = Stringify()
|
|
43
|
+
const parse = Parse(schema, myModule)
|
|
44
|
+
|
|
27
45
|
const run = (program) => parse(stringify(program))
|
|
28
46
|
|
|
29
47
|
const RealPromise = Promise
|
|
@@ -35,7 +53,6 @@ test('should evaluate basic expressions correctly', async (t) => {
|
|
|
35
53
|
resolve,
|
|
36
54
|
delayedIdentity,
|
|
37
55
|
noop,
|
|
38
|
-
Function,
|
|
39
56
|
Promise,
|
|
40
57
|
instanceOf,
|
|
41
58
|
asyncFn,
|
|
@@ -46,8 +63,24 @@ test('should evaluate basic expressions correctly', async (t) => {
|
|
|
46
63
|
concat,
|
|
47
64
|
identity,
|
|
48
65
|
always,
|
|
49
|
-
multiply3
|
|
50
|
-
|
|
66
|
+
multiply3,
|
|
67
|
+
nested
|
|
68
|
+
} = api
|
|
69
|
+
|
|
70
|
+
t.test('mix await without await', async t => {
|
|
71
|
+
const [one, two] = await run([await resolve(1), resolve(2)])
|
|
72
|
+
|
|
73
|
+
t.equal(one, 1)
|
|
74
|
+
t.ok(two instanceof RealPromise)
|
|
75
|
+
|
|
76
|
+
t.end()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
t.test('calling nested function works', t => {
|
|
80
|
+
t.equal(run(nested.again.T()), true)
|
|
81
|
+
|
|
82
|
+
t.end()
|
|
83
|
+
})
|
|
51
84
|
|
|
52
85
|
t.test('nested await works', async (t) => {
|
|
53
86
|
// Async identity passthrough
|
|
@@ -110,9 +143,6 @@ test('should evaluate basic expressions correctly', async (t) => {
|
|
|
110
143
|
t.strictSame(await run(asyncFn()), 'my-async-function')
|
|
111
144
|
t.strictSame(await run(await resolve('my-promise')), 'my-promise')
|
|
112
145
|
t.strictSame(run(instanceOf(resolve(asyncFn), Promise)), true)
|
|
113
|
-
t.strictSame(
|
|
114
|
-
await run(instanceOf(await resolve(asyncFn), Function)), true
|
|
115
|
-
)
|
|
116
146
|
t.strictSame(
|
|
117
147
|
await run({ a: await resolve('b') }),
|
|
118
148
|
{ a: 'b' }
|
|
@@ -177,6 +207,16 @@ test('should evaluate basic expressions correctly', async (t) => {
|
|
|
177
207
|
run(identity(['ref', 'hello'])),
|
|
178
208
|
run(identity(toArray('ref', 'hello')))
|
|
179
209
|
)
|
|
210
|
+
t.strictSame(
|
|
211
|
+
run(identity(['leaf', 1, 2, 3])),
|
|
212
|
+
['leaf', 1, 2, 3]
|
|
213
|
+
)
|
|
214
|
+
t.end()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
t.test('null-prototype object traversal', (t) => {
|
|
218
|
+
const { nullProto } = api
|
|
219
|
+
t.strictSame(run({ a: nullProto() }), { a: { b: 'c' } })
|
|
180
220
|
t.end()
|
|
181
221
|
})
|
|
182
222
|
|
|
@@ -195,3 +235,78 @@ test('should evaluate basic expressions correctly', async (t) => {
|
|
|
195
235
|
t.end()
|
|
196
236
|
})
|
|
197
237
|
})
|
|
238
|
+
|
|
239
|
+
test('stringify: invalid children throws', t => {
|
|
240
|
+
const invalidSchema = [
|
|
241
|
+
['math', 'add']
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
t.throws(() => Parse(invalidSchema, {}))
|
|
245
|
+
t.throws(() => Parse([Symbol('no-allowed')], {}))
|
|
246
|
+
t.end()
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('forbidden/reflection access should be blocked', async (t) => {
|
|
250
|
+
function run (program) {
|
|
251
|
+
return parse(JSON.stringify(program))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
t.test('cannot access constructor via ref', (t) => {
|
|
255
|
+
// try to reach the constructor of nested.again.T
|
|
256
|
+
t.throws(() => run(['ref', 'nested', 'again', 'T', 'constructor']))
|
|
257
|
+
t.end()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
t.test('cannot access prototype property', (t) => {
|
|
261
|
+
// direct prototype traversal attempt
|
|
262
|
+
t.throws(() => run(['ref', 'nested', '__proto__']))
|
|
263
|
+
t.throws(() => run(['ref', 'nested', 'again', '__proto__']))
|
|
264
|
+
t.end()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
t.test('cannot reach Function constructor', (t) => {
|
|
268
|
+
// many modules may accidentally expose Function; this should not be available via ref
|
|
269
|
+
t.throws(() => run(['ref', 'Function']))
|
|
270
|
+
t.end()
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
t.test('cannot call Function to execute code', (t) => {
|
|
274
|
+
// If Function were reachable, this would execute arbitrary code
|
|
275
|
+
const program = ['call', ['ref', 'Function'], [['leaf', '"return 1 + 2"']]]
|
|
276
|
+
t.throws(() => run(program))
|
|
277
|
+
t.end()
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
t.test('cannot access global Promise constructor via ref', (t) => {
|
|
281
|
+
t.throws(() => run(['ref', 'Promise', 'prototype', 'then']))
|
|
282
|
+
t.end()
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
t.test('cannot access Object.prototype methods', (t) => {
|
|
286
|
+
// toString is a common vector for prototype access
|
|
287
|
+
t.throws(() => run(['ref', 'toString']))
|
|
288
|
+
t.throws(() => run(['ref', 'nested', 'again', 'T', 'toString']))
|
|
289
|
+
t.end()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
t.test('cannot reach process or global (if accidentally exposed)', (t) => {
|
|
293
|
+
// attempt common global names; parser should not resolve them
|
|
294
|
+
t.throws(() => run(['ref', 'process']))
|
|
295
|
+
t.throws(() => run(['ref', 'global']))
|
|
296
|
+
t.end()
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
t.test('cannot use constructor.constructor to reach Function', (t) => {
|
|
300
|
+
// attempt: nested.again.T.constructor.constructor
|
|
301
|
+
t.throws(() => run(['ref', 'nested', 'again', 'T', 'constructor', 'constructor']))
|
|
302
|
+
t.end()
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
t.test('throws when trying to get something that does not exist', (t) => {
|
|
306
|
+
// Ensure array that contains a ref-like array stays as data if not a SendScript ref wrapper:
|
|
307
|
+
t.throws(() => run(['ref', 'doesNotExist']))
|
|
308
|
+
t.end()
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
t.end()
|
|
312
|
+
})
|
|
@@ -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,71 @@
|
|
|
1
|
+
import { test } from 'tap'
|
|
2
|
+
import SuperJSON from 'superjson'
|
|
3
|
+
import Parse from './parse.mjs'
|
|
4
|
+
import Stringify from './stringify.mjs'
|
|
5
|
+
|
|
6
|
+
const leafSerializer = (value) => {
|
|
7
|
+
if (value === undefined) return JSON.stringify({ __sendscript_undefined__: true })
|
|
8
|
+
return JSON.stringify(SuperJSON.serialize(value))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const leafDeserializer = (text) => {
|
|
12
|
+
const parsed = JSON.parse(text)
|
|
13
|
+
if (parsed && parsed.__sendscript_undefined__ === true) return undefined
|
|
14
|
+
return SuperJSON.deserialize(parsed)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const module = {
|
|
18
|
+
identity: (x) => x
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const schema = Object.keys(module)
|
|
22
|
+
|
|
23
|
+
const run = (program, serializer, deserializer) => {
|
|
24
|
+
const parse = Parse(schema, module, deserializer)
|
|
25
|
+
const stringify = Stringify(serializer)
|
|
26
|
+
|
|
27
|
+
return parse(stringify(program))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('custom leaf serializer/deserializer using superjson', async (t) => {
|
|
31
|
+
const value = {
|
|
32
|
+
date: new Date('2020-01-01T00:00:00.000Z'),
|
|
33
|
+
regex: /abc/gi,
|
|
34
|
+
big: BigInt('123456789012345678901234567890'),
|
|
35
|
+
undef: undefined,
|
|
36
|
+
nested: {
|
|
37
|
+
set: new Set([1, 2, 3]),
|
|
38
|
+
map: new Map([['a', 1], ['b', 2]])
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const result = await run(value, leafSerializer, leafDeserializer)
|
|
43
|
+
|
|
44
|
+
t.ok(result.date instanceof Date)
|
|
45
|
+
t.equal(result.date.toISOString(), value.date.toISOString())
|
|
46
|
+
|
|
47
|
+
t.ok(result.regex instanceof RegExp)
|
|
48
|
+
t.equal(result.regex.source, 'abc')
|
|
49
|
+
t.equal(result.regex.flags, 'gi')
|
|
50
|
+
|
|
51
|
+
t.equal(result.big, value.big)
|
|
52
|
+
|
|
53
|
+
t.ok(Object.prototype.hasOwnProperty.call(result, 'undef'))
|
|
54
|
+
t.equal(result.undef, undefined)
|
|
55
|
+
|
|
56
|
+
t.ok(result.nested.set instanceof Set)
|
|
57
|
+
t.strictSame(Array.from(result.nested.set), [1, 2, 3])
|
|
58
|
+
|
|
59
|
+
t.ok(result.nested.map instanceof Map)
|
|
60
|
+
t.strictSame(Array.from(result.nested.map.entries()), [['a', 1], ['b', 2]])
|
|
61
|
+
|
|
62
|
+
t.end()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('default leaf deserializer when not provided', async (t) => {
|
|
66
|
+
const value = { a: 1, b: 'hello' }
|
|
67
|
+
const result = await run(value)
|
|
68
|
+
|
|
69
|
+
t.strictSame(result, value)
|
|
70
|
+
t.end()
|
|
71
|
+
})
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sendscript",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
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,9 +24,13 @@
|
|
|
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
|
-
"debug": "^4.4.3"
|
|
32
|
+
"debug": "^4.4.3",
|
|
33
|
+
"typedoc": "^0.28.18",
|
|
34
|
+
"typedoc-plugin-markdown": "^4.11.0"
|
|
30
35
|
}
|
|
31
36
|
}
|
package/parse.mjs
CHANGED
|
@@ -1,43 +1,125 @@
|
|
|
1
1
|
import Debug from './debug.mjs'
|
|
2
|
-
import isNil from './is-nil.mjs'
|
|
3
2
|
import { SendScriptReferenceError } from './error.mjs'
|
|
4
3
|
|
|
4
|
+
function flattenSchema (schema) {
|
|
5
|
+
const obj = {}
|
|
6
|
+
|
|
7
|
+
for (const item of schema) {
|
|
8
|
+
if (typeof item === 'string') {
|
|
9
|
+
// leaf function
|
|
10
|
+
obj[item] = true
|
|
11
|
+
} else if (Array.isArray(item)) {
|
|
12
|
+
const [name, children] = item
|
|
13
|
+
// TODO: Test this
|
|
14
|
+
if (!Array.isArray(children)) {
|
|
15
|
+
throw new Error(`Expected children array for namespace "${name}"`)
|
|
16
|
+
}
|
|
17
|
+
obj[name] = flattenSchema(children)
|
|
18
|
+
// TODO: Test this also
|
|
19
|
+
} else {
|
|
20
|
+
throw new Error('Schema items must be strings or [name, children] arrays')
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return obj
|
|
25
|
+
}
|
|
26
|
+
|
|
5
27
|
const debug = Debug.extend('parse')
|
|
6
28
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
29
|
+
const isThenable = (value) => (
|
|
30
|
+
value != null && typeof value.then === 'function'
|
|
31
|
+
)
|
|
10
32
|
|
|
11
|
-
|
|
12
|
-
|
|
33
|
+
const isAwaitPromise = Symbol('sendscript-await')
|
|
34
|
+
const undefinedSentinel = Symbol('sendscript-undefined')
|
|
13
35
|
|
|
14
|
-
|
|
15
|
-
|
|
36
|
+
const isPlainObject = (value) => {
|
|
37
|
+
if (!value || typeof value !== 'object') return false
|
|
38
|
+
const proto = Object.getPrototypeOf(value)
|
|
39
|
+
return proto === Object.prototype || proto === null
|
|
40
|
+
}
|
|
16
41
|
|
|
17
|
-
|
|
42
|
+
const markAwait = (promise) => {
|
|
43
|
+
promise[isAwaitPromise] = true
|
|
44
|
+
return promise
|
|
45
|
+
}
|
|
18
46
|
|
|
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
|
-
}
|
|
47
|
+
const isExplicitAwait = (value) => (
|
|
48
|
+
isThenable(value) && value[isAwaitPromise] === true
|
|
49
|
+
)
|
|
29
50
|
|
|
30
|
-
|
|
51
|
+
// Recursively resolve awaited values in a parsed tree
|
|
52
|
+
const resolveAwaitedValues = (value) => {
|
|
53
|
+
if (value === undefinedSentinel) return undefined
|
|
54
|
+
|
|
55
|
+
if (isThenable(value)) {
|
|
56
|
+
return isExplicitAwait(value)
|
|
57
|
+
? markAwait(value.then(resolveAwaitedValues))
|
|
58
|
+
: value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (Array.isArray(value)) {
|
|
62
|
+
let hasAwait = false
|
|
63
|
+
const result = value.map((item) => {
|
|
64
|
+
const resolved = resolveAwaitedValues(item)
|
|
65
|
+
if (isExplicitAwait(resolved)) hasAwait = true
|
|
66
|
+
return resolved
|
|
31
67
|
})
|
|
32
68
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
69
|
+
if (!hasAwait) return result
|
|
70
|
+
|
|
71
|
+
const awaited = result.map((item, index) =>
|
|
72
|
+
isExplicitAwait(item)
|
|
73
|
+
? Promise.resolve(item).then((resolved) => {
|
|
74
|
+
result[index] = resolved
|
|
75
|
+
return resolved
|
|
76
|
+
})
|
|
77
|
+
: item
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return markAwait(Promise.all(awaited).then(() => result))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (isPlainObject(value)) {
|
|
84
|
+
const result = {}
|
|
85
|
+
const promises = []
|
|
86
|
+
|
|
87
|
+
for (const key of Object.keys(value)) {
|
|
88
|
+
const resolved = resolveAwaitedValues(value[key])
|
|
89
|
+
if (isExplicitAwait(resolved)) {
|
|
90
|
+
promises.push(
|
|
91
|
+
Promise.resolve(resolved).then((resolvedValue) => {
|
|
92
|
+
result[key] = resolvedValue
|
|
93
|
+
})
|
|
94
|
+
)
|
|
95
|
+
} else {
|
|
96
|
+
result[key] = resolved
|
|
97
|
+
}
|
|
37
98
|
}
|
|
38
99
|
|
|
100
|
+
if (!promises.length) return result
|
|
101
|
+
return markAwait(Promise.all(promises).then(() => result))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return value
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const spy = (fn) => (...args) => {
|
|
108
|
+
const value = fn(...args)
|
|
109
|
+
debug(args, ' => ', value)
|
|
110
|
+
return value
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const defaultLeafDeserializer = (text) => JSON.parse(text)
|
|
114
|
+
|
|
115
|
+
export default (schemaArg, env, deserialize = defaultLeafDeserializer) => {
|
|
116
|
+
const schema = flattenSchema(schemaArg)
|
|
117
|
+
|
|
118
|
+
return function parse (program) {
|
|
119
|
+
debug('program', program)
|
|
120
|
+
|
|
39
121
|
const reviver = spy((key, value) => {
|
|
40
|
-
if (
|
|
122
|
+
if (value === null) return value
|
|
41
123
|
|
|
42
124
|
if (!Array.isArray(value)) {
|
|
43
125
|
return value
|
|
@@ -45,48 +127,64 @@ export default (env) =>
|
|
|
45
127
|
|
|
46
128
|
const [operator, ...rest] = value
|
|
47
129
|
|
|
48
|
-
if (operator === '
|
|
49
|
-
const
|
|
50
|
-
|
|
130
|
+
if (operator === 'leaf') {
|
|
131
|
+
const leafValue = deserialize(rest[0])
|
|
132
|
+
return leafValue === undefined ? undefinedSentinel : leafValue
|
|
133
|
+
}
|
|
51
134
|
|
|
52
|
-
|
|
135
|
+
if (operator === 'await') {
|
|
136
|
+
const [program] = rest
|
|
137
|
+
return markAwait(Promise.resolve(program))
|
|
53
138
|
}
|
|
54
139
|
|
|
55
140
|
if (Array.isArray(operator) && operator[0] === 'quote') {
|
|
56
141
|
const [, quoted] = operator
|
|
57
|
-
|
|
58
142
|
return [quoted, ...rest]
|
|
59
143
|
}
|
|
60
144
|
|
|
61
145
|
if (operator === 'call') {
|
|
62
146
|
const [fn, args] = rest
|
|
63
|
-
|
|
64
|
-
|
|
147
|
+
const resolvedFn = isExplicitAwait(fn) ? Promise.resolve(fn) : fn
|
|
148
|
+
const resolvedArgs = resolveAwaitedValues(args)
|
|
149
|
+
|
|
150
|
+
if (isExplicitAwait(resolvedFn) || isExplicitAwait(resolvedArgs)) {
|
|
151
|
+
const promiseFn = isExplicitAwait(resolvedFn)
|
|
152
|
+
? Promise.resolve(resolvedFn)
|
|
153
|
+
: resolvedFn
|
|
154
|
+
const promiseArgs = isExplicitAwait(resolvedArgs)
|
|
155
|
+
? Promise.resolve(resolvedArgs)
|
|
156
|
+
: resolvedArgs
|
|
157
|
+
|
|
158
|
+
return Promise.all([promiseFn, promiseArgs])
|
|
159
|
+
.then(([resolvedFnValue, resolvedArgsValue]) => resolvedFnValue(...resolvedArgsValue))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return fn(...resolvedArgs)
|
|
65
163
|
}
|
|
66
164
|
|
|
67
165
|
if (operator === 'ref') {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
166
|
+
const path = rest // e.g., ["math","add"]
|
|
167
|
+
let current = env
|
|
168
|
+
let schemaCurrent = schema
|
|
169
|
+
|
|
170
|
+
for (const segment of path) {
|
|
171
|
+
if (schemaCurrent && Object.hasOwn(schemaCurrent, segment)) {
|
|
172
|
+
current = current[segment]
|
|
173
|
+
schemaCurrent = schemaCurrent[segment]
|
|
174
|
+
} else {
|
|
175
|
+
throw new SendScriptReferenceError({ key, value })
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return current
|
|
73
180
|
}
|
|
74
181
|
|
|
75
182
|
return value
|
|
76
183
|
})
|
|
77
184
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return JSON.parse(program, reviver)
|
|
81
|
-
})
|
|
82
|
-
}
|
|
185
|
+
const parsed = JSON.parse(program, reviver)
|
|
186
|
+
const result = resolveAwaitedValues(parsed)
|
|
83
187
|
|
|
84
|
-
return
|
|
188
|
+
return result
|
|
85
189
|
}
|
|
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
190
|
}
|