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-narrowedPluginEnvelopeOut.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.stringifyrejected the envelope (e.g. it carried aBigInt).SAND_WASM_BRIDGE.FRAME_OVERSIZE— payload exceedsMAX_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
PluginEnvelopeInschema 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 invalidPluginEnvelopeIn.