jscall 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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: []