Skip to content

feat(stdlib): WasmValue + wasm_export_call typed wasm-exports binding (closes #455) - #467

Merged
hyperpolymath merged 1 commit into
mainfrom
stdlib/wasm-export-call-455
May 30, 2026
Merged

feat(stdlib): WasmValue + wasm_export_call typed wasm-exports binding (closes #455)#467
hyperpolymath merged 1 commit into
mainfrom
stdlib/wasm-export-call-455

Conversation

@hyperpolymath

Copy link
Copy Markdown
Owner

Summary

Tier 1 #5 of the AS bindings top-50 roadmap (#446). Owner's #455-decided 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

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

🤖 Generated with Claude Code

…closes #455)

Tier 1 #5 of the AS bindings top-50 roadmap (#446). Owner's #455-decided
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).

Future-proof: covers any wasm signature including i64, multi-typed args,
future spec additions. No binding change required as wasm evolves. Tiny
addition vs Option A's ~30 per-signature variants. Typed wrappers can
be layered on top of this generic as ergonomic helpers in a follow-up
sub-issue.

## 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
that pairs `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 in stdlib/Deno.affine. 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.

## What this PR ships

- `stdlib/Deno.affine`: +87 lines
  * `pub extern type WasmValue`
  * `wv_i32` / `wv_i64` / `wv_f32` / `wv_f64` constructors
  * `wv_as_int` / `wv_as_float` / `wv_kind` accessors
  * `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 owner's #455 implementation-scope breakdown)

- Zig FFI implementation for the native backend — separate PR
- Idris2 ABI pattern doc (Zig=APIs/FFIs, Idris2=ABIs convention) — separate PR
- `examples/wasm-exports-demo.affine` end-to-end demo — separate PR
- Smoke-test through the Zig FFI — separate PR

Each of these is independently shippable now that the Deno.affine surface
is in place. Will file follow-up tracking issues after this lands.

## 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.

## Refs

- closes #455 (Tier 1 #5, scope: Deno.affine + JS codegen)
- #446 — AS bindings top-50 umbrella
- `project_affinescript_bindings_top50_roadmap.md` — memory tracker
- `stdlib/Deno.affine:155-176` — existing `wasmCall` / `wasmInstance`
  surface for context

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hyperpolymath
hyperpolymath enabled auto-merge (squash) May 30, 2026 15:32
@github-actions

Copy link
Copy Markdown

🔍 Hypatia Security Scan

Findings: 83 issues detected

Severity Count
🔴 Critical 4
🟠 High 11
🟡 Medium 68

⚠️ Action Required: Critical security issues found!

View findings
[
  {
    "reason": "Action perpolymath/standards/.github/workflows/governance-reusable.yml@main\n needs attention",
    "type": "unpinned_action",
    "file": "governance.yml",
    "action": "pin_sha",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Action ons/checkout@v6\n    needs attention",
    "type": "unpinned_action",
    "file": "publish-jsr.yml",
    "action": "pin_sha",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Action land/setup-deno@v2\n    needs attention",
    "type": "unpinned_action",
    "file": "publish-jsr.yml",
    "action": "pin_sha",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Issue in affine-vscode-publish.yml",
    "type": "unknown",
    "file": "affine-vscode-publish.yml",
    "action": "flag",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Issue in casket-pages.yml",
    "type": "unknown",
    "file": "casket-pages.yml",
    "action": "flag",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Issue in casket-pages.yml",
    "type": "unknown",
    "file": "casket-pages.yml",
    "action": "flag",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Issue in ci.yml",
    "type": "unknown",
    "file": "ci.yml",
    "action": "flag",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Issue in ci.yml",
    "type": "unknown",
    "file": "ci.yml",
    "action": "flag",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Issue in ci.yml",
    "type": "unknown",
    "file": "ci.yml",
    "action": "flag",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Issue in ci.yml",
    "type": "unknown",
    "file": "ci.yml",
    "action": "flag",
    "rule_module": "workflow_audit",
    "severity": "medium"
  }
]

Powered by Hypatia Neurosymbolic CI/CD Intelligence

@hyperpolymath
hyperpolymath merged commit 9074c20 into main May 30, 2026
19 of 25 checks passed
@hyperpolymath
hyperpolymath deleted the stdlib/wasm-export-call-455 branch May 30, 2026 16:29
hyperpolymath pushed a commit that referenced this pull request May 31, 2026
…s (refs #446)

The Tracking section carried two '(TBD — opened alongside this PR)'
placeholders for the umbrella tracker and per-tier child issues. Those
issues now exist, so the placeholders were stale per the doc's own
'do not let the table drift' rule:

- Umbrella tracker: #446
- Per-tier child issues: #450 (Tier 1) .. #454 (Tier 5)
- Shipped kickoff: #455 (WASM-exports calling pattern, via PR #467)

Completes the canonical-tracking-link portion of the #446 umbrella's
STEP 1 deliverable.
hyperpolymath pushed a commit that referenced this pull request May 31, 2026
…s (refs #446)

The Tracking section carried two '(TBD — opened alongside this PR)'
placeholders for the umbrella tracker and per-tier child issues. Those
issues now exist, so the placeholders were stale per the doc's own
'do not let the table drift' rule:

- Umbrella tracker: #446
- Per-tier child issues: #450 (Tier 1) .. #454 (Tier 5)
- Shipped kickoff: #455 (WASM-exports calling pattern, via PR #467)

Completes the canonical-tracking-link portion of the #446 umbrella's
STEP 1 deliverable.
hyperpolymath added a commit that referenced this pull request May 31, 2026
…ngs #1) (#502)

## Summary

Extends the Tier-1 PixiJS binding (#446 row #1) with the 11
most-load-bearing accessors + on/off pointer-event registration. This is
the **largest single chunk** of idaptik's `src/bindings/Pixi.res`
surface still not bound — 215 `src/app/*.res` files depend on Container
transforms and FederatedPointerEvent handlers, so this PR is a forward
unblocker for the ReScript→AffineScript migration.

## What lands

`stdlib/Pixi.affine` (+46 lines): 11 new `extern fn`s.

| Surface | Externs |
|---|---|
| Container transforms | `pixiContainerSetScale`, `SetPivot`,
`SetRotation`, `SetAlpha`, `SetZIndex`, `SetSortableChildren`,
`SetEventMode`, `SetCursor` |
| FederatedPointerEvent registration | `pixiContainerOn`,
`pixiContainerOff` |
| Sprite | `pixiSpriteSetAnchor` |

`lib/codegen_deno.ml` (+22 lines): 11 `__as_*` prelude helpers + 11
entries in the existing `deno_builtins` dispatch block, matching the
existing wasmCall/motion/pixi pattern.

`tests/codegen-deno/pixi_smoke.{affine,harness.mjs}` (+80 lines
combined): new `smokeAccessorsFlow` exercises every new extern via the
existing harness pattern. `MockContainer` grows `scale`/`pivot` Point
mocks + handler `Map` + the new fields; asserts handler identity is
preserved across `on(...)` → `off(...)`.

`docs/bindings-roadmap.adoc` row #1 status note expanded — Container 8.x
transform-and-event surface promoted from "deferred" to "landed";
remaining deferred items (typed `FederatedPointerEvent` accessors,
`parent` read accessor with Option-null handling, Point/Rectangle/Circle
helper types, sprite atlases, filters, hitArea) listed explicitly.

## Design notes

**Why `pub extern fn pixiContainerSetEventMode(c, mode: String)` rather
than a sum type?** Pixi 8's `eventMode` values are open strings
(`"static"` / `"dynamic"` / `"passive"` / `"none"` / `"auto"`). A sum
type would either freeze the set or require codegen-tagged-variant
lowering that doesn't yet exist on the Deno-ESM backend (deferred to
json.affine v0.3, mirroring the `WasmValue` decision in #467). Matches
the existing pattern in `stdlib/PixiUI.affine` for `slider.orientation`.

**Why `handler: Json`?** The `FederatedPointerEvent` reaches the handler
as a JS object; AffineScript-side code uses existing `Json` accessors to
read `e.global.x`, `e.target`, etc. A typed `FederatedPointerEvent`
extern type with dedicated accessor `extern fn`s is the natural
follow-up — captured in the roadmap row as a deferred item, not in this
PR's scope. The Json handler avoids forcing every caller through a typed
surface they may not want.

**Anchor is on Sprite, not Container.** Pixi 8 keeps the same split as
7.x — `Container` has no `anchor`. The binding mirrors that with
`pixiSpriteSetAnchor` rather than putting it on
`pixiContainerSetAnchor`.

## Test plan

- [x] `dune build bin/main.exe` — clean (only the expected parser
warnings)
- [x] `dune runtest` — 354 tests pass
- [x] `tools/run_codegen_deno_tests.sh` — all 17 harnesses including
extended `pixi_smoke.harness.mjs` OK
- [ ] CI build job
- [ ] CI `tools/run_codegen_deno_tests.sh` job
- [ ] CI governance + Hypatia (known baselines per repo CLAUDE.md may be
red — those are not from this PR)

## Refs

- Umbrella: #446 (Tier 1 — idaptik blockers)
- Tier-1 sub-issue: #450
- Row updated: `docs/bindings-roadmap.adoc` row #1
- Prior PixiJS-related PRs for context: #429 (restart on Deno-ESM), #435
(@pixi/ui MVP), #436 (motion ●), #437 (@pixi/sound)
- Compile-time pattern doc: `docs/specs/zig-ffi-patterns.adoc` (PR #474
— non-conflicting siblings)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
hyperpolymath added a commit that referenced this pull request May 31, 2026
) (#506)

## Summary

Ships the first concrete surface for Tier-1 #9 of #446 — **IPC /
structuredClone** for host↔guest message passing. This is the binding
`idaptik-ums` Gossamer IPC for level I/O depends on, and the pattern any
embedded-engine binding in the estate needs.

## What lands

`stdlib/Ipc.affine` (+105 lines, new module): 2 extern types + 9 extern
fns covering MessageChannel construction, port handoff, post +
onmessage, start/close lifecycle, generic `targetPostMessage`, and
`structuredCloneValue`.

| Surface | Externs |
|---|---|
| Channel | `MessageChannel` / `MessagePort` opaque types;
`messageChannelNew` / `messageChannelPort1` / `messageChannelPort2` |
| Port | `messagePortPostMessage` / `messagePortOnMessage` /
`messagePortStart` / `messagePortClose` |
| Cross-context | `targetPostMessage` (Worker / iframe.contentWindow /
self-from-worker) |
| Deep-clone | `structuredCloneValue` |

`lib/codegen_deno.ml` (+23 lines): 9 `__as_*` prelude helpers + 9
dispatch entries adjacent to the pixiSound block. No consumer init —
`MessageChannel` / `MessagePort` / `structuredClone` are standard
web-platform globals.

`tests/codegen-deno/ipc_smoke.{affine,harness.mjs}` (+98 lines
combined): port-pair postMessage round-trip with handler-identity
preservation, standalone close lifecycle, target-post stub, and
structuredClone deep-copy with reference-distinctness assertions across
nested arrays + objects.

`docs/bindings-roadmap.adoc` row #9 promoted `○ → ◑`; deferred items
listed.

## Design notes

**Why is `MessagePort` an opaque `extern type` instead of a
record/tagged-union?** Same reason `WasmExports` and `WasmValue` (#467)
are opaque — the Deno-ESM backend doesn't yet have tagged-variant
codegen (deferred to json.affine v0.3), and a real `MessagePort` carries
internal worker-thread state that isn't usefully observable from
AffineScript anyway. The handler observes the `MessageEvent` as opaque
`Json` and reads `event.data` via the existing Json accessors. A typed
`MessageEvent` extern-type with dedicated accessor externs is the
natural follow-up axis, captured in the roadmap deferred-items list.

**Why is the host responsible for closing the ports?** Inline `close()`
calls in `smokeChannelFlow` would race the microtask-async delivery —
MessagePort drops queued messages on close. The fixture surfaces this as
a documented authoring pattern (it lives in a top-of-file comment) so
anyone writing IPC code with this binding doesn't get bitten.

**Why `setTimeout(50)` in the harness instead of
`setImmediate`/microtask flush?** Empirically verified — Node 20's
`worker_threads`-backed `MessageChannel` batches delivery beyond a
single setImmediate tick. The comment in the harness records the test
that surfaced it (a standalone `node` repl reproducer with the same
shape).

**Why no `transfer` list yet?** Owner directive at #455 (Option-B
kickoff scope) — ship the generic surface, layer typed-and-richer
variants as follow-ups once usage patterns crystallise. Same pattern as
`WasmValue` — opaque tagged scalar first, typed wrappers next.

## Test plan

- [x] `dune build bin/main.exe` — clean (only the expected parser
warnings)
- [x] `dune runtest` — 354 tests pass
- [x] `tools/run_codegen_deno_tests.sh` — all 18 harnesses including the
new `ipc_smoke.harness.mjs` OK
- [ ] CI build job
- [ ] CI `tools/run_codegen_deno_tests.sh` job
- [ ] CI governance + Hypatia (known baselines per repo CLAUDE.md may be
red — not from this PR)

## Refs

- Umbrella: #446 (Tier 1 — idaptik blockers)
- Tier-1 sub-issue: #450
- Row updated: `docs/bindings-roadmap.adoc` row #9
- Pattern siblings: #467 (`wasm_export_call` Option-B), #474 (Zig-FFI
patterns doc), #502 (PixiJS 8.x Container)
- Adjacent: Tier-3 #25 Web Workers — `Worker` constructors would consume
this surface

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant