sendscript 2.0.1 → 2.2.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.
@@ -1,4 +1,4 @@
1
- name: Publish Package
1
+ name: Release and Publish
2
2
 
3
3
  on:
4
4
  push:
@@ -6,20 +6,57 @@ on:
6
6
  - '*'
7
7
 
8
8
  permissions:
9
+ contents: write
9
10
  id-token: write # Required for OIDC
10
- contents: read
11
11
 
12
12
  jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ matrix:
17
+ node: ['lts/*', 'latest']
18
+ fail-fast: true
19
+
20
+ steps:
21
+ - uses: actions/checkout@v6
22
+
23
+ - uses: actions/setup-node@v6
24
+ with:
25
+ node-version: ${{ matrix.node }}
26
+ cache: npm
27
+
28
+ - run: npm ci
29
+ - run: npm test
30
+
31
+ release:
32
+ needs: test
33
+ runs-on: ubuntu-latest
34
+ steps:
35
+ - uses: actions/checkout@v6
36
+
37
+ - uses: actions/setup-node@v6
38
+ with:
39
+ node-version: 'latest'
40
+ cache: npm
41
+
42
+ - run: npm ci
43
+ - name: Create GitHub Release
44
+ uses: softprops/action-gh-release@v2
45
+ with:
46
+ generate_release_notes: true
47
+
13
48
  publish:
49
+ needs: test
14
50
  runs-on: ubuntu-latest
15
51
  steps:
16
52
  - uses: actions/checkout@v6
17
53
 
18
54
  - uses: actions/setup-node@v6
19
55
  with:
20
- node-version: '24'
56
+ node-version: 'latest'
57
+ cache: npm
21
58
  registry-url: 'https://registry.npmjs.org'
59
+
22
60
  - run: npm ci
23
- - run: npm run build --if-present
24
61
  - run: npm test
25
- - run: npm publish
62
+ - run: npm publish
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
+ #### [v2.2.0](https://github.com/bas080/sendscript/compare/v2.1.0...v2.2.0)
8
+
9
+ - Have default leaf serializer throw if value has no JSON equivalant [`6752f2f`](https://github.com/bas080/sendscript/commit/6752f2f3ed51e252ab93a31b632fe8fbae51d7cb)
10
+ - Test that prototype chain works when parsing [`2cf0e7e`](https://github.com/bas080/sendscript/commit/2cf0e7e7ced32258102382e7ff1e3f8e91a2cc77)
11
+
12
+ #### [v2.1.0](https://github.com/bas080/sendscript/compare/v2.0.1...v2.1.0)
13
+
14
+ > 9 April 2026
15
+
16
+ - Make eval order when using await inline with JS [`00db8aa`](https://github.com/bas080/sendscript/commit/00db8aaa563378db814035e3e550269593ed82e2)
17
+ - Merge release workflow into publish [`6a5db12`](https://github.com/bas080/sendscript/commit/6a5db128b659dd295390505b2e470c5913617abf)
18
+ - Fix the cli repl [`3cf8b0d`](https://github.com/bas080/sendscript/commit/3cf8b0d9b4531989fb01a53fa9ab4cb6a011c183)
19
+
7
20
  #### [v2.0.1](https://github.com/bas080/sendscript/compare/v2.0.0...v2.0.1)
8
21
 
22
+ > 8 April 2026
23
+
9
24
  - Publish to npm on tag push [`357ac0e`](https://github.com/bas080/sendscript/commit/357ac0ef3384f294e4f9bb179d5a675afda5fd19)
10
25
 
11
26
  ### [v2.0.0](https://github.com/bas080/sendscript/compare/v1.1.0...v2.0.0)
package/README.md CHANGED
@@ -92,7 +92,7 @@ composition and even await.
92
92
  This package is nothing more than the absolute core of sendscript. It
93
93
  includes:
94
94
 
95
- - The `module` function to create stubs to write the programs.
95
+ - The `references` function to create stubs to write the programs.
96
96
  - `stringify` which takes the program and returns a JSON string.
97
97
  - `parse` which takes the `stringify` JSON string and a real module and returns the result.
98
98
 
@@ -429,19 +429,19 @@ npm t -- report text-summary
429
429
  ```
430
430
  ```
431
431
 
432
- > sendscript@2.0.1 test
432
+ > sendscript@2.2.0 test
433
433
  > tap -R silent
434
434
 
435
435
 
436
- > sendscript@2.0.1 test
436
+ > sendscript@2.2.0 test
437
437
  > tap report text-summary
438
438
 
439
439
 
440
440
  =============================== Coverage summary ===============================
441
- Statements : 100% ( 348/348 )
442
- Branches : 100% ( 145/145 )
443
- Functions : 100% ( 24/24 )
444
- Lines : 100% ( 348/348 )
441
+ Statements : 100% ( 338/338 )
442
+ Branches : 100% ( 123/123 )
443
+ Functions : 100% ( 22/22 )
444
+ Lines : 100% ( 338/338 )
445
445
  ================================================================================
446
446
  ```
447
447
 
package/README.mz CHANGED
@@ -63,7 +63,7 @@ composition and even await.
63
63
  This package is nothing more than the absolute core of sendscript. It
64
64
  includes:
65
65
 
66
- - The `module` function to create stubs to write the programs.
66
+ - The `references` function to create stubs to write the programs.
67
67
  - `stringify` which takes the program and returns a JSON string.
68
68
  - `parse` which takes the `stringify` JSON string and a real module and returns the result.
69
69
 
package/cli.mjs CHANGED
@@ -2,13 +2,14 @@
2
2
 
3
3
  import path from 'path'
4
4
  import { pathToFileURL } from 'url'
5
- import stringify from './stringify.mjs'
5
+ import Stringify from './stringify.mjs'
6
6
  import repl from './repl.mjs'
7
7
  import Parse from './parse.mjs'
8
8
 
9
+ const stringify = Stringify()
9
10
  const modulePath = pathToFileURL(path.resolve(process.cwd(), process.argv[2])).href
10
11
  const mod = await import(modulePath)
11
- const parse = Parse(mod)
12
+ const parse = Parse(Object.keys(mod), mod)
12
13
 
13
14
  const send = program => parse(stringify(program))
14
15
 
package/error.mjs CHANGED
@@ -1,2 +1,3 @@
1
1
  export class SendScriptError extends Error {}
2
2
  export class SendScriptReferenceError extends SendScriptError {}
3
+ export class SendScriptSerializationError extends SendScriptError {}
@@ -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/357ac0ef3384f294e4f9bb179d5a675afda5fd19/example/typescript/math.ts#L1)
11
+ Defined in: [math.ts:1](https://github.com/bas080/sendscript/blob/2cf0e7e7ced32258102382e7ff1e3f8e91a2cc77/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/357ac0ef3384f294e4f9bb179d5a675afda5fd19/example/typescript/math.ts#L2)
11
+ Defined in: [math.ts:2](https://github.com/bas080/sendscript/blob/2cf0e7e7ced32258102382e7ff1e3f8e91a2cc77/example/typescript/math.ts#L2)
12
12
 
13
13
  ## Parameters
14
14
 
package/index.test.mjs CHANGED
@@ -2,8 +2,11 @@ import { test } from 'tap'
2
2
  import Stringify from './stringify.mjs'
3
3
  import references from './references.mjs'
4
4
  import Parse from './parse.mjs'
5
+ import { SendScriptSerializationError } from './error.mjs'
5
6
 
6
- const myModule = {
7
+ const order = []
8
+
9
+ const myModuleOrig = {
7
10
  nested: {
8
11
  again: {
9
12
  T: () => true
@@ -12,6 +15,16 @@ const myModule = {
12
15
  add: (a, b) => a + b,
13
16
  identity: (x) => x,
14
17
  concat: (a, b) => a.concat(b),
18
+ delay: (value, ms) => {
19
+ return new Promise(resolve => {
20
+ order.push(`${value} start`)
21
+ setTimeout(() => {
22
+ order.push(`${value} end`)
23
+
24
+ resolve(value)
25
+ }, ms)
26
+ })
27
+ },
15
28
  toArray: (...array) => array,
16
29
  always: (x) => () => x,
17
30
  multiply3: (a) => (b) => (c) => a * b * c,
@@ -33,7 +46,10 @@ const myModule = {
33
46
  Promise
34
47
  }
35
48
 
36
- const schema = Object.keys(myModule)
49
+ // We want to support prototype chain when parsing.
50
+ const myModule = Object.create(myModuleOrig)
51
+ const schema = Object.keys(myModuleOrig)
52
+
37
53
  schema.push(['nested', [
38
54
  ['again', ['T']]
39
55
  ]])
@@ -51,6 +67,7 @@ test('should evaluate basic expressions correctly', async (t) => {
51
67
  aPromise,
52
68
  asyncAdd,
53
69
  resolve,
70
+ delay,
54
71
  delayedIdentity,
55
72
  noop,
56
73
  Promise,
@@ -67,6 +84,48 @@ test('should evaluate basic expressions correctly', async (t) => {
67
84
  nested
68
85
  } = api
69
86
 
87
+ t.test('await evaluation order matches JS semantics', async (t) => {
88
+ const lorder = []
89
+
90
+ const ldelay = (label, ms) => new RealPromise((resolve) => {
91
+ lorder.push(label + ' start')
92
+ setTimeout(() => {
93
+ lorder.push(label + ' end')
94
+ resolve(label)
95
+ }, ms)
96
+ })
97
+
98
+ const lresult = [
99
+ await ldelay('a', 30),
100
+ await ldelay('b', 10)
101
+ ]
102
+
103
+ t.same(lresult, ['a', 'b'])
104
+ t.same(lorder, [
105
+ 'a start',
106
+ 'a end',
107
+ 'b start',
108
+ 'b end'
109
+ ])
110
+
111
+ // TODO: Write it what ss touches
112
+
113
+ const result = await run([
114
+ await delay('a', 30),
115
+ await delay('b', 10)
116
+ ])
117
+
118
+ t.same(result, ['a', 'b'])
119
+ t.same(order, [
120
+ 'a start',
121
+ 'a end',
122
+ 'b start',
123
+ 'b end'
124
+ ])
125
+
126
+ t.end()
127
+ })
128
+
70
129
  t.test('mix await without await', async t => {
71
130
  const [one, two] = await run([await resolve(1), resolve(2)])
72
131
 
@@ -170,12 +229,30 @@ test('should evaluate basic expressions correctly', async (t) => {
170
229
  t.end()
171
230
  })
172
231
 
232
+ t.test('throws if stringify returns undefined', t => {
233
+ // This is to prevent accidentally sending payloads that make little sense.
234
+
235
+ // Check if it throw the expected error type.
236
+ try {
237
+ run(undefined)
238
+ } catch (error) {
239
+ t.ok(error instanceof SendScriptSerializationError)
240
+ }
241
+
242
+ t.throws(() => run(undefined))
243
+ t.throws(() => run(new Set()))
244
+ t.throws(() => run(identity(undefined)))
245
+ t.throws(() => run(() => {}))
246
+
247
+ t.end()
248
+ })
249
+
173
250
  t.test('basic types and identity', (t) => {
174
251
  t.equal(run(identity(null)), null)
175
- t.equal(run(identity(undefined)), null)
176
252
  t.equal(run(noop()), undefined)
177
253
  t.equal(run(identity(1)), 1)
178
254
  t.strictSame(run(identity([])), [])
255
+ t.strictSame(run(identity({})), {})
179
256
  t.strictSame(run(identity([identity(1), 2, 3])), [1, 2, 3])
180
257
  t.strictSame(run(always('hello')()), 'hello')
181
258
  t.end()
@@ -216,7 +293,8 @@ test('should evaluate basic expressions correctly', async (t) => {
216
293
 
217
294
  t.test('null-prototype object traversal', (t) => {
218
295
  const { nullProto } = api
219
- t.strictSame(run({ a: nullProto() }), { a: { b: 'c' } })
296
+ const other = myModule.nullProto()
297
+ t.strictSame(run({ a: nullProto() }), { a: other })
220
298
  t.end()
221
299
  })
222
300
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sendscript",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "description": "Blur the line between server and client code.",
5
5
  "module": true,
6
6
  "main": "index.mjs",
package/parse.mjs CHANGED
@@ -10,12 +10,10 @@ function flattenSchema (schema) {
10
10
  obj[item] = true
11
11
  } else if (Array.isArray(item)) {
12
12
  const [name, children] = item
13
- // TODO: Test this
14
13
  if (!Array.isArray(children)) {
15
14
  throw new Error(`Expected children array for namespace "${name}"`)
16
15
  }
17
16
  obj[name] = flattenSchema(children)
18
- // TODO: Test this also
19
17
  } else {
20
18
  throw new Error('Schema items must be strings or [name, children] arrays')
21
19
  }
@@ -26,11 +24,6 @@ function flattenSchema (schema) {
26
24
 
27
25
  const debug = Debug.extend('parse')
28
26
 
29
- const isThenable = (value) => (
30
- value != null && typeof value.then === 'function'
31
- )
32
-
33
- const isAwaitPromise = Symbol('sendscript-await')
34
27
  const undefinedSentinel = Symbol('sendscript-undefined')
35
28
 
36
29
  const isPlainObject = (value) => {
@@ -39,66 +32,57 @@ const isPlainObject = (value) => {
39
32
  return proto === Object.prototype || proto === null
40
33
  }
41
34
 
42
- const markAwait = (promise) => {
43
- promise[isAwaitPromise] = true
44
- return promise
45
- }
46
-
47
- const isExplicitAwait = (value) => (
48
- isThenable(value) && value[isAwaitPromise] === true
49
- )
50
-
51
35
  // Recursively resolve awaited values in a parsed tree
52
- const resolveAwaitedValues = (value) => {
36
+ const evaluate = (value, awaits = []) => {
53
37
  if (value === undefinedSentinel) return undefined
54
38
 
55
- if (isThenable(value)) {
56
- return isExplicitAwait(value)
57
- ? markAwait(value.then(resolveAwaitedValues))
58
- : value
59
- }
60
-
61
39
  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
67
- })
40
+ const [operator, ...rest] = value
41
+
42
+ if (operator === 'await') {
43
+ const [index] = rest
44
+ // No need to check index. It is closely tied to the program.
45
+ // if (typeof index !== 'number' || index < 0 || index >= awaitsResolved.length) {
46
+ // throw new Error(`Invalid await index: ${index}`);
47
+ // }
48
+ return awaits[index]
49
+ }
68
50
 
69
- if (!hasAwait) return result
51
+ if (operator === 'call') {
52
+ // Step 1: evaluate the function itself
53
+ let [fn, args] = rest
54
+ fn = evaluate(fn, awaits)
70
55
 
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
- )
56
+ // Step 2: evaluate each argument AFTER fn is ready
57
+ for (let i = 0; i < args.length; i++) {
58
+ args[i] = evaluate(args[i], awaits)
59
+ }
60
+
61
+ // Step 3: call the function
62
+ return fn(...args)
63
+ }
79
64
 
80
- return markAwait(Promise.all(awaited).then(() => result))
65
+ if (operator === 'quote') {
66
+ const [quoted] = rest
67
+ return quoted // return as-is without evaluating
68
+ }
69
+
70
+ // fallback: evaluate each element
71
+ // re-uses the array again.
72
+
73
+ for (let index = 0; index < value.length; index++) {
74
+ const item = value[index]
75
+ value[index] = evaluate(item, awaits)
76
+ }
77
+ return value
81
78
  }
82
79
 
83
80
  if (isPlainObject(value)) {
84
- const result = {}
85
- const promises = []
86
-
81
+ // We muatate the object itself. No need to make a new one.
87
82
  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
- }
83
+ value[key] = evaluate(value[key], awaits)
98
84
  }
99
-
100
- if (!promises.length) return result
101
- return markAwait(Promise.all(promises).then(() => result))
85
+ return value
102
86
  }
103
87
 
104
88
  return value
@@ -117,7 +101,10 @@ export default (schemaArg, env, deserialize = defaultLeafDeserializer) => {
117
101
 
118
102
  return function parse (program) {
119
103
  debug('program', program)
104
+ const awaits = []
120
105
 
106
+ // Creates the list of awaits that will resolve in order
107
+ // and also deserializes the leaves.
121
108
  const reviver = spy((key, value) => {
122
109
  if (value === null) return value
123
110
 
@@ -134,36 +121,12 @@ export default (schemaArg, env, deserialize = defaultLeafDeserializer) => {
134
121
 
135
122
  if (operator === 'await') {
136
123
  const [program] = rest
137
- return markAwait(Promise.resolve(program))
138
- }
139
-
140
- if (Array.isArray(operator) && operator[0] === 'quote') {
141
- const [, quoted] = operator
142
- return [quoted, ...rest]
143
- }
144
-
145
- if (operator === 'call') {
146
- const [fn, args] = rest
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
124
 
162
- return fn(...resolvedArgs)
125
+ return ['await', awaits.push(program) - 1]
163
126
  }
164
127
 
165
128
  if (operator === 'ref') {
166
- const path = rest // e.g., ["math","add"]
129
+ const path = rest
167
130
  let current = env
168
131
  let schemaCurrent = schema
169
132
 
@@ -183,8 +146,23 @@ export default (schemaArg, env, deserialize = defaultLeafDeserializer) => {
183
146
  })
184
147
 
185
148
  const parsed = JSON.parse(program, reviver)
186
- const result = resolveAwaitedValues(parsed)
187
149
 
188
- return result
150
+ debug('parsed', parsed)
151
+
152
+ if (awaits.length) {
153
+ debug('awaits', awaits)
154
+
155
+ return (async function () {
156
+ for (let index = 0; index < awaits.length; index++) {
157
+ awaits[index] = await evaluate(awaits[index], awaits)
158
+ }
159
+
160
+ debug('awaits(awaited)', awaits)
161
+
162
+ return evaluate(parsed, awaits)
163
+ })()
164
+ }
165
+
166
+ return evaluate(parsed)
189
167
  }
190
168
  }
package/repl.mjs CHANGED
@@ -1,8 +1,8 @@
1
- import instrument from './module.mjs'
1
+ import references from './references.mjs'
2
2
  import repl from 'node:repl'
3
3
 
4
4
  export default async function sendscriptRepl (send, module) {
5
- Object.assign(global, instrument(module))
5
+ Object.assign(global, references(Object.keys(module)))
6
6
 
7
7
  async function cb (cmd, context, filename, callback) {
8
8
  callback(null, await send(eval(cmd))) // eslint-disable-line no-eval
package/stringify.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import Debug from './debug.mjs'
2
+ import { SendScriptSerializationError } from './error.mjs'
2
3
  import {
3
4
  awaitSymbol,
4
5
  call,
@@ -69,7 +70,17 @@ function transformValue (value, leafSerializer) {
69
70
  return ['leaf', leafSerializer(value)]
70
71
  }
71
72
 
72
- export default function stringify (leafSerializer = JSON.stringify) {
73
+ function strictStringify (x) {
74
+ const typeOf = typeof x
75
+
76
+ if (typeOf === 'object' || typeOf === 'function' || x === undefined) {
77
+ throw new SendScriptSerializationError(`Cannot and should not attempt to serialize ${x}`)
78
+ }
79
+
80
+ return JSON.stringify(x)
81
+ }
82
+
83
+ export default function stringify (leafSerializer = strictStringify) {
73
84
  function stringify (program) {
74
85
  return JSON.stringify(transformValue(program, leafSerializer))
75
86
  }
@@ -1,45 +0,0 @@
1
- name: Release on tag
2
-
3
- on:
4
- push:
5
- tags:
6
- - '*'
7
-
8
- permissions:
9
- contents: write
10
-
11
- jobs:
12
- test:
13
- runs-on: ubuntu-latest
14
- strategy:
15
- matrix:
16
- node: ['lts/*', 'latest']
17
-
18
- steps:
19
- - uses: actions/checkout@v4
20
-
21
- - uses: actions/setup-node@v4
22
- with:
23
- node-version: ${{ matrix.node }}
24
- cache: npm
25
-
26
- - run: npm ci
27
- - run: npm test
28
-
29
- release:
30
- needs: test
31
- runs-on: ubuntu-latest
32
-
33
- steps:
34
- - uses: actions/checkout@v4
35
-
36
- - uses: actions/setup-node@v4
37
- with:
38
- node-version: 'latest'
39
- cache: npm
40
-
41
- - run: npm ci
42
- - name: Create GitHub Release
43
- uses: softprops/action-gh-release@v2
44
- with:
45
- generate_release_notes: true