From ffc508fb5ba7b25c396182dd6b925534652560a1 Mon Sep 17 00:00:00 2001 From: "shigeru.nakajima" Date: Tue, 5 May 2026 16:50:59 +0900 Subject: [PATCH] Add browser data-env support --- docs/cheat_sheet.md | 9 ++++ .../ruby-wasm-wasi/example/README.md | 1 + .../ruby-wasm-wasi/example/ruby-box.html | 22 +++++++++ .../ruby-wasm-wasi/src/browser.script.ts | 43 +++++++++++++++++- .../npm-packages/ruby-wasm-wasi/src/vm.ts | 16 ++++++- .../test-e2e/examples/examples.spec.ts | 45 ++++++++++++++++++- .../test-e2e/integrations/data-env.spec.ts | 35 +++++++++++++++ 7 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 packages/npm-packages/ruby-wasm-wasi/example/ruby-box.html create mode 100644 packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/data-env.spec.ts diff --git a/docs/cheat_sheet.md b/docs/cheat_sheet.md index e75580402..0e74b13f0 100644 --- a/docs/cheat_sheet.md +++ b/docs/cheat_sheet.md @@ -46,6 +46,15 @@ The easiest way to run Ruby on browser is to use `browser.script.iife.js` script ``` +Use `data-env` on the `browser.script.iife.js` script tag to pass environment variables when the Ruby VM starts: + +```html + +``` + If you want to control Ruby VM from JavaScript, you can use `@ruby/wasm-wasi` package API: ```html diff --git a/packages/npm-packages/ruby-wasm-wasi/example/README.md b/packages/npm-packages/ruby-wasm-wasi/example/README.md index d181d6543..2feeedc40 100644 --- a/packages/npm-packages/ruby-wasm-wasi/example/README.md +++ b/packages/npm-packages/ruby-wasm-wasi/example/README.md @@ -8,6 +8,7 @@ This is a simple example of how to use the `ruby-wasm-wasi` family packages $ ruby -run -e httpd . -p 8000 $ # Open http://localhost:8000/hello.html $ # Open http://localhost:8000/lucky.html +$ # Open http://localhost:8000/ruby-box.html $ # Open http://localhost:8000/script-src ``` diff --git a/packages/npm-packages/ruby-wasm-wasi/example/ruby-box.html b/packages/npm-packages/ruby-wasm-wasi/example/ruby-box.html new file mode 100644 index 000000000..c8a4130d5 --- /dev/null +++ b/packages/npm-packages/ruby-wasm-wasi/example/ruby-box.html @@ -0,0 +1,22 @@ + + +
+
+
+
+ + 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 2c5b2bd32..7e0f77d7a 100644 --- a/packages/npm-packages/ruby-wasm-wasi/src/browser.script.ts +++ b/packages/npm-packages/ruby-wasm-wasi/src/browser.script.ts @@ -8,11 +8,18 @@ export const main = async ( pkg: { name: string; version: string }, options?: Parameters[1], ) => { + const scriptEnv = deriveEnv(document.currentScript); const response = fetch( `https://cdn.jsdelivr.net/npm/${pkg.name}@${pkg.version}/dist/ruby+stdlib.wasm`, ); const module = await compileWebAssemblyModule(response); - const { vm } = await DefaultRubyVM(module, options); + const { vm } = await DefaultRubyVM(module, { + ...options, + env: { + ...scriptEnv, + ...options?.env, + }, + }); await mainWithRubyVM(vm); }; @@ -24,12 +31,18 @@ export const componentMain = async ( options: { instantiate: RubyComponentInstantiator; wasip2: any; + env?: Record | undefined; } ) => { + const scriptEnv = deriveEnv(document.currentScript); const componentUrl = `https://cdn.jsdelivr.net/npm/${pkg.name}@${pkg.version}/dist/component`; const fetchComponentFile = (relativePath: string) => fetch(`${componentUrl}/${relativePath}`); const { vm } = await RubyVM.instantiateComponent({ ...options, + env: { + ...scriptEnv, + ...options.env, + }, getCoreModule: (relativePath: string) => { const response = fetchComponentFile(relativePath); return compileWebAssemblyModule(response); @@ -90,6 +103,34 @@ const deriveEvalStyle = (tag: Element): "async" | "sync" => { return rawEvalStyle; }; +const deriveEnv = (tag: Element | null): Record => { + const rawEnv = tag?.getAttribute("data-env"); + if (!rawEnv) { + return {}; + } + + const trimmedEnv = rawEnv.trim(); + if (!trimmedEnv) { + return {}; + } + + return trimmedEnv + .split(/\s+/) + .reduce>((env, entry) => { + const delimiterIndex = entry.indexOf("="); + if (delimiterIndex <= 0) { + console.warn( + `data-env entry must be in the KEY=value format. ${entry} is ignored.`, + ); + return env; + } + + // Only the first "=" separates key and value so values can contain "=". + env[entry.slice(0, delimiterIndex)] = entry.slice(delimiterIndex + 1); + return env; + }, {}); +}; + const loadScriptAsync = async ( tag: Element, ): Promise<{ scriptContent: string; evalStyle: "async" | "sync" } | null> => { diff --git a/packages/npm-packages/ruby-wasm-wasi/src/vm.ts b/packages/npm-packages/ruby-wasm-wasi/src/vm.ts index d7e3f4d60..c9a263845 100644 --- a/packages/npm-packages/ruby-wasm-wasi/src/vm.ts +++ b/packages/npm-packages/ruby-wasm-wasi/src/vm.ts @@ -54,6 +54,11 @@ export type RubyInitComponentOptions = { */ wasip2: any; + /** + * Environment variables to pass to the Ruby VM. + */ + env?: Record | undefined; + /** * The arguments to pass to the Ruby VM. Note that the first argument must be the Ruby program name. * @@ -179,9 +184,18 @@ export class RubyVM { initComponent = async (jsRuntime) => { const { instantiate, getCoreModule, wasip2 } = options; const { cli, clocks, filesystem, io, random, sockets, http } = wasip2; + const environment = options.env ? { + ...cli.environment, + getEnvironment: () => Array.from( + new Map([ + ...cli.environment.getEnvironment(), + ...Object.entries(options.env ?? {}), + ]).entries(), + ), + } : cli.environment; const importObject = { "ruby:js/js-runtime": jsRuntime, - "wasi:cli/environment": cli.environment, + "wasi:cli/environment": environment, "wasi:cli/exit": cli.exit, "wasi:cli/stderr": cli.stderr, "wasi:cli/stdin": cli.stdin, diff --git a/packages/npm-packages/ruby-wasm-wasi/test-e2e/examples/examples.spec.ts b/packages/npm-packages/ruby-wasm-wasi/test-e2e/examples/examples.spec.ts index 362695cd7..d5cab7cd6 100644 --- a/packages/npm-packages/ruby-wasm-wasi/test-e2e/examples/examples.spec.ts +++ b/packages/npm-packages/ruby-wasm-wasi/test-e2e/examples/examples.spec.ts @@ -10,6 +10,29 @@ import { readFileSync } from "fs"; import http from "http"; import https from "https"; +const ruby4OrLater = () => { + const packageName = path.basename( + process.env.RUBY_NPM_PACKAGE_ROOT ?? "ruby-head-wasm-wasi", + ); + if (packageName.startsWith("ruby-head-")) { + return true; + } + + const match = packageName.match(/^ruby-(\d+)\.(\d+)-wasm-wasi/); + if (!match) { + return false; + } + + return Number(match[1]) >= 4; +}; + +const wasiPreview1 = () => { + const packageName = path.basename( + process.env.RUBY_NPM_PACKAGE_ROOT ?? "ruby-head-wasm-wasi", + ); + return packageName.endsWith("-wasm-wasi"); +}; + test.beforeEach(async ({ context, page }) => { setupDebugLog(context); setupUncaughtExceptionRejection(page); @@ -55,6 +78,26 @@ test("lucky.html is healthy", async ({ page }) => { expect(result).toMatch(/(Lucky|Unlucky)/); }); +test.describe("ruby-box.html", () => { + // Ruby::Box hooks require/load and copies extension libraries to a temporary + // file before loading them. The browser WASI Preview 2 setup does not + // currently provide writable /tmp, so the Ruby::Box example is limited to + // WASI Preview 1. + test.skip( + !ruby4OrLater() || !wasiPreview1(), + "Ruby::Box browser example requires Ruby 4.0 or later with WASI Preview 1. WASI Preview 2 does not currently provide writable /tmp.", + ); + + test("is healthy", async ({ page }) => { + await page.goto("/ruby-box.html"); + await waitForRubyVM(page); + await expect(page.locator("#enabled")).toHaveText( + "Ruby::Box.enabled?: true", + ); + await expect(page.locator("#constant")).toHaveText("box::X: 123"); + }); +}); + test("script-src/index.html is healthy", async ({ page }) => { const messages: string[] = []; page.on("console", (msg) => messages.push(msg.text())); @@ -80,7 +123,7 @@ if (process.env.RUBY_NPM_PACKAGE_ROOT) { await page.goto("/require_relative/index.html"); await waitForRubyVM(page); - while (!messages.some((msg) => /Hello, world\!/.test(msg))) { + while (!messages.some((msg) => /Hello, world\!/.test(msg))) { await page.waitForEvent("console"); } }); diff --git a/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/data-env.spec.ts b/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/data-env.spec.ts new file mode 100644 index 000000000..c29add017 --- /dev/null +++ b/packages/npm-packages/ruby-wasm-wasi/test-e2e/integrations/data-env.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from "@playwright/test"; + +import { + setupDebugLog, + setupProxy, + setupUncaughtExceptionRejection, + resolveBinding, +} from "../support"; + +if (!process.env.RUBY_NPM_PACKAGE_ROOT) { + test.skip("skip", () => {}); +} else { + test.beforeEach(async ({ context, page }) => { + setupDebugLog(context); + setupProxy(context); + setupUncaughtExceptionRejection(page); + }); + + test.describe("data-env", () => { + test("passes environment variables to the Ruby VM", async ({ page }) => { + const resolve = await resolveBinding(page, "checkResolved"); + await page.setContent(` + + + `); + expect(await resolve()).toBe("ok,a=b"); + }); + }); +}