Skip to content

Commit

Permalink
Merge pull request #292 from ledsun/require_relative
Browse files Browse the repository at this point in the history
  • Loading branch information
kateinoigakukun authored Dec 26, 2023
2 parents f63b6d1 + b9db613 commit 6acf403
Show file tree
Hide file tree
Showing 16 changed files with 422 additions and 12 deletions.
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
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

0 comments on commit 6acf403

Please sign in to comment.