Writing QIP Components In Zig

Zig works well for QIP components because it can emit small freestanding WebAssembly without bringing a runtime, filesystem, or package graph along for the ride.

The tradeoff is that you are responsible for being explicit about the WebAssembly shape you want. For QIP, that means exporting a small ABI, using fixed buffers, and compiling with a maximum memory size.

Build With A Memory Maximum #

Compile Zig components with --max-memory so the component's worst-case linear memory is visible in the Wasm binary. For the cross-language resource policy, including fixed memory and memory.grow checks, see Hard Limits.

Without this flag, Zig can emit a memory with an initial size but no declared maximum. That still runs in qip, but it is harder to inspect and it fails stricter safety checks that require fixed memory. A maximum also keeps review honest: if a component needs 20 MiB, the build command says so.

Use a value that covers static buffers, stack, and compiler-required runtime space:

zig build-exe component.zig \
  -target wasm32-freestanding \
  -O ReleaseSmall \
  -fno-entry \
  -rdynamic \
  --max-memory=1048576 \
  -femit-bin=component.wasm

If the cap is too small, Zig/wasm-ld reports the required size. Raise the limit deliberately rather than using a very large default.

When not to use a tight cap:

Even then, add the cap before checking in the module.

Minimal Content Component #

This component accepts UTF-8 text and returns it unchanged. The example is simple so the ABI shape is visible.

const INPUT_CAP: usize = 64 * 1024;
const OUTPUT_CAP: usize = INPUT_CAP;

var input_buf: [INPUT_CAP]u8 = undefined;
var output_buf: [OUTPUT_CAP]u8 = undefined;

export fn input_ptr() u32 {
    return @as(u32, @intCast(@intFromPtr(&input_buf)));
}

export fn input_utf8_cap() u32 {
    return @as(u32, @intCast(INPUT_CAP));
}

export fn output_ptr() u32 {
    return @as(u32, @intCast(@intFromPtr(&output_buf)));
}

export fn output_utf8_cap() u32 {
    return @as(u32, @intCast(OUTPUT_CAP));
}

export fn render(input_size_in: u32) u32 {
    const input_size: usize = @intCast(input_size_in);
    if (input_size > INPUT_CAP) @trap();

    @memcpy(output_buf[0..input_size], input_buf[0..input_size]);
    return input_size_in;
}

Build it:

zig build-exe echo.zig \
  -target wasm32-freestanding \
  -O ReleaseSmall \
  -fno-entry \
  -rdynamic \
  --max-memory=1048576 \
  -femit-bin=echo.wasm

Run it:

printf 'hello' | qip run echo.wasm

Defaults We Prefer #

QIP components age well when their buffer sizes and memory use are obvious from the source.

Use these defaults unless the module has a concrete reason not to:

Avoid these by default:

This does not mean every useful component must be tiny. It means the cost of a component should be visible in constants and exports instead of discovered at runtime.

Choose The Right Buffers #

Use input_utf8_cap / output_utf8_cap for text and input_bytes_cap / output_bytes_cap for raw bytes.

input_utf8_cap is a precondition maintained by the host. Component code may assume that the complete input is valid UTF-8 and should not add another full validation pass only to establish that fact. Check sequence structure only when the transform already needs it to decode code points. If malformed UTF-8 reaches render, the host broke the contract and the component may trap.

output_utf8_cap is a guarantee made by the component. Emit valid UTF-8 on every successful return. Later UTF-8 stages may trust that guarantee without rescanning the output.

QIP checks the host side of the capacity contract before writing input and after render returns. The component should still check its own assumptions and trap when an invariant fails. That keeps bugs obvious and prevents accidental truncation.

Good defaults:

For assertion pass-through validators, copy the input unchanged or use the same pointer for input and output when the host contract allows it. In a fallible Content transaction, a validator accepts a wider input domain, returns normally, and uses commit to reject values which do not establish its output guarantee. A later transform can require that guarantee and trap if the caller breaks the precondition.

Derive Output Capacity From Input Capacity #

Prefer a mathematical output bound to a guessed output buffer size. Start with the largest accepted input, find the transform's maximum expansion, and derive OUTPUT_CAP from INPUT_CAP.

For example:

const INPUT_CAP: usize = 1024 * 1024;

// Every input byte can produce at most three output bytes.
const OUTPUT_CAP: usize = 3 * INPUT_CAP;

Other common bounds include:

Prove the bound for the algorithm, not only for current examples. Account for terminators, separators, headers, padding, escaping, and final flush output. Check the capacity arithmetic at compile time so an overflowing calculation cannot silently produce a smaller buffer.

Keep a guard at the write boundary even after proving the bound. If valid input reaches that guard, the capacity formula, parser, or generated table is wrong. The guard should trap as an invariant failure; treating it as ordinary invalid input would hide the broken proof behind a recoverable error.

Put the proof next to the implementation. Use inline Zig tests for:

Input byte length does not bound every output. A small compressed image or archive can expand to a much larger result. In that case, define and validate another finite domain limit, such as maximum width, height, pixel count, uncompressed bytes, archive entries, or nesting depth. Derive the output buffer from those limits. If a valid in-cap input can still exceed the advertised output capacity, overflow is an expected input-dependent failure rather than an invariant. Under the Content contract, render returns normally and [commit rejects the provisional output](/docs/content-component#optional-commit-export).

Do not increase a derived output capacity to a round memory profile merely for consistency. Keep formulas exact enough that reviewers can see why overflow is unreachable, then budget the component's total Wasm memory separately.

Export Content Types When Known #

Content-type exports make pipelines easier to compose and inspect.

const INPUT_CONTENT_TYPE = "application/wasm";
const OUTPUT_CONTENT_TYPE = "application/wasm";

export fn input_content_type_ptr() u32 {
    return @as(u32, @intCast(@intFromPtr(INPUT_CONTENT_TYPE.ptr)));
}

export fn input_content_type_size() u32 {
    return @as(u32, @intCast(INPUT_CONTENT_TYPE.len));
}

export fn output_content_type_ptr() u32 {
    return @as(u32, @intCast(@intFromPtr(OUTPUT_CONTENT_TYPE.ptr)));
}

export fn output_content_type_size() u32 {
    return @as(u32, @intCast(OUTPUT_CONTENT_TYPE.len));
}

Do not export text/plain for generic UTF-8 transforms. The UTF-8 cap already communicates that. Export a MIME type when the component requires or guarantees a specific format, such as application/wasm, text/html, image/bmp, or image/svg+xml.

Use Uniforms For Configuration #

Prefer uniforms over parsing ad hoc control bytes from the main input.

Uniforms keep the data plane clean: the input stays the content being transformed, while runtime options configure behavior before render runs.

var color_rgba: u32 = 0x000000FF;

export fn uniform_set_color_rgba(value: u32) u32 {
    color_rgba = value;
    return color_rgba;
}

Callers pass uniforms next to the module path:

qip run components/image/svg+xml/svg-recolor-current-color.wasm -u color_rgba=0xff5511ff

Use packed integer uniforms for compact settings like colors, flags, and modes. Use f32 uniforms for image math where fractional values are natural.

Keep The Wasm Easy To Inspect #

Sandboxing is not enough by itself. Keep the Wasm easy to audit.

For safety-oriented modules, prefer:

For strict safety-check-clean modules, replace recursion with an explicit stack. A recursive-descent parser with a MAX_DEPTH guard is often fine for practical transforms, but it still has a recursive call graph. If the module is a safety gate, validator, or infrastructure component, use iterative traversal so the binary passes no-recursion checks.

For loops, write the bound in the loop condition when possible:

var i: usize = 0;
while (i < input.len) : (i += 1) {
    // parse one byte or advance deliberately
}

The safety checker looks at the final Wasm. It accepts the normal counter-loop shape where a local counter is compared to a bound, updated by +1 or -1, and then branches back. If a loop advances by variable amounts, make every branch either advance or trap; this is easier to review, but it may still need a simpler counter shape if the strict checker cannot prove the bound.

You can inspect the resulting module with WABT:

wasm-objdump -x component.wasm

Look for a memory entry with both initial and max pages:

Memory[1]:
 - memory[0] pages: initial=... max=...

Makefile Pattern #

For checked-in modules, prefer the project rule over a one-off command. The Makefile sets a default ZIG_WASM_MAX_MEMORY for Zig modules, and individual targets can override it when they need a larger or tighter budget.

ZIG_WASM_MAX_MEMORY ?= 67108864

components/%.wasm: components/%.zig
	$(ZIG_ENV) zig build-exe $< $(ZIG_WASM_FLAGS) --max-memory=$(ZIG_WASM_MAX_MEMORY) -femit-bin=$@

components/bytes/example.wasm: ZIG_WASM_MAX_MEMORY = 1048576

Use the generic default for ordinary modules. Add target-specific overrides for modules with large static buffers, frame buffers, embedded tables, or intentionally tighter safety budgets.

For C components compiled through zig cc, pass the linker spelling instead:

components/utf8/example-c.wasm: components/utf8/example-c.c
	$(ZIG_ENV) zig cc $< -target wasm32-freestanding -nostdlib \
		-Wl,--no-entry -Wl,--max-memory=1048576 \
		-Wl,--export=render -Wl,--export-memory \
		-Wl,--export=input_ptr -Wl,--export=input_utf8_cap \
		-Wl,--export=output_ptr -Wl,--export=output_utf8_cap \
		-Oz -o $@

Zig uses --max-memory=...; zig cc passes -Wl,--max-memory=... to the Wasm linker.

Testing And Review #

Each component should have at least one direct smoke test through qip.

printf 'hello' | qip run components/utf8/your-module.wasm

Use inline Zig test blocks for checks tied to the implementation. They are the best place for:

Run them directly while iterating:

zig test components/utf8/your-module.zig

Keep portable behavior in a Compliance oracle when alternative implementations should satisfy the same cases. Do not make a portable oracle match one Zig implementation's exact buffer capacity. Test that boundary in the Zig source instead, unless the component contract specifies a minimum capacity for every implementation.

For binary modules, round-trip through files or compare bytes:

qip run -i input.bin -- components/bytes/your-module.wasm > /tmp/out.bin
cmp expected.bin /tmp/out.bin

For validators, test both success and failure:

printf 'valid' | qip run components/utf8/your-validator.wasm
printf '\xff' | qip run components/utf8/your-validator.wasm

For a validator with commit, test recovery on a reused instance: reject a range of invalid inputs, then feed valid input through the same instance and confirm the result is still correct. Do not reuse an instance after render traps. The host ignores its output and creates a new instance because a trap does not undo memory or global changes.

Review the binary shape before trusting the source shape:

wasm-objdump -x components/bytes/your-module.wasm
qip score components/application/wasm/your-module.wasm

Use qip score as a quick smell test for imports, indirect calls, recursion, loop-bound evidence, and control-flow weight. Use components/application/wasm/wasm-validate-core-1.0.wasm for WebAssembly Core 1.0 specification validation. Use the policy checkers components/application/wasm/wasm-strict-profile.wasm (fixed memory, no imports, no banned instructions, no recursion, and static content-type metadata) and components/application/wasm/wasm-bounded-loops.wasm (fixed-bound loops) when the valid module should also obey the strict profile. Use components/application/wasm/wasm-bounded-output.wasm when render carries the recognized proof that its successful result does not exceed the static output capacity.

The QIP ABI can be expressed in WebAssembly Core 1.0, while the standard component build targets Core 2.0 features such as bulk memory. Current Chrome, Firefox, and Safari releases implement the Core 2.0 feature set, but there is no single browser switch named “Core 2.0”: support arrived feature by feature and older browsers remain in use. Check the WebAssembly project's Feature Status table for the first browser version supporting each feature, and use WebAssembly.validate() when selecting an optional optimized component such as a SIMD variant.

Checklist #