# `wasi-stdio-frame` ## Purpose Owns the WASI-stdio wire format: a single length-prefixed JSON frame exchanged on the guest's standard streams. Encodes outgoing `PluginEnvelopeIn` values into `[u32 BE length][JSON bytes]` byte buffers and decodes incoming frames back into Zod-validated `PluginEnvelopeOut` values. Every failure mode surfaces as a stable `SAND_WASM_BRIDGE.*` code so the caller can branch on `code` without parsing messages. The encoder refuses oversize frames BEFORE allocating the output buffer, so no partial writes ever reach fd 0. The decoder enforces the same 16 MiB ceiling on incoming frames AND validates the JSON payload against the schema for `PluginEnvelopeOut`, preventing a malformed sandbox reply from propagating into the host pipeline. ## Features - `encodeWasiStdioFrame(envelope, pluginName)` — emit a single frame ready to write into fd 0 in one syscall. - `decodeWasiStdioFrame(bytes, pluginName)` — parse one frame and validate its envelope against the strict Zod schema. Returns a type-narrowed `PluginEnvelopeOut`. - `PluginEnvelopeInSchema` / `PluginEnvelopeOutSchema` — Zod schemas re-exported for callers that need to validate other byte sources (e.g. integration tests, replay tooling). - `MAX_WASI_STDIO_FRAME_BYTES` — 16 MiB ceiling per direction. - Six distinct decoder failure reasons each carry a specific `context.reason`: `short_header`, `length_mismatch`, `invalid_utf8`, `invalid_json`, `shape_mismatch`, plus the oversize case under its own code. ## Usage: Basic ```ts import { decodeWasiStdioFrame, encodeWasiStdioFrame, } from "@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio"; const frame = encodeWasiStdioFrame({ kind: "routes" }, "example"); // → write frame to fd 0 declare const incoming: Uint8Array; const reply = decodeWasiStdioFrame(incoming, "example"); if (reply.kind === "routes") { for (const route of reply.routes) { // route.method, route.path } } ``` ## Usage: Advanced ```ts import { decodeWasiStdioFrame, encodeWasiStdioFrame, MAX_WASI_STDIO_FRAME_BYTES, PluginEnvelopeOutSchema, } from "@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio"; import { SandboxWasmBridgeError } from "@suaveplan/api-sandbox-wasm-host-bridge/error-codes"; // Catch the oversize-frame case BEFORE the write hits the guest: try { const frame = encodeWasiStdioFrame( { kind: "request", request: buildHugeRequest() }, "example", ); void frame; } catch (err) { if ( err instanceof SandboxWasmBridgeError && err.code === "SAND_WASM_BRIDGE.FRAME_OVERSIZE" ) { // Surface a 413 to the caller without touching the guest. } } // Use the raw schema to validate replay-buffer bytes outside the // runtime path: declare const replayBytes: Uint8Array; declare function buildHugeRequest(): never; const view = new DataView( replayBytes.buffer, replayBytes.byteOffset, replayBytes.byteLength, ); const length = view.getUint32(0, false); if (length <= MAX_WASI_STDIO_FRAME_BYTES) { const result = PluginEnvelopeOutSchema.safeParse( JSON.parse( new TextDecoder().decode(replayBytes.subarray(4, 4 + length)), ), ); void result; } declare function decodeRoundtrip(): void; void decodeRoundtrip; void decodeWasiStdioFrame; ``` ## API Reference ### `encodeWasiStdioFrame(envelope, pluginName): Uint8Array` Throws: - `SAND_WASM_BRIDGE.MARSHAL_FAILED` — `JSON.stringify` rejected the envelope (e.g. it carried a `BigInt`). - `SAND_WASM_BRIDGE.FRAME_OVERSIZE` — payload exceeds `MAX_WASI_STDIO_FRAME_BYTES`. ### `decodeWasiStdioFrame(bytes, pluginName): PluginEnvelopeOut` Throws `SAND_WASM_BRIDGE.PROTOCOL_MISMATCH` for `short_header`, `length_mismatch`, `invalid_utf8`, `invalid_json`, `shape_mismatch`; `SAND_WASM_BRIDGE.FRAME_OVERSIZE` when the declared length exceeds the ceiling. ### `MAX_WASI_STDIO_FRAME_BYTES` `16 * 1024 * 1024` — fixed by the protocol. ### `PluginEnvelopeInSchema` / `PluginEnvelopeOutSchema` Zod schemas for the two envelope unions. Strict object shapes; `safeParse` returns the discriminated union after passing. ## Implementation Notes - Length is encoded big-endian (`DataView.setUint32(..., false)`) to match every other length-prefix wire format in the Suaveplan stack (CBOR, drive-template manifests). - The encoder refuses oversize frames BEFORE allocating the output buffer — emitting a partial header followed by truncated payload would leave the guest's fd 0 in an unrecoverable state and force a sandbox restart. - The decoder rejects extra bytes implicitly by ignoring anything past `HEADER_BYTES + length` — the runner is expected to slice exactly one frame's worth of bytes per call. Extra bytes after a valid frame are the caller's responsibility (they belong to the NEXT frame). - Zod schemas are pinned at module-load time so each encode/decode is allocation-stable past the first invocation. - The `PluginEnvelopeIn` schema is exported for callers that need to validate envelopes they constructed by hand (e.g. test factories authoring corrupt payloads); the production encode path does NOT validate outgoing envelopes — type-narrowed callers cannot construct an invalid `PluginEnvelopeIn`.