Writing QIP Components In Zig
Zig is a strong default 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 #
We prefer every Zig component to compile with --max-memory because it makes the component's worst-case linear memory visible in the Wasm binary.
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:
- Early prototyping, when buffer sizes are still moving.
- Large static tables, where the right cap is easier to choose after the first successful build.
- Interactive components with frame buffers, where width, height, and scratch space should be budgeted together.
Even then, add the cap before checking in the module.
Minimal Content Component #
This component accepts UTF-8 text and returns it unchanged. It is intentionally boring: the shape is the important part.
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 resource shape is obvious from the source.
Use these defaults unless the module has a concrete reason not to:
- Static input, output, and scratch buffers sized by named constants.
- No allocator for normal content transforms.
@trap() for invalid input, violated invariants, and output overflow.
error unions internally, converted to @trap() at the exported boundary.
- Small exported surface: QIP ABI exports plus intentional
uniform_set_* functions.
usize for indexing inside Zig; cast at the ABI boundary.
u32 for exported pointers, sizes, caps, and return values.
- Simple
while loops with visible bounds.
- Explicit content-type exports when the module knows its exact input or output format.
Avoid these by default:
@panic for expected validation failures. Prefer @trap() so the component fails hard without pretending there is a recoverable runtime.
- Heap allocation for ordinary one-input/one-output transforms. Static buffers are easier to inspect and budget.
- Hidden global state that changes
render behavior unless it is set through a documented uniform.
- Recursion in modules intended to pass strict safety checks.
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.
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:
- Validate
input_size <= INPUT_CAP even though the host also checks it.
- Trap on malformed input for normalizers and transforms.
- Trap on output overflow.
- Return
0 only when empty output is a meaningful success.
For assertion pass-through validators, copy the input unchanged or use the same pointer for input and output when the host contract allows it. modules/utf8/utf8-must-be-valid.zig is the model: validate every byte, trap on failure, and preserve the original bytes on success.
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.
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 query arguments 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 modules/image/svg+xml/svg-recolor-current-color.wasm '?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 #
The strongest QIP components are not only sandboxed; they are easy to audit.
For safety-oriented modules, prefer:
- No imports.
- No
memory.grow.
- No recursion.
- No indirect calls unless there is a specific need.
- Fixed memory maximum via
--max-memory.
- Small exported surface: only the QIP contract and intentional uniforms.
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) {
}
When a loop advances by variable amounts, make every branch either advance or trap. That makes review much easier.
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
modules/%.wasm: modules/%.zig
$(ZIG_ENV) zig build-exe $< $(ZIG_WASM_FLAGS) --max-memory=$(ZIG_WASM_MAX_MEMORY) -femit-bin=$@
modules/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:
modules/utf8/example-c.wasm: modules/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 modules/utf8/your-module.wasm
For binary modules, round-trip through files or compare bytes:
qip run -i input.bin -- modules/bytes/your-module.wasm > /tmp/out.bin
cmp expected.bin /tmp/out.bin
For validators, test both success and failure:
printf 'valid' | qip run modules/utf8/your-validator.wasm
printf '\xff' | qip run modules/utf8/your-validator.wasm
Also test recovery on a reused instance: feed a range of invalid inputs that trap, then feed valid input through the same instance and confirm the result is still correct. A WebAssembly trap stops the current call, but it does not reset memory or globals. This catches parsers that mutate persistent state before rejecting malformed input.
Review the binary shape before trusting the source shape:
wasm-objdump -x modules/bytes/your-module.wasm
qip run -i modules/application/wasm/your-module.wasm -- modules/application/wasm/wasm-score.wasm
Use wasm-score as a quick smell test for imports, indirect calls, recursion, and control-flow weight. Use stricter validator modules such as modules/application/wasm/wasm-safety-check.wasm when the module should obey the fixed-memory/no-import/no-recursion profile.
Checklist #
- Pick UTF-8 or bytes caps before writing parsing logic.
- Keep input and output buffers sized from explicit constants.
- Trap on invalid input, oversized input, and output overflow.
- Test invalid input followed by valid input on the same instance.
- Compile with
--max-memory.
- Run
wasm-objdump -x and confirm max=... is present.
- Check for accidental imports, indirect calls, tables, and recursion.
- Smoke test with
qip run.