jscall 1.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2c3d95c5cc2e431807e613c0ba1f3fa4c7bc2d74b14974bd780eb787e9db4d13
4
+ data.tar.gz: 6a301b76f12d69fde106d923f0f22e355e38450c1f4c1d7673508984c222d64f
5
+ SHA512:
6
+ metadata.gz: 572d0824bc9d2058a4016e28a469fec90bf718124ce4fdd647ef4543fca98b5d6f5b2e43ff06aa2bc11bdcf898cfb0cd7655ef0d09e4ac60263f8c689e72bc6d
7
+ data.tar.gz: 4a7ce3b1340f3df751c6f5b5467d3242fe53e0a557018d99a0b4475855f54661de8c638376de8e2c4d0a38609f1e9acf9de91dd3ecb25c0dca6534ba9de211d8
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in jscall.gemspec
6
+ gemspec
7
+
8
+ gem "webrick", "~> 1.4"
9
+
10
+ gem "rake", "~> 13.0"
11
+
12
+ gem "minitest", "~> 5.0"
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022- Shigeru Chiba.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,260 @@
1
+ # Jscall
2
+
3
+ Jscall allows executing a program in JavaScript on node.js or a web browser.
4
+ By default, node.js is used for the execution.
5
+ To choose a web browser, call `Jscall.config`.
6
+
7
+ ```
8
+ Jscall.config(browser: true)
9
+ ```
10
+
11
+ To run JavaScript code, call `Jscall.exec`.
12
+ For example,
13
+
14
+ ```
15
+ Jscall.exec '1 + 1'
16
+ ```
17
+
18
+ This returns `2`. The argument passed to `Jscall.exec` can be
19
+ multipe lines. It is executed as source code written in JavaScript.
20
+
21
+ `Jscall.exec` returns a resulting value. Numbers, character strings (and symbols), boolean values, and `nil` (and `null`)
22
+ are copied when passing between Ruby and JavaScript. An array is also shallow-copied.
23
+ Other objects are not copied. When they are passed, a remote reference is created at the destination.
24
+
25
+ A remote reference is a proxy object. A method call on a remote reference invokes a method on the corresponding object on the remote site. For example,
26
+
27
+ ```
28
+ js_obj = Jscall.exec '({ foo: (x) => x + 1, bar: 7 })'
29
+ js_obj.foo(3) # 4
30
+ js_obj.bar # 7
31
+ js_obj.baz = 9
32
+ js_obj.baz # 9
33
+ ```
34
+
35
+ The `foo` method is executed in JavaScript.
36
+ Since `bar` is not a function, its value is returned to Ruby as it is.
37
+
38
+ Setting a object property to a given value is also
39
+ allowed. The expression `js_obj.baz = 9` above sets
40
+ the object property `baz` to 9.
41
+
42
+ To call a JavaScript function from Ruby, call a mehtod on `Jscall`.
43
+ For example,
44
+
45
+ ```
46
+ Jscall.exec <<CODE
47
+ function foo(x) {
48
+ return x + 1
49
+ }
50
+ CODE
51
+ Jscall.foo(7) # 8
52
+ ```
53
+
54
+ `Jscall.foo(7)` invokes the JavaScript function with the name following `Jscall.`
55
+ with the given argument. In this case,
56
+ the `foo` function is executed with the argument `7`.
57
+
58
+ When a Ruby object is passed to a JavaScript function/method,
59
+ it can call a method on the passed Ruby object.
60
+
61
+ ```
62
+ Jscall.exec <<CODE
63
+ async function foo(obj) {
64
+ return await obj.to_a()
65
+ }
66
+ CODE
67
+ Jscall.foo((1..3)) # [], 2, 3]
68
+ ```
69
+
70
+ Here, `obj.to_a()` calls the `to_a` method on a `Range` object
71
+ created in Ruby.
72
+ Note that you must `await` every call to Ruby object since it is
73
+ asynchronous call.
74
+
75
+ In JavaScript, `Ruby.exec` is availale to run a program in Ruby.
76
+ For example,
77
+
78
+ ```
79
+ Jscall.exec <<CODE
80
+ async function foo() {
81
+ return await Ruby.exec('RUBY_VERSION')
82
+ }
83
+ CODE
84
+ Jscall.foo()
85
+ ```
86
+
87
+ `Jscall.foo()` returns the result of evaluating `RUBY_VERSION`
88
+ in Ruby.
89
+ Don't forget to `await` a call to `Ruby.exec`.
90
+
91
+ Remote references will be automatically reclaimed when they are no
92
+ longer used. To reclaim them immediately, call:
93
+
94
+ ```
95
+ Jscall.scavenge_references
96
+ ```
97
+
98
+ ## DOM manipulation
99
+
100
+ When JavaScript code is run on a browser, some utility methods
101
+ are available in Ruby for manipulating DOM objects.
102
+
103
+ - `Jscall.dom.append_css(css_file_path)`
104
+
105
+ This adds a `link` element to the DOM tree so that the specified
106
+ css file will be linked. For example, `append_css('/mystyle.css')`
107
+ links `mystyle.css` in the current directory.
108
+
109
+ - `Jscall.dom.print(msg)`
110
+
111
+ This adds a `p` element to the DOM tree.
112
+
113
+
114
+ ## Variable scope
115
+
116
+ Since Jscall uses `eval` to execute JavaScript code, the scope of
117
+ variable/constant names is within the code passed to `eval`.
118
+ For example,
119
+
120
+ ```
121
+ Jscall.exec 'const k = 3'
122
+ Jscall.exec 'k + 3' # Can't find variable: k
123
+ ```
124
+
125
+ The second line causes an error. `k` is not visible
126
+ when `'k + 3'` is executed.
127
+
128
+ To avoid this, use a global variable.
129
+
130
+ ```
131
+ Jscall.exec 'k = 3'
132
+ Jscall.exec 'globalThis.j = 4'
133
+ Jscall.exec 'k + j' # 7
134
+ ```
135
+
136
+ ## Loading a module
137
+
138
+ When JavaScript code is executed on node.js, `require` is available in JavaScript
139
+ for loading a CommonJS module. For example,
140
+
141
+ ```
142
+ Jscall.exec "mime = require('./mime.js')"
143
+ ```
144
+
145
+ The file `./mime.js` is loaded and the module is bound to a global variable `mime`.
146
+
147
+ You may want to call `Jscall.dyn_import` in Ruby.
148
+
149
+ ```
150
+ fs = Jscall.dyn_import('fs')
151
+ ```
152
+
153
+ This executes dynamic importing in JavaScript.
154
+ For node.js, the file name of the imported module should be a full path name. For a web browser, the root directory is a current working directory. So `Jscall.dyn_import('/mine.mjs')` loads the file `./mine.mjs`.
155
+
156
+ `Jscall.dyn_import` takes the second argument. If it is given,
157
+ a global variable in JavaScript is bound to the loaded module.
158
+
159
+ ```
160
+ fs = Jscall.dyn_import('fs', 'fs_module')
161
+ ```
162
+
163
+ This is quite equivalent to the following JavaScript code:
164
+
165
+ ```
166
+ fs_module = await load('fs')
167
+ ```
168
+
169
+
170
+ ## Configuration
171
+
172
+ To import JavaScript modules when node.js starts,
173
+
174
+ ```
175
+ Jscall.config(module_names: [["Foo", "./foo.mjs"], ["Bar", "./bar.mjs"]], options: "--use-strict")
176
+ ```
177
+
178
+ This specifies that `./foo.mjs` and `./bar.mjs` are impoted when node.js starts.
179
+ This is equivalent to the following import declarations:
180
+
181
+ ```
182
+ import * as "Foo" from "./foo.mjs"
183
+ import * as "Bar" from "./bar.mjs"
184
+ ```
185
+
186
+ `'--use-strict'` is passed to node.js as a command line argument.
187
+
188
+ `module_names:` and `options:` are optional arguments to `Jscall.config`.
189
+
190
+ To change the name of the node command,
191
+
192
+ ```
193
+ Jscall::PipeToJs.node_command = "node.exe"
194
+ ```
195
+
196
+ The default command name is `"node"`.
197
+
198
+ When running JavaScript code on a web browser,
199
+
200
+ ```
201
+ Jscall.config(browser: true, options: {port: 10082})
202
+ ```
203
+
204
+ `options:` is an optional argument.
205
+ The example above specifies that Ruby receives http requests
206
+ sent to http://localhost:10082 from JavaScript on a web browser.
207
+
208
+ Passing `false` for `browser:` to `Jscall.config` switches
209
+ the execution engine to node.js.
210
+ Call `Jscall.close` to detach the current execution engine.
211
+ A new enigine with a new configuration will be created.
212
+
213
+ To change the command for launching a web browser,
214
+
215
+ ```
216
+ Jscall::FetchServer.open_command = "open -a '/Applications/Safari.app'"
217
+ ```
218
+
219
+ By default, the command name is `open` for macOS, `start` for Windows,
220
+ or `xdg-open` for Linux.
221
+ Jscall launches a web browser by the command like the following:
222
+
223
+ ```
224
+ open http://localhost:10082/jscall/jscall.html
225
+ ```
226
+
227
+ ## Installation
228
+
229
+ Add this line to your application's Gemfile:
230
+
231
+ ```ruby
232
+ gem 'jscall'
233
+ ```
234
+
235
+ And then execute:
236
+
237
+ $ bundle install
238
+
239
+ Or install it yourself as:
240
+
241
+ $ gem install jscall
242
+
243
+ ## Development
244
+
245
+ After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
246
+
247
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
248
+
249
+ ## Contributing
250
+
251
+ Bug reports and pull requests are welcome on GitHub at https://github.com/csg-tokyo/jscall.
252
+
253
+ ## License
254
+
255
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
256
+
257
+ ## Acknowledgement
258
+
259
+ The icon image for jscall was created by partly using the Ruby logo, which was obtained
260
+ from https://www.ruby-lang.org/en/about/logo/ under CC BY-SA 2.5.
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/test_*.rb"]
10
+ end
11
+
12
+ task default: :test
13
+ # task default: %i[]
data/jscall.gemspec ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/jscall/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "jscall"
7
+ spec.version = Jscall::VERSION
8
+ spec.authors = ["Shigeru Chiba"]
9
+ spec.email = ["?"]
10
+
11
+ spec.summary = "a library for calling JavaScript functions"
12
+ spec.description = "a library for executing JavaScript code on node.js or a web browser"
13
+ spec.homepage = "https://github.com/csg-tokyo/jscall"
14
+ spec.required_ruby_version = ">= 2.6.0"
15
+
16
+ # spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'"
17
+
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = spec.homepage
20
+ # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
21
+
22
+ # Specify which files should be added to the gem when it is released.
23
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ `git ls-files -z`.split("\x0").reject do |f|
26
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
27
+ end
28
+ end
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+
33
+ # Uncomment to register a new dependency of your gem
34
+ spec.add_dependency "webrick", "~> 1.4"
35
+
36
+ # For more information and examples about making a new gem, check out our
37
+ # guide at: https://bundler.io/guides/creating_gem.html
38
+ end
Binary file
@@ -0,0 +1,67 @@
1
+ // Copyright (C) 2022- Shigeru Chiba. All rights reserved.
2
+
3
+ export class HttpStream {
4
+ constructor() {
5
+ this.send_buffer = ['start']
6
+ this.send_callback = null
7
+ }
8
+
9
+ [Symbol.asyncIterator]() {
10
+ const http_stream = this
11
+ return {
12
+ next() {
13
+ let msg = http_stream.send_buffer.shift()
14
+ if (msg === undefined)
15
+ return new Promise((resolve, reject) => {
16
+ if (http_stream.send_callback === null)
17
+ http_stream.send_callback = resolve
18
+ else
19
+ throw new Error('(fatal) send_callback is not null!')
20
+ }).then(() => this.next())
21
+ else
22
+ return http_stream.do_fetch(msg)
23
+ }
24
+ }
25
+ }
26
+
27
+ do_fetch(msg) {
28
+ const hs = new Headers()
29
+ hs.append('Content-Type', 'text/plain')
30
+ return fetch('/cmd/', { method: 'POST', headers: hs, body: msg })
31
+ .then(async (response) => {
32
+ if (response.ok) {
33
+ const text = await response.text()
34
+ return { value: text, done: text === 'done' }
35
+ }
36
+ else
37
+ throw new Error(`HTTP error! Status: ${response.status}`)
38
+ },
39
+ (reason) => { return { value: 'failure', done: true } })
40
+ }
41
+
42
+ puts(msg) {
43
+ this.send_buffer.push(msg)
44
+ if (this.send_callback !== null) {
45
+ const callback = this.send_callback
46
+ this.send_callback = null
47
+ callback()
48
+ }
49
+ }
50
+
51
+ setEncoding(encoding) {}
52
+ }
53
+
54
+ export const Jscall = new class {
55
+ print(msg) {
56
+ const e = document.createElement('p')
57
+ e.textContent = msg
58
+ document.body.append(e)
59
+ }
60
+
61
+ append_css(file_name) {
62
+ const link = document.createElement('link')
63
+ link.rel = "stylesheet"
64
+ link.href = file_name
65
+ document.head.append(link)
66
+ }
67
+ }
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (C) 2022- Shigeru Chiba. All rights reserved.
4
+
5
+ require 'webrick'
6
+
7
+ module Jscall
8
+ WEBrick::HTTPUtils::DefaultMimeTypes['mjs'] ||= "application/javascript"
9
+
10
+ class Dom
11
+ def method_missing(name, *args)
12
+ Jscall.__getpipe__.funcall(nil, "Jscall.#{name}", args)
13
+ end
14
+ end
15
+
16
+ @js_dom = Dom.new
17
+
18
+ def self.dom
19
+ @js_dom
20
+ end
21
+
22
+ class PipeToJs
23
+ end
24
+
25
+ class PipeToBrowser < PipeToJs
26
+ def startJS(module_names, options)
27
+ port = 10081
28
+ port = options[:port] if options.is_a?(Hash) && options.has_key?(:port)
29
+ @pipe = FetchServer.new(port)
30
+ @pipe.open
31
+ end
32
+
33
+ def close
34
+ @pipe.shutdown
35
+ sleep(0.5)
36
+ true
37
+ end
38
+
39
+ def soft_close
40
+ @pipe.close
41
+ false
42
+ end
43
+ end
44
+
45
+ class FetchServer
46
+ @@webpage = '/jscall/jscall.html'
47
+
48
+ @@run_cmd = case RbConfig::CONFIG['host_os']
49
+ when /linux/
50
+ 'xdg-open'
51
+ when /darwin|mac os/
52
+ 'open'
53
+ else
54
+ 'start'
55
+ end
56
+
57
+ def self.open_command=(name)
58
+ @@run_cmd = name
59
+ end
60
+
61
+ def initialize(port)
62
+ @send_buffer = Thread::Queue.new
63
+ @receive_buffer = Thread::Queue.new
64
+ @server_running = false
65
+
66
+ @server = WEBrick::HTTPServer.new(
67
+ # :DoNotReverseLookup => true,
68
+ :DocumentRoot => './',
69
+ :BindAddress => '0.0.0.0',
70
+ :Port => port,
71
+ :ServerType => Thread,
72
+ :Logger => WEBrick::Log.new(nil, WEBrick::Log::ERROR),
73
+ :AccessLog => []
74
+ )
75
+
76
+ @server.mount_proc('/') do |req, res|
77
+ peer_address = req.peeraddr[3]
78
+ if peer_address != '127.0.0.1'
79
+ $stderr.puts "access denied address=#{peer_address}"
80
+ raise WEBrick::HTTPStatus::Forbidden
81
+ end
82
+
83
+ if req.path.start_with?('/cmd/')
84
+ read_stream(req, res)
85
+ else
86
+ read_file(req, res)
87
+ end
88
+ end
89
+ end
90
+
91
+ def read_stream(req, res)
92
+ body = req.body
93
+ @receive_buffer.push(if body.nil? then '' else body end)
94
+ res.content_type = "text/plain"
95
+ res.body = @send_buffer.pop
96
+ end
97
+
98
+ def read_file(req, res)
99
+ if req.path.start_with?('/jscall/')
100
+ root = "#{__dir__}/../"
101
+ else
102
+ root = @server.config[:DocumentRoot]
103
+ end
104
+ WEBrick::HTTPServlet::FileHandler.new(@server, root).service(req, res)
105
+ end
106
+
107
+ def open
108
+ if @server_running
109
+ close
110
+ else
111
+ @server_running = true
112
+ if @server.status != :Running
113
+ Signal.trap(:INT){ @server.shutdown }
114
+ @server.start
115
+ Thread.pass
116
+ end
117
+ end
118
+ raise "A web page was reloaded" unless @receive_buffer.empty?
119
+ status = system "#{@@run_cmd} http://localhost:#{@server[:Port]}#{@@webpage}"
120
+ raise "cannot launch a web browser by '#{@@run_cmd}'" if status.nil?
121
+ unless @receive_buffer.pop == 'start'
122
+ raise 'failed to initialize JavaScript'
123
+ end
124
+ end
125
+
126
+ def close
127
+ puts('done')
128
+ @server_running = false
129
+ end
130
+
131
+ def closed?
132
+ !@server_running
133
+ end
134
+
135
+ def autoclose=(value)
136
+ false
137
+ end
138
+
139
+ def shutdown
140
+ close
141
+ @server.stop
142
+ end
143
+
144
+ def puts(msg)
145
+ @send_buffer.push(msg)
146
+ Thread.pass
147
+ end
148
+
149
+ def gets
150
+ @receive_buffer.pop
151
+ end
152
+ end
153
+ end
Binary file
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Jscall</title>
6
+ <link rel="icon" type="image/png" sizes="32x32" href="./favicon.ico">
7
+ <link rel="apple-touch-icon" href="./apple-touch-icon.png">
8
+
9
+ <script type="module" src="./main.mjs"></script>
10
+ <script type="module" src="./browser.mjs"></script>
11
+ <script type="module">
12
+ import * as Ruby from './main.mjs'
13
+ import { HttpStream, Jscall } from './browser.mjs'
14
+ globalThis.Ruby = Ruby
15
+ globalThis.Jscall = Jscall
16
+ Ruby.start(new HttpStream(), false)
17
+ </script>
18
+ </head>
19
+
20
+ <body>
21
+ </body>
22
+ </html>
@@ -0,0 +1,340 @@
1
+ // Copyright (C) 2022- Shigeru Chiba. All rights reserved.
2
+
3
+ const cmd_eval = 1
4
+ const cmd_call = 2
5
+ const cmd_reply = 3
6
+ const param_array = 0
7
+ const param_object = 1
8
+ const param_local_object = 2
9
+ const param_error = 3
10
+
11
+ const table_size = 100
12
+
13
+ const exported = new class {
14
+ constructor() {
15
+ this.objects = Array(table_size).fill().map((_, i) => i + 1)
16
+ this.objects[table_size - 1] = -1
17
+ this.free_element = 0
18
+ this.hashtable = new Map()
19
+ }
20
+
21
+ export(obj) {
22
+ const idx = this.hashtable.get(obj)
23
+ if (idx !== undefined)
24
+ return idx
25
+ else {
26
+ const idx = this.next_element()
27
+ this.objects[idx] = obj
28
+ this.hashtable.set(obj, idx)
29
+ return idx
30
+ }
31
+ }
32
+
33
+ export_remoteref(prref) { // proxy for remote reference
34
+ return prref.__self__.id
35
+ }
36
+
37
+ next_element() {
38
+ const idx = this.free_element
39
+ if (idx < 0)
40
+ return this.objects.length
41
+ else {
42
+ this.free_element = this.objects[idx]
43
+ return idx
44
+ }
45
+ }
46
+
47
+ find(idx) {
48
+ const obj = this.objects[idx]
49
+ if (typeof obj === 'number')
50
+ throw `unknown index is given to find(): ${idx}`
51
+ else
52
+ return obj
53
+ }
54
+
55
+ remove(idx) {
56
+ const obj = this.objects[idx]
57
+ if (typeof obj === 'number')
58
+ throw `unknown index is given to remove(): ${idx}`
59
+ else {
60
+ this.objects[idx] = this.free_element
61
+ this.free_element = idx
62
+ this.hashtable.delete(obj)
63
+ }
64
+ }
65
+ }
66
+
67
+ const RemoteRef = class {
68
+ constructor(id) {
69
+ this.id = id
70
+ }
71
+ }
72
+
73
+ const remoteRefHandler = new class {
74
+ get(obj, name) {
75
+ if (name === '__self__')
76
+ return obj
77
+ else
78
+ return (...args) => {
79
+ // this returns Promise
80
+ return funcall_to_ruby(obj.id, name, args)
81
+ }
82
+ }
83
+ }
84
+
85
+ const imported = new class {
86
+ constructor() {
87
+ this.objects = Array(table_size).fill(null)
88
+ this.canary = new WeakRef(new RemoteRef(-1))
89
+ }
90
+
91
+ import(index) {
92
+ const wref = this.objects[index]
93
+ const obj = wref === null || wref === undefined ? null : wref.deref()
94
+ if (obj !== null && obj !== undefined)
95
+ return obj
96
+ else {
97
+ const rref = new Proxy(new RemoteRef(index), remoteRefHandler)
98
+ this.objects[index] = new WeakRef(rref)
99
+ return rref
100
+ }
101
+ }
102
+
103
+ kill_canary() { this.canary = null }
104
+
105
+ // collect reclaimed RemoteRef objects.
106
+ dead_references() {
107
+ if (this.canary === null || this.canary.deref() === undefined)
108
+ this.canary = new WeakRef(new RemoteRef(-1))
109
+ else
110
+ return [] // the canary is alive, so no GC has happened yet.
111
+
112
+ const deads = []
113
+ this.objects.forEach((wref, index) => {
114
+ if (wref !== null && wref !== undefined && wref.deref() === undefined) {
115
+ this.objects[index] = null
116
+ deads.push(index)
117
+ }
118
+ })
119
+ return deads
120
+ }
121
+ }
122
+
123
+ const encode_obj = obj => {
124
+ if (typeof obj === 'number' || typeof obj === 'string' || obj === true || obj === false || obj === null)
125
+ return obj
126
+ else if (obj === undefined)
127
+ return null
128
+ else if (obj.constructor === Array)
129
+ return [param_array, obj.map(e => encode_obj(e))]
130
+ else if (obj instanceof RemoteRef)
131
+ return [param_local_object, exported.export_remoteref(obj)]
132
+ else
133
+ return [param_object, exported.export(obj)]
134
+ }
135
+
136
+ const encode_error = msg => [param_error, msg]
137
+
138
+ const RubyError = class {
139
+ constructor(msg) { this.message = msg }
140
+ get() { return 'RubyError: ' + this.message }
141
+ }
142
+
143
+ const decode_obj = obj => {
144
+ if (typeof obj === 'number' || typeof obj === 'string' || obj === true || obj === false || obj === null)
145
+ return obj
146
+ else if (obj.constructor === Array && obj.length == 2)
147
+ if (obj[0] == param_array)
148
+ return obj[1].map(e => decode_obj(e))
149
+ else if (obj[0] == param_object)
150
+ return imported.import(obj[1])
151
+ else if (obj[0] == param_local_object)
152
+ return exported.find(obj[1])
153
+ else if (obj[0] == param_error)
154
+ return new RubyError(obj[1])
155
+
156
+ throw `decode_obj: unsupported value, ${obj}`
157
+ }
158
+
159
+ const js_eval = eval
160
+
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))
165
+ if (name.endsWith('=')) {
166
+ const name2 = name.substring(0, name.length - 1)
167
+ if (receiver === null)
168
+ throw `assignment to a global object ${name2} is not supported`
169
+ else if (args.length === 1) {
170
+ if (Reflect.set(receiver, name2, args[0]))
171
+ return args[0]
172
+ }
173
+ throw `faild to set an object property ${name2}`
174
+ }
175
+
176
+ if (receiver === null) {
177
+ const f = js_eval(name)
178
+ if (typeof f === 'function')
179
+ return f.apply(this, args)
180
+ else if (args.length === 0)
181
+ return f // obtain a property
182
+ }
183
+ else {
184
+ const f = Reflect.get(receiver, name)
185
+ if (f)
186
+ if (typeof f === 'function')
187
+ return Reflect.apply(f, receiver, args)
188
+ else if (args.length === 0)
189
+ return f // obtain a propety
190
+ }
191
+
192
+ throw `unknown function/method was called, ${name}`
193
+ }
194
+
195
+ let stdout_puts = console.log
196
+
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'
204
+
205
+ try {
206
+ const cmd = reply_with_piggyback([cmd_reply, encode_obj(value)])
207
+ const data = JSON.stringify(cmd)
208
+ stdout_puts(data)
209
+ } catch (e) {
210
+ reply_error(e)
211
+ }
212
+ }
213
+ }
214
+
215
+ const reply_error = e => {
216
+ const cmd = reply_with_piggyback([cmd_reply, encode_error(e)])
217
+ stdout_puts(JSON.stringify(cmd))
218
+ }
219
+
220
+ export const scavenge_references = () => {
221
+ reply_counter = 200
222
+ imported.kill_canary()
223
+ return true
224
+ }
225
+
226
+ const reply_with_piggyback = cmd => {
227
+ const threashold = 100
228
+ if (++reply_counter > threashold) {
229
+ reply_counter = 0
230
+ const dead_refs = imported.dead_references()
231
+ if (dead_refs.length > 0) {
232
+ const cmd2 = cmd.concat()
233
+ cmd2[4] = dead_refs
234
+ return cmd2
235
+ }
236
+ }
237
+
238
+ return cmd
239
+ }
240
+
241
+ const callback_stack = []
242
+ let last_callback_stack_depth = 0
243
+ let reply_counter = 0
244
+
245
+ export const exec = src => {
246
+ return new Promise((resolve, reject) => {
247
+ const cmd = reply_with_piggyback([cmd_eval, src])
248
+ callback_stack.push([resolve, reject])
249
+ stdout_puts(JSON.stringify(cmd))
250
+ })
251
+ }
252
+
253
+ const funcall_to_ruby = (receiver_id, name, args) => {
254
+ 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])
259
+ stdout_puts(JSON.stringify(cmd))
260
+ })
261
+ }
262
+
263
+ 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)
270
+ }
271
+
272
+ class JsonReader {
273
+ constructor(stream) {
274
+ this.stream = stream
275
+ this.acc = ""
276
+ }
277
+
278
+ async *[Symbol.asyncIterator]() {
279
+ for await (let data of this.stream) {
280
+ this.acc += data
281
+ try {
282
+ yield JSON.parse(this.acc)
283
+ this.acc = ""
284
+ } catch {
285
+ // keep data in this.acc
286
+ }
287
+ }
288
+ if (this.acc != "") {
289
+ yield JSON.parse(this.acc)
290
+ }
291
+ }
292
+ }
293
+
294
+ export const start = async (stdin, use_stdout) => {
295
+ if (use_stdout)
296
+ console.log = console.error // on node.js
297
+ else
298
+ stdout_puts = (m) => stdin.puts(m) // on browser
299
+
300
+ stdin.setEncoding('utf8')
301
+ for await (const cmd of new JsonReader(stdin)) {
302
+ try {
303
+ last_callback_stack_depth = callback_stack.length
304
+
305
+ // scavenge remote references
306
+ if (cmd.length > 4)
307
+ cmd[4].forEach(i => exported.remove(i))
308
+
309
+ if (cmd[0] == cmd_eval) {
310
+ const result = js_eval(cmd[1])
311
+ reply(result)
312
+ }
313
+ else if (cmd[0] == cmd_call) {
314
+ const result = funcall_from_ruby(cmd)
315
+ reply(result)
316
+ }
317
+ else if (cmd[0] == cmd_reply)
318
+ returned_from_callback(cmd)
319
+ else
320
+ reply_error('invalid command')
321
+ } catch (error) {
322
+ const msg = typeof error === 'string' ? error : error.toString() +
323
+ '\n ---\n' + error.stack
324
+ reply_error(msg)
325
+ }
326
+ }
327
+ }
328
+
329
+ // for testing and debugging
330
+ export const get_exported_imported = () => {
331
+ return [exported, imported]
332
+ }
333
+
334
+ export const dyn_import = async (file_name, var_name) => {
335
+ const m = await import(file_name) // dynamic import
336
+ if (var_name)
337
+ eval(`(v)=>{globalThis.${var_name} = v}`)(m)
338
+
339
+ return m
340
+ }
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (C) 2022- Shigeru Chiba. All rights reserved.
4
+
5
+ module Jscall
6
+ VERSION = "1.0.0"
7
+ end
data/lib/jscall.rb ADDED
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (C) 2022- Shigeru Chiba. All rights reserved.
4
+
5
+ require "json"
6
+ require "weakref"
7
+
8
+ require_relative "jscall/version"
9
+ require_relative "jscall/browser"
10
+
11
+ module Jscall
12
+ TableSize = 100
13
+
14
+ class JavaScriptError < RuntimeError
15
+ def initialize(msg)
16
+ super(msg)
17
+ end
18
+ end
19
+
20
+ class HiddenRef
21
+ def initialize(obj)
22
+ @object = obj
23
+ end
24
+ def __getobj__()
25
+ @object
26
+ end
27
+ end
28
+
29
+ class Exported # inbound referneces from Node to Ruby
30
+ attr_reader :objects
31
+
32
+ def initialize
33
+ ary = Array.new(Jscall::TableSize) {|i| i + 1}
34
+ ary[-1] = -1
35
+ @objects = HiddenRef.new(ary)
36
+ @free_element = 0
37
+ @hashtable = HiddenRef.new({})
38
+ end
39
+
40
+ def export(obj)
41
+ hash = @hashtable.__getobj__
42
+ if hash.include?(obj)
43
+ hash[obj]
44
+ else
45
+ objs = @objects.__getobj__
46
+ idx = @free_element
47
+ if idx < 0
48
+ idx = objs.size
49
+ else
50
+ @free_element = objs[idx]
51
+ end
52
+ objs[idx] = obj
53
+ hash[obj] = idx # return idx
54
+ end
55
+ end
56
+
57
+ def find(idx)
58
+ obj = @objects.__getobj__[idx]
59
+ if obj.is_a?(Numeric)
60
+ raise JavaScriptError.new("unknown index is given to find(): #{idx}")
61
+ else
62
+ obj
63
+ end
64
+ end
65
+
66
+ def remove(idx)
67
+ objects = @objects.__getobj__
68
+ obj = objects[idx]
69
+ if obj.is_a?(Numeric)
70
+ raise JavaScriptError.new("unknown index is given to remove(): #{idx}")
71
+ else
72
+ objects[idx] = @free_element
73
+ @free_element = idx
74
+ @hashtable.__getobj__.delete(obj)
75
+ end
76
+ end
77
+ end
78
+
79
+ class RemoteRef
80
+ attr_reader :id
81
+
82
+ def initialize(id)
83
+ @id = id
84
+ end
85
+
86
+ def method_missing(name, *args)
87
+ Jscall.__getpipe__.funcall(self, name, args)
88
+ end
89
+ end
90
+
91
+ class Imported # outbound references from Ruby to Node
92
+ attr_reader :objects
93
+
94
+ def initialize
95
+ ary = Array.new(Jscall::TableSize, nil)
96
+ @objects = HiddenRef.new(ary)
97
+ @canary = WeakRef.new(RemoteRef.new(-1))
98
+ end
99
+
100
+ def import(index)
101
+ objects = @objects.__getobj__
102
+ wref = objects[index]
103
+ if wref&.weakref_alive?
104
+ wref.__getobj__
105
+ else
106
+ rref = RemoteRef.new(index)
107
+ objects[index] = WeakRef.new(rref)
108
+ rref
109
+ end
110
+ end
111
+
112
+ # forces dead_references() to check the liveness of references.
113
+ def kill_canary
114
+ @canary = nil
115
+ end
116
+
117
+ def dead_references()
118
+ if @canary&.weakref_alive?
119
+ return []
120
+ else
121
+ @canary = WeakRef.new(RemoteRef.new(-1))
122
+ end
123
+ deads = []
124
+ objects = @objects.__getobj__
125
+ objects.each_index do |index|
126
+ wref = objects[index]
127
+ if !wref.nil? && !wref.weakref_alive?
128
+ objects[index] = nil
129
+ deads << index
130
+ end
131
+ end
132
+ deads
133
+ end
134
+ end
135
+
136
+ class PipeToJs
137
+ CMD_EVAL = 1
138
+ CMD_CALL = 2
139
+ CMD_REPLY = 3
140
+ Param_array = 0
141
+ Param_object = 1
142
+ Param_local_object = 2
143
+ Param_error = 3
144
+
145
+ @@node_cmd = 'node'
146
+
147
+ def self.node_command=(cmd)
148
+ @@node_cmd = cmd
149
+ end
150
+
151
+ # do import * as 'module_names[i][0]' from 'module_names[i][1]'
152
+ #
153
+ def initialize(module_names=[], options='')
154
+ startJS(module_names, options)
155
+ @exported = Exported.new
156
+ @imported = Imported.new
157
+ @send_counter = 0
158
+ end
159
+
160
+ # module_names: an array of [module_name, module_file_name]
161
+ #
162
+ def startJS(module_names, options)
163
+ script2 = ''
164
+ module_names.each_index do |i|
165
+ script2 += "import * as m#{i + 2} from \"#{module_names[i][1]}\"; globalThis.#{module_names[i][0]} = m#{i + 2}; "
166
+ end
167
+ script2 += "import { createRequire } from \"node:module\"; globalThis.require = createRequire(\"file://#{Dir.pwd}/\");"
168
+ script = "'import * as m1 from \"#{__dir__}/jscall/main.mjs\"; globalThis.Ruby = m1; #{script2}; Ruby.start(process.stdin, true)'"
169
+ @pipe = IO.popen("#{@@node_cmd} #{options} --input-type 'module' -e #{script}", "r+t")
170
+ @pipe.autoclose = true
171
+ end
172
+
173
+ def get_exported_imported
174
+ [@exported, @imported]
175
+ end
176
+
177
+ def close
178
+ @pipe.close
179
+ true
180
+ end
181
+
182
+ def encode_obj(obj)
183
+ if obj.is_a?(Numeric) || obj.is_a?(String) || obj.is_a?(Symbol) || obj == true || obj == false || obj == nil
184
+ obj
185
+ elsif obj.is_a?(Array)
186
+ [Param_array, obj.map {|e| encode_obj(e)}]
187
+ elsif obj.is_a?(RemoteRef)
188
+ [Param_local_object, obj.id]
189
+ else
190
+ [Param_object, @exported.export(obj)]
191
+ end
192
+ end
193
+
194
+ def encode_error(msg)
195
+ [Param_error, msg]
196
+ end
197
+
198
+ def decode_obj(obj)
199
+ if obj.is_a?(Numeric) || obj.is_a?(String) || obj == true || obj == false || obj == nil
200
+ obj
201
+ elsif obj.is_a?(Array) && obj.size == 2
202
+ if (obj[0] == Param_array)
203
+ obj[1].map {|e| decode_obj(e)}
204
+ elsif (obj[0] == Param_object)
205
+ @imported.import(obj[1])
206
+ elsif (obj[0] == Param_local_object)
207
+ @exported.find(obj[1])
208
+ else # if Param_error
209
+ JavaScriptError.new(obj[1])
210
+ end
211
+ else
212
+ raise JavaScriptError.new('the result is a broken value')
213
+ end
214
+ end
215
+
216
+ def funcall(receiver, name, args)
217
+ cmd = [CMD_CALL, encode_obj(receiver), name, args.map {|e| encode_obj(e)}]
218
+ send_command(cmd)
219
+ end
220
+
221
+ def exec(src)
222
+ cmd = [CMD_EVAL, src]
223
+ send_command(cmd)
224
+ end
225
+
226
+ def encode_eval_error(e)
227
+ traces = e.backtrace
228
+ location = if traces.size > 0 then traces[0] else '' end
229
+ encode_error(location + ' ' + e.to_s)
230
+ end
231
+
232
+ def scavenge
233
+ @send_counter = 200
234
+ @imported.kill_canary
235
+ exec 'Ruby.scavenge_references()'
236
+ end
237
+
238
+ def send_with_piggyback(cmd)
239
+ threashold = 100
240
+ @send_counter += 1
241
+ if (@send_counter > threashold)
242
+ @send_counter = 0
243
+ dead_refs = @imported.dead_references()
244
+ if (dead_refs.length > 0)
245
+ cmd2 = cmd.dup
246
+ cmd2[4] = dead_refs
247
+ return cmd2
248
+ end
249
+ end
250
+ return cmd
251
+ end
252
+
253
+ def send_command(cmd)
254
+ json_data = JSON.generate(send_with_piggyback(cmd))
255
+ @pipe.puts(json_data)
256
+ reply_data = @pipe.gets
257
+ reply = JSON.parse(reply_data || '[]')
258
+ if reply.length > 4
259
+ reply[4].each {|idx| @exported.remove(idx) }
260
+ end
261
+ if @pipe.closed?
262
+ raise RuntimeError.new("connection closed: #{reply}")
263
+ elsif reply[0] == CMD_REPLY
264
+ result = decode_obj(reply[1])
265
+ if result.is_a?(JavaScriptError)
266
+ raise result
267
+ else
268
+ return result
269
+ end
270
+ elsif reply[0] == CMD_EVAL
271
+ begin
272
+ result = Object::TOPLEVEL_BINDING.eval(reply[1])
273
+ encoded = encode_obj(result)
274
+ rescue => e
275
+ encoded = encode_eval_error(e)
276
+ end
277
+ send_command([CMD_REPLY, encoded])
278
+ elsif reply[0] == CMD_CALL
279
+ begin
280
+ receiver = decode_obj(reply[1])
281
+ name = reply[2]
282
+ args = reply[3].map {|e| decode_obj(e)}
283
+ result = receiver.public_send(name, *args)
284
+ encoded = encode_obj(result)
285
+ rescue => e
286
+ encoded = encode_eval_error(e)
287
+ end
288
+ send_command([CMD_REPLY, encoded])
289
+ else
290
+ raise RuntimeError.new("bad reply: #{reply}")
291
+ end
292
+ end
293
+ end
294
+
295
+ @pipe = nil
296
+ @module_names = []
297
+ @options = ''
298
+ @pipeToJsClass = PipeToJs
299
+
300
+ def self.config(module_names: [], options: '', browser: false)
301
+ @module_names = module_names
302
+ @options = options
303
+ @pipeToJsClass = if browser then PipeToBrowser else PipeToJs end
304
+ nil
305
+ end
306
+
307
+ def self.close
308
+ @pipe = nil if @pipe.close unless @pipe.nil?
309
+ end
310
+
311
+ Signal.trap(0) { self.close } # close before termination
312
+
313
+ def self.exec(src)
314
+ __getpipe__.exec(src)
315
+ end
316
+
317
+ def self.dyn_import(name, var_name=nil)
318
+ __getpipe__.funcall(nil, 'Ruby.dyn_import', [name, var_name])
319
+ end
320
+
321
+ # name is a string object.
322
+ # Evaluating this string in JavaScript results in a JavaScript function.
323
+ #
324
+ def self.funcall(name, *args)
325
+ __getpipe__.funcall(nil, name, args)
326
+ end
327
+
328
+ # reclaim unused remote references.
329
+ #
330
+ def self.scavenge_references
331
+ __getpipe__.scavenge
332
+ end
333
+
334
+ def self.method_missing(name, *args)
335
+ __getpipe__.funcall(nil, name, args)
336
+ end
337
+
338
+ def self.__getpipe__
339
+ if @pipe.nil?
340
+ @pipe = @pipeToJsClass.new(@module_names, @options)
341
+ end
342
+ @pipe
343
+ end
344
+ end
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jscall
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Shigeru Chiba
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2022-07-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: webrick
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.4'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.4'
27
+ description: a library for executing JavaScript code on node.js or a web browser
28
+ email:
29
+ - "?"
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - Gemfile
35
+ - LICENSE
36
+ - README.md
37
+ - Rakefile
38
+ - jscall.gemspec
39
+ - lib/jscall.rb
40
+ - lib/jscall/apple-touch-icon.png
41
+ - lib/jscall/browser.mjs
42
+ - lib/jscall/browser.rb
43
+ - lib/jscall/favicon.ico
44
+ - lib/jscall/jscall.html
45
+ - lib/jscall/main.mjs
46
+ - lib/jscall/version.rb
47
+ homepage: https://github.com/csg-tokyo/jscall
48
+ licenses: []
49
+ metadata:
50
+ homepage_uri: https://github.com/csg-tokyo/jscall
51
+ source_code_uri: https://github.com/csg-tokyo/jscall
52
+ post_install_message:
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 2.6.0
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 3.1.6
68
+ signing_key:
69
+ specification_version: 4
70
+ summary: a library for calling JavaScript functions
71
+ test_files: []