Skip to content

C, Zig and Go

Checked against 2994ef5 on 2026-11-10 by Capy (review pass: Subagent 124 and fable). The files it covers are unchanged since.

C, Zig and Go use niso through its C API, niso.h: one function per DOM member, generated from the same WebIDL as the JS binding, plus niso_runtime.h for what a host does (making a DOM, running its event loop, rendering frames, input). crates/niso-capi builds both into a static and a dynamic library, libniso_capi. It has no JavaScript engine: niso-capi depends only on niso-core, niso-host and niso-xpath. The full reference is generated: C API.

Each member of a WebIDL interface is a function named after the interface and the member, in snake case: document.createElement is niso_document_create_element, node.textContent = … is niso_node_set_text_content, and an attribute’s getter is niso_<interface>_get_<attribute>. Every call takes the DOM first and returns an int32_t status, NISO_OK (0) or the DOMException it would throw (NISO_HIERARCHY_REQUEST_ERROR and so on), with the message in niso_dom_last_error(dom). Results come back through out-parameters.

  • Nodes and other DOM objects are niso_handles, 64-bit ids. The DOM collects between tasks, so keep a handle across callbacks only while its node is in the document, or retain it with niso_retain.
  • Strings go in as UTF-8 pointer and length, and come back as niso_str (also pointer and length, not NUL-terminated).
  • Callbacks (listeners, timers, animation frames) are a function pointer, a void* userdata, and an optional drop function the DOM calls once when it lets go of the userdata.

The types are in types.h and the statuses in status.h.

This program is its own host: it makes a DOM, adds a heading and prints the body’s HTML.

examples/lang/c/hello.c
#include <stdio.h>
#include "niso.h"
#define S(lit) lit, sizeof(lit) - 1
int main(void) {
niso_dom* dom = niso_dom_new(800, 600, 1.0f);
niso_handle doc = niso_dom_document(dom), body = 0, h1 = 0, added = 0;
if (niso_document_get_body(dom, doc, &body)
|| niso_document_create_element(dom, doc, S("h1"), &h1)
|| niso_node_set_text_content(dom, h1, S("Hello from C"))
|| niso_node_append_child(dom, body, h1, &added)) {
niso_str e = niso_dom_last_error(dom);
fprintf(stderr, "niso: %.*s\n", (int)e.len, e.ptr);
return 1;
}
niso_str html;
niso_element_get_outer_html(dom, body, &html);
printf("%.*s\n", (int)html.len, html.ptr);
niso_dom_free(dom);
return 0;
}

The headers are generated into cargo’s build output for niso-capi, so build it first and find them there. This is what examples/lang/capi.sh does for the examples’ build scripts:

Terminal window
cargo build -q -p niso-capi
include=$(dirname "$(ls -t target/debug/build/niso-capi-*/out/include/niso.h | head -1)")
cc -std=c11 -Wall -I"$include" examples/lang/c/hello.c -Ltarget/debug -lniso_capi -lm \
-Wl,-rpath,"$PWD/target/debug" -o target/hello
target/hello
<body><h1>Hello from C</h1></body>

A real app doesn’t make its own DOM: it’s a library that exports niso_app_main, and a host (the iOS, Android or a headless test) makes the DOM and calls it (Native apps). Each language’s timer builds that library and a headless test that drives it.

examples/lang/c/timer.c is written against niso_app.h, which includes niso.h. A listener and its registration:

examples/lang/c/timer.c
static int32_t on_lap(void* userdata, niso_handle event) {
(void)userdata;
if (!app.running) return 0;
char label[32], time[32];
int label_len = snprintf(label, sizeof label, "Lap %d", ++app.lap_count);
int time_len = format(time, sizeof time, elapsed_at(time_stamp(event)));
niso_handle li = 0;
TRY(append(app.laps, S("li"), NULL, 0, &li));
TRY(append(li, S("span"), label, (size_t)label_len, NULL));
return append(li, S("span"), time, (size_t)time_len, NULL);
}

examples/lang/c/timer.c, line 105

examples/lang/c/timer.c
static int32_t listen(niso_handle target, const char* type, size_t len, niso_event_listener_fn fn) {
niso_add_event_listener_options options = niso_add_event_listener_options_default();
return niso_event_target_add_event_listener(app.dom, target, type, len, fn, NULL, NULL, &options);
}

examples/lang/c/timer.c, line 138

Terminal window
examples/lang/c/build.sh
c timer: ok

The script builds target/lang/c/libtimer.so (the app) and target/lang/c/timer_test (the host, timer_test.c), and runs the test.

Zig reads the C headers itself: the build script turns niso_app.h into a Zig module with zig translate-c, and timer.zig imports it as niso. Errors become a Zig error set:

examples/lang/zig/timer.zig
export fn niso_app_main(dom: ?*c.niso_dom) i32 {
return result(build(dom));
}

examples/lang/zig/timer.zig, line 239

Terminal window
examples/lang/zig/build.sh

Zig’s test runner reports on stderr: 1/1 timer_test.test.timer...OK and All 1 tests passed.

The script’s comment asks for Zig 0.17; the run above used Zig 0.16.0.

Go calls the C API through cgo. timer.go declares its callbacks in the cgo preamble and exports them with //export, and builds as a c-shared library:

examples/lang/go/timer.go
//export onLap
func onLap(userdata unsafe.Pointer, event C.niso_handle) C.int32_t {
if !app.running {
return 0
}
app.lapCount++
li, err := appendChild(app.laps, "li", "")
if err == nil {
_, err = appendChild(li, "span", fmt.Sprintf("Lap %d", app.lapCount))
}
if err == nil {
_, err = appendChild(li, "span", format(elapsedAt(timeStamp(event))))
}
return result(err)
}

examples/lang/go/timer.go, line 180

Terminal window
examples/lang/go/build.sh

The script needs Go with cgo (CGO_ENABLED=1, which it sets). It wasn’t run for this page.