diff --git a/docs/cheat_sheet.md b/docs/cheat_sheet.md index e75580402..e22aeedb7 100644 --- a/docs/cheat_sheet.md +++ b/docs/cheat_sheet.md @@ -46,6 +46,17 @@ 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 + +``` + +The `data-env` value must be a JSON object string whose values are strings. + 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..9bf3a79b6 --- /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..b18a7ed49 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 = parseDataEnv(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 = parseDataEnv(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,66 @@ const deriveEvalStyle = (tag: Element): "async" | "sync" => { return rawEvalStyle; }; +/** + * Parses the data-env attribute as a JSON object string, for example: + * data-env='{"RUBY_BOX":"1","MSG":"hello world"}' + * + * The parsed value must be a non-null object, not an array. Each key must not + * contain "=" or NUL, and each value must be a string. + * + * JSON is used instead of a shell-like KEY=value list so keys and values can + * contain spaces and values can contain "=" without custom escaping rules. + */ +const parseDataEnv = (tag: Element | null): Record => { + const rawEnv = tag?.getAttribute("data-env"); + if (!rawEnv) { + return {}; + } + + const trimmedEnv = rawEnv.trim(); + if (!trimmedEnv) { + return {}; + } + + let parsedEnv: unknown; + try { + parsedEnv = JSON.parse(trimmedEnv); + } catch (error) { + console.warn(`data-env must be a JSON object string. ${rawEnv} is ignored.`); + return {}; + } + + if ( + typeof parsedEnv !== "object" || + parsedEnv === null || + Array.isArray(parsedEnv) + ) { + console.warn(`data-env must be a JSON object string. ${rawEnv} is ignored.`); + return {}; + } + + return Object.entries(parsedEnv).reduce>( + (env, [key, value]) => { + if (key.includes("=") || key.includes("\0")) { + console.warn( + `data-env key must not contain "=" or NUL. ${key} is ignored.`, + ); + return env; + } + + // POSIX environment values may contain arbitrary bytes, but data-env only accepts strings. + if (typeof value !== "string") { + console.warn(`data-env value for ${key} must be a string. It is ignored.`); + return env; + } + + env[key] = value; + 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..22f632322 --- /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,hello world"); + }); + }); +}