Files
suaveplan/openspec/changes/wasm-compiler-javy/proposal.md
2026-05-27 23:29:53 +01:00

98 lines
7.3 KiB
Markdown

# TS→WASM Compiler (Javy-backed, WASI-stdio variant)
**Package**: `@suaveplan/wasm-compiler-javy`
**Status**: PROPOSED (rewritten 2026-05-27 after architectural blocker — see §Why)
**Type**: new-package (compilation tooling)
**Depends on**: `api-sandbox-wasm-stdio-abi` (defines the wire protocol — SHIPPED at `cddccbdb7c`) AND `api-plugin-example-dual-shape` (provides the `/wasm` subpath the keystone test compiles — must ship first)
## Why
The original proposal sketched a direct-export ABI emitted by Javy (`__suaveplan_alloc`, `__suaveplan_handle_request`, etc.). The build agent surfaced an architectural blocker against Javy v8.1.1: Javy's `-C wit=...` flag restricts WIT-exposed exports to "function exports with no arguments and no return values", and Javy's host-interaction model is fundamentally WASI fd 0 / fd 1 (stdin / stdout). The direct-export ABI cannot be produced from a Javy-compiled JavaScript module without authoring a hand-encoded outer-WASM wrapper — a route the orchestrator rejected as too brittle.
The chosen path (orchestrator decision, 2026-05-27) is to extend `@suaveplan/api-sandbox-wasm-host-bridge` to accept a WASI-stdio ABI variant (sibling proposal `api-sandbox-wasm-stdio-abi`) and let this compiler be a **thin Javy wrapper**: bundle TS → inject a WASI-stdio ABI shim → invoke `javy build` → emit manifest. No outer-WASM authorship, no Component Model migration, no novel encoding work.
## Concrete consumer
- `@suaveplan/api-plugin-example` — TypeScript reference plugin. The keystone end-to-end test compiles it via this package and deploys the resulting WASM through the WASI-stdio bridge variant.
- `e2e/foundation-stack-full` Stage-8 closure smoke (gate 2.8) — calls `compilePluginToWasm({ pluginEntry: "<api-plugin-example>/src/wasm/index.ts" })` and asserts the produced artifact responds to a real HTTP request via `host.installPlugin` + the WASI-stdio bridge.
- Plugin operators authoring third-party TS plugins for the wasm tier.
## End-to-end test
- `tests/integration/api-plugin-example.test.ts` — compile the example plugin, instantiate via `api-sandbox-wasm-host-bridge` with `abiVariant: "wasi-stdio"`, deploy via `host.installPlugin`, exercise every declared route via a real HTTP request through `createTestHost`.
## What changes
### Compilation pipeline
```text
pluginEntry ──[1]──> bundled ESM JS ──[2]──> ABI-shimmed JS ──[3]──> .wasm ──[4]──> validate ──[5]──> manifest sidecar
Bun.build inject WASI-stdio loop javy build ABI check + Zod <basename>.wasm.json
```
1. **`Bun.build({ entrypoints, target: "browser", format: "esm" })`** — tree-shake + inline. Reject `node:*` specifiers with `WASM_COMPILER.BROWSER_INCOMPAT_DEPENDENCY` + `context.specifier`.
2. **Inject the WASI-stdio ABI shim** — prepend a generated header that:
- Reads from `process.stdin` (Javy maps this to fd 0) in length-prefix-framed loop.
- Validates each frame as `PluginEnvelopeIn` (Zod-validated runtime check, using the same Zod schemas exported by `@suaveplan/api-sandbox-wasm-host-bridge`).
- Dispatches by `kind`: `routes` → write the plugin's `routes` array; `request` → call the plugin's `handleRequest(request)` and write the response; `start` → call optional `start()` then `{kind:"ok"}`; `dispose` → call optional `dispose()` then `{kind:"ok"}` and exit.
- Writes length-prefixed `PluginEnvelopeOut` frames to `process.stdout` (Javy maps to fd 1).
- Surfaces thrown `SuaveplanError`-shaped values as `{kind:"error", code, message}` envelopes.
3. **`javy build <bundled.js> -o <output.wasm>`** — invokes the platform-appropriate `javy-cli-*` binary via `optionalDependencies` resolution. Non-zero exit → `WASM_COMPILER.JAVY_INVOCATION_FAILED` with `{exitCode, stderr (8 KiB trunc), command}`.
4. **`WebAssembly.compile(bytes)`** + reflection → assert `memory` + `_start` + the required `wasi_snapshot_preview1.*` imports. Missing → `WASM_COMPILER.ABI_VALIDATION_FAILED` + `context.missing`. This matches the WASI-stdio bridge variant's import allowlist exactly.
5. **Emit `<basename>.wasm.json` sidecar** — `{ routes, abiVersion, abiVariant: "wasi-stdio", javyVersion, compilerVersion, compiledAt (RFC 3339 UTC), sourceDigest (SHA-256 of pre-Javy bundle) }`. Empty `routes` → `WASM_COMPILER.MANIFEST_MISSING_ROUTES`. Routes are extracted at compile time by booting the WASM in a one-shot host-side WASI runner (`@bjorn3/browser_wasi_shim` or equivalent) and capturing the `{kind:"routes"}` reply.
### Package surface
```ts
import { compilePluginToWasm } from "@suaveplan/wasm-compiler-javy";
const result = await compilePluginToWasm({
pluginEntry: "/path/to/plugin/src/index.ts",
outDir: "/tmp/plugin-wasm",
optimizeFor: "size", // default "size" — passes -O to javy build
sourcemaps: false, // default false
javyVersion: "v8.1.1", // default catalog-pinned version
});
// result.wasmPath: string
// result.metadataPath: string
// result.sizeBytes: number
// result.compilationMs: number
```
### Platform-wrapper packages
5 thin-wrapper packages under `packages/devtools/tooling/`:
- `javy-cli-linux-x64`, `javy-cli-linux-arm64`, `javy-cli-darwin-x64`, `javy-cli-darwin-arm64`, `javy-cli-win32-x64`.
Each declares matching `os` + `cpu` in `package.json` so npm/bun skips installation on mismatched platforms. Binaries are fetched via a `postinstall` script from Bytecode Alliance's Javy GitHub releases at the catalog-pinned tag, with SHA-256 checksum verification of every download. Checksums are committed; binaries are not.
### CLI binary
`suaveplan-wasm-compile <entry> --out-dir <dir> [--optimize-for size|speed] [--sourcemaps]` — delegates to `compilePluginToWasm`. JSON result on stdout, structured error JSON on stderr, non-zero exit on any failure.
### OpenTelemetry
Meter `@suaveplan/wasm-compiler-javy`:
- counter `wasm_compiler.compiles_total{result}` where `result ∈ {"ok","bundle_failed","javy_failed","abi_failed","manifest_failed"}`.
- histogram `wasm_compiler.compile_duration_ms{result}`.
- counter `wasm_compiler.abi_validation_failures_total{missing_export}`.
### Conformance harness
`runWasmCompilerConformance({ compileFn })` from `./testing` with four invariants:
1. ABI completeness — every required WASI import + `_start` + `memory` present.
2. Deterministic output — same input + pinned Javy version → identical SHA-256.
3. Manifest fidelity — `routes` array matches the plugin's declared routes.
4. Cross-bridge deployability — produced WASM passes `runWasmHostBridgeConformance({ buildBridge, abiVariant: "wasi-stdio" })`.
### Error codes
`WASM_COMPILER.{BUNDLE_FAILED, BROWSER_INCOMPAT_DEPENDENCY, JAVY_PLATFORM_MISMATCH, JAVY_INVOCATION_FAILED, ABI_VALIDATION_FAILED, MANIFEST_MISSING_ROUTES}` — 6 codes via `defineCodes`, error class with **public constructor** (rule §19).
## Out of scope
- Direct-i32-i64 ABI compilation. That variant remains supported by `api-sandbox-wasm-host-bridge` but is not what this compiler emits — and is not required by `api-plugin-example` or any planned consumer.
- Component Model output. A future `wasm-compiler-component` package could target that surface; not in this scope.
- Compilation of non-suaveplan plugin shapes. The ABI shim is hard-coded to the suaveplan WASI-stdio protocol.