Skip to content

Commit 9074c20

Browse files
feat(stdlib): WasmValue + wasm_export_call typed wasm-exports binding (closes #455) (#467)
## Summary Tier 1 #5 of the AS bindings top-50 roadmap (#446). Owner's [#455-decided](#455 (comment)) **Option B**: generic \`wasm_export_call(exports, name, args: [WasmValue]) -> WasmValue\` with \`WasmValue\` as a tagged scalar carrier covering all four wasm numeric kinds (i32, i64, f32, f64). ## Encoding decision \`WasmValue\` lands as an OPAQUE \`pub extern type\` rather than a true AS sum type. Rationale: the JS interop boundary needs a hand-written marshaller pairing \`wv_i32(42) -> { kind: "i32", v: 42 }\` with the export-call dispatch \`__as_wasm_export_call(exports, name, args)\`. Mirrors the existing \`WasmExports\` opaque pattern. A true sum-type variant on top of this opaque base ships in a follow-up once \`json.affine\`-style tagged-variant codegen lands for the Deno-ESM backend. Per #455 "weaker static safety acknowledged trade-off; typed wrappers will land as a follow-up sub-issue once usage patterns crystallise." ## What this PR ships ### \`stdlib/Deno.affine\` (+87 lines) - \`pub extern type WasmValue\` - Constructors: \`wv_i32\` / \`wv_i64\` / \`wv_f32\` / \`wv_f64\` - Accessors: \`wv_as_int\` / \`wv_as_float\` / \`wv_kind\` - \`wasm_export_call(exports, name, args: [WasmValue]) -> WasmValue\` - Worked example: \`addI32ViaWasm(bytes, a, b)\` in the docstring ### \`lib/codegen_deno.ml\` (+47 lines) - JS prelude: 8 \`__as_wv_*\` / \`__as_wasm_export_call\` helpers - \`deno_builtins\` dispatch table: 8 entries - BigInt for i64 (preserves precision beyond 2^53); \`Math.fround\` for f32 - Return wraps as f64 by default (lossless for any numeric); i64 returns detected via \`typeof result === "bigint"\` and wrapped as i64 ## What's deferred (per #455 implementation-scope breakdown) Each independently shippable now that the Deno.affine surface is in place; will file follow-up tracking issues after merge: - Zig FFI implementation for the native backend - Idris2 ABI pattern doc (Zig=APIs/FFIs, Idris2=ABIs convention) - \`examples/wasm-exports-demo.affine\` end-to-end demo - Smoke-test through the Zig FFI ## Owner-directive compliance - Adds 8 externs in the WebAssembly section adjacent to existing \`wasmCall\`. - Adds 8 codegen dispatch entries in the existing \`let () = ...\` block. - Pure additive change; no existing surface modified. - Owner Option B confirmed in #455 comment 2026-05-30 13:18Z. ## Test plan - [ ] CI build job (\`opam exec -- dune build\`) green - [ ] CI \`dune runtest\` green - [ ] CI \`tools/run_codegen_deno_tests.sh\` green (existing wasm tests stay unaffected) - [ ] Manual smoke: load a tiny wasm module emitting an \`add(i32, i32) -> i32\` export; call via \`wasm_export_call\` with \`[wv_i32(2), wv_i32(3)]\`; verify \`wv_as_int(result)\` returns 5. Defer to follow-up PR with example file + harness. ## Refs - closes #455 (Tier 1 #5, scope: Deno.affine + JS codegen) - #446 — AS bindings top-50 umbrella - \`stdlib/Deno.affine:155-176\` — existing \`wasmCall\` / \`wasmInstance\` surface for context 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 666795b commit 9074c20

2 files changed

Lines changed: 134 additions & 0 deletions

File tree

lib/codegen_deno.ml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,44 @@ const __as_regexMatch = (s, pat) => new RegExp(pat).test(String(s));
171171
const __as_wasmInstance = (bytes) =>
172172
new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports;
173173
const __as_wasmCall = (exports, name, args) => Number(exports[name](...(args || [])));
174+
// ---- WasmValue (Deno.affine #455Tier 1 #5, Option B) ----
175+
// Opaque tagged value crossing the AS/JS boundary as `{ kind, v }`.
176+
// `kind` is one of "i32" | "i64" | "f32" | "f64". The `v` payload is
177+
// `BigInt` for i64 (preserves precision beyond 2^53), `Number` otherwise.
178+
const __as_wv_i32 = (n) => ({ kind: "i32", v: (Number(n) | 0) });
179+
const __as_wv_i64 = (n) => ({ kind: "i64", v: BigInt(n) });
180+
const __as_wv_f32 = (f) => ({ kind: "f32", v: Math.fround(Number(f)) });
181+
const __as_wv_f64 = (f) => ({ kind: "f64", v: Number(f) });
182+
const __as_wv_as_int = (v) => {
183+
if (v == null) return 0;
184+
if (typeof v.v === "bigint") {
185+
// i64: truncate to safe-integer Number; caller's responsibility for
186+
// precision-sensitive paths (use wv_kind to detect).
187+
return Number(v.v);
188+
}
189+
// i32 / f32 / f64: truncate toward zero per AS Int semantics.
190+
return (Number(v.v) | 0);
191+
};
192+
const __as_wv_as_float = (v) => {
193+
if (v == null) return 0;
194+
return typeof v.v === "bigint" ? Number(v.v) : Number(v.v);
195+
};
196+
const __as_wv_kind = (v) => (v && typeof v.kind === "string") ? v.kind : "";
197+
const __as_wasm_export_call = (exports, name, args) => {
198+
// Unmarshal AS-side [WasmValue] to raw JS scalars for the wasm call.
199+
const rawArgs = (args || []).map((wv) => {
200+
if (wv == null) return 0;
201+
// i64 payload is BigInt; wasm i64 imports accept BigInt directly.
202+
return wv.v;
203+
});
204+
const result = exports[name](...rawArgs);
205+
// Wrap return as f64 (lossless for any numeric; callers expecting i32/i64
206+
// can rebuild via wv_i32(wv_as_int(result)) or inspect wv_kind).
207+
if (typeof result === "bigint") {
208+
return { kind: "i64", v: result };
209+
}
210+
return { kind: "f64", v: Number(result) };
211+
};
174212
// ---- motion (bindings #4): consumer-provided import ----
175213
// Host JS environment must expose globalThis.__as_motion (the motion
176214
// library or a compatible mock). Tests set it in the harness before
@@ -431,6 +469,15 @@ let () =
431469
b "regexMatch" (fun a -> Printf.sprintf "__as_regexMatch(%s, %s)" (arg 0 a) (arg 1 a));
432470
b "wasmInstance" (fun a -> Printf.sprintf "__as_wasmInstance(%s)" (arg 0 a));
433471
b "wasmCall" (fun a -> Printf.sprintf "__as_wasmCall(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
472+
(* WasmValue constructors / accessors / typed export call — #455. *)
473+
b "wv_i32" (fun a -> Printf.sprintf "__as_wv_i32(%s)" (arg 0 a));
474+
b "wv_i64" (fun a -> Printf.sprintf "__as_wv_i64(%s)" (arg 0 a));
475+
b "wv_f32" (fun a -> Printf.sprintf "__as_wv_f32(%s)" (arg 0 a));
476+
b "wv_f64" (fun a -> Printf.sprintf "__as_wv_f64(%s)" (arg 0 a));
477+
b "wv_as_int" (fun a -> Printf.sprintf "__as_wv_as_int(%s)" (arg 0 a));
478+
b "wv_as_float" (fun a -> Printf.sprintf "__as_wv_as_float(%s)" (arg 0 a));
479+
b "wv_kind" (fun a -> Printf.sprintf "__as_wv_kind(%s)" (arg 0 a));
480+
b "wasm_export_call" (fun a -> Printf.sprintf "__as_wasm_export_call(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
434481
(* ---- motion (bindings #4) ---- *)
435482
b "motionAnimate" (fun a -> Printf.sprintf "__as_motionAnimate(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
436483
b "motionAwait" (fun a -> Printf.sprintf "(await __as_motionAwait(%s))" (arg 0 a));

stdlib/Deno.affine

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,93 @@ pub extern fn wasmInstance(bytes: Bytes) -> WasmExports;
175175
/// };
176176
pub extern fn wasmCall(exports: WasmExports, name: String, args: [Float]) -> Float;
177177

178+
// ── WebAssembly typed export call (#455 — Tier 1 #5, Option B) ────
179+
//
180+
// Generic `wasm_export_call` covering any wasm signature including i64,
181+
// multi-typed args, future spec additions. Future-proof: no binding
182+
// change required as wasm evolves. Tiny addition vs Option A's ~30
183+
// per-signature variants. Typed wrappers can be layered on top of this
184+
// generic as ergonomic helpers in a follow-up sub-issue.
185+
//
186+
// Trade-off: weaker static safety at the call site — user marshals
187+
// manually via the `wv_*` constructors and reads via `wv_as_*`. Errors
188+
// (wrong arity, missing export, type mismatch) deferred to runtime per
189+
// owner's accepted trade-off in #455 comment.
190+
//
191+
// **Encoding decision (2026-05-30):** `WasmValue` lands as an OPAQUE
192+
// extern type rather than a true AffineScript sum type. Rationale:
193+
// the JS interop boundary needs a hand-written marshaller that pairs
194+
// `wv_i32(42) -> { tag: "i32", v: 42 }` with the export-call dispatch
195+
// `__as_wasm_export_call(exports, name, args)`. Mirrors the existing
196+
// `WasmExports` opaque pattern. A true sum-type variant on top of this
197+
// opaque base ships in a follow-up once `json.affine`-style tagged-
198+
// variant codegen lands for the Deno-ESM backend.
199+
200+
/// Opaque wasm scalar value. Constructed via `wv_i32` / `wv_i64` /
201+
/// `wv_f32` / `wv_f64`. Read via `wv_as_int` (i32/i64 → Int) or
202+
/// `wv_as_float` (f32/f64 → Float). The kind tag is opaque to AS code
203+
/// but inspectable host-side via `wv_kind` for diagnostics.
204+
pub extern type WasmValue;
205+
206+
/// Wrap an `Int` as a wasm i32. Truncates to the low 32 bits at the
207+
/// host boundary if `n` exceeds the i32 range.
208+
pub extern fn wv_i32(n: Int) -> WasmValue;
209+
210+
/// Wrap an `Int` as a wasm i64. Crosses the boundary as a `BigInt`
211+
/// host-side. Values outside the safe-integer range (>= 2^53) are
212+
/// preserved as BigInt; arithmetic on the AS side that goes through
213+
/// `wv_as_int` truncates to the safe-integer range.
214+
pub extern fn wv_i64(n: Int) -> WasmValue;
215+
216+
/// Wrap a `Float` as a wasm f32. Rounded to f32 precision via
217+
/// `Math.fround` at the host boundary.
218+
pub extern fn wv_f32(f: Float) -> WasmValue;
219+
220+
/// Wrap a `Float` as a wasm f64. Preserved at full f64 precision.
221+
pub extern fn wv_f64(f: Float) -> WasmValue;
222+
223+
/// Read a wasm scalar back as `Int`. Defined for both i32 and i64
224+
/// variants. For f32/f64, truncates toward zero. Caller is responsible
225+
/// for knowing the variant — there is no runtime check; reading the
226+
/// wrong kind silently coerces.
227+
pub extern fn wv_as_int(v: WasmValue) -> Int;
228+
229+
/// Read a wasm scalar back as `Float`. Defined for both f32 and f64
230+
/// variants. For i32/i64, converts via JS `Number()` — i64 values
231+
/// beyond 2^53 lose precision; caller can detect via `wv_kind`.
232+
pub extern fn wv_as_float(v: WasmValue) -> Float;
233+
234+
/// Return the kind tag ("i32" / "i64" / "f32" / "f64") for runtime
235+
/// dispatch when the AS-side caller doesn't statically know the
236+
/// variant. Use sparingly — the typed `wv_as_*` accessors should be
237+
/// the default path.
238+
pub extern fn wv_kind(v: WasmValue) -> String;
239+
240+
/// `exports[name](...args)` with typed `WasmValue` marshalling.
241+
/// Returns a `WasmValue` wrapping the export's return — kind is `f64`
242+
/// by default (the lossless choice for any numeric return); callers
243+
/// expecting i32/i64 should rebuild via `wv_i32(wv_as_int(result))`
244+
/// or inspect `wv_kind` host-side. Multi-value returns are out of
245+
/// scope at this binding — add a `wasm_export_call_multi` extern when
246+
/// needed.
247+
///
248+
/// Example:
249+
///
250+
/// use Deno::{
251+
/// Bytes, WasmExports, wasmInstance, wasm_export_call,
252+
/// wv_i32, wv_as_int,
253+
/// };
254+
///
255+
/// pub fn addI32ViaWasm(bytes: Bytes, a: Int, b: Int) -> Int {
256+
/// let exports = wasmInstance(bytes);
257+
/// let result = wasm_export_call(
258+
/// exports, "add", [wv_i32(a), wv_i32(b)]);
259+
/// wv_as_int(result)
260+
/// }
261+
pub extern fn wasm_export_call(
262+
exports: WasmExports, name: String, args: [WasmValue]
263+
) -> WasmValue;
264+
178265
// ── Array helper ───────────────────────────────────────────────────
179266
//
180267
// AffineScript has no mutable-array push primitive in this subset;

0 commit comments

Comments
 (0)