From bb24b9441bc11604881c1dae762d7188a9e822de Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Thu, 18 Jun 2026 01:01:09 +0000 Subject: [PATCH] feat: Node.js adapter loading --- crates/cli/tests/build.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ crates/core/src/adapter.js | 16 ++++++++++++++++ docs/internal/specs/15_js_host_abi.md | 8 ++++++++ docs/internal/tasks/15_js_host_abi.md | 4 ++-- docs/website/development/js_abi_contract.md | 7 +++++++ crates/cli/src/commands/builder.rs | 4 +++- crates/cli/src/commands/compiler.rs | 2 +- crates/cli/tests/fixtures/node_load.mjs | 8 ++++++++ 8 file(s) changed, 93 insertion(s)(+), 4 deletion(s)(-) diff --git a/crates/cli/tests/build.rs b/crates/cli/tests/build.rs --- a/crates/cli/tests/build.rs +++ b/crates/cli/tests/build.rs @@ -439,6 +439,54 @@ let _ = fs::remove_dir_all(temp); } +#[test] +fn compile_nodejs_target_emits_adapter_and_loads_generated_wasm() { + let temp = unique_temp_dir("regulus_cli_nodejs_load"); + let out_dir = temp.join("out"); + 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"); + + let output = Command::new(env!("CARGO_BIN_EXE_reggie")) + .arg("compile") + .arg(&input) + .arg("--target") + .arg("nodejs") + .arg("--out-dir") + .arg(&out_dir) + .output() + .expect("run reggie compile"); + + assert!( + output.status.success(), + "compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(out_dir.join("app.wasm").is_file()); + assert!(out_dir.join("app.mjs").is_file()); + + let adapter = fs::read_to_string(out_dir.join("app.mjs")).expect("read node adapter"); + assert!(adapter.contains("async function initNode"), "{adapter}"); + + fs::write(out_dir.join("smoke.mjs"), include_str!("fixtures/node_load.mjs")).expect("write node smoke test"); + + let smoke = Command::new("node") + .arg(out_dir.join("smoke.mjs")) + .output() + .expect("run node load smoke test"); + + assert!( + smoke.status.success(), + "node smoke failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&smoke.stdout), + String::from_utf8_lossy(&smoke.stderr) + ); + + let _ = fs::remove_dir_all(temp); +} + fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() diff --git a/crates/core/src/adapter.js b/crates/core/src/adapter.js --- a/crates/core/src/adapter.js +++ b/crates/core/src/adapter.js @@ -57,6 +57,22 @@ } /** + * Instantiate a Node.js-profile module from a filesystem `.wasm` artifact. + * + * Relative paths are resolved next to this generated adapter. URL, bytes, and + * precompiled module inputs follow the same rules as `init`. + * + * @param {URL|string|ArrayBuffer|ArrayBufferView|WebAssembly.Module} wasm Wasm + * module, bytes, URL, or filesystem path. Defaults to the generated sibling + * `.wasm` file. + * @param {Record>} imports Host imports. + * @returns {Promise} The instantiated Wasm exports. + */ +export async function initNode(wasm = defaultWasmUrl, imports = {}) { + return init(wasm, imports); +} + +/** * Instantiate a browser-profile module with the standard browser imports. * * @param {URL|string|ArrayBuffer|ArrayBufferView|WebAssembly.Module} wasm Wasm diff --git a/docs/internal/specs/15_js_host_abi.md b/docs/internal/specs/15_js_host_abi.md --- a/docs/internal/specs/15_js_host_abi.md +++ b/docs/internal/specs/15_js_host_abi.md @@ -165,6 +165,14 @@ Node support is useful for CLI smoke tests, local examples, and host-side tests 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. + ## Diagnostics Unsupported JS host imports, exports, value shapes, opaque handle uses, target diff --git a/docs/internal/tasks/15_js_host_abi.md b/docs/internal/tasks/15_js_host_abi.md --- a/docs/internal/tasks/15_js_host_abi.md +++ b/docs/internal/tasks/15_js_host_abi.md @@ -101,8 +101,8 @@ ### Node.js profile -- [ ] Define Node.js loading for generated or packaged `.wasm` files. -- [ ] Implement Node.js loading for generated or packaged `.wasm` files. +- [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. diff --git a/docs/website/development/js_abi_contract.md b/docs/website/development/js_abi_contract.md --- a/docs/website/development/js_abi_contract.md +++ b/docs/website/development/js_abi_contract.md @@ -233,6 +233,13 @@ 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. + Non-JS targets use different modules and are outside this contract. Wasmtime uses `env`, and WASI uses `wasi_snapshot_preview1`. diff --git a/crates/cli/src/commands/builder.rs b/crates/cli/src/commands/builder.rs --- a/crates/cli/src/commands/builder.rs +++ b/crates/cli/src/commands/builder.rs @@ -114,7 +114,9 @@ echo::status("wasm", format!("{} ({} bytes)", output.display(), wasm.bytes.len())); if matches!( target, - compiler_core::target::CompileTarget::Browser | compiler_core::target::CompileTarget::Bundler + compiler_core::target::CompileTarget::Browser + | compiler_core::target::CompileTarget::Bundler + | compiler_core::target::CompileTarget::Nodejs ) { let adapter_path = super::artifact_path(self.out_dir.as_deref(), &output, &artifact_base, "mjs"); let adapter = compiler_core::adapter::js_adapter_for_module( diff --git a/crates/cli/src/commands/compiler.rs b/crates/cli/src/commands/compiler.rs --- a/crates/cli/src/commands/compiler.rs +++ b/crates/cli/src/commands/compiler.rs @@ -69,7 +69,7 @@ "wasm", format!("{} ({} bytes)", output.display(), compiled.wasm.bytes.len()), ); - if matches!(self.target, Target::Browser | Target::Bundler) { + if matches!(self.target, Target::Browser | Target::Bundler | Target::Nodejs) { let adapter_path = super::artifact_path(self.out_dir.as_deref(), &output, artifact_base, "mjs"); let adapter = compiler_core::adapter::js_adapter_for_module( output diff --git a/crates/cli/tests/fixtures/node_load.mjs b/crates/cli/tests/fixtures/node_load.mjs new file mode 100644 --- /dev/null +++ b/crates/cli/tests/fixtures/node_load.mjs @@ -0,0 +1,8 @@ +import { callString, initNode } from "./app.mjs"; + +await initNode(); + +const result = callString("main", "hello"); +if (result !== "hello") { + throw new Error(`unexpected result: ${result}`); +} -- tangled.sh