Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RequireRemote#load to load external Ruby scripts from server #292

Merged
merged 4 commits into from
Dec 26, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions ext/js/lib/js/require_remote.rb
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions ext/js/lib/js/require_remote/evaluator.rb
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions ext/js/lib/js/require_remote/url_resolver.rb
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to carefully handle relative path -> absolute path resolution here. IIUC JavaScript's new URL(path, base) does not behave as we expect for Kernel#require_relative (and also File.expand_path).

Here is an example:

path, base File.expand_path(path, base) new URL(path, "https://example.com" + base)
path="b.rb", base="/lib" /lib/b.rb https://example.com/b.rb
path="./b.rb", base="/lib" /lib/b.rb https://example.com/lib/b.rb

I think the test suite of File.expand_path would be helpful for you to see what should be cared https://github.com/ruby/ruby/blob/master/spec/ruby/core/file/expand_path_spec.rb

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this behavior of the JavaScript's URL constructor is as expected.

In most cases, I assume that the first value for base is the URL of an HTML file, such as http://exapmle.com/index.html, or a URL such as http://exapmle.com/index without the extension.
Also, once the ruby script is loaded, the base value will be the URL of the ruby script, such as http://exapmle.com/a.rb.

So we want the string after the last / in the base value to be ignored.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ok. I misunderstood the second argument. If the second argument is always a URL to a file, then it makes sense to me.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anyway, unit tests covering this area would be preferable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote unit testsfor JS::RequireRemote::URLResolver.
Please let me know if there are any tests that you think we should add.

end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class Greeting
def say
puts "Hello, world!"
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<html>
<script src="https://cdn.jsdelivr.net/npm/@ruby/[email protected]/dist/browser.script.iife.js"></script>
<script type="text/ruby" data-eval="async">
# Patch require_relative to load from remote
require 'js/require_remote'

module Kernel
alias original_require_relative require_relative

# The require_relative may be used in the embedded Gem.
# First try to load from the built-in filesystem, and if that fails,
# load from the URL.
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

# The above patch does not break the original require_relative
require 'csv'
csv = CSV.new "foo\nbar\n"

# Load the main script
require_relative 'main'
</script>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
require_relative "greeting"

Greeting.new.say
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
setupProxy,
setupUncaughtExceptionRejection,
expectUncaughtException,
resolveBinding,
} from "../support";

if (!process.env.RUBY_NPM_PACKAGE_ROOT) {
Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
raise "load twice" if defined?(ALREADY_LOADED)

ALREADY_LOADED = true
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require_relative "./recursive_require/a.rb"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require_relative "./b.rb"
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module RecursiveRequire
class B
def message
"Hello from RecursiveRequire::B"
end
end
end
Original file line number Diff line number Diff line change
@@ -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(`
<script src="browser.script.iife.js"></script>
<script type="text/ruby" data-eval="async">
require 'js/require_remote'
JS.global.checkResolved JS::RequireRemote.instance.load 'fixtures/error_on_load_twice'
</script>
`);

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(`
<script src="browser.script.iife.js"></script>
<script type="text/ruby" data-eval="async">
require 'js/require_remote'
JS::RequireRemote.instance.load 'fixtures/error_on_load_twice'
JS.global.checkResolved JS::RequireRemote.instance.load 'fixtures/error_on_load_twice'
</script>
`);

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(`
<script src="browser.script.iife.js">
</script>
<script type="text/ruby" data-eval="async">
require 'js/require_remote'
JS::RequireRemote.instance.load 'not_found'
</script>
`);

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(`
<script src="browser.script.iife.js"></script>
<script type="text/ruby" data-eval="async">
require 'js/require_remote'
module Kernel
def require_relative(path) = JS::RequireRemote.instance.load(path)
end

require_relative 'fixtures/recursive_require'
JS.global.checkResolved RecursiveRequire::B.new.message
</script>
`);

expect(await resolve()).toBe("Hello from RecursiveRequire::B");
});
});
}
Loading