Skip to content

Commit 96115a6

Browse files
committed
Load external Ruby scripts from the browser with require_relative.
1 parent 84707d6 commit 96115a6

File tree

10 files changed

+197
-3
lines changed

10 files changed

+197
-3
lines changed

ext/js/lib/js.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
require_relative "js/hash.rb"
33
require_relative "js/array.rb"
44
require_relative "js/nil_class.rb"
5+
require_relative "js/loader.rb"
56

67
# The JS module provides a way to interact with JavaScript from Ruby.
78
#

ext/js/lib/js/loader.rb

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
require "singleton"
2+
require_relative "./url_resolver"
3+
4+
module JS
5+
ScriptLocation = Data.define(:url, :filename)
6+
7+
class Loader
8+
include Singleton
9+
10+
def initialize
11+
set_base_url
12+
end
13+
14+
def require_relative(relative_feature)
15+
location = get_location(relative_feature)
16+
response = JS.global.fetch(location.url).await
17+
18+
if response[:status].to_i == 200
19+
code = response.text().await.to_s
20+
eval_code(code, location)
21+
else
22+
raise LoadError.new "cannot load such url -- #{location.url}"
23+
end
24+
end
25+
26+
private
27+
28+
def set_base_url
29+
base_url = JS.global[:URL].new(JS.global[:location][:href])
30+
@resolver = URLResolver.new(base_url)
31+
end
32+
33+
def get_location(relative_feature)
34+
filename = filename_from(relative_feature)
35+
url = resolver.resolve(filename)
36+
ScriptLocation.new(url, filename)
37+
end
38+
39+
# Evaluate the given Ruby code with the given location and save the URL to the stack.
40+
def eval_code(code, location)
41+
begin
42+
resolver.push(location.url)
43+
Kernel.eval(code, ::Object::TOPLEVEL_BINDING, location.filename)
44+
ensure
45+
resolver.pop
46+
end
47+
end
48+
49+
def filename_from(relative_feature)
50+
if relative_feature.end_with?(".rb")
51+
relative_feature
52+
else
53+
"#{relative_feature}.rb"
54+
end
55+
end
56+
57+
attr_reader :resolver
58+
end
59+
end

ext/js/lib/js/url_resolver.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
module JS
2+
# When require_relative is called within a running Ruby script,
3+
# the URL is resolved from a relative file path based on the URL of the running Ruby script.
4+
# It uses a stack to store URLs of running Ruby Script.
5+
# Push the URL onto the stack before executing the new script.
6+
# Then pop it when the script has finished executing.
7+
class URLResolver
8+
def initialize(base_url)
9+
@url_stack = [base_url]
10+
end
11+
12+
# Return a URL object of JavaScript.
13+
def resolve(relative_filepath)
14+
JS.global[:URL].new relative_filepath, @url_stack.last
15+
end
16+
17+
def push(url)
18+
@url_stack.push url
19+
end
20+
21+
def pop()
22+
@url_stack.pop
23+
end
24+
end
25+
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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<html>
2+
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/browser.script.iife.js"></script>
3+
<script type="text/ruby" data-eval="async">
4+
require_relative 'main'
5+
</script>
6+
</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/src/browser.script.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { RubyVM } from ".";
12
import { DefaultRubyVM } from "./browser";
23

34
export const main = async (pkg: { name: string; version: string }) => {
@@ -11,6 +12,8 @@ export const main = async (pkg: { name: string; version: string }) => {
1112

1213
globalThis.rubyVM = vm;
1314

15+
patchRequireRelative(vm);
16+
1417
// Wait for the text/ruby script tag to be read.
1518
// It may take some time to read ruby+stdlib.wasm
1619
// and DOMContentLoaded has already been fired.
@@ -93,3 +96,27 @@ const compileWebAssemblyModule = async function (
9396
return WebAssembly.compileStreaming(response);
9497
}
9598
};
99+
100+
const patchRequireRelative = (vm: RubyVM) => {
101+
const patch = `
102+
require 'js'
103+
104+
module Kernel
105+
alias original_require_relative require_relative
106+
107+
# The require_relative may be used in the embedded Gem.
108+
# First try to load from the built-in filesystem, and if that fails,
109+
# load from the URL.
110+
def require_relative(path)
111+
caller_path = caller_locations(1, 1).first.absolute_path || ''
112+
dir = File.dirname(caller_path)
113+
file = File.absolute_path(path, dir)
114+
115+
original_require_relative(file)
116+
rescue LoadError
117+
JS::Loader.instance.require_relative(path)
118+
end
119+
end
120+
`;
121+
vm.eval(patch);
122+
};

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
@@ -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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,38 @@ if (!process.env.RUBY_NPM_PACKAGE_ROOT) {
6060
expect(await resolve()).toBe("ok");
6161
});
6262
});
63+
64+
test.describe("require_relative", () => {
65+
test("patch does not break original require_relative", async ({ page }) => {
66+
const resolve = await resolveBinding(page, "checkResolved");
67+
await page.setContent(`
68+
<script src="https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/dist/browser.script.iife.js">
69+
</script>
70+
<script type="text/ruby" data-eval="async">
71+
require 'csv'
72+
csv = CSV.new "foo\nbar\n"
73+
JS.global.checkResolved csv.first
74+
</script>
75+
`);
76+
expect(await resolve()).toStrictEqual(["foo"]);
77+
});
78+
79+
test("require_relative throws error when gem is not found", async ({
80+
page,
81+
}) => {
82+
// Opens the URL that will be used as the basis for determining the relative URL.
83+
await page.goto(
84+
"https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/dist/",
85+
);
86+
await page.setContent(`
87+
<script src="https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@latest/dist/browser.script.iife.js">
88+
</script>
89+
<script type="text/ruby" data-eval="async">
90+
require_relative 'foo'
91+
</script>
92+
`);
93+
const error = await page.waitForEvent("pageerror");
94+
expect(error.message).toMatch(/cannot load such url -- .+\/foo.rb/);
95+
});
96+
});
6397
}

packages/npm-packages/ruby-wasm-wasi/test-e2e/support.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { BrowserContext, Page } from "@playwright/test";
22
import path from "path";
3+
import fs from "fs";
34

45
export const waitForRubyVM = async (page: Page) => {
56
await page.waitForFunction(() => window["rubyVM"]);
@@ -23,8 +24,20 @@ export const setupProxy = (context: BrowserContext) => {
2324
const request = route.request();
2425
console.log(">> [MOCK]", request.method(), request.url());
2526
const relativePath = request.url().match(cdnPattern)[1];
26-
route.fulfill({
27-
path: path.join(process.env.RUBY_NPM_PACKAGE_ROOT, "dist", relativePath),
28-
});
27+
const mockedPath = path.join(
28+
process.env.RUBY_NPM_PACKAGE_ROOT,
29+
"dist",
30+
relativePath,
31+
);
32+
33+
if (fs.existsSync(mockedPath)) {
34+
route.fulfill({
35+
path: mockedPath,
36+
});
37+
} else {
38+
route.fulfill({
39+
status: 404,
40+
});
41+
}
2942
});
3043
};

0 commit comments

Comments
 (0)