From 94dddacc963bb7e80a358c391551e4676a43c7aa Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Wed, 17 Jun 2026 20:08:22 -0500 Subject: [PATCH] feat: add stable Node host imports to JS ABI --- crates/cli/tests/build.rs | 9 +++- crates/cli/tests/fixtures/node_load.mjs | 31 +++++++++++-- crates/core/src/adapter.js | 42 +++++++++++++++++- crates/core/src/wasm.rs | 1 + crates/core/src/wasm/tests.rs | 43 +++++++++++++++++++ docs/internal/specs/15_js_host_abi.md | 21 ++++++--- docs/internal/tasks/15_js_host_abi.md | 8 ++-- docs/website/development/index.md | 2 +- .../{js_abi_contract.md => js_abi.md} | 25 ++++++++--- package.json | 1 + 10 files changed, 159 insertions(+), 24 deletions(-) rename docs/website/development/{js_abi_contract.md => js_abi.md} (92%) diff --git a/crates/cli/tests/build.rs b/crates/cli/tests/build.rs index 43d97d8..8a2c805 100644 --- a/crates/cli/tests/build.rs +++ b/crates/cli/tests/build.rs @@ -446,7 +446,13 @@ fn compile_nodejs_target_emits_adapter_and_loads_generated_wasm() { fs::create_dir_all(&out_dir).expect("create output dir"); let input = temp.join("app.gleam"); - fs::write(&input, "pub fn main(input: String) -> String { input }\n").expect("write Gleam input"); + fs::write( + &input, + r#"external fn env_get(key: String) -> String = "nodejs" "env.get" +pub fn main(input: String) -> String { env_get(input) } +"#, + ) + .expect("write Gleam input"); let output = Command::new(env!("CARGO_BIN_EXE_reggie")) .arg("compile") @@ -469,6 +475,7 @@ fn compile_nodejs_target_emits_adapter_and_loads_generated_wasm() { let adapter = fs::read_to_string(out_dir.join("app.mjs")).expect("read node adapter"); assert!(adapter.contains("async function initNode"), "{adapter}"); + assert!(adapter.contains("function createNodeImports"), "{adapter}"); fs::write(out_dir.join("smoke.mjs"), include_str!("fixtures/node_load.mjs")).expect("write node smoke test"); diff --git a/crates/cli/tests/fixtures/node_load.mjs b/crates/cli/tests/fixtures/node_load.mjs index 898f054..b08597d 100644 --- a/crates/cli/tests/fixtures/node_load.mjs +++ b/crates/cli/tests/fixtures/node_load.mjs @@ -1,8 +1,31 @@ -import { callString, initNode } from "./app.mjs"; +import { abi, callString, createNodeImports, initNode, nodeImportNames } from "./app.mjs"; -await initNode(); +if (abi.imports["nodejs.env.get"].params.join(",") !== "String") { + throw new Error("missing Node import ABI metadata"); +} + +const nodeImports = createNodeImports({ + env: { REGULUS_NODE_SMOKE: "hello from node" }, + now() { + return 1234; + }, +}); + +if (nodeImports.nodejs[nodeImportNames.envGet]("REGULUS_NODE_SMOKE") !== "hello from node") { + throw new Error("Node env import did not read configured value"); +} +if (nodeImports.nodejs[nodeImportNames.timeNow]() !== 1234n) { + throw new Error("Node time import returned an unexpected value"); +} + +await initNode(undefined, {}, { + env: { REGULUS_NODE_SMOKE: "hello from node" }, + now() { + return 1234; + }, +}); -const result = callString("main", "hello"); -if (result !== "hello") { +const result = callString("main", "REGULUS_NODE_SMOKE"); +if (result !== "hello from node") { throw new Error(`unexpected result: ${result}`); } diff --git a/crates/core/src/adapter.js b/crates/core/src/adapter.js index 7901eb4..b18b19b 100644 --- a/crates/core/src/adapter.js +++ b/crates/core/src/adapter.js @@ -29,6 +29,14 @@ export const browserImportNames = Object.freeze({ onlineIsOnline: "online.isOnline", }); +/** + * Stable Node.js-profile import function names. + */ +export const nodeImportNames = Object.freeze({ + envGet: "env.get", + timeNow: "time.now", +}); + /** * Instantiate the Regulus Wasm module and wrap host imports through the JS ABI. * @@ -66,10 +74,11 @@ export async function init(wasm = defaultWasmUrl, imports = {}) { * module, bytes, URL, or filesystem path. Defaults to the generated sibling * `.wasm` file. * @param {Record>} imports Host imports. + * @param {object=} options Node API overrides passed to `createNodeImports`. * @returns {Promise} The instantiated Wasm exports. */ -export async function initNode(wasm = defaultWasmUrl, imports = {}) { - return init(wasm, imports); +export async function initNode(wasm = defaultWasmUrl, imports = {}, options = {}) { + return init(wasm, mergeImports(createNodeImports(options), imports)); } /** @@ -140,6 +149,35 @@ export function createBrowserImports(options = {}) { }; } +/** + * Build the standard Node.js-profile imports. + * + * Returned functions use JavaScript values. `initNode` wraps them through the + * generated ABI metadata before passing them to WebAssembly.instantiate. + * + * @param {object=} options Node API overrides for tests or custom hosts. + * @param {Record=} options.env Environment map. + * Defaults to `process.env` when available. + * @param {Function=} options.now Clock implementation. Defaults to + * `Date.now` and returns Unix time in milliseconds. + * @returns {{ nodejs: Record }} Node import module. + */ +export function createNodeImports(options = {}) { + const env = options.env ?? globalThis.process?.env ?? {}; + const now = options.now ?? (() => Date.now()); + + return { + nodejs: { + [nodeImportNames.envGet](key) { + return env[String(key)] ?? ""; + }, + [nodeImportNames.timeNow]() { + return BigInt(Math.trunc(Number(now()))); + }, + }, + }; +} + /** * Call an exported Gleam function using compiler-generated ABI metadata. * diff --git a/crates/core/src/wasm.rs b/crates/core/src/wasm.rs index a552ee6..2471f58 100644 --- a/crates/core/src/wasm.rs +++ b/crates/core/src/wasm.rs @@ -123,6 +123,7 @@ impl WasmTarget { | "debug_bool" | "debug_value" ), + Self::Nodejs if module == "nodejs" => matches!(name, "env.get" | "time.now"), _ => true, } } diff --git a/crates/core/src/wasm/tests.rs b/crates/core/src/wasm/tests.rs index 7769135..1467d48 100644 --- a/crates/core/src/wasm/tests.rs +++ b/crates/core/src/wasm/tests.rs @@ -1092,6 +1092,49 @@ pub fn main(i: Int, f: Float, b: Bool, s: String) -> String { host(i, f, b, s) } compile_wasm_target(source, CompileTarget::Bundler).expect("stable JS ABI shapes should compile"); } +#[test] +fn nodejs_target_accepts_stable_node_import_names() { + let wasm = compile_wasm_target( + r#"external fn env_get(key: String) -> String = "nodejs" "env.get" +external fn time_now() -> Int = "nodejs" "time.now" +pub fn main(key: String) -> String { + let _ = time_now() + env_get(key) +}"#, + CompileTarget::Nodejs, + ) + .expect("compile Node.js external imports"); + + assert!( + wasm.wat + .contains("(import \"nodejs\" \"env.get\" (func (type 0) (param i32) (result i32)))"), + "{}", + wasm.wat + ); + assert!( + wasm.wat + .contains("(import \"nodejs\" \"time.now\" (func (type 1) (result i64)))"), + "{}", + wasm.wat + ); +} + +#[test] +fn nodejs_target_rejects_unknown_node_import_names() { + let diagnostics = compile_wasm_target( + r#"external fn read(path: String) -> String = "nodejs" "fs.read" +pub fn main(path: String) -> String { read(path) }"#, + CompileTarget::Nodejs, + ) + .expect_err("unknown Node.js import should be rejected"); + + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic + .message + .contains("imports host function `nodejs.fs.read`, but target Nodejs does not allow that import name") + })); +} + #[test] fn emits_string_export_adapters_for_host_boundaries() { let wasm = compile_wasm("pub fn greeting() { \"hello\" }"); diff --git a/docs/internal/specs/15_js_host_abi.md b/docs/internal/specs/15_js_host_abi.md index 5644652..15776fb 100644 --- a/docs/internal/specs/15_js_host_abi.md +++ b/docs/internal/specs/15_js_host_abi.md @@ -167,11 +167,22 @@ that should not require a browser. Node glue is emitted as an ES module next to the generated `.wasm` file. The default Wasm location is the sibling `.wasm` URL resolved from -`import.meta.url`. The adapter exposes `initNode(wasm, imports)`, which accepts -the default sibling artifact, a relative or absolute filesystem path, a `file:` -URL, bytes, or a precompiled `WebAssembly.Module`. File-backed loads use -`node:fs/promises` and then instantiate with the same checked import wrapping -and export helpers as the browser and bundler profiles. +`import.meta.url`. The adapter exposes `initNode(wasm, imports, options)`, +which accepts the default sibling artifact, a relative or absolute filesystem +path, a `file:` URL, bytes, or a precompiled `WebAssembly.Module`. +File-backed loads use `node:fs/promises` and then instantiate with the same +checked import wrapping and export helpers as the browser and bundler profiles. + +The first stable Node-specific imports are: + +| Import module | Import name | Gleam ABI shape | JavaScript behavior | +| ------------- | ----------- | --------------- | ------------------- | +| `nodejs` | `env.get` | `String -> String` | Reads an environment key and maps missing values to the empty string. | +| `nodejs` | `time.now` | `Nil -> Int` | Returns Unix time in milliseconds. | + +Node glue exposes `createNodeImports(options)` for those APIs. `initNode` +merges those standard imports with any additional application imports before +calling the shared checked JS ABI instantiation path. ## Diagnostics diff --git a/docs/internal/tasks/15_js_host_abi.md b/docs/internal/tasks/15_js_host_abi.md index eba941e..d27f922 100644 --- a/docs/internal/tasks/15_js_host_abi.md +++ b/docs/internal/tasks/15_js_host_abi.md @@ -6,7 +6,7 @@ Define and implement the first usable JavaScript host ABI for Regulus. This is the primary usability milestone before broad stdlib completion. The public ABI contract lives in -[JavaScript host ABI contract](../../website/development/js_abi_contract.md). +[JavaScript host ABI contract](../../website/development/js_abi.md). ## Milestone slice @@ -103,9 +103,9 @@ The public ABI contract lives in - [x] Define Node.js loading for generated or packaged `.wasm` files. - [x] Implement Node.js loading for generated or packaged `.wasm` files. -- [ ] Add Node.js-profile validation for allowed external modules and names. -- [ ] Add Node.js glue that instantiates Wasm with checked imports. -- [ ] Add a Node.js smoke test for one string import and one string export. +- [x] Add Node.js-profile validation for allowed external modules and names. +- [x] Add Node.js glue that instantiates Wasm with checked imports. +- [x] Add a Node.js smoke test for one string import and one string export. ### Generated or packaged JS glue diff --git a/docs/website/development/index.md b/docs/website/development/index.md index e0abe1e..3438ca2 100644 --- a/docs/website/development/index.md +++ b/docs/website/development/index.md @@ -24,7 +24,7 @@ Gleam source - [Runtime representation](./runtime-representation.md) - [Runtime memory](./runtime-memory.md) - [Wasm backend and runtime](./wasm-backend-and-runtime.md) -- [JavaScript host ABI contract](./js_abi_contract.md) +- [JavaScript host ABI contract](./js_abi.md) - [Project model](../reference/project-model-and-modules.md) [project-compilation]: ./projects.md diff --git a/docs/website/development/js_abi_contract.md b/docs/website/development/js_abi.md similarity index 92% rename from docs/website/development/js_abi_contract.md rename to docs/website/development/js_abi.md index 96d86ac..01da5be 100644 --- a/docs/website/development/js_abi_contract.md +++ b/docs/website/development/js_abi.md @@ -233,12 +233,24 @@ Browser-profile adapters also expose `initBrowserPage(wasm, imports, options)`, which merges those standard browser imports with additional application imports and instantiates the Wasm module. -Node-profile adapters expose `initNode(wasm, imports)`. The generated adapter -defaults to loading the sibling `.wasm` file resolved from `import.meta.url`. -Hosts may also pass a relative or absolute filesystem path, a `file:` URL, -bytes, or a precompiled `WebAssembly.Module`. Filesystem-backed loads use -`node:fs/promises`; after bytes are loaded, imports and exports use the same -checked JS ABI conversion as the browser and bundler profiles. +Node-profile glue exposes `createNodeImports(options)` for the first standard +Node APIs. It returns a `nodejs` import module with these names: + +| Import name | Gleam ABI shape | JavaScript behavior | +| ----------- | --------------- | ------------------- | +| `env.get` | `String -> String` | Reads an environment key and maps missing values to the empty string. | +| `time.now` | `Nil -> Int` | Returns Unix time in milliseconds. | + +The helper accepts `env` and `now` overrides so tests and custom Node hosts can +supply checked implementations. + +Node-profile adapters also expose `initNode(wasm, imports, options)`. The +generated adapter defaults to loading the sibling `.wasm` file resolved from +`import.meta.url`. Hosts may also pass a relative or absolute filesystem path, +a `file:` URL, bytes, or a precompiled `WebAssembly.Module`. Filesystem-backed +loads use `node:fs/promises`; after bytes are loaded, standard Node imports, +additional application imports, and exports use the same checked JS ABI +conversion as the browser and bundler profiles. Non-JS targets use different modules and are outside this contract. Wasmtime uses `env`, and WASI uses `wasi_snapshot_preview1`. @@ -329,7 +341,6 @@ This contract intentionally does not define: - writing structured JavaScript values into Gleam - structured import parameters or returns -- Node.js loading semantics - generated binding metadata Those pieces build on the scalar, string, module-name, and validation contract diff --git a/package.json b/package.json index 9cf2cbd..45a9817 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "docs:dev": "pnpm --filter regulus-docs docs:dev", "docs:build": "pnpm --filter regulus-docs docs:build", "docs:preview": "pnpm --filter regulus-docs docs:preview", + "docs:deploy": "pnpm --filter regulus-docs docs:deploy", "test:js": "pnpm --filter @regulus/js-tests test" } } -- 2.51.2