diff --git a/ext/js/lib/js/require_remote.rb b/ext/js/lib/js/require_remote.rb
new file mode 100644
index 0000000000..5c9946cf45
--- /dev/null
+++ b/ext/js/lib/js/require_remote.rb
@@ -0,0 +1,85 @@
+require "singleton"
+require "js"
+require_relative "./require_remote/url_resolver"
+require_relative "./require_remote/evaluator"
+
+module JS
+ # This class is used to load remote Ruby scripts.
+ #
+ # == Example
+ #
+ # require 'js/require_remote'
+ # JS::RequireRemote.instance.load("foo")
+ #
+ # This class is intended to be used to replace Kernel#require_relative.
+ #
+ # == Example
+ #
+ # require 'js/require_remote'
+ # module Kernel
+ # def require_relative(path) = JS::RequireRemote.instance.load(path)
+ # end
+ #
+ # If you want to load the bundled gem
+ #
+ # == Example
+ #
+ # require 'js/require_remote'
+ # module Kernel
+ # alias original_require_relative require_relative
+ #
+ # def require_relative(path)
+ # caller_path = caller_locations(1, 1).first.absolute_path || ''
+ # dir = File.dirname(caller_path)
+ # file = File.absolute_path(path, dir)
+ #
+ # original_require_relative(file)
+ # rescue LoadError
+ # JS::RequireRemote.instance.load(path)
+ # end
+ # end
+ #
+ class RequireRemote
+ include Singleton
+
+ def initialize
+ base_url = JS.global[:URL].new(JS.global[:location][:href])
+ @resolver = URLResolver.new(base_url)
+ @evaluator = Evaluator.new
+ end
+
+ # Load the given feature from remote.
+ def load(relative_feature)
+ location = @resolver.get_location(relative_feature)
+
+ # Do not load the same URL twice.
+ return false if @evaluator.evaluated?(location.url[:href].to_s)
+
+ response = JS.global.fetch(location.url).await
+ unless response[:status].to_i == 200
+ raise LoadError.new "cannot load such url -- #{response[:status]} #{location.url}"
+ end
+
+ # The fetch API may have responded to a redirect response
+ # and fetched the script from a different URL than the original URL.
+ # Retrieve the final URL again from the response object.
+ final_url = response[:url].to_s
+
+ # Do not evaluate the same URL twice.
+ return false if @evaluator.evaluated?(final_url)
+
+ code = response.text().await.to_s
+
+ evaluate(code, location.filename, final_url)
+ end
+
+ private
+
+ def evaluate(code, filename, final_url)
+ @resolver.push(final_url)
+ @evaluator.evaluate(code, filename, final_url)
+ @resolver.pop
+ true
+ end
+ end
+end
diff --git a/ext/js/lib/js/require_remote/evaluator.rb b/ext/js/lib/js/require_remote/evaluator.rb
new file mode 100644
index 0000000000..815993f94f
--- /dev/null
+++ b/ext/js/lib/js/require_remote/evaluator.rb
@@ -0,0 +1,15 @@
+module JS
+ class RequireRemote
+ # Execute the body of the response and record the URL.
+ class Evaluator
+ def evaluate(code, filename, final_url)
+ Kernel.eval(code, ::Object::TOPLEVEL_BINDING, filename)
+ $LOADED_FEATURES << final_url
+ end
+
+ def evaluated?(url)
+ $LOADED_FEATURES.include?(url)
+ end
+ end
+ end
+end
diff --git a/ext/js/lib/js/require_remote/url_resolver.rb b/ext/js/lib/js/require_remote/url_resolver.rb
new file mode 100644
index 0000000000..7bbaae095c
--- /dev/null
+++ b/ext/js/lib/js/require_remote/url_resolver.rb
@@ -0,0 +1,45 @@
+module JS
+ class RequireRemote
+ ScriptLocation = Data.define(:url, :filename)
+
+ # When require_relative is called within a running Ruby script,
+ # the URL is resolved from a relative file path based on the URL of the running Ruby script.
+ # It uses a stack to store URLs of running Ruby Script.
+ # Push the URL onto the stack before executing the new script.
+ # Then pop it when the script has finished executing.
+ class URLResolver
+ def initialize(base_url)
+ @url_stack = [base_url]
+ end
+
+ def get_location(relative_feature)
+ filename = filename_from(relative_feature)
+ url = resolve(filename)
+ ScriptLocation.new(url, filename)
+ end
+
+ def push(url)
+ @url_stack.push url
+ end
+
+ def pop()
+ @url_stack.pop
+ end
+
+ private
+
+ def filename_from(relative_feature)
+ if relative_feature.end_with?(".rb")
+ relative_feature
+ else
+ "#{relative_feature}.rb"
+ end
+ end
+
+ # Return a URL object of JavaScript.
+ def resolve(relative_filepath)
+ JS.global[:URL].new relative_filepath, @url_stack.last
+ end
+ end
+ end
+end
diff --git a/packages/npm-packages/ruby-wasm-wasi/example/require_relative/greeting.rb b/packages/npm-packages/ruby-wasm-wasi/example/require_relative/greeting.rb
new file mode 100644
index 0000000000..7df2752347
--- /dev/null
+++ b/packages/npm-packages/ruby-wasm-wasi/example/require_relative/greeting.rb
@@ -0,0 +1,5 @@
+class Greeting
+ def say
+ puts "Hello, world!"
+ end
+end
diff --git a/packages/npm-packages/ruby-wasm-wasi/example/require_relative/index.html b/packages/npm-packages/ruby-wasm-wasi/example/require_relative/index.html
new file mode 100644
index 0000000000..0c3a20139d
--- /dev/null
+++ b/packages/npm-packages/ruby-wasm-wasi/example/require_relative/index.html
@@ -0,0 +1,31 @@
+
+
+
+
diff --git a/packages/npm-packages/ruby-wasm-wasi/example/require_relative/main.rb b/packages/npm-packages/ruby-wasm-wasi/example/require_relative/main.rb
new file mode 100644
index 0000000000..fe43d84d9a
--- /dev/null
+++ b/packages/npm-packages/ruby-wasm-wasi/example/require_relative/main.rb
@@ -0,0 +1,3 @@
+require_relative "greeting"
+
+Greeting.new.say
diff --git a/packages/npm-packages/ruby-wasm-wasi/test-e2e/examples/examples.spec.ts b/packages/npm-packages/ruby-wasm-wasi/test-e2e/examples/examples.spec.ts
index 9bdd1b1bef..af6b71059f 100644
--- a/packages/npm-packages/ruby-wasm-wasi/test-e2e/examples/examples.spec.ts
+++ b/packages/npm-packages/ruby-wasm-wasi/test-e2e/examples/examples.spec.ts
@@ -64,3 +64,24 @@ test("script-src/index.html is healthy", async ({ page }) => {
await page.waitForEvent("console");
}
});
+
+// The browser.script.iife.js obtained from CDN does not include the patch to require_relative.
+// Skip when testing against the CDN.
+if (process.env.RUBY_NPM_PACKAGE_ROOT) {
+ test("require_relative/index.html is healthy", async ({ page }) => {
+ // Add a listener to detect errors in the page
+ page.on("pageerror", (error) => {
+ console.log(`page error occurs: ${error.message}`);
+ });
+
+ const messages: string[] = [];
+ page.on("console", (msg) => messages.push(msg.text()));
+ await page.goto("/require_relative/index.html");
+
+ await waitForRubyVM(page);
+ const expected = "Hello, world!\n";
+ while (messages[messages.length - 1] != expected) {
+ await page.waitForEvent("console");
+ }
+ });
+}
diff --git a/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/browser-script.spec.ts b/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/data-eval-async.spec.ts
similarity index 86%
rename from packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/browser-script.spec.ts
rename to packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/data-eval-async.spec.ts
index ad25207430..96e6fbe2cb 100644
--- a/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/browser-script.spec.ts
+++ b/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/data-eval-async.spec.ts
@@ -5,6 +5,7 @@ import {
setupProxy,
setupUncaughtExceptionRejection,
expectUncaughtException,
+ resolveBinding,
} from "../support";
if (!process.env.RUBY_NPM_PACKAGE_ROOT) {
@@ -16,17 +17,6 @@ if (!process.env.RUBY_NPM_PACKAGE_ROOT) {
setupUncaughtExceptionRejection(page);
});
- const resolveBinding = async (page: Page, name: string) => {
- let checkResolved;
- const resolvedValue = new Promise((resolve) => {
- checkResolved = resolve;
- });
- await page.exposeBinding(name, async (source, v) => {
- checkResolved(v);
- });
- return async () => await resolvedValue;
- };
-
test.describe('data-eval="async"', () => {
test("JS::Object#await returns value", async ({ page }) => {
const resolve = await resolveBinding(page, "checkResolved");
diff --git a/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/fixtures/error_on_load_twice.rb b/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/fixtures/error_on_load_twice.rb
new file mode 100644
index 0000000000..7b4ce92112
--- /dev/null
+++ b/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/fixtures/error_on_load_twice.rb
@@ -0,0 +1,3 @@
+raise "load twice" if defined?(ALREADY_LOADED)
+
+ALREADY_LOADED = true
diff --git a/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/fixtures/recursive_require.rb b/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/fixtures/recursive_require.rb
new file mode 100644
index 0000000000..c14cbb2386
--- /dev/null
+++ b/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/fixtures/recursive_require.rb
@@ -0,0 +1 @@
+require_relative "./recursive_require/a.rb"
diff --git a/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/fixtures/recursive_require/a.rb b/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/fixtures/recursive_require/a.rb
new file mode 100644
index 0000000000..9f68c6a221
--- /dev/null
+++ b/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/fixtures/recursive_require/a.rb
@@ -0,0 +1 @@
+require_relative "./b.rb"
diff --git a/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/fixtures/recursive_require/b.rb b/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/fixtures/recursive_require/b.rb
new file mode 100644
index 0000000000..511db1e29b
--- /dev/null
+++ b/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/fixtures/recursive_require/b.rb
@@ -0,0 +1,7 @@
+module RecursiveRequire
+ class B
+ def message
+ "Hello from RecursiveRequire::B"
+ end
+ end
+end
diff --git a/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/js-require-remote.spec.ts b/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/js-require-remote.spec.ts
new file mode 100644
index 0000000000..2bd1e0ed17
--- /dev/null
+++ b/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/js-require-remote.spec.ts
@@ -0,0 +1,119 @@
+import fs from "fs";
+import path from "path";
+import { test, expect } from "@playwright/test";
+import {
+ setupDebugLog,
+ setupProxy,
+ setupUncaughtExceptionRejection,
+ expectUncaughtException,
+ resolveBinding,
+} from "../support";
+
+if (!process.env.RUBY_NPM_PACKAGE_ROOT) {
+ test.skip("skip", () => {});
+} else {
+ test.beforeEach(async ({ context, page }) => {
+ setupDebugLog(context);
+ setupProxy(context);
+
+ const fixturesPattern = /fixtures\/(.+)/;
+ context.route(fixturesPattern, (route) => {
+ const subPath = route.request().url().match(fixturesPattern)[1];
+ const mockedPath = path.join("./test-e2e/integrations/fixtures", subPath);
+
+ route.fulfill({
+ path: mockedPath,
+ });
+ });
+
+ context.route(/not_found/, (route) => {
+ route.fulfill({
+ status: 404,
+ });
+ });
+
+ setupUncaughtExceptionRejection(page);
+ });
+
+ test.describe("JS::RequireRemote#load", () => {
+ test("JS::RequireRemote#load returns true", async ({ page }) => {
+ const resolve = await resolveBinding(page, "checkResolved");
+ await page.goto(
+ "https://cdn.jsdelivr.net/npm/@ruby/head-wasm-wasi@latest/dist/",
+ );
+ await page.setContent(`
+
+
+ `);
+
+ expect(await resolve()).toBe(true);
+ });
+
+ test("JS::RequireRemote#load returns false when same gem is loaded twice", async ({
+ page,
+ }) => {
+ const resolve = await resolveBinding(page, "checkResolved");
+ await page.goto(
+ "https://cdn.jsdelivr.net/npm/@ruby/head-wasm-wasi@latest/dist/",
+ );
+ await page.setContent(`
+
+
+ `);
+
+ expect(await resolve()).toBe(false);
+ });
+
+ test("JS::RequireRemote#load throws error when gem is not found", async ({
+ page,
+ }) => {
+ expectUncaughtException(page);
+
+ // Opens the URL that will be used as the basis for determining the relative URL.
+ await page.goto(
+ "https://cdn.jsdelivr.net/npm/@ruby/head-wasm-wasi@latest/dist/",
+ );
+ await page.setContent(`
+
+
+ `);
+
+ const error = await page.waitForEvent("pageerror");
+ expect(error.message).toMatch(/cannot load such url -- .+\/not_found.rb/);
+ });
+
+ test("JS::RequireRemote#load recursively loads dependencies", async ({
+ page,
+ }) => {
+ const resolve = await resolveBinding(page, "checkResolved");
+ await page.goto(
+ "https://cdn.jsdelivr.net/npm/@ruby/head-wasm-wasi@latest/dist/",
+ );
+ await page.setContent(`
+
+
+ `);
+
+ expect(await resolve()).toBe("Hello from RecursiveRequire::B");
+ });
+ });
+}
diff --git a/packages/npm-packages/ruby-wasm-wasi/test-e2e/support.ts b/packages/npm-packages/ruby-wasm-wasi/test-e2e/support.ts
index e37cf27f22..72c13075e0 100644
--- a/packages/npm-packages/ruby-wasm-wasi/test-e2e/support.ts
+++ b/packages/npm-packages/ruby-wasm-wasi/test-e2e/support.ts
@@ -24,8 +24,14 @@ export const setupProxy = (context: BrowserContext) => {
const request = route.request();
console.log(">> [MOCK]", request.method(), request.url());
const relativePath = request.url().match(cdnPattern)[1];
+ const mockedPath = path.join(
+ process.env.RUBY_NPM_PACKAGE_ROOT,
+ "dist",
+ relativePath,
+ );
+
route.fulfill({
- path: path.join(process.env.RUBY_NPM_PACKAGE_ROOT, "dist", relativePath),
+ path: mockedPath,
});
});
};
@@ -44,3 +50,14 @@ export const { setupUncaughtExceptionRejection, expectUncaughtException } =
},
};
})();
+
+export const resolveBinding = async (page: Page, name: string) => {
+ let checkResolved;
+ const resolvedValue = new Promise((resolve) => {
+ checkResolved = resolve;
+ });
+ await page.exposeBinding(name, async (source, v) => {
+ checkResolved(v);
+ });
+ return async () => await resolvedValue;
+};
diff --git a/packages/npm-packages/ruby-wasm-wasi/test/test_unit.rb b/packages/npm-packages/ruby-wasm-wasi/test/test_unit.rb
index e04b07f434..f75898c087 100644
--- a/packages/npm-packages/ruby-wasm-wasi/test/test_unit.rb
+++ b/packages/npm-packages/ruby-wasm-wasi/test/test_unit.rb
@@ -8,3 +8,4 @@
require_relative "./unit/test_array"
require_relative "./unit/test_hash"
require_relative "./unit/test_nil_class"
+require_relative "./unit/require_remote/url_resolver"
diff --git a/packages/npm-packages/ruby-wasm-wasi/test/unit/require_remote/url_resolver.rb b/packages/npm-packages/ruby-wasm-wasi/test/unit/require_remote/url_resolver.rb
new file mode 100644
index 0000000000..8ed274161b
--- /dev/null
+++ b/packages/npm-packages/ruby-wasm-wasi/test/unit/require_remote/url_resolver.rb
@@ -0,0 +1,66 @@
+require "test-unit"
+require "js"
+require "js/require_remote"
+
+class TestURLResolver < Test::Unit::TestCase
+ def test_get_location
+ url_resolver = JS::RequireRemote::URLResolver.new("https://example.com")
+ script_location = url_resolver.get_location("foo.rb")
+ assert_equal "https://example.com/foo.rb", script_location.url.to_s
+ assert_equal "foo.rb", script_location.filename
+ end
+
+ def test_get_location_with_relative_path
+ url_resolver = JS::RequireRemote::URLResolver.new("https://example.com")
+ script_location = url_resolver.get_location("./foo.rb")
+ assert_equal "https://example.com/foo.rb", script_location.url.to_s
+ assert_equal "./foo.rb", script_location.filename
+ end
+
+ def test_get_location_with_relative_path_and_filename
+ url_resolver = JS::RequireRemote::URLResolver.new("https://example.com/bar.rb")
+ script_location = url_resolver.get_location("./foo.rb")
+ assert_equal "https://example.com/foo.rb", script_location.url.to_s
+ assert_equal "./foo.rb", script_location.filename
+ end
+
+ def test_get_location_with_relative_path_and_filename_without_extension
+ url_resolver = JS::RequireRemote::URLResolver.new("https://example.com/bar")
+ script_location = url_resolver.get_location("./foo")
+ assert_equal "https://example.com/foo.rb", script_location.url.to_s
+ assert_equal "./foo.rb", script_location.filename
+ end
+
+ def test_get_location_with_relative_path_and_directory
+ url_resolver = JS::RequireRemote::URLResolver.new("https://example.com/bar/")
+ script_location = url_resolver.get_location("./foo.rb")
+ assert_equal "https://example.com/bar/foo.rb", script_location.url.to_s
+ assert_equal "./foo.rb", script_location.filename
+ end
+
+ def test_get_location_with_backward_relative_path
+ url_resolver = JS::RequireRemote::URLResolver.new("https://example.com/bar/")
+ script_location = url_resolver.get_location("../foo.rb")
+ assert_equal "https://example.com/foo.rb", script_location.url.to_s
+ assert_equal "../foo.rb", script_location.filename
+ end
+
+ def test_get_location_with_backward_relative_path_and_filename
+ url_resolver = JS::RequireRemote::URLResolver.new("https://example.com/baz.rb")
+ script_location = url_resolver.get_location("../foo.rb")
+ assert_equal "https://example.com/foo.rb", script_location.url.to_s
+ assert_equal "../foo.rb", script_location.filename
+ end
+
+ def test_push_and_pop
+ url_resolver = JS::RequireRemote::URLResolver.new("https://example.com")
+ url_resolver.push("https://example.com/foo/bar.rb")
+ script_location = url_resolver.get_location("./baz.rb")
+ assert_equal "https://example.com/foo/baz.rb", script_location.url.to_s
+ assert_equal "./baz.rb", script_location.filename
+ url_resolver.pop
+ script_location = url_resolver.get_location("./baz.rb")
+ assert_equal "https://example.com/baz.rb", script_location.url.to_s
+ assert_equal "./baz.rb", script_location.filename
+ end
+end