Skip to content

Commit 1923112

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 22795ca commit 1923112

File tree

11 files changed

+347
-19
lines changed

11 files changed

+347
-19
lines changed

ext/js/lib/js/require_remote.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
require "singleton"
2+
require "js"
3+
require_relative "./require_remote/url_resolver"
4+
require_relative "./require_remote/response_handler"
5+
6+
module JS
7+
class RequireRemote
8+
include Singleton
9+
10+
def initialize
11+
base_url = JS.global[:URL].new(JS.global[:location][:href])
12+
@resolver = URLResolver.new(base_url)
13+
@handler = ResponseHandler.new(method(:update_current_url))
14+
end
15+
16+
# Load the given feature from remote.
17+
def load(relative_feature)
18+
location = @resolver.get_location(relative_feature)
19+
20+
# Do not load the same URL twice.
21+
return false if @handler.evaluated?(location)
22+
23+
response = JS.global.fetch(location.url).await
24+
@handler.execute_and_record(response, location)
25+
end
26+
27+
private
28+
29+
def update_current_url(url)
30+
@resolver.push(url)
31+
yield
32+
ensure
33+
@resolver.pop
34+
end
35+
end
36+
end
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
module JS
2+
class RequireRemote
3+
# Execute the body of the response and record the URL.
4+
class ResponseHandler
5+
# The around_eval parameter is a block that is executed before and after the body of the response.
6+
# The block is passed the URL of the response.
7+
def initialize(around_eval)
8+
@around_eval = around_eval
9+
end
10+
11+
# Execute the body of the response and record the URL.
12+
def execute_and_record(response, location)
13+
if response[:status].to_i == 200
14+
# Check if the redirected URL has already evaluated.
15+
return false if url_evaluated?(response[:url].to_s)
16+
17+
code = response.text().await.to_s
18+
evaluate(code, location)
19+
20+
# The redirect that occurred may have been temporary.
21+
# The original URL is not recorded.
22+
# Only the URL after the redirect is recorded.
23+
$LOADED_FEATURES << response[:url].to_s
24+
true
25+
else
26+
raise LoadError.new "cannot load such url -- #{response[:status]} #{location.url}"
27+
end
28+
end
29+
30+
def evaluated?(location)
31+
url_evaluated?(location.url[:href].to_s)
32+
end
33+
34+
private
35+
36+
def url_evaluated?(url)
37+
$LOADED_FEATURES.include?(url)
38+
end
39+
40+
# Evaluate the given Ruby code with the given location and save the URL to the stack.
41+
def evaluate(code, location)
42+
@around_eval.call(location.url) do
43+
Kernel.eval(code, ::Object::TOPLEVEL_BINDING, location.filename)
44+
end
45+
end
46+
end
47+
end
48+
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: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import https from "https";
88
test.beforeEach(async ({ context }) => {
99
setupDebugLog(context);
1010
if (process.env.RUBY_NPM_PACKAGE_ROOT) {
11-
setupProxy(context);
11+
setupProxy(context, null);
1212
} else {
1313
console.info("Testing against CDN deployed files");
1414
const packagePath = path.join(__dirname, "..", "..", "package.json");
@@ -58,3 +58,24 @@ test("script-src/index.html is healthy", async ({ page }) => {
5858
await page.waitForEvent("console");
5959
}
6060
});
61+
62+
// The browser.script.iife.js obtained from CDN does not include the patch to require_relative.
63+
// Skip when testing against the CDN.
64+
if (process.env.RUBY_NPM_PACKAGE_ROOT) {
65+
test("require_relative/index.html is healthy", async ({ page }) => {
66+
// Add a listener to detect errors in the page
67+
page.on("pageerror", (error) => {
68+
console.log(`page error occurs: ${error.message}`);
69+
});
70+
71+
const messages: string[] = [];
72+
page.on("console", (msg) => messages.push(msg.text()));
73+
await page.goto("/require_relative/index.html");
74+
75+
await waitForRubyVM(page);
76+
const expected = "Hello, world!\n";
77+
while (messages[messages.length - 1] != expected) {
78+
await page.waitForEvent("console");
79+
}
80+
});
81+
}

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: 2 additions & 12 deletions
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");
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: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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="https://cdn.jsdelivr.net/npm/@ruby/head-wasm-wasi@latest/dist/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+
// TODO: This test fails.
85+
// In the integration test, response#url returns the URL before the redirect.
86+
// I do not know the cause. Under investigation.
87+
test.skip("JS::RequireRemote#load identifies by URL after redirect", async ({
88+
page,
89+
context,
90+
}) => {
91+
// Stop tests immediately when an error occurs in the page.
92+
page.on("pageerror", (error) => {
93+
throw error;
94+
});
95+
96+
// Use the proxy to redirect the request.
97+
context.route(/redirect/, (route) => {
98+
route.fulfill({
99+
status: 302,
100+
headers: {
101+
location: "error_on_load_twice.rb",
102+
},
103+
});
104+
});
105+
106+
const resolve = await resolveBinding(page, "checkResolved");
107+
await page.goto(
108+
"https://cdn.jsdelivr.net/npm/@ruby/head-wasm-wasi@latest/dist/",
109+
);
110+
await page.setContent(`
111+
<script src="browser.script.iife.js"></script>
112+
<script type="text/ruby" data-eval="async">
113+
require 'js/require_remote'
114+
JS::RequireRemote.instance.load 'redirect_to_error_on_load_twice'
115+
JS.global.checkResolved JS::RequireRemote.instance.load 'error_on_load_twice'
116+
</script>
117+
`);
118+
119+
expect(await resolve()).toBe(false);
120+
});
121+
});
122+
}

0 commit comments

Comments
 (0)