diff --git a/packages/gems/js/lib/js.rb b/packages/gems/js/lib/js.rb index eaf0d34cbb..cbf1074485 100644 --- a/packages/gems/js/lib/js.rb +++ b/packages/gems/js/lib/js.rb @@ -84,7 +84,7 @@ def await(promise) ) if @loop == current raise ( - "JS::Object#await can be called only from RubyVM#evalAsync JS API\n" + + "JS::Object#await can be called only from RubyVM#evalAsync or RbValue#callAsync JS API\n" + "If you are using browser.script.iife.js, please ensure that you specify `data-eval=\"async\"` in your script tag\n" + "e.g. \n" + "Or " @@ -105,15 +105,19 @@ def self.promise_scheduler private def self.__eval_async_rb(rb_code, future) + self.__async(future) do + JS::Object.wrap(Kernel.eval(rb_code.to_s, TOPLEVEL_BINDING, "eval_async")) + end + end + + def self.__call_async_method(recv, method_name, future, *args) + self.__async(future) { recv.send(method_name.to_s, *args) } + end + + def self.__async(future, &block) Fiber .new do - future.resolve JS::Object.wrap( - Kernel.eval( - rb_code.to_s, - TOPLEVEL_BINDING, - "eval_async" - ) - ) + future.resolve block.call rescue => e future.reject JS::Object.wrap(e) end diff --git a/packages/npm-packages/ruby-wasm-wasi/README.md b/packages/npm-packages/ruby-wasm-wasi/README.md index b4ce77839f..8df1d6c449 100644 --- a/packages/npm-packages/ruby-wasm-wasi/README.md +++ b/packages/npm-packages/ruby-wasm-wasi/README.md @@ -18,6 +18,41 @@ See [Cheat Sheet](https://github.com/ruby/ruby.wasm/blob/main/docs/cheat_sheet.m +### consolePrinter + +Create a console printer that can be used as an overlay of WASI imports. +See the example below for how to use it. + +```javascript +const imports = { + wasi_snapshot_preview1: wasi.wasiImport, +}; +const printer = consolePrinter(); +printer.addToImports(imports); + +const instance = await WebAssembly.instantiate(module, imports); +printer.setMemory(instance.exports.memory); +``` + +Note that the `stdout` and `stderr` functions are called with text, not +bytes. This means that bytes written to stdout/stderr will be decoded as +UTF-8 and then passed to the `stdout`/`stderr` functions every time a write +occurs without buffering. + +#### Parameters + +- `$0` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** (optional, default `{stdout:console.log,stderr:console.warn}`) + + - `$0.stdout` + - `$0.stderr` + +- `stdout` A function that will be called when stdout is written to. + Defaults to `console.log`. +- `stderr` A function that will be called when stderr is written to. + Defaults to `console.warn`. + +Returns **any** An object that can be used as an overlay of WASI imports. + ### RubyVM A Ruby VM instance @@ -46,7 +81,7 @@ Initialize the Ruby VM with the given command line arguments ##### Parameters - `args` The command line arguments to pass to Ruby. Must be - an array of strings starting with the Ruby program name. (optional, default `["ruby.wasm","--disable-gems","-EUTF-8","-e_=0"]`) + an array of strings starting with the Ruby program name. (optional, default `["ruby.wasm","-EUTF-8","-e_=0"]`) #### setInstance @@ -151,6 +186,37 @@ ary.call("push", 4); console.log(ary.call("sample").toString()); ``` +Returns **any** The result of the method call as a new RbValue. + +#### callAsync + +Call a given method that may call `JS::Object#await` with given arguments + +##### Parameters + +- `callee` name of the Ruby method to call +- `args` **...any** arguments to pass to the method. Must be an array of RbValue + +##### Examples + +```javascript +const client = vm.eval(` + require 'js' + class HttpClient + def get(url) + JS.global.fetch(url).await + end + end + HttpClient.new +`); +const response = await client.callAsync( + "get", + vm.eval(`"https://example.com"`), +); +``` + +Returns **any** A Promise that resolves to the result of the method call as a new RbValue. + #### toPrimitive - **See**: diff --git a/packages/npm-packages/ruby-wasm-wasi/src/vm.ts b/packages/npm-packages/ruby-wasm-wasi/src/vm.ts index 2ea09597bb..6631164acd 100644 --- a/packages/npm-packages/ruby-wasm-wasi/src/vm.ts +++ b/packages/npm-packages/ruby-wasm-wasi/src/vm.ts @@ -337,25 +337,8 @@ export class RubyVM { */ evalAsync(code: string): Promise { const JS = this.eval("require 'js'; JS"); - return new Promise((resolve, reject) => { - JS.call( - "__eval_async_rb", - this.wrap(code), - this.wrap({ - resolve, - reject: (error: RbValue) => { - reject( - new RbError( - this.exceptionFormatter.format( - error, - this, - this.privateObject(), - ), - ), - ); - }, - }), - ); + return newRbPromise(this, this.privateObject(), (future) => { + JS.call("__eval_async_rb", this.wrap(code), future); }); } @@ -454,12 +437,12 @@ export class RbValue { * * @param callee name of the Ruby method to call * @param args arguments to pass to the method. Must be an array of RbValue + * @returns The result of the method call as a new RbValue. * * @example * const ary = vm.eval("[1, 2, 3]"); * ary.call("push", 4); * console.log(ary.call("sample").toString()); - * */ call(callee: string, ...args: RbValue[]): RbValue { const innerArgs = args.map((arg) => arg.inner); @@ -470,6 +453,38 @@ export class RbValue { ); } + /** + * Call a given method that may call `JS::Object#await` with given arguments + * + * @param callee name of the Ruby method to call + * @param args arguments to pass to the method. Must be an array of RbValue + * @returns A Promise that resolves to the result of the method call as a new RbValue. + * + * @example + * const client = vm.eval(` + * require 'js' + * class HttpClient + * def get(url) + * JS.global.fetch(url).await + * end + * end + * HttpClient.new + * `); + * const response = await client.callAsync("get", vm.eval(`"https://example.com"`)); + */ + callAsync(callee: string, ...args: RbValue[]): Promise { + const JS = this.vm.eval("require 'js'; JS"); + return newRbPromise(this.vm, this.privateObject, (future) => { + JS.call( + "__call_async_method", + this, + this.vm.wrap(callee), + future, + ...args, + ); + }); + } + /** * @see {@link https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Symbol/toPrimitive} * @param hint Preferred type of the result primitive value. `"number"`, `"string"`, or `"default"`. @@ -702,6 +717,25 @@ const evalRbCode = (vm: RubyVM, privateObject: RubyVMPrivate, code: string) => { }); }; +function newRbPromise( + vm: RubyVM, + privateObject: RubyVMPrivate, + body: (future: RbValue) => void, +): Promise { + return new Promise((resolve, reject) => { + const future = vm.wrap({ + resolve, + reject: (error: RbValue) => { + const rbError = new RbError( + privateObject.exceptionFormatter.format(error, vm, privateObject), + ); + reject(rbError); + }, + }); + body(future); + }); +} + /** * Error class thrown by Ruby execution */ diff --git a/packages/npm-packages/ruby-wasm-wasi/test/eval_async.test.js b/packages/npm-packages/ruby-wasm-wasi/test/eval_async.test.js index eaaeef89e0..209c3cd078 100644 --- a/packages/npm-packages/ruby-wasm-wasi/test/eval_async.test.js +++ b/packages/npm-packages/ruby-wasm-wasi/test/eval_async.test.js @@ -30,12 +30,49 @@ describe("Async Ruby code evaluation", () => { expect(ret1.toString()).toBe("43"); }); - test("await outside of evalAsync", async () => { + test("call async Ruby method from JS", async () => { + const vm = await initRubyVM(); + const x = vm.eval(` + class X + def async_method + JS.global[:Promise].resolve(42).await + end + def async_method_with_args(a, b) + JS.global[:Promise].resolve(a + b).await + end + end + X.new + `); + + const ret1 = await x.callAsync("async_method"); + expect(ret1.toString()).toBe("42"); + + const ret2 = await x.callAsync( + "async_method_with_args", + vm.eval("1"), + vm.eval("2"), + ); + expect(ret2.toString()).toBe("3"); + }); + + test("await outside of evalAsync or callAsync", async () => { const vm = await initRubyVM(); expect(() => { vm.eval(`require "js"; JS.global[:Promise].resolve(42).await`); - }).toThrowError( - "JS::Object#await can be called only from RubyVM#evalAsync JS API", + }).toThrow( + "JS::Object#await can be called only from RubyVM#evalAsync or RbValue#callAsync JS API", + ); + + const x = vm.eval(` + class X + def async_method + JS.global[:Promise].resolve(42).await + end + end + X.new + `); + expect(() => x.call("async_method")).toThrow( + "JS::Object#await can be called only from RubyVM#evalAsync or RbValue#callAsync JS API", ); }); });