From 79a999a7c162ad8f35d5e8b848ad5f5d703e2b9f Mon Sep 17 00:00:00 2001 From: "shigeru.nakajima" Date: Sun, 25 Oct 2026 22:30:06 +0900 Subject: [PATCH 1/2] Allow the browser script loader to use a custom Ruby WASM URL --- README.md | 16 ++ .../ruby-wasm-wasi/src/browser.script.ts | 25 +- .../integrations/data-ruby-wasm-url.spec.ts | 226 ++++++++++++++++++ 3 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/data-ruby-wasm-url.spec.ts diff --git a/README.md b/README.md index 143fbcfef..14b2baf59 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,22 @@ Create and save an `index.html` page with the following contents: ``` +To use a custom Ruby WASM binary containing additional gems, set +`data-ruby-wasm-url` on the loader script (WASI Preview 1 only): + +```html + + +``` + +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) diff --git a/packages/npm-packages/ruby-wasm-wasi/src/browser.script.ts b/packages/npm-packages/ruby-wasm-wasi/src/browser.script.ts index b18a7ed49..8f50d4816 100644 --- a/packages/npm-packages/ruby-wasm-wasi/src/browser.script.ts +++ b/packages/npm-packages/ruby-wasm-wasi/src/browser.script.ts @@ -8,10 +8,12 @@ export const main = async ( pkg: { name: string; version: string }, options?: Parameters[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, @@ -103,6 +105,21 @@ const deriveEvalStyle = (tag: Element): "async" | "sync" => { return rawEvalStyle; }; +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"}' diff --git a/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/data-ruby-wasm-url.spec.ts b/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/data-ruby-wasm-url.spec.ts new file mode 100644 index 000000000..529ccf197 --- /dev/null +++ b/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/data-ruby-wasm-url.spec.ts @@ -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: ``, + }), + ); + 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: ``, + }), + ); + 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: ``, + }), + ); + 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( + ``, + ); + 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( + ``, + ); + 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( + ``, + ); + 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( + ``, + ); + 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( + ``, + ); + 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( + ``, + ); + 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( + ``, + ); + await error; + expect(wasmRequests).toEqual([customUrl]); + expect(await page.evaluate(() => window["rubyVM"])).toBeUndefined(); +}); From dc6b0ffd5800ed9bfc49268ed8021d456f10ddf8 Mon Sep 17 00:00:00 2001 From: "shigeru.nakajima" Date: Sun, 25 Oct 2026 22:35:41 +0900 Subject: [PATCH 2/2] Document the custom Ruby WASM URL attribute behavior --- .../npm-packages/ruby-wasm-wasi/src/browser.script.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/npm-packages/ruby-wasm-wasi/src/browser.script.ts b/packages/npm-packages/ruby-wasm-wasi/src/browser.script.ts index 8f50d4816..f7b13c85d 100644 --- a/packages/npm-packages/ruby-wasm-wasi/src/browser.script.ts +++ b/packages/npm-packages/ruby-wasm-wasi/src/browser.script.ts @@ -105,6 +105,17 @@ 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 . + * 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) {