Skip to content

Commit 6acf403

Browse files
Merge pull request #292 from ledsun/require_relative
2 parents f63b6d1 + b9db613 commit 6acf403

File tree

16 files changed

+422
-12
lines changed

16 files changed

+422
-12
lines changed

ext/js/lib/js/require_remote.rb

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
require "singleton"
2+
require "js"
3+
require_relative "./require_remote/url_resolver"
4+
require_relative "./require_remote/evaluator"
5+
6+
module JS
7+
# This class is used to load remote Ruby scripts.
8+
#
9+
# == Example
10+
#
11+
# require 'js/require_remote'
12+
# JS::RequireRemote.instance.load("foo")
13+
#
14+
# This class is intended to be used to replace Kernel#require_relative.
15+
#
16+
# == Example
17+
#
18+
# require 'js/require_remote'
19+
# module Kernel
20+
# def require_relative(path) = JS::RequireRemote.instance.load(path)
21+
# end
22+
#
23+
# If you want to load the bundled gem
24+
#
25+
# == Example
26+
#
27+
# require 'js/require_remote'
28+
# module Kernel
29+
# alias original_require_relative require_relative
30+
#
31+
# def require_relative(path)
32+
# caller_path = caller_locations(1, 1).first.absolute_path || ''
33+
# dir = File.dirname(caller_path)
34+
# file = File.absolute_path(path, dir)
35+
#
36+
# original_require_relative(file)
37+
# rescue LoadError
38+
# JS::RequireRemote.instance.load(path)
39+
# end
40+
# end
41+
#
42+
class RequireRemote
43+
include Singleton
44+
45+
def initialize
46+
base_url = JS.global[:URL].new(JS.global[:location][:href])
47+
@resolver = URLResolver.new(base_url)
48+
@evaluator = Evaluator.new
49+
end
50+
51+
# Load the given feature from remote.
52+
def load(relative_feature)
53+
location = @resolver.get_location(relative_feature)
54+
55+
# Do not load the same URL twice.
56+
return false if @evaluator.evaluated?(location.url[:href].to_s)
57+
58+
response = JS.global.fetch(location.url).await
59+
unless response[:status].to_i == 200
60+
raise LoadError.new "cannot load such url -- #{response[:status]} #{location.url}"
61+
end
62+
63+
# The fetch API may have responded to a redirect response
64+
# and fetched the script from a different URL than the original URL.
65+
# Retrieve the final URL again from the response object.
66+
final_url = response[:url].to_s
67+
68+
# Do not evaluate the same URL twice.
69+
return false if @evaluator.evaluated?(final_url)
70+
71+
code = response.text().await.to_s
72+
73+
evaluate(code, location.filename, final_url)
74+
end
75+
76+
private
77+
78+
def evaluate(code, filename, final_url)
79+
@resolver.push(final_url)
80+
@evaluator.evaluate(code, filename, final_url)
81+
@resolver.pop
82+
true
83+
end
84+
end
85+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module JS
2+
class RequireRemote
3+
# Execute the body of the response and record the URL.
4+
class Evaluator
5+
def evaluate(code, filename, final_url)
6+
Kernel.eval(code, ::Object::TOPLEVEL_BINDING, filename)
7+
$LOADED_FEATURES << final_url
8+
end
9+
10+
def evaluated?(url)
11+
$LOADED_FEATURES.include?(url)
12+
end
13+
end
14+
end
15+
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
module JS
2+
class RequireRemote
3+
ScriptLocation = Data.define(:url, :filename)
4+
5+
# When require_relative is called within a running Ruby script,
6+
# the URL is resolved from a relative file path based on the URL of the running Ruby script.
7+
# It uses a stack to store URLs of running Ruby Script.
8+
# Push the URL onto the stack before executing the new script.
9+
# Then pop it when the script has finished executing.
10+
class URLResolver
11+
def initialize(base_url)
12+
@url_stack = [base_url]
13+
end
14+
15+
def get_location(relative_feature)
16+
filename = filename_from(relative_feature)
17+
url = resolve(filename)
18+
ScriptLocation.new(url, filename)
19+
end
20+
21+
def push(url)
22+
@url_stack.push url
23+
end
24+
25+
def pop()
26+
@url_stack.pop
27+
end
28+
29+
private
30+
31+
def filename_from(relative_feature)
32+
if relative_feature.end_with?(".rb")
33+
relative_feature
34+
else
35+
"#{relative_feature}.rb"
36+
end
37+
end
38+
39+
# Return a URL object of JavaScript.
40+
def resolve(relative_filepath)
41+
JS.global[:URL].new relative_filepath, @url_stack.last
42+
end
43+
end
44+
end
45+
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class Greeting
2+
def say
3+
puts "Hello, world!"
4+
end
5+
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<html>
2+
<script src="https://cdn.jsdelivr.net/npm/@ruby/[email protected]/dist/browser.script.iife.js"></script>
3+
<script type="text/ruby" data-eval="async">
4+
# Patch require_relative to load from remote
5+
require 'js/require_remote'
6+
7+
module Kernel
8+
alias original_require_relative require_relative
9+
10+
# The require_relative may be used in the embedded Gem.
11+
# First try to load from the built-in filesystem, and if that fails,
12+
# load from the URL.
13+
def require_relative(path)
14+
caller_path = caller_locations(1, 1).first.absolute_path || ''
15+
dir = File.dirname(caller_path)
16+
file = File.absolute_path(path, dir)
17+
18+
original_require_relative(file)
19+
rescue LoadError
20+
JS::RequireRemote.instance.load(path)
21+
end
22+
end
23+
24+
# The above patch does not break the original require_relative
25+
require 'csv'
26+
csv = CSV.new "foo\nbar\n"
27+
28+
# Load the main script
29+
require_relative 'main'
30+
</script>
31+
</html>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
require_relative "greeting"
2+
3+
Greeting.new.say

packages/npm-packages/ruby-wasm-wasi/test-e2e/examples/examples.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,24 @@ test("script-src/index.html is healthy", async ({ page }) => {
6464
await page.waitForEvent("console");
6565
}
6666
});
67+
68+
// The browser.script.iife.js obtained from CDN does not include the patch to require_relative.
69+
// Skip when testing against the CDN.
70+
if (process.env.RUBY_NPM_PACKAGE_ROOT) {
71+
test("require_relative/index.html is healthy", async ({ page }) => {
72+
// Add a listener to detect errors in the page
73+
page.on("pageerror", (error) => {
74+
console.log(`page error occurs: ${error.message}`);
75+
});
76+
77+
const messages: string[] = [];
78+
page.on("console", (msg) => messages.push(msg.text()));
79+
await page.goto("/require_relative/index.html");
80+
81+
await waitForRubyVM(page);
82+
const expected = "Hello, world!\n";
83+
while (messages[messages.length - 1] != expected) {
84+
await page.waitForEvent("console");
85+
}
86+
});
87+
}

packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/browser-script.spec.ts renamed to packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/data-eval-async.spec.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
setupProxy,
66
setupUncaughtExceptionRejection,
77
expectUncaughtException,
8+
resolveBinding,
89
} from "../support";
910

1011
if (!process.env.RUBY_NPM_PACKAGE_ROOT) {
@@ -16,17 +17,6 @@ if (!process.env.RUBY_NPM_PACKAGE_ROOT) {
1617
setupUncaughtExceptionRejection(page);
1718
});
1819

19-
const resolveBinding = async (page: Page, name: string) => {
20-
let checkResolved;
21-
const resolvedValue = new Promise((resolve) => {
22-
checkResolved = resolve;
23-
});
24-
await page.exposeBinding(name, async (source, v) => {
25-
checkResolved(v);
26-
});
27-
return async () => await resolvedValue;
28-
};
29-
3020
test.describe('data-eval="async"', () => {
3121
test("JS::Object#await returns value", async ({ page }) => {
3222
const resolve = await resolveBinding(page, "checkResolved");
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
raise "load twice" if defined?(ALREADY_LOADED)
2+
3+
ALREADY_LOADED = true
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
require_relative "./recursive_require/a.rb"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
require_relative "./b.rb"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module RecursiveRequire
2+
class B
3+
def message
4+
"Hello from RecursiveRequire::B"
5+
end
6+
end
7+
end
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import fs from "fs";
2+
import path from "path";
3+
import { test, expect } from "@playwright/test";
4+
import {
5+
setupDebugLog,
6+
setupProxy,
7+
setupUncaughtExceptionRejection,
8+
expectUncaughtException,
9+
resolveBinding,
10+
} from "../support";
11+
12+
if (!process.env.RUBY_NPM_PACKAGE_ROOT) {
13+
test.skip("skip", () => {});
14+
} else {
15+
test.beforeEach(async ({ context, page }) => {
16+
setupDebugLog(context);
17+
setupProxy(context);
18+
19+
const fixturesPattern = /fixtures\/(.+)/;
20+
context.route(fixturesPattern, (route) => {
21+
const subPath = route.request().url().match(fixturesPattern)[1];
22+
const mockedPath = path.join("./test-e2e/integrations/fixtures", subPath);
23+
24+
route.fulfill({
25+
path: mockedPath,
26+
});
27+
});
28+
29+
context.route(/not_found/, (route) => {
30+
route.fulfill({
31+
status: 404,
32+
});
33+
});
34+
35+
setupUncaughtExceptionRejection(page);
36+
});
37+
38+
test.describe("JS::RequireRemote#load", () => {
39+
test("JS::RequireRemote#load returns true", async ({ page }) => {
40+
const resolve = await resolveBinding(page, "checkResolved");
41+
await page.goto(
42+
"https://cdn.jsdelivr.net/npm/@ruby/head-wasm-wasi@latest/dist/",
43+
);
44+
await page.setContent(`
45+
<script src="browser.script.iife.js"></script>
46+
<script type="text/ruby" data-eval="async">
47+
require 'js/require_remote'
48+
JS.global.checkResolved JS::RequireRemote.instance.load 'fixtures/error_on_load_twice'
49+
</script>
50+
`);
51+
52+
expect(await resolve()).toBe(true);
53+
});
54+
55+
test("JS::RequireRemote#load returns false when same gem is loaded twice", async ({
56+
page,
57+
}) => {
58+
const resolve = await resolveBinding(page, "checkResolved");
59+
await page.goto(
60+
"https://cdn.jsdelivr.net/npm/@ruby/head-wasm-wasi@latest/dist/",
61+
);
62+
await page.setContent(`
63+
<script src="browser.script.iife.js"></script>
64+
<script type="text/ruby" data-eval="async">
65+
require 'js/require_remote'
66+
JS::RequireRemote.instance.load 'fixtures/error_on_load_twice'
67+
JS.global.checkResolved JS::RequireRemote.instance.load 'fixtures/error_on_load_twice'
68+
</script>
69+
`);
70+
71+
expect(await resolve()).toBe(false);
72+
});
73+
74+
test("JS::RequireRemote#load throws error when gem is not found", async ({
75+
page,
76+
}) => {
77+
expectUncaughtException(page);
78+
79+
// Opens the URL that will be used as the basis for determining the relative URL.
80+
await page.goto(
81+
"https://cdn.jsdelivr.net/npm/@ruby/head-wasm-wasi@latest/dist/",
82+
);
83+
await page.setContent(`
84+
<script src="browser.script.iife.js">
85+
</script>
86+
<script type="text/ruby" data-eval="async">
87+
require 'js/require_remote'
88+
JS::RequireRemote.instance.load 'not_found'
89+
</script>
90+
`);
91+
92+
const error = await page.waitForEvent("pageerror");
93+
expect(error.message).toMatch(/cannot load such url -- .+\/not_found.rb/);
94+
});
95+
96+
test("JS::RequireRemote#load recursively loads dependencies", async ({
97+
page,
98+
}) => {
99+
const resolve = await resolveBinding(page, "checkResolved");
100+
await page.goto(
101+
"https://cdn.jsdelivr.net/npm/@ruby/head-wasm-wasi@latest/dist/",
102+
);
103+
await page.setContent(`
104+
<script src="browser.script.iife.js"></script>
105+
<script type="text/ruby" data-eval="async">
106+
require 'js/require_remote'
107+
module Kernel
108+
def require_relative(path) = JS::RequireRemote.instance.load(path)
109+
end
110+
111+
require_relative 'fixtures/recursive_require'
112+
JS.global.checkResolved RecursiveRequire::B.new.message
113+
</script>
114+
`);
115+
116+
expect(await resolve()).toBe("Hello from RecursiveRequire::B");
117+
});
118+
});
119+
}

0 commit comments

Comments
 (0)