Skip to content

napi

@gjsify/napi is the forward mirror of node-gi: where node-gi carries GObject-Introspection out to Node.js, Bun and Deno, @gjsify/napi brings native Node.js addons into GJS. A compiled .node addon — the exact binary you would require() on Node — loads and runs unchanged under GJS.

The point is reuse: a GJS app can pull a database driver, a hashing library or a codec straight from the native npm ecosystem, with no pure-JS reimplementation.

Node-API (N-API) is an engine-agnostic C ABI: the same .node binary already runs on Node (V8), Deno and Bun (JSC). @gjsify/napi implements that ABI a fourth time — over GJS’s SpiderMonkey engine (mozjs). The shim ships as a GObject-Introspection package (.so + .gir + .typelib), so GJS loads it through imports.gi like any other GI library. It then exposes a loadAddon(path) call that dlopens the addon and binds its napi_* symbols to the shim’s implementation.

TypeScript
import { loadAddon, hasNapi } from '@gjsify/napi';
if (hasNapi()) {
const sqlite = loadAddon('./build/Release/better_sqlite3.node');
// `sqlite` is the addon's module.exports — a normal object.
const db = new sqlite.Database(':memory:');
}

Your addon is built the normal way (node-gyp / prebuilds) — nothing about it changes. The hard part is on the shim side: SpiderMonkey has a moving GC, so an napi_value can’t be a raw engine value (the shortcut Bun takes on JSC) — it is a handle into a per-environment arena of GC-traced slots. Asynchronous work is bridged to GLib instead of libuv: threadsafe-function calls and napi_async_work completions dispatch onto the GLib main context through a g_idle source, while napi_async_work runs its execute callback on a GThreadPool worker — so async addons get real concurrency, with no separate libuv loop to run.

A golden-diff harness runs a deterministic workout of each addon on Node (the reference) and on GJS-under-the-shim, and requires byte-identical output:

AddonLanguage / codegenKindResult
better-sqlite3C, node-addon-apisynchronous✅ byte-identical
bufferutilC, node-gyp-buildsynchronous
utf-8-validateC++, node-gyp-buildsynchronous
@node-rs/argon2Rust, napi-rssynchronous✅ byte-identical
node-sqlite3C++, node-addon-apiasynchronous✅ byte-identical

Those first four are C, C++ and Rust addons emitted by three different N-API code generators — strong evidence that an arbitrary synchronous addon runs unmodified. node-sqlite3 additionally drives the async surface (napi_async_work + threadsafe functions), so the whole asynchronous node-addon-api ecosystem is reachable, not just the sync subset.

The full synchronous N-API surface is implemented, plus the async / threadsafe-function group. napi_async_work runs each addon’s execute on a GThreadPool worker and marshals complete back onto the GLib main context, so async addons run with genuine concurrency (a 5×1s workload finishes in ~1s on a five-thread pool, not ~5s). A few Node-specific corners stay stubbed because they have no engine-agnostic meaning on GJS — most notably napi_get_uv_event_loop (GJS has no libuv loop; the GLib main context is the loop).

Prebuilt and CI-validated on Linux (x86_64) and macOS (arm64) — the same GJS + mozjs-140 pairing on both, with the full load / value-model / threadsafe-function gates green. Both prebuilds are rebuilt from the released source on every release and ship inside the npm tarball (prebuilds/linux-x64/, prebuilds/darwin-arm64/ — each a .so/.dylib plus its .gir and .typelib). Windows is groundwork-only: the loader and build wiring exist behind a manual CI job, but it is blocked upstream — no prebuilt libgjs for Windows exists yet (the shim links GJS’s SpiderMonkey, so it needs a Windows libgjs the ecosystem doesn’t ship). See the package README for the platform matrix.

@gjsify/napi exists to reuse native npm addons — not to talk to GObject libraries. On GJS the native gi:// / imports.gi binding stays the way to use GTK, GLib and every introspected library; always prefer it. The one place the two directions meet is testing: running @gjsify/node-gi under the shim and diffing its output against native gi:// is a differential oracle that validates both sides at once — a node-gi program built --app node runs on the shim byte-identical to the same source under native GJS.

Terminal
gjsify install @gjsify/napi

@gjsify/napi publishes on the release train like every other @gjsify/* package, with the Linux x86_64 and macOS arm64 prebuilds baked into the tarball — so the install brings the shim itself, not just a build recipe. Installing through the gjsify CLI puts the shim’s typelib on GI_TYPELIB_PATH for you. From then on a normal native-addon import just works in a gjsify build --app gjs build — no wrapper, no manual loadAddon:

TypeScript
import Database from 'better-sqlite3'; // require('bufferutil') / etc.
const db = new Database(':memory:');

Under the hood the napiNodeAddonPlugin (in @gjsify/rolldown-plugin-gjsify, the forward mirror of the gi://requireGi rewrite) intercepts the addon’s own acquisition helper — bindings, node-gyp-build, a direct .node import, or a napi-rs generated loader (@node-rs/*) — and routes the compiled .node through loadAddon. For the node-gyp-build/bindings case it locates the binary with node-gyp-build’s own probe order (build/Releasebuild/Debugprebuilds/); for a napi-rs package it detects the generated loader (a package.json napi/optionalDependencies signal plus a native-main match) and replaces the whole module with module.exports = loadAddon(<current-platform sibling .node>), so the generated createRequire loader body never reaches the bundle. It is always-on for --app gjs and inert when no native addon is in the graph.

Escape hatch — loadAddon(path) (shown above) stays available for loading a .node by an arbitrary runtime path.

At runtime you additionally need a built .node addon to load — compiled locally with a C++ toolchain, or a shipped prebuild, exactly as on Node. See the package README for the full API surface and the current addon matrix.

Outside the two prebuilt platforms — or against a GJS built on a different SpiderMonkey major, which the shim is intrinsically pinned to — build it from the sources the tarball ships alongside the prebuilds (meson.build, src/vala, src/cc, src/napi-headers):

Terminal
cd node_modules/@gjsify/napi
meson setup build . && meson compile -C build

That needs meson, vala, g-ir-compiler, a C++ toolchain and the gjs-1.0 + mozjs-140 development headers; point GI_TYPELIB_PATH (plus LD_LIBRARY_PATH, or DYLD_LIBRARY_PATH on macOS) at the resulting build/ directory.

  • node-gi — the reverse bridge (GObject-Introspection on Node, Bun and Deno)
  • Runtimes — how both bridge directions fit the target picture
  • Versioning — the release train and stability model