Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ Create and save an `index.html` page with the following contents:
</html>
```

To use a custom Ruby WASM binary containing additional gems, set
`data-ruby-wasm-url` on the loader script (WASI Preview 1 only):

```html
<script
src="./browser.script.iife.js"
data-ruby-wasm-url="./ruby-with-custom-gems.wasm"
></script>
<script type="text/ruby">
require "my_custom_gem"
</script>
```

Relative URLs are resolved against the HTML page's URL. Omitting the attribute
uses the default CDN binary.

## Quick Example: How to package your Ruby application as a WASI application

Dependencies: [wasmtime](https://github.com/bytecodealliance/wasmtime)
Expand Down
36 changes: 32 additions & 4 deletions packages/npm-packages/ruby-wasm-wasi/src/browser.script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ export const main = async (
pkg: { name: string; version: string },
options?: Parameters<typeof DefaultRubyVM>[1],
) => {
const scriptEnv = parseDataEnv(document.currentScript);
const response = fetch(
`https://cdn.jsdelivr.net/npm/${pkg.name}@${pkg.version}/dist/ruby+stdlib.wasm`,
);
const script = document.currentScript;
const scriptEnv = parseDataEnv(script);
const rubyWasmUrl =
parseDataRubyWasmUrl(script) ??
`https://cdn.jsdelivr.net/npm/${pkg.name}@${pkg.version}/dist/ruby+stdlib.wasm`;
const response = fetch(rubyWasmUrl);
const module = await compileWebAssemblyModule(response);
const { vm } = await DefaultRubyVM(module, {
...options,
Expand Down Expand Up @@ -103,6 +105,32 @@ const deriveEvalStyle = (tag: Element): "async" | "sync" => {
return rawEvalStyle;
};

/**
* Parses data-ruby-wasm-url on the loader script to select a custom Ruby WASM
* binary, for example one built with additional gems:
* data-ruby-wasm-url="./ruby-with-custom-gems.wasm"
*
* This replaces ruby+stdlib.wasm for WASI Preview 1. Absolute URLs are accepted;
* relative URLs are resolved against the HTML page's URL, ignoring <base>.
* Omitting the attribute returns undefined so the caller uses the default CDN.
* An empty value or leading/trailing whitespace throws an error; values are
* not trimmed. Loading or compilation failures do not fall back to the CDN.
*/
const parseDataRubyWasmUrl = (tag: Element | null): string | undefined => {
const rawUrl = tag?.getAttribute("data-ruby-wasm-url");
if (rawUrl == null) {
return undefined;
}

// Validate before URL parsing can silently strip surrounding whitespace.
if (rawUrl === "" || /^\s|\s$/.test(rawUrl)) {
throw new Error(
"data-ruby-wasm-url must be non-empty and must not have leading or trailing whitespace.",
);
}
return new URL(rawUrl, location.href).href;
};

/**
* Parses the data-env attribute as a JSON object string, for example:
* data-env='{"RUBY_BOX":"1","MSG":"hello world"}'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { test, expect } from "@playwright/test";
import path from "path";
import { setupProxy, waitForRubyVM } from "../support";

const loaderUrl =
"https://cdn.jsdelivr.net/npm/@ruby/head-wasm-wasi@latest/dist/browser.script.iife.js";
const pageUrl = "https://example.test/app/index.html";

test.skip(!process.env.RUBY_NPM_PACKAGE_ROOT, "Requires a Ruby npm package");

// Preview 2 loads a component directory rather than a single Ruby WASM file.
test.skip(
process.env.RUBY_NPM_PACKAGE_ROOT?.includes("wasip2") ?? false,
"Only supported by Preview 1",
);

test.beforeEach(async ({ context }) => {
setupProxy(context);
});

test("uses the default CDN when the attribute is omitted", async ({ page }) => {
const wasmRequests: string[] = [];
page.on("request", (request) => {
if (request.url().endsWith(".wasm")) wasmRequests.push(request.url());
});
page.on("pageerror", (error) => {
throw error;
});
await page.route(pageUrl, (route) =>
route.fulfill({
contentType: "text/html",
body: `<script src="${loaderUrl}" ></script>`,
}),
);
await page.goto(pageUrl);
await waitForRubyVM(page);
expect(wasmRequests).toHaveLength(1);
expect(wasmRequests[0]).toMatch(
/^https:\/\/cdn\.jsdelivr\.net\/.*\/ruby\+stdlib\.wasm$/,
);
expect(
await page.evaluate(() => window["rubyVM"].eval("21 * 2").toString()),
).toBe("42");
});

test("resolves a relative URL against the HTML page and starts Ruby", async ({
page,
}) => {
const wasmRequests: string[] = [];
page.on("request", (request) => {
if (request.url().endsWith(".wasm")) wasmRequests.push(request.url());
});
page.on("pageerror", (error) => {
throw error;
});
const customUrl = "https://example.test/app/ruby-with-custom-gems.wasm";
await page.route(customUrl, (route) =>
route.fulfill({
path: path.join(
process.env.RUBY_NPM_PACKAGE_ROOT!,
"dist/ruby+stdlib.wasm",
),
contentType: "application/wasm",
}),
);
await page.route(pageUrl, (route) =>
route.fulfill({
contentType: "text/html",
body: `<script src="${loaderUrl}" data-ruby-wasm-url="./ruby-with-custom-gems.wasm"></script>`,
}),
);
await page.goto(pageUrl);
await waitForRubyVM(page);
expect(wasmRequests).toEqual([customUrl]);
expect(
await page.evaluate(() => window["rubyVM"].eval("21 * 2").toString()),
).toBe("42");
});

test("loads an absolute URL and starts Ruby", async ({ page }) => {
const wasmRequests: string[] = [];
page.on("request", (request) => {
if (request.url().endsWith(".wasm")) wasmRequests.push(request.url());
});
page.on("pageerror", (error) => {
throw error;
});
const customUrl = "https://binaries.test/custom.wasm";
await page.route(customUrl, (route) =>
route.fulfill({
path: path.join(
process.env.RUBY_NPM_PACKAGE_ROOT!,
"dist/ruby+stdlib.wasm",
),
contentType: "application/wasm",
}),
);
await page.route(pageUrl, (route) =>
route.fulfill({
contentType: "text/html",
body: `<script src="${loaderUrl}" data-ruby-wasm-url="https://binaries.test/custom.wasm"></script>`,
}),
);
await page.goto(pageUrl);
await waitForRubyVM(page);
expect(wasmRequests).toEqual([customUrl]);
expect(
await page.evaluate(() => window["rubyVM"].eval("21 * 2").toString()),
).toBe("42");
});

test("rejects an empty URL before fetching", async ({ page }) => {
const wasmRequests: string[] = [];
page.on("request", (request) => {
if (request.url().endsWith(".wasm")) wasmRequests.push(request.url());
});
const error = page.waitForEvent("pageerror");
await page.setContent(
`<script src="${loaderUrl}" data-ruby-wasm-url=""></script>`,
);
expect((await error).message).toContain(
"data-ruby-wasm-url must be non-empty",
);
expect(wasmRequests).toEqual([]);
});

test("rejects a whitespace-only URL before fetching", async ({ page }) => {
const wasmRequests: string[] = [];
page.on("request", (request) => {
if (request.url().endsWith(".wasm")) wasmRequests.push(request.url());
});
const error = page.waitForEvent("pageerror");
await page.setContent(
`<script src="${loaderUrl}" data-ruby-wasm-url=" "></script>`,
);
expect((await error).message).toContain(
"data-ruby-wasm-url must be non-empty",
);
expect(wasmRequests).toEqual([]);
});

test("rejects leading whitespace before fetching", async ({ page }) => {
const wasmRequests: string[] = [];
page.on("request", (request) => {
if (request.url().endsWith(".wasm")) wasmRequests.push(request.url());
});
const error = page.waitForEvent("pageerror");
await page.setContent(
`<script src="${loaderUrl}" data-ruby-wasm-url=" ./custom.wasm"></script>`,
);
expect((await error).message).toContain(
"data-ruby-wasm-url must be non-empty",
);
expect(wasmRequests).toEqual([]);
});

test("rejects trailing whitespace before fetching", async ({ page }) => {
const wasmRequests: string[] = [];
page.on("request", (request) => {
if (request.url().endsWith(".wasm")) wasmRequests.push(request.url());
});
const error = page.waitForEvent("pageerror");
await page.setContent(
`<script src="${loaderUrl}" data-ruby-wasm-url="./custom.wasm "></script>`,
);
expect((await error).message).toContain(
"data-ruby-wasm-url must be non-empty",
);
expect(wasmRequests).toEqual([]);
});

test("rejects a tab-only URL before fetching", async ({ page }) => {
const wasmRequests: string[] = [];
page.on("request", (request) => {
if (request.url().endsWith(".wasm")) wasmRequests.push(request.url());
});
const error = page.waitForEvent("pageerror");
await page.setContent(
`<script src="${loaderUrl}" data-ruby-wasm-url="&#9;"></script>`,
);
expect((await error).message).toContain(
"data-ruby-wasm-url must be non-empty",
);
expect(wasmRequests).toEqual([]);
});

test("does not fall back to the CDN after a fetch failure", async ({
page,
}) => {
const wasmRequests: string[] = [];
page.on("request", (request) => {
if (request.url().endsWith(".wasm")) wasmRequests.push(request.url());
});
const customUrl = "https://example.test/broken.wasm";
await page.route(customUrl, (route) => route.abort());
const error = page.waitForEvent("pageerror");
await page.setContent(
`<script src="${loaderUrl}" data-ruby-wasm-url="${customUrl}"></script>`,
);
await error;
expect(wasmRequests).toEqual([customUrl]);
expect(await page.evaluate(() => window["rubyVM"])).toBeUndefined();
});

test("does not fall back to the CDN after a compile failure", async ({
page,
}) => {
const wasmRequests: string[] = [];
page.on("request", (request) => {
if (request.url().endsWith(".wasm")) wasmRequests.push(request.url());
});
const customUrl = "https://example.test/broken.wasm";
await page.route(customUrl, (route) =>
route.fulfill({
contentType: "application/wasm",
body: "invalid wasm",
}),
);
const error = page.waitForEvent("pageerror");
await page.setContent(
`<script src="${loaderUrl}" data-ruby-wasm-url="${customUrl}"></script>`,
);
await error;
expect(wasmRequests).toEqual([customUrl]);
expect(await page.evaluate(() => window["rubyVM"])).toBeUndefined();
});
Loading