Files
skills/docs/api-stack/api/api-sandbox-wasm-host-bridge/src/wasi-stdio/wasi-stdio-frame.md
2026-05-29 08:20:56 +01:00

5.2 KiB

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

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

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.