Skip to content

Commit

Permalink
Add JS::RequireRemote to load external Ruby scripts from the browser
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ledsun committed Dec 12, 2023
1 parent 22795ca commit 1923112
Show file tree
Hide file tree
Showing 11 changed files with 347 additions and 19 deletions.
36 changes: 36 additions & 0 deletions ext/js/lib/js/require_remote.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
require "singleton"
require "js"
require_relative "./require_remote/url_resolver"
require_relative "./require_remote/response_handler"

module JS
class RequireRemote
include Singleton

def initialize
base_url = JS.global[:URL].new(JS.global[:location][:href])
@resolver = URLResolver.new(base_url)
@handler = ResponseHandler.new(method(:update_current_url))
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 @handler.evaluated?(location)

response = JS.global.fetch(location.url).await
@handler.execute_and_record(response, location)
end

private

def update_current_url(url)
@resolver.push(url)
yield
ensure
@resolver.pop
end
end
end
48 changes: 48 additions & 0 deletions ext/js/lib/js/require_remote/response_handler.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
module JS
class RequireRemote
# Execute the body of the response and record the URL.
class ResponseHandler
# The around_eval parameter is a block that is executed before and after the body of the response.
# The block is passed the URL of the response.
def initialize(around_eval)
@around_eval = around_eval
end

# Execute the body of the response and record the URL.
def execute_and_record(response, location)
if response[:status].to_i == 200
# Check if the redirected URL has already evaluated.
return false if url_evaluated?(response[:url].to_s)

code = response.text().await.to_s
evaluate(code, location)

# The redirect that occurred may have been temporary.
# The original URL is not recorded.
# Only the URL after the redirect is recorded.
$LOADED_FEATURES << response[:url].to_s
true
else
raise LoadError.new "cannot load such url -- #{response[:status]} #{location.url}"
end
end

def evaluated?(location)
url_evaluated?(location.url[:href].to_s)
end

private

def url_evaluated?(url)
$LOADED_FEATURES.include?(url)
end

# Evaluate the given Ruby code with the given location and save the URL to the stack.
def evaluate(code, location)
@around_eval.call(location.url) do
Kernel.eval(code, ::Object::TOPLEVEL_BINDING, location.filename)
end
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
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 @@ -8,7 +8,7 @@ import https from "https";
test.beforeEach(async ({ context }) => {
setupDebugLog(context);
if (process.env.RUBY_NPM_PACKAGE_ROOT) {
setupProxy(context);
setupProxy(context, null);
} else {
console.info("Testing against CDN deployed files");
const packagePath = path.join(__dirname, "..", "..", "package.json");
Expand Down Expand Up @@ -58,3 +58,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,28 +5,18 @@ import {
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);
setupProxy(context, null);
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,122 @@
import fs from "fs";
import path from "path";
import { test, expect } from "@playwright/test";
import { setupDebugLog, setupProxy, resolveBinding } from "../support";

if (!process.env.RUBY_NPM_PACKAGE_ROOT) {
test.skip("skip", () => {});
} else {
test.beforeEach(async ({ context }) => {
setupDebugLog(context);
setupProxy(context, (route, relativePath, mockedPath) => {
if (relativePath.match("fixtures")) {
route.fulfill({
path: path.join("./test-e2e/integrations", relativePath),
});
} else if (fs.existsSync(mockedPath)) {
route.fulfill({
path: mockedPath,
});
} else {
route.fulfill({
status: 404,
});
}
});
});

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,
}) => {
// 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="https://cdn.jsdelivr.net/npm/@ruby/head-wasm-wasi@latest/dist/browser.script.iife.js">
</script>
<script type="text/ruby" data-eval="async">
require 'js/require_remote'
JS::RequireRemote.instance.load 'foo'
</script>
`);

const error = await page.waitForEvent("pageerror");
expect(error.message).toMatch(/cannot load such url -- .+\/foo.rb/);
});

// TODO: This test fails.
// In the integration test, response#url returns the URL before the redirect.
// I do not know the cause. Under investigation.
test.skip("JS::RequireRemote#load identifies by URL after redirect", async ({
page,
context,
}) => {
// Stop tests immediately when an error occurs in the page.
page.on("pageerror", (error) => {
throw error;
});

// Use the proxy to redirect the request.
context.route(/redirect/, (route) => {
route.fulfill({
status: 302,
headers: {
location: "error_on_load_twice.rb",
},
});
});

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 'redirect_to_error_on_load_twice'
JS.global.checkResolved JS::RequireRemote.instance.load 'error_on_load_twice'
</script>
`);

expect(await resolve()).toBe(false);
});
});
}
Loading

0 comments on commit 1923112

Please sign in to comment.