Skip to content

Commit 115dea3

Browse files
committed
Add JS::RequireRemote to load external Ruby scripts from the browser
Users use JS::RequireRemote#load. The user uses this method to replace the require_relative method. Fix some test codes. - Extract a custom router for integration tests - Split integration test files - Files the body returned by the proxy server
1 parent 3122466 commit 115dea3

File tree

11 files changed

+330
-18
lines changed

11 files changed

+330
-18
lines changed

ext/js/lib/js/require_remote.rb

+85
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
+15
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
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
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
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>
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

+22-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ test.beforeEach(async ({ context, page }) => {
1414
setupDebugLog(context);
1515
setupUncaughtExceptionRejection(page);
1616
if (process.env.RUBY_NPM_PACKAGE_ROOT) {
17-
setupProxy(context);
17+
setupProxy(context, null);
1818
} else {
1919
console.info("Testing against CDN deployed files");
2020
const packagePath = path.join(__dirname, "..", "..", "package.json");
@@ -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 packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/data-eval-async.spec.ts

+2-12
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,18 @@ import {
55
setupProxy,
66
setupUncaughtExceptionRejection,
77
expectUncaughtException,
8+
resolveBinding,
89
} from "../support";
910

1011
if (!process.env.RUBY_NPM_PACKAGE_ROOT) {
1112
test.skip("skip", () => {});
1213
} else {
1314
test.beforeEach(async ({ context, page }) => {
1415
setupDebugLog(context);
15-
setupProxy(context);
16+
setupProxy(context, null);
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");
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import fs from "fs";
2+
import path from "path";
3+
import { test, expect } from "@playwright/test";
4+
import { setupDebugLog, setupProxy, resolveBinding } from "../support";
5+
6+
if (!process.env.RUBY_NPM_PACKAGE_ROOT) {
7+
test.skip("skip", () => {});
8+
} else {
9+
test.beforeEach(async ({ context }) => {
10+
setupDebugLog(context);
11+
setupProxy(context, (route, relativePath, mockedPath) => {
12+
if (relativePath.match("fixtures")) {
13+
route.fulfill({
14+
path: path.join("./test-e2e/integrations", relativePath),
15+
});
16+
} else if (fs.existsSync(mockedPath)) {
17+
route.fulfill({
18+
path: mockedPath,
19+
});
20+
} else {
21+
route.fulfill({
22+
status: 404,
23+
});
24+
}
25+
});
26+
});
27+
28+
test.describe("JS::RequireRemote#load", () => {
29+
test("JS::RequireRemote#load returns true", async ({ page }) => {
30+
const resolve = await resolveBinding(page, "checkResolved");
31+
await page.goto(
32+
"https://cdn.jsdelivr.net/npm/@ruby/head-wasm-wasi@latest/dist/",
33+
);
34+
await page.setContent(`
35+
<script src="browser.script.iife.js"></script>
36+
<script type="text/ruby" data-eval="async">
37+
require 'js/require_remote'
38+
JS.global.checkResolved JS::RequireRemote.instance.load 'fixtures/error_on_load_twice'
39+
</script>
40+
`);
41+
42+
expect(await resolve()).toBe(true);
43+
});
44+
45+
test("JS::RequireRemote#load returns false when same gem is loaded twice", async ({
46+
page,
47+
}) => {
48+
const resolve = await resolveBinding(page, "checkResolved");
49+
await page.goto(
50+
"https://cdn.jsdelivr.net/npm/@ruby/head-wasm-wasi@latest/dist/",
51+
);
52+
await page.setContent(`
53+
<script src="browser.script.iife.js"></script>
54+
<script type="text/ruby" data-eval="async">
55+
require 'js/require_remote'
56+
JS::RequireRemote.instance.load 'fixtures/error_on_load_twice'
57+
JS.global.checkResolved JS::RequireRemote.instance.load 'fixtures/error_on_load_twice'
58+
</script>
59+
`);
60+
61+
expect(await resolve()).toBe(false);
62+
});
63+
64+
test("JS::RequireRemote#load throws error when gem is not found", async ({
65+
page,
66+
}) => {
67+
// Opens the URL that will be used as the basis for determining the relative URL.
68+
await page.goto(
69+
"https://cdn.jsdelivr.net/npm/@ruby/head-wasm-wasi@latest/dist/",
70+
);
71+
await page.setContent(`
72+
<script src="browser.script.iife.js">
73+
</script>
74+
<script type="text/ruby" data-eval="async">
75+
require 'js/require_remote'
76+
JS::RequireRemote.instance.load 'foo'
77+
</script>
78+
`);
79+
80+
const error = await page.waitForEvent("pageerror");
81+
expect(error.message).toMatch(/cannot load such url -- .+\/foo.rb/);
82+
});
83+
});
84+
}

0 commit comments

Comments
 (0)