jscall 1.0.1 → 1.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.
data/lib/jscall/main.mjs CHANGED
@@ -1,12 +1,18 @@
1
1
  // Copyright (C) 2022- Shigeru Chiba. All rights reserved.
2
2
 
3
- const cmd_eval = 1
4
- const cmd_call = 2
5
- const cmd_reply = 3
3
+ export const cmd_eval = 1
4
+ export const cmd_call = 2
5
+ export const cmd_reply = 3
6
+ export const cmd_async_call = 4
7
+ export const cmd_async_eval = 5
8
+ export const cmd_retry = 6
9
+ export const cmd_reject = 7
10
+
6
11
  const param_array = 0
7
12
  const param_object = 1
8
13
  const param_local_object = 2
9
14
  const param_error = 3
15
+ const param_hash = 4
10
16
 
11
17
  const table_size = 100
12
18
 
@@ -64,8 +70,9 @@ const exported = new class {
64
70
  }
65
71
  }
66
72
 
67
- const RemoteRef = class {
73
+ class RemoteRef extends Function {
68
74
  constructor(id) {
75
+ super()
69
76
  this.id = id
70
77
  }
71
78
  }
@@ -74,12 +81,20 @@ const remoteRefHandler = new class {
74
81
  get(obj, name) {
75
82
  if (name === '__self__')
76
83
  return obj
84
+ else if (name === 'then')
85
+ // to prevent the Promise from handling RemoteRefs as thenable
86
+ // e.g., `Jscall.exec("{ call: (x) => Promise.resolve(x) }").call(obj)' should return that obj itself
87
+ return undefined
77
88
  else
78
89
  return (...args) => {
79
90
  // this returns Promise
80
91
  return funcall_to_ruby(obj.id, name, args)
81
92
  }
82
93
  }
94
+ apply(obj, self, args) {
95
+ // this returns Promise
96
+ return funcall_to_ruby(obj.id, 'call', args)
97
+ }
83
98
  }
84
99
 
85
100
  const imported = new class {
@@ -94,7 +109,8 @@ const imported = new class {
94
109
  if (obj !== null && obj !== undefined)
95
110
  return obj
96
111
  else {
97
- const rref = new Proxy(new RemoteRef(index), remoteRefHandler)
112
+ const ref = new RemoteRef(index)
113
+ const rref = new Proxy(ref, remoteRefHandler)
98
114
  this.objects[index] = new WeakRef(rref)
99
115
  return rref
100
116
  }
@@ -104,14 +120,15 @@ const imported = new class {
104
120
 
105
121
  // collect reclaimed RemoteRef objects.
106
122
  dead_references() {
107
- if (this.canary === null || this.canary.deref() === undefined)
123
+ // In Safari, deref() may return null
124
+ if (this.canary === null || this.canary.deref() == undefined)
108
125
  this.canary = new WeakRef(new RemoteRef(-1))
109
126
  else
110
127
  return [] // the canary is alive, so no GC has happened yet.
111
128
 
112
129
  const deads = []
113
130
  this.objects.forEach((wref, index) => {
114
- if (wref !== null && wref !== undefined && wref.deref() === undefined) {
131
+ if (wref !== null && wref !== undefined && wref.deref() == undefined) {
115
132
  this.objects[index] = null
116
133
  deads.push(index)
117
134
  }
@@ -127,15 +144,21 @@ const encode_obj = obj => {
127
144
  return null
128
145
  else if (obj.constructor === Array)
129
146
  return [param_array, obj.map(e => encode_obj(e))]
147
+ else if (obj instanceof Map) {
148
+ const hash = {}
149
+ for (const [key, value] of obj.entries())
150
+ hash[key] = value
151
+ return [param_hash, hash]
152
+ }
130
153
  else if (obj instanceof RemoteRef)
131
154
  return [param_local_object, exported.export_remoteref(obj)]
132
155
  else
133
156
  return [param_object, exported.export(obj)]
134
157
  }
135
158
 
136
- const encode_error = msg => [param_error, msg]
159
+ const encode_error = msg => [param_error, msg.toString()]
137
160
 
138
- const RubyError = class {
161
+ class RubyError {
139
162
  constructor(msg) { this.message = msg }
140
163
  get() { return 'RubyError: ' + this.message }
141
164
  }
@@ -146,6 +169,12 @@ const decode_obj = obj => {
146
169
  else if (obj.constructor === Array && obj.length == 2)
147
170
  if (obj[0] == param_array)
148
171
  return obj[1].map(e => decode_obj(e))
172
+ else if (obj[0] == param_hash) {
173
+ const hash = {}
174
+ for (const [key, value] of Object.entries(obj[1]))
175
+ hash[key] = decode_obj(value)
176
+ return hash
177
+ }
149
178
  else if (obj[0] == param_object)
150
179
  return imported.import(obj[1])
151
180
  else if (obj[0] == param_local_object)
@@ -156,12 +185,20 @@ const decode_obj = obj => {
156
185
  throw `decode_obj: unsupported value, ${obj}`
157
186
  }
158
187
 
188
+ export const decode_obj_or_error = obj => {
189
+ const result = decode_obj(obj)
190
+ if (result instanceof RubyError)
191
+ return result.get()
192
+ else
193
+ return result
194
+ }
195
+
159
196
  const js_eval = eval
160
197
 
161
- const funcall_from_ruby = cmd => {
162
- const receiver = decode_obj(cmd[1])
163
- const name = cmd[2]
164
- const args = cmd[3].map(e => decode_obj(e))
198
+ export const funcall_from_ruby = cmd => {
199
+ const receiver = decode_obj(cmd[2])
200
+ const name = cmd[3]
201
+ const args = cmd[4].map(e => decode_obj(e))
165
202
  if (name.endsWith('=')) {
166
203
  const name2 = name.substring(0, name.length - 1)
167
204
  if (receiver === null)
@@ -189,34 +226,41 @@ const funcall_from_ruby = cmd => {
189
226
  return f // obtain a propety
190
227
  }
191
228
 
192
- throw `unknown function/method was called, ${name}`
229
+ throw `unknown JS function/method was called: ${name} on <${receiver}>`
193
230
  }
194
231
 
195
- let stdout_puts = console.log
232
+ export let stdout_puts = console.log
233
+ let num_generated_ids = 0
196
234
 
197
- const reply = value => {
198
- if (value instanceof Promise)
199
- value.then(result => { reply(result) })
200
- .catch(err => reply_error(err))
201
- else {
202
- if (last_callback_stack_depth < callback_stack.length)
203
- throw 'Ruby code was called without await. Call Jscall.close for recovery'
235
+ const fresh_id = () => {
236
+ num_generated_ids += 1
237
+ return num_generated_ids
238
+ }
204
239
 
240
+ export const reply = (message_id, value, sync_mode) => {
241
+ if (sync_mode && value instanceof Promise)
242
+ value.then(result => { reply(message_id, result, true) })
243
+ .catch(err => reply_error(message_id, err))
244
+ else {
205
245
  try {
206
- const cmd = reply_with_piggyback([cmd_reply, encode_obj(value)])
246
+ const cmd = reply_with_piggyback([cmd_reply, message_id, encode_obj(value)])
207
247
  const data = JSON.stringify(cmd)
208
248
  stdout_puts(data)
209
249
  } catch (e) {
210
- reply_error(e)
250
+ reply_error(message_id, e)
211
251
  }
212
252
  }
213
253
  }
214
254
 
215
- const reply_error = e => {
216
- const cmd = reply_with_piggyback([cmd_reply, encode_error(e)])
255
+ export const reply_error = (message_id, e) => {
256
+ const cmd = reply_with_piggyback([cmd_reply, message_id, encode_error(e)])
217
257
  stdout_puts(JSON.stringify(cmd))
218
258
  }
219
259
 
260
+ const puts_retry_cmd = msg_id => {
261
+ stdout_puts(JSON.stringify([cmd_retry, msg_id, encode_obj(false)]))
262
+ }
263
+
220
264
  export const scavenge_references = () => {
221
265
  reply_counter = 200
222
266
  imported.kill_canary()
@@ -230,7 +274,7 @@ const reply_with_piggyback = cmd => {
230
274
  const dead_refs = imported.dead_references()
231
275
  if (dead_refs.length > 0) {
232
276
  const cmd2 = cmd.concat()
233
- cmd2[4] = dead_refs
277
+ cmd2[5] = dead_refs
234
278
  return cmd2
235
279
  }
236
280
  }
@@ -239,58 +283,159 @@ const reply_with_piggyback = cmd => {
239
283
  }
240
284
 
241
285
  const callback_stack = []
242
- let last_callback_stack_depth = 0
243
286
  let reply_counter = 0
244
287
 
245
288
  export const exec = src => {
246
289
  return new Promise((resolve, reject) => {
247
- const cmd = reply_with_piggyback([cmd_eval, src])
248
- callback_stack.push([resolve, reject])
290
+ const cmd = make_cmd_eval(src)
291
+ const message_id = cmd[1]
292
+ callback_stack.push([message_id, resolve, reject])
249
293
  stdout_puts(JSON.stringify(cmd))
250
294
  })
251
295
  }
252
296
 
253
- const funcall_to_ruby = (receiver_id, name, args) => {
297
+ export const make_cmd_eval = src => {
298
+ const message_id = fresh_id()
299
+ return reply_with_piggyback([cmd_eval, message_id, src])
300
+ }
301
+
302
+ let funcall_to_ruby = (receiver_id, name, args) => {
254
303
  return new Promise((resolve, reject) => {
255
- const receiver = [param_local_object, receiver_id]
256
- const encoded_args = args.map(e => encode_obj(e))
257
- const cmd = reply_with_piggyback([cmd_call, receiver, name, encoded_args])
258
- callback_stack.push([resolve, reject])
304
+ const cmd = make_cmd_call(receiver_id, name, args)
305
+ const message_id = cmd[1]
306
+ callback_stack.push([message_id, resolve, reject])
259
307
  stdout_puts(JSON.stringify(cmd))
260
308
  })
261
309
  }
262
310
 
311
+ export const set_funcall_to_ruby = f => { funcall_to_ruby = f }
312
+
313
+ export const make_cmd_call = (receiver_id, name, args) => {
314
+ const message_id = fresh_id()
315
+ const receiver = [param_local_object, receiver_id]
316
+ const encoded_args = args.map(e => encode_obj(e))
317
+ return reply_with_piggyback([cmd_call, message_id, receiver, name, encoded_args])
318
+ }
319
+
263
320
  const returned_from_callback = cmd => {
264
- const result = decode_obj(cmd[1])
265
- const callback = callback_stack.pop()
266
- if (result instanceof RubyError)
267
- callback[1](result.get())
268
- else
269
- callback[0](result)
321
+ const message_id = cmd[1]
322
+ const result = decode_obj(cmd[2])
323
+ for (let i = callback_stack.length - 1; i >= 0; i--) {
324
+ // check the most recent element first since callbacks are
325
+ // assumed to be synchronously executed
326
+ if (callback_stack[i][0] === message_id) {
327
+ const [[_, resolve, reject]] = callback_stack.splice(i, 1)
328
+ if (result instanceof RubyError)
329
+ reject(result.get())
330
+ else
331
+ resolve(result)
332
+ }
333
+ }
270
334
  }
271
335
 
272
- class JsonReader {
336
+ export class MessageReader {
337
+ static HeaderSize = 6
338
+
273
339
  constructor(stream) {
274
- this.stream = stream
275
- this.acc = ""
340
+ this.stream = stream
341
+ this.state = "header"
342
+ this.acc = ""
343
+ this.bodySize = 0
344
+ }
345
+
346
+ parseHeader(pos) {
347
+ // skip leading whitespace characters as a countermeasure against leftover "\n"
348
+ while (pos < this.acc.length && /\s/.test(this.acc[pos]))
349
+ pos++
350
+
351
+ if (this.acc.length >= MessageReader.HeaderSize) {
352
+ const start = pos
353
+ pos += MessageReader.HeaderSize
354
+ return [parseInt(this.acc.slice(start, pos), 16), pos]
355
+ }
356
+ else
357
+ return undefined
358
+ }
359
+
360
+ parseBody(pos) {
361
+ if (this.acc.length >= this.bodySize) {
362
+ const start = pos
363
+ pos += this.bodySize
364
+ return [this.acc.slice(start, pos), pos]
365
+ }
366
+ else
367
+ return undefined
368
+ }
369
+
370
+ consume(pos) {
371
+ if (pos > 0)
372
+ this.acc = this.acc.slice(pos)
276
373
  }
277
374
 
278
375
  async *[Symbol.asyncIterator]() {
279
- for await (let data of this.stream) {
376
+ for await (const data of this.stream) {
280
377
  this.acc += data
281
- try {
282
- yield JSON.parse(this.acc)
283
- this.acc = ""
284
- } catch {
285
- // keep data in this.acc
378
+ let pos = 0
379
+ while (true) {
380
+ const result = this.iteratorBody(pos)
381
+ if (result[0] === false)
382
+ break
383
+ else if (result[0] !== true)
384
+ yield result[0] // result[0] is a string
385
+
386
+ pos = result[1]
387
+ if (this.checkEmptiness(pos))
388
+ break
389
+ }
390
+ }
391
+ this.checkEOS()
392
+ }
393
+
394
+ iteratorBody(pos) {
395
+ if (this.state === "header") {
396
+ const header = this.parseHeader(pos)
397
+ if (header === undefined) {
398
+ this.consume(pos)
399
+ return [false, pos]
400
+ } else {
401
+ this.bodySize = header[0]
402
+ pos = header[1]
403
+ this.state = "body"
286
404
  }
287
405
  }
288
- if (this.acc != "") {
289
- yield JSON.parse(this.acc)
406
+ if (this.state === "body") {
407
+ const body = this.parseBody(pos)
408
+ if (body === undefined) {
409
+ this.consume(pos)
410
+ return [false, pos]
411
+ } else {
412
+ this.state = "header"
413
+ return body
414
+ }
415
+ }
416
+ return [true, pos]
417
+ }
418
+
419
+ checkEmptiness(pos) {
420
+ if (pos == this.acc.length || (pos == this.acc.length - 1
421
+ && this.acc[this.acc.length - 1] === "\n")) {
422
+ this.acc = ""
423
+ return true
290
424
  }
425
+ else
426
+ return false
427
+ }
428
+
429
+ checkEOS() {
430
+ if (this.acc.length > 0)
431
+ throw new Error("The pipe closed after receiving an incomplete message")
291
432
  }
292
433
  }
293
434
 
435
+ let make_message_reader = (stdin) => new MessageReader(stdin)
436
+
437
+ export const set_make_message_reader = (f) => { make_message_reader = f }
438
+
294
439
  export const start = async (stdin, use_stdout) => {
295
440
  if (use_stdout)
296
441
  console.log = console.error // on node.js
@@ -298,30 +443,41 @@ export const start = async (stdin, use_stdout) => {
298
443
  stdout_puts = (m) => stdin.puts(m) // on browser
299
444
 
300
445
  stdin.setEncoding('utf8')
301
- for await (const cmd of new JsonReader(stdin)) {
446
+ for await (const json_data of make_message_reader(stdin)) {
447
+ let cmd
302
448
  try {
303
- last_callback_stack_depth = callback_stack.length
449
+ cmd = JSON.parse(json_data)
304
450
 
305
451
  // scavenge remote references
306
- if (cmd.length > 4)
307
- cmd[4].forEach(i => exported.remove(i))
452
+ if (cmd.length > 5)
453
+ cmd[5].forEach(i => exported.remove(i))
308
454
 
309
455
  if (cmd[0] == cmd_eval) {
310
- const result = js_eval(cmd[1])
311
- reply(result)
456
+ const result = js_eval(cmd[2])
457
+ reply(cmd[1], result, true)
312
458
  }
313
459
  else if (cmd[0] == cmd_call) {
314
460
  const result = funcall_from_ruby(cmd)
315
- reply(result)
461
+ reply(cmd[1], result, true)
316
462
  }
317
463
  else if (cmd[0] == cmd_reply)
318
464
  returned_from_callback(cmd)
319
- else
320
- reply_error('invalid command')
465
+ else if (cmd[0] == cmd_async_call) {
466
+ const result = funcall_from_ruby(cmd)
467
+ reply(cmd[1], result, false)
468
+ }
469
+ else if (cmd[0] == cmd_async_eval) {
470
+ const result = js_eval(cmd[2])
471
+ reply(cmd[1], result, false)
472
+ }
473
+ else if (cmd[0] == cmd_reject)
474
+ puts_retry_cmd(cmd[1])
475
+ else // cmd_retry and other unknown commands
476
+ reply_error(cmd[1], `invalid command ${cmd[0]}`)
321
477
  } catch (error) {
322
478
  const msg = typeof error === 'string' ? error : error.toString() +
323
479
  '\n ---\n' + error.stack
324
- reply_error(msg)
480
+ reply_error(cmd[1], msg)
325
481
  }
326
482
  }
327
483
  }
@@ -0,0 +1,185 @@
1
+ // Copyright (C) 2022- Shigeru Chiba. All rights reserved.
2
+ // This works only with node.js on Linux
3
+
4
+ import * as main from './main.mjs'
5
+ import { readSync, openSync } from 'fs'
6
+
7
+ class SynchronousStdin {
8
+ constructor() {
9
+ this.buf_size = 4096
10
+ this.buffer = Buffer.alloc(this.buf_size);
11
+ this.stdin = openSync('/dev/stdin', 'rs')
12
+ }
13
+
14
+ *[Symbol.iterator]() {
15
+ let str
16
+ while ((str = this.readOne()) !== null)
17
+ yield str
18
+ }
19
+
20
+ readOne() {
21
+ while (true) {
22
+ try {
23
+ const nbytes = readSync(this.stdin, this.buffer, 0, this.buf_size)
24
+ if (nbytes > 0)
25
+ return this.buffer.toString('utf-8', 0, nbytes)
26
+ else
27
+ return null // maybe EOF on macOS
28
+ }
29
+ catch (e) {
30
+ if (e.code === 'EOF')
31
+ return null
32
+ else if (e.code !== 'EAGAIN')
33
+ throw e
34
+ }
35
+ }
36
+ }
37
+ }
38
+
39
+ class SyncMessageReader extends main.MessageReader {
40
+ constructor(stream) {
41
+ super(stream)
42
+ this.stdin = new SynchronousStdin()
43
+ this.generator = null
44
+ }
45
+
46
+ gets_function() {
47
+ const iterator = this[Symbol.iterator]()
48
+ return () => {
49
+ const v = iterator.next()
50
+ if (v.done)
51
+ return null
52
+ else
53
+ return v.value
54
+ }
55
+ }
56
+
57
+ async *[Symbol.asyncIterator]() {
58
+ if (this.generator !== null) {
59
+ while (true) {
60
+ const v = this.generator.next()
61
+ if (v.done)
62
+ break
63
+ else
64
+ yield v.value
65
+ }
66
+ }
67
+ for await (const data of this.stream) {
68
+ this.acc += data
69
+ this.generator = this.generatorBody()
70
+ while (true) {
71
+ const v = this.generator.next()
72
+ if (v.done)
73
+ break
74
+ else
75
+ yield v.value
76
+ }
77
+ }
78
+ this.checkEOS()
79
+ }
80
+
81
+ *[Symbol.iterator]() {
82
+ if (this.generator !== null) {
83
+ while (true) {
84
+ const v = this.generator.next()
85
+ if (v.done)
86
+ break
87
+ else
88
+ yield v.value
89
+ }
90
+ }
91
+ for (const data of this.stdin) {
92
+ this.acc += data
93
+ this.generator = this.generatorBody()
94
+ while (true) {
95
+ const v = this.generator.next()
96
+ if (v.done)
97
+ break
98
+ else
99
+ yield v.value
100
+ }
101
+ }
102
+ this.checkEOS()
103
+ }
104
+
105
+ *generatorBody() {
106
+ let pos = 0
107
+ while (true) {
108
+ const result = this.iteratorBody(pos)
109
+ if (result[0] === false)
110
+ break
111
+ else if (result[0] !== true)
112
+ yield result[0] // result[0] is a string
113
+
114
+ pos = result[1]
115
+ if (this.checkEmptiness(pos))
116
+ break
117
+ }
118
+ }
119
+ }
120
+
121
+ export const start = main.start
122
+
123
+ let stdin_gets = null
124
+
125
+ main.set_make_message_reader((stdin) => {
126
+ const reader = new SyncMessageReader(stdin)
127
+ stdin_gets = reader.gets_function()
128
+ return reader
129
+ })
130
+
131
+ const js_eval = eval
132
+ const exported = main.get_exported_imported()[0]
133
+
134
+ const event_loop = () => {
135
+ let json_data
136
+ while ((json_data = stdin_gets()) !== null) {
137
+ let cmd
138
+ try {
139
+ cmd = JSON.parse(json_data)
140
+
141
+ // scavenge remote references
142
+ if (cmd.length > 5)
143
+ cmd[5].forEach(i => exported.remove(i))
144
+
145
+ if (cmd[0] == main.cmd_eval || cmd[0] == main.cmd_async_eval) {
146
+ const result = js_eval(cmd[2])
147
+ main.reply(cmd[1], result, false)
148
+ }
149
+ else if (cmd[0] == main.cmd_call || cmd[0] == main.cmd_async_call) {
150
+ const result = main.funcall_from_ruby(cmd)
151
+ main.reply(cmd[1], result, false)
152
+ }
153
+ else if (cmd[0] == main.cmd_reply)
154
+ return main.decode_obj_or_error(cmd[2])
155
+ else { // cmd_retry, cmd_reject, and other unknown commands
156
+ console.error(`*** node.js; bad message received: ${json_data}`)
157
+ break
158
+ }
159
+ } catch (error) {
160
+ const msg = typeof error === 'string' ? error : error.toString() +
161
+ '\n ---\n' + error.stack
162
+ main.reply_error(cmd[1], msg)
163
+ }
164
+ }
165
+ return undefined
166
+ }
167
+
168
+ export const exec = src => {
169
+ const cmd = main.make_cmd_eval(src)
170
+ main.stdout_puts(JSON.stringify(cmd))
171
+ return event_loop()
172
+ }
173
+
174
+ main.set_funcall_to_ruby((receiver_id, name, args) => {
175
+ const cmd = main.make_cmd_call(receiver_id, name, args)
176
+ main.stdout_puts(JSON.stringify(cmd))
177
+ return event_loop()
178
+ })
179
+
180
+ export const scavenge_references = main.scavenge_references
181
+
182
+ // for testing and debugging
183
+ export const get_exported_imported = main.get_exported_imported
184
+
185
+ export const dyn_import = main.dyn_import
@@ -3,5 +3,5 @@
3
3
  # Copyright (C) 2022- Shigeru Chiba. All rights reserved.
4
4
 
5
5
  module Jscall
6
- VERSION = "1.0.1"
6
+ VERSION = "1.2.0"
7
7
  end