6.2 KiB
WASI-stdio ABI variant for the WASM host bridge
Package: @suaveplan/api-sandbox-wasm-host-bridge (extension) + @suaveplan/api-types/sandbox-bridge (additive)
Status: PROPOSED
Type: extension + types-additive
Why
The shipped wasm host-bridge (commit a9fa31db95) accepts WASM modules exporting six custom-shaped functions (__suaveplan_alloc(i32) → i32, __suaveplan_handle_request(i32,i32) → i64, etc.) plus memory. Producing such a module by compiling TypeScript is the job of @suaveplan/wasm-compiler-javy.
When the build agent investigated against Javy v8.1.1 (Bytecode Alliance, current latest), it surfaced an architectural blocker: Javy cannot emit custom-shaped WASM exports. Its -C wit=... flag restricts WIT-exposed exports to "function exports with no arguments and no return values" (verified via javy build -C help). Javy's host-interaction model is fixed: the host writes input to fd 0 (stdin), reads output from fd 1 (stdout). This is the WASI standard interface and is the only ABI Javy honors.
Three resolutions were considered (full transcript in the agent's stop report); the chosen path is to extend the bridge to accept either the existing direct-export ABI OR a WASI-stdio ABI variant. The compiler then becomes a thin Javy wrapper that emits Javy's native artifact — no outer-WASM authorship, no Component Model migration, no bridge-spec rewrite for the existing variant.
Concrete consumer
@suaveplan/wasm-compiler-javy(rewritten in a sibling proposal) — every TS→WASM artifact it emits speaks the WASI-stdio variant.- Plugin authors who choose Javy as their toolchain (the recommended TS→WASM path).
e2e/foundation-stack-fullStage-8 closure smoke — once both proposals ship,api-plugin-exampledeploys under the wasm tier via Javy + WASI-stdio ABI.
Host bridge
The bridge surface gains one additive option. The default remains the current direct-export ABI so already-shipped consumers see no behaviour change.
type WasmAbiVariant = "direct-i32-i64" | "wasi-stdio";
createSandboxedWasmPluginModule({
sandbox,
manifest,
abiVariant: "wasi-stdio", // default: "direct-i32-i64"
});
When abiVariant === "wasi-stdio", the bridge:
- Skips the six-export ABI validation (those exports won't exist).
- Routes every request through the WASI-stdio framing protocol defined below.
- Treats the WASM module as a long-running guest process: a single
_startcall (the WASI entry point) launches the plugin, which then enters a request loop reading from fd 0.
End-to-end test
packages/api-stack/api/api-sandbox-wasm-host-bridge/tests/integration/wasi-stdio-flow.test.ts— instantiates a hand-encoded WASM fixture that speaks the WASI-stdio protocol, runs the conformance harness against it, and asserts: route discovery via{kind:"routes"}envelope; request round-trip via{kind:"request"}/{kind:"response"}; error envelope surfaces as SuaveplanError host-side; lifecycle commands ({kind:"start"}/{kind:"dispose"}) idempotent.
What changes
Shared protocol contract (additive to @suaveplan/api-types/sandbox-bridge)
New types alongside the existing SerializedRequest/SerializedResponse/SerializedRoute. Wire format is length-prefixed JSON frames so the protocol survives any WASI implementation's buffering semantics.
// Each envelope is a single WASI-stdio frame:
// [u32 BE length][JSON bytes of length 'length']
export type PluginEnvelopeIn =
| { readonly kind: "request"; readonly request: SerializedRequest }
| { readonly kind: "routes" }
| { readonly kind: "start" }
| { readonly kind: "dispose" };
export type PluginEnvelopeOut =
| { readonly kind: "response"; readonly response: SerializedResponse }
| { readonly kind: "routes"; readonly routes: readonly SerializedRoute[] }
| { readonly kind: "ok" }
| { readonly kind: "error"; readonly code: string; readonly message: string };
export const WASI_STDIO_PROTOCOL_VERSION: 1;
The contract lives in api-types because both bridges (host-side framer) and Javy-compiled plugins (sandbox-side framer, via the suaveplan ABI shim) MUST agree on the wire shape down to the byte. Putting it in either runtime package would re-introduce the SerializedRequest/Response divergence the orchestrator just resolved.
Bridge runtime (extends @suaveplan/api-sandbox-wasm-host-bridge)
src/wasi-stdio/wasi-stdio.ts— the framer: length-prefix encode/decode, Zod-validated envelope schemas, frame-too-large guard (SAND_WASM_BRIDGE.FRAME_OVERSIZE).src/wasi-stdio/wasi-stdio-runner.ts— wraps aWasmSandboxHandleconfigured for WASI-stdio: writes envelopes to fd 0, reads from fd 1, surfaces stderr lines as logger events.src/bridge/bridge.ts—createSandboxedWasmPluginModulebecomes aswitch (abiVariant). The existing direct-i32-i64 path is renamedsrc/abi-direct/and the new path issrc/abi-wasi-stdio/. Routes returned by{kind:"routes"}envelope feed into the sameBridgedPluginshape.- New error codes:
SAND_WASM_BRIDGE.FRAME_OVERSIZE,SAND_WASM_BRIDGE.PROTOCOL_MISMATCH,SAND_WASM_BRIDGE.STDIO_TIMEOUT. The shipped 7 codes are preserved. - ABI validation: under
abiVariant: "wasi-stdio", validatememory+_start+ WASI imports (wasi_snapshot_preview1.{fd_read, fd_write, environ_get, environ_sizes_get, ...}). Missing →SAND_WASM_BRIDGE.INVALID_PLUGIN_SURFACEwithcontext.missing. - Trap classification, capability-gate routing, idempotent lifecycle: all preserved — but lifecycle commands now go through
{kind:"start"}/{kind:"dispose"}envelopes, not direct export calls.
Conformance harness (extends)
runWasmHostBridgeConformance({ buildBridge, abiVariant }) gains an abiVariant parameter. Both variants must pass all five existing invariants; the harness picks the right fixture WASM based on the variant.
Out of scope
- No change to
@suaveplan/api-types/sandbox-bridge'sSerializedRequest/SerializedResponse/SerializedRoute. They round-trip unchanged through both variants. - No change to
@suaveplan/api-sandbox-vm-host-bridge. The vm tier uses structuredClone, not stdio. - No deprecation of the direct-i32-i64 variant. Both stay first-class.