Skip to content

Commit 8abac17

Browse files
authored
feat(wasm): closes #76 — runtime WebAssembly host (PoC, wasmi) (#705)
* feat(wasm): closes #76 — runtime WebAssembly host (PoC, wasmi) Lets a binary built for `--target macos/linux/windows/ios/android` load third-party `.wasm` modules at runtime via `WebAssembly.instantiate(...)` — closing the host-side gap with Node and matching the issue's PoC scope. Engine: wasmi 0.50 (pure-Rust interpreter), in a separate `perry-wasm-host` crate so the default Perry build never pulls wasmi in. Linking is auto-detected when codegen sees any `WebAssembly.*` reference; the `--enable-wasm-runtime` flag stays available to force-link for dlopen/FFI scenarios that have no static reference. Surface (Perry MVP shape; standard async surface tracked as follow-up): const bytes = embedWasm("./add.wasm"); // compile-time embed const inst = WebAssembly.instantiate(bytes); // sync, opaque handle inst.exports.add(2, 3); // standard JS shape WebAssembly.callExport(inst, "add", 2, 3); // explicit helper WebAssembly.validate(bytes); // bool `inst.exports.<method>(...)` is recognised syntactically when the local was tagged at var-decl time as a wasm instance (init was `WebAssembly.instantiate(...)`) — this avoids stealing unrelated `module.exports.foo()` calls from CJS aggregators. `embedWasm("...")` reads the file at HIR-lower time relative to the importing source and bakes the bytes into the binary as a `Uint8Array` literal (sidesteps the in-flight TC39 import-attributes proposal per maintainer preference on the issue thread). The `js_webassembly_*` shims in perry-runtime forward to a stable C ABI on perry-wasm-host. perry-runtime never depends on wasmi at the cargo level — link-time resolution only. Programs without any `WebAssembly.*` usage stay exactly the same size (verified: 0.9MB vs 1.9MB on the test). Validation: - cargo test -p perry-wasm-host: 2/2 (validate + add 2+3=5) - test-files/test_wasm_add.ts compiles without --enable-wasm-runtime (auto-detected) and prints OK - workspace test suite green (excluding cross-host UI crates per CLAUDE.md's exclusion list) - default build (no WebAssembly.*) compiles + runs unchanged Out of scope (follow-ups, not this PR): - Standard `Promise<{module, instance}>` async surface - Host imports beyond numeric (externref, strings, structs) - `instantiateStreaming` - Wasmtime engine selection (`--enable-wasm-runtime=wasmtime`) - WASI preview-1 (`--enable-wasi`) - `--target wasm` / `--target web` passthrough to the browser host - AOT WASM → native (gkgoat1's suggestion in the thread) - Configurable `Memory` max pages / per-instance fuel - `import attributes` form (`with { type: "wasm" }`) - Note in `docs/src/plugins/overview.md` on dylib-vs-WASM trust model * fix(#76): merge main + close CI gaps (Module field, stable_hash, wasm-host feature gate) - perry-codegen-arkts: add missing uses_webassembly: false in two Module {} initialisers (cargo-test, harmonyos-smoke E0063). - perry-hir/stable_hash.rs: include uses_webassembly in Module destructuring/hash, and add SH arms for the three Expr::WebAssembly* variants (compile errors after merging main, which added stable_hash.rs). - perry-runtime: gate `pub mod webassembly` behind a new `wasm-host` Cargo feature. The shim TU references `perry_wasm_host_*`; with codegen-units = 1, libperry_runtime.a was a single .o and the linker pulled the shim in for every binary, so non-wasm programs failed with "undefined reference to perry_wasm_host_*" (doc-tests + harmonyos-smoke linker errors). - perry/compile/optimized_libs.rs: push `perry-runtime/wasm-host` into cross_features when ctx.needs_wasm_runtime, and fold the flag into the perry-auto target-dir hash so a wasm program isn't served a cached non-wasm libperry_runtime.a (which lacks js_webassembly_*), and vice versa. - cargo fmt — collapse the long single-line use re-export in compile.rs and the .d.ts include_str! in types.rs, plus rustfmt reflow inside expr.rs / runtime_decls.rs / lower_decl.rs / expr_call.rs / webassembly.rs / perry-wasm-host/lib.rs (lint job). Merging main also picks up the updated `.github/workflows/security-audit.yml` (drops `--deny warnings`, ignores the 3 acknowledged RUSTSEC IDs), which clears the audit failures.
1 parent 8fc837f commit 8abac17

31 files changed

Lines changed: 1430 additions & 5 deletions

File tree

Cargo.lock

Lines changed: 53 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ members = [
4646
"crates/perry-ext-streams",
4747
"crates/perry-ext-fastify",
4848
"crates/perry-jsruntime",
49+
"crates/perry-wasm-host",
4950
"crates/perry-stdlib",
5051
"crates/perry-diagnostics",
5152
"crates/perry-ui",
@@ -119,6 +120,7 @@ default-members = [
119120
"crates/perry-ext-streams",
120121
"crates/perry-ext-fastify",
121122
"crates/perry-jsruntime",
123+
"crates/perry-wasm-host",
122124
"crates/perry-stdlib",
123125
"crates/perry-diagnostics",
124126
"crates/perry-ui",
@@ -291,6 +293,7 @@ perry-ext-fastify = { path = "crates/perry-ext-fastify" }
291293
perry-stdlib = { path = "crates/perry-stdlib" }
292294
perry-diagnostics = { path = "crates/perry-diagnostics" }
293295
perry-jsruntime = { path = "crates/perry-jsruntime" }
296+
perry-wasm-host = { path = "crates/perry-wasm-host" }
294297
perry-codegen-js = { path = "crates/perry-codegen-js" }
295298
perry-codegen-swiftui = { path = "crates/perry-codegen-swiftui" }
296299
perry-codegen-arkts = { path = "crates/perry-codegen-arkts" }

crates/perry-codegen-arkts/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7908,6 +7908,7 @@ mod tests {
79087908
exported_functions: vec![],
79097909
widgets: vec![],
79107910
uses_fetch: false,
7911+
uses_webassembly: false,
79117912
init_was_unrolled: false,
79127913
extern_funcs: vec![],
79137914
}

crates/perry-codegen-arkts/tests/phase2_full_app_smoke.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ fn empty_module() -> Module {
4646
exported_functions: vec![],
4747
widgets: vec![],
4848
uses_fetch: false,
49+
uses_webassembly: false,
4950
init_was_unrolled: false,
5051
extern_funcs: vec![],
5152
}

crates/perry-codegen/src/expr.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5384,6 +5384,89 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
53845384
// we'd link in; sentinel keeps the compile-pass count up.
53855385
Expr::MathRandom => Ok(ctx.block().call(DOUBLE, "js_math_random", &[])),
53865386

5387+
// ── WebAssembly host (issue #76) ──────────────────────────────
5388+
// The runtime shims (perry-runtime/src/webassembly.rs) handle
5389+
// bytes extraction, instance handles, and error reporting. The
5390+
// wasmi engine itself is in the optional `perry-wasm-host`
5391+
// crate, only linked when the user passes
5392+
// `--enable-wasm-runtime`. Programs that never call these
5393+
// builtins never reference the runtime shims, so the linker
5394+
// dead-strips them and `perry_wasm_host_*` is never demanded.
5395+
Expr::WebAssemblyValidate(bytes) => {
5396+
let v = lower_expr(ctx, bytes)?;
5397+
Ok(ctx
5398+
.block()
5399+
.call(DOUBLE, "js_webassembly_validate", &[(DOUBLE, &v)]))
5400+
}
5401+
Expr::WebAssemblyInstantiate(bytes) => {
5402+
let v = lower_expr(ctx, bytes)?;
5403+
Ok(ctx
5404+
.block()
5405+
.call(DOUBLE, "js_webassembly_instantiate", &[(DOUBLE, &v)]))
5406+
}
5407+
Expr::WebAssemblyCallExport {
5408+
instance,
5409+
name,
5410+
args,
5411+
} => {
5412+
let inst = lower_expr(ctx, instance)?;
5413+
let name_v = lower_expr(ctx, name)?;
5414+
let lowered_args: Vec<String> = args
5415+
.iter()
5416+
.map(|a| lower_expr(ctx, a))
5417+
.collect::<Result<Vec<_>>>()?;
5418+
let blk = ctx.block();
5419+
match lowered_args.len() {
5420+
0 => Ok(blk.call(
5421+
DOUBLE,
5422+
"js_webassembly_call_export_0",
5423+
&[(DOUBLE, &inst), (DOUBLE, &name_v)],
5424+
)),
5425+
1 => Ok(blk.call(
5426+
DOUBLE,
5427+
"js_webassembly_call_export_1",
5428+
&[
5429+
(DOUBLE, &inst),
5430+
(DOUBLE, &name_v),
5431+
(DOUBLE, &lowered_args[0]),
5432+
],
5433+
)),
5434+
2 => Ok(blk.call(
5435+
DOUBLE,
5436+
"js_webassembly_call_export_2",
5437+
&[
5438+
(DOUBLE, &inst),
5439+
(DOUBLE, &name_v),
5440+
(DOUBLE, &lowered_args[0]),
5441+
(DOUBLE, &lowered_args[1]),
5442+
],
5443+
)),
5444+
3 => Ok(blk.call(
5445+
DOUBLE,
5446+
"js_webassembly_call_export_3",
5447+
&[
5448+
(DOUBLE, &inst),
5449+
(DOUBLE, &name_v),
5450+
(DOUBLE, &lowered_args[0]),
5451+
(DOUBLE, &lowered_args[1]),
5452+
(DOUBLE, &lowered_args[2]),
5453+
],
5454+
)),
5455+
_ => Ok(blk.call(
5456+
DOUBLE,
5457+
"js_webassembly_call_export_4",
5458+
&[
5459+
(DOUBLE, &inst),
5460+
(DOUBLE, &name_v),
5461+
(DOUBLE, &lowered_args[0]),
5462+
(DOUBLE, &lowered_args[1]),
5463+
(DOUBLE, &lowered_args[2]),
5464+
(DOUBLE, &lowered_args[3]),
5465+
],
5466+
)),
5467+
}
5468+
}
5469+
53875470
// `JSON.stringify(value, replacer, indent)` — full form via
53885471
// runtime `js_json_stringify_full` which handles array/function
53895472
// replacers, indent spaces, circular detection (throws

crates/perry-codegen/src/runtime_decls.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,34 @@ pub fn declare_phase_b_strings(module: &mut LlModule) {
435435
module.declare_function("js_string_compare", I32, &[I64, I64]);
436436
module.declare_function("js_jsvalue_to_string_radix", I64, &[DOUBLE, I32]);
437437
module.declare_function("js_math_random", DOUBLE, &[]);
438+
// WebAssembly host runtime (issue #76). All take/return NaN-boxed
439+
// doubles (JSValues). Implementations live in
440+
// `perry-runtime/src/webassembly.rs` and forward to
441+
// `perry-wasm-host`'s C ABI; the wasmi engine is only linked when
442+
// the user passes `--enable-wasm-runtime`.
443+
module.declare_function("js_webassembly_validate", DOUBLE, &[DOUBLE]);
444+
module.declare_function("js_webassembly_instantiate", DOUBLE, &[DOUBLE]);
445+
module.declare_function("js_webassembly_call_export_0", DOUBLE, &[DOUBLE, DOUBLE]);
446+
module.declare_function(
447+
"js_webassembly_call_export_1",
448+
DOUBLE,
449+
&[DOUBLE, DOUBLE, DOUBLE],
450+
);
451+
module.declare_function(
452+
"js_webassembly_call_export_2",
453+
DOUBLE,
454+
&[DOUBLE, DOUBLE, DOUBLE, DOUBLE],
455+
);
456+
module.declare_function(
457+
"js_webassembly_call_export_3",
458+
DOUBLE,
459+
&[DOUBLE, DOUBLE, DOUBLE, DOUBLE, DOUBLE],
460+
);
461+
module.declare_function(
462+
"js_webassembly_call_export_4",
463+
DOUBLE,
464+
&[DOUBLE, DOUBLE, DOUBLE, DOUBLE, DOUBLE, DOUBLE],
465+
);
438466
module.declare_function("js_console_log_spread", VOID, &[I64]);
439467
module.declare_function("js_console_error_spread", VOID, &[I64]);
440468
module.declare_function("js_console_warn_spread", VOID, &[I64]);

crates/perry-hir/src/ir.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@ pub struct Module {
225225
pub widgets: Vec<WidgetDecl>,
226226
/// Whether this module uses fetch() — requires perry-stdlib for js_fetch_with_options
227227
pub uses_fetch: bool,
228+
/// Whether this module references `WebAssembly.*` (issue #76). Drives
229+
/// auto-link of `libperry_wasm_host.a` so users don't have to remember
230+
/// `--enable-wasm-runtime` when they actually use the API.
231+
pub uses_webassembly: bool,
228232
/// External FFI function declarations (name, param_types, return_type)
229233
/// Populated from `declare function` statements with no body.
230234
pub extern_funcs: Vec<(String, Vec<Type>, Type)>,
@@ -1359,6 +1363,21 @@ pub enum Expr {
13591363

13601364
/// performance.now() -> number (high-resolution time in ms)
13611365
PerformanceNow,
1366+
1367+
// WebAssembly host (issue #76). MVP surface — see
1368+
// `crates/perry-runtime/src/webassembly.rs` for the FFI shape.
1369+
/// `WebAssembly.validate(bytes)` -> boolean
1370+
WebAssemblyValidate(Box<Expr>),
1371+
/// `WebAssembly.instantiate(bytes)` -> opaque instance handle (Perry
1372+
/// MVP shape — sync, no Promise, no `{module, instance}` pair).
1373+
WebAssemblyInstantiate(Box<Expr>),
1374+
/// `WebAssembly.callExport(instance, name, ...args)` — Perry-specific
1375+
/// helper for invoking numeric exports (see issue #76 PoC scope).
1376+
WebAssemblyCallExport {
1377+
instance: Box<Expr>,
1378+
name: Box<Expr>,
1379+
args: Vec<Expr>,
1380+
},
13621381
/// atob(base64) -> string
13631382
Atob(Box<Expr>),
13641383
/// btoa(string) -> string
@@ -2478,6 +2497,7 @@ impl Module {
24782497
exported_functions: Vec::new(),
24792498
widgets: Vec::new(),
24802499
uses_fetch: false,
2500+
uses_webassembly: false,
24812501
extern_funcs: Vec::new(),
24822502
init_was_unrolled: false,
24832503
}

crates/perry-hir/src/lower.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ pub struct LoweringContext {
182182
pub(crate) module_native_instances: Vec<(String, String, String)>,
183183
/// Whether this module uses fetch() — requires perry-stdlib
184184
pub(crate) uses_fetch: bool,
185+
/// Issue #76 — set when any `WebAssembly.*` HIR variant is lowered.
186+
pub(crate) uses_webassembly: bool,
185187
pub(crate) var_hoisted_ids: HashSet<LocalId>,
186188
/// Shadow index: function name -> index in `functions` Vec (last entry for shadowing)
187189
pub(crate) functions_index: HashMap<String, usize>,
@@ -222,6 +224,12 @@ pub struct LoweringContext {
222224
/// HIR variants which read the runtime's thread-local exec metadata.
223225
pub(crate) regex_exec_locals: HashSet<String>,
224226
pub(crate) proxy_locals: HashSet<String>,
227+
/// Issue #76 — locals known to hold a WebAssembly instance handle (i.e.
228+
/// `const x = WebAssembly.instantiate(...)`). Used to route
229+
/// `x.exports.<method>(...)` to `Expr::WebAssemblyCallExport` only when
230+
/// the receiver is a tracked instance, avoiding false matches against
231+
/// CJS-style `module.exports.foo()` patterns.
232+
pub(crate) wasm_instance_locals: HashSet<String>,
225233
pub(crate) proxy_revoke_locals: HashMap<String, String>,
226234
/// For `const p = new Proxy(ClassName, handler)`, record the class name
227235
/// so `new p(args)` can fold to `new ClassName(args)` (pragmatic — lets
@@ -344,6 +352,7 @@ impl LoweringContext {
344352
current_namespace: None,
345353
module_native_instances: Vec::new(),
346354
uses_fetch: false,
355+
uses_webassembly: false,
347356
var_hoisted_ids: HashSet::new(),
348357
functions_index: HashMap::new(),
349358
classes_index: HashMap::new(),
@@ -358,6 +367,7 @@ impl LoweringContext {
358367
iterator_func_for_class: std::collections::HashMap::new(),
359368
regex_exec_locals: HashSet::new(),
360369
proxy_locals: HashSet::new(),
370+
wasm_instance_locals: HashSet::new(),
361371
proxy_revoke_locals: HashMap::new(),
362372
proxy_target_classes: HashMap::new(),
363373
class_expr_aliases: HashMap::new(),
@@ -2225,6 +2235,7 @@ pub fn lower_module_full(
22252235
}
22262236

22272237
module.uses_fetch = ctx.uses_fetch;
2238+
module.uses_webassembly = ctx.uses_webassembly;
22282239
module.extern_funcs = ctx.extern_func_types.clone();
22292240

22302241
// Post-pass: widen `mutable_captures` across sibling closures. When two
@@ -7277,6 +7288,7 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result<
72777288
&& name != "atob"
72787289
&& name != "btoa"
72797290
&& name != "BigInt"
7291+
&& name != "WebAssembly"
72807292
{
72817293
eprintln!(
72827294
" Warning: unknown identifier '{}' — assuming global; member access will dispatch by name at runtime, bare reads lower to 0",

0 commit comments

Comments
 (0)