@gjsify/node-gi 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,12 +2,11 @@
2
2
 
3
3
  **GObject-Introspection runtime for Node.js** — the native engine that lets
4
4
  unchanged GJS / GObject-Introspection code run under Node.js, the inverse of
5
- gjsify's Node/Web/DOM → GJS direction (see the gjsify `AGENTS.md`
6
- `### Axis 5 active track`).
5
+ gjsify's Node/Web/DOM → GJS direction.
7
6
 
8
- It loads `gi://` namespaces (GLib, GObject, Gio, …) via `libgirepository` and
9
- exposes them with GJS-compatible semantics, so the same source builds and runs
10
- on both GJS and Node via `gjsify build --app {gjs,node}`.
7
+ It loads `gi://` namespaces (GLib, GObject, Gio, Gtk, Adw, …) via
8
+ `libgirepository` and exposes them with GJS-compatible semantics, so the same
9
+ source builds and runs on both GJS and Node via `gjsify build --app {gjs,node}`.
11
10
 
12
11
  > **Status: product (Tier 2 —
13
12
  > [ADR 0005](../../../docs/adr/0005-node-gi-scope.md)).** node-gi graduated
@@ -22,57 +21,6 @@ on both GJS and Node via `gjsify build --app {gjs,node}`.
22
21
  > (`--runtime node` dev flows, the `@gjsify/sqlite` consumer) and the conditional
23
22
  > `--app node` build injection — enforced by `scripts/audit-runtimes.mjs` in CI.
24
23
 
25
- > **Status: milestone 1 (headless core) — in progress.** The native engine over
26
- > the modern `girepository-2.0` API now does: resolve the default repository,
27
- > `require` a namespace, enumerate its infos; call namespace-level functions and
28
- > **instance methods** (own + implemented-interface methods, up the parent chain)
29
- > with value marshalling (numbers, booleans, strings, GObjects, enums/flags),
30
- > including **OUT and INOUT parameters** surfaced per the GJS return-tuple
31
- > convention (`[returnValue?, ...outArgs]` — one value bare, several as an Array),
32
- > containers, struct OUT params and **caller-allocates OUT structs** — boxed
33
- > (incl. the GValue auto-unbox) AND plain non-boxed C structs (the engine
34
- > g_malloc0's the struct, the callee fills it in place, JS gets a field-readable
35
- > handle that owns the storage — e.g. the `PangoRectangle`s of
36
- > `PangoLayout.get_pixel_extents()`, the canvas2d `measureText` path). A JS
37
- > `Uint8Array` (or `Buffer`/`DataView`/`ArrayBuffer`) passed where a **`GLib.Bytes`
38
- > IN-arg** is expected is copied into a fresh GBytes and released per transfer
39
- > after the call, exactly as GJS (`GdkPixbuf.Pixbuf.new_from_bytes(pixels, …)`);
40
- > construct GObjects and read/write properties (GValue round-trip); connect /
41
- > emit / disconnect signals (incl. detailed names like `notify::prop`); and
42
- > **register GObject subclasses** (subtype + construct-by-type, inheriting the
43
- > parent's properties/methods, plus **custom properties + signals** declared on
44
- > the subclass). Ownership rides
45
- > N-API finalizers (no V8-GC reentrancy). On top of the engine, an **L1
46
- > GJS-compatibility layer** (`@gjsify/node-gi/gi`, `requireGi`) surfaces a
47
- > GJS-shaped namespace: `new Gio.SimpleAction({ name })`, `action.name` property
48
- > access, `action.get_name()` methods, `.connect()/.emit()/.disconnect()`, and
49
- > enums / flags / constants (`Gio.BusType.SESSION`, `GLib.PRIORITY_DEFAULT`);
50
- > constructor/static methods (`Gio.File.new_for_path(...)`); and both snake_case
51
- > and camelCase accessors. The L1 layer also surfaces a **GJS-shaped
52
- > `GObject.registerClass(meta, class)` decorator** (with `GObject.ParamSpec` /
53
- > `ParamFlags` / `SignalFlags`): a JS `class extends GObject.Object { … }` with
54
- > `Properties` / `Signals` / `vfunc_*` methods becomes a constructor whose
55
- > instances carry both the user methods and the GObject property/signal surface.
56
- > A **libuv↔GLib mainloop bridge** (`startMainLoop`,
57
- > auto-attached by `requireGi`) nests Node's libuv loop inside the GLib loop, so a
58
- > blocking `GLib.MainLoop.run()` keeps Node's timers/I/O alive — including the
59
- > **boxed/struct slice** that needs (`GLib.MainLoop.new(...)` → a boxed handle →
60
- > `.run()`/`.quit()`). The same call arms the **uv-driven auto-pump** for the
61
- > NON-blocking case: pending GLib sources (Gio async completions, GLib
62
- > timeouts/idles, DBus) dispatch from Node's own event loop, so a plain
63
- > `node bundle.mjs` that `await`s a Gio async op needs no explicit mainloop —
64
- > matching GJS, where the GLib loop is the process loop. **JS functions marshal as GI callbacks** via an ffi
65
- > closure (`GLib.timeout_add`/`idle_add` fire from the loop, the boolean return
66
- > drives source continuation; the hidden user_data/destroy slots are auto-filled).
67
- > register GObject subclasses (subtype + construct-by-type, inheriting the
68
- > parent's properties/methods, plus **custom properties + signals**, plus
69
- > **vfunc overrides** — a JS function overriding a parent GObject vfunc, hooked
70
- > into the new type's class vtable). The gjsify `--app node` bundler integration
71
- > already rewrites `gi://` onto the L1 layer. vfunc **chain-up** to the parent
72
- > implementation (with the toggle-ref GC bridge) and general struct field access
73
- > land in subsequent drops — for now a vfunc override fully replaces the inherited
74
- > implementation.
75
-
76
24
  ## Provenance
77
25
 
78
26
  Derived from [node-gtk](https://github.com/romgrk/node-gtk) (romgrk and
@@ -85,7 +33,8 @@ vendored as-is — gjsify ships its own dual (GJS + Node) example/test infra.
85
33
 
86
34
  ## Requirements
87
35
 
88
- - Node.js ≥ 20
36
+ - **Node.js ≥ 20, Bun ≥ 1.3, or Deno ≥ 2** — the engine is a Node-API addon, so
37
+ ONE binary serves all three (see [Runtimes](#runtimes-node--bun--deno)).
89
38
  - **No C++ toolchain on the four prebuilt targets.** The published tarball ships
90
39
  `prebuilds/<platform>-<arch>/node_gi.node` for `linux-x64`, `linux-arm64`,
91
40
  `darwin-arm64` and `win32-x64` (`package.json#gjsify.platforms`), and the
@@ -98,313 +47,47 @@ vendored as-is — gjsify ships its own dual (GJS + Node) example/test infra.
98
47
  (Fedora: `glib2-devel gobject-introspection-devel cairo-devel gcc-c++`;
99
48
  Debian/Ubuntu: `libglib2.0-dev libgirepository-2.0-dev libcairo2-dev g++`)
100
49
  - At runtime, the target libraries' typelibs must be installed (same as `gi://`
101
- under GJS) — **except on macOS (both arm64 and Intel x64) and Windows x64**,
102
- where a batteries-included, relocated GTK/GI runtime bundle ships so `gi://`
103
- works with no Homebrew/gvsbuild GTK (Phase 2). node-gi auto-detects a bundle at
104
- load and prepends its typelib dir to the GIRepository search path
105
- (`gtk-runtime.js`) — nothing in that loader keys on a specific target, so each
106
- new bundle is found by the mechanism that found the first.
107
- - **Those bundles are a MANUAL install today.**
108
- [`@gjsify/gtk-runtime-darwin-arm64`](../gtk-runtime-darwin-arm64),
109
- [`@gjsify/gtk-runtime-darwin-x64`](../gtk-runtime-darwin-x64) and
110
- [`@gjsify/gtk-runtime-win32-x64`](../gtk-runtime-win32-x64) are published, and
111
- node-gi finds whichever one is present in the tree — but **no
112
- package declares them as a dependency**, so nothing installs them for you.
113
- Install the one for your platform alongside node-gi:
114
-
115
- ```bash
116
- npm install @gjsify/gtk-runtime-win32-x64 # Windows x64
117
- npm install @gjsify/gtk-runtime-darwin-arm64 # macOS Apple silicon
118
- npm install @gjsify/gtk-runtime-darwin-x64 # macOS Intel
119
- ```
120
-
121
- All three declare `os`/`cpu`, so npm/yarn/pnpm skip them off-platform. Making them
122
- `optionalDependencies` of this package (which would remove the manual step
123
- without any consumer-side platform branching) is pending two decisions outside
124
- this package — the ADR-0003 dependency-direction rule between node-gi's tier
125
- and the bundles', and `os`/`cpu` filtering in `gjsify install`'s native
126
- backend, which currently places foreign-platform optional deps.
127
- - **Node.js ≥ 20, Bun ≥ 1.3, or Deno ≥ 2** — the addon is Node-API, so one binary
128
- runs on all three (see [Runtimes](#runtimes-node--bun--deno)).
129
-
130
- ## Runtimes (Node / Bun / Deno)
131
-
132
- The engine is a **Node-API** addon, so the same binary loads and runs on **Node,
133
- Bun and Deno** — Node-API is their common native ABI (no separate bindings, no Rust
134
- rewrite). `index.js` detects the runtime and prefers a shipped
135
- `prebuilds/<platform>-<arch>/node_gi.node` over a local `build/` (Deno runs no
136
- postinstall build, so a prebuild is its only install path — stage one with
137
- `npm run build:prebuild`).
138
-
139
- Two libuv-coupled subsystems are portable across all three:
50
+ under GJS). A distro GTK on Linux needs no env wiring at all; on **macOS (both
51
+ arm64 and Intel x64) and Windows x64** a batteries-included, relocated GTK/GI
52
+ runtime bundle ships instead, so `gi://` works with no Homebrew/gvsbuild GTK.
53
+ node-gi auto-detects a bundle at load and prepends its typelib dir to the
54
+ GIRepository search path (`gtk-runtime.js`) — nothing in that loader keys on a
55
+ specific target, so each new bundle is found by the mechanism that found the
56
+ first. The loader contract and the per-platform measurements are
57
+ [docs/node-gi-platform-notes.md](../../../docs/node-gi-platform-notes.md).
140
58
 
141
- - **GC bridge** — the toggle-ref teardown drain uses a `napi_threadsafe_function`
142
- (core Node-API), not node-gtk's raw `uv_async_t` (Deno exports no libuv symbols;
143
- Bun panics on `uv_async_init`).
144
- - **Main loop** — Node runs BOTH directions: the uv-nesting bridge co-pumps
145
- Node's own event loop during a blocking GLib loop, and the **uv-driven
146
- auto-pump** dispatches pending GLib sources from Node's loop when NO blocking
147
- GLib loop runs (async Gio + `await` just works, no explicit loop). Bun/Deno
148
- co-pump the non-blocking case by hand, iterating the default GLib context from
149
- a runtime timer (`startMainContextPump` from `@gjsify/node-gi/gi`), so GIO
150
- async callbacks / GLib timeouts / DBus fire while the runtime's own loop stays
151
- live.
152
-
153
- | Capability | Node | Bun | Deno |
154
- |---|:--:|:--:|:--:|
155
- | introspection, marshalling, enums, variants | ✅ | ✅ | ✅ |
156
- | GObject create / properties / signals | ✅ | ✅ | ✅ |
157
- | `registerClass` / subclass / vfunc chain-up | ✅ | ✅ | ✅ |
158
- | toggle-ref GC + cross-thread teardown | ✅ | ✅ | ✅ |
159
- | GLib async with NO mainloop (timeouts, GIO async, DBus) | ✅ auto | via `startMainContextPump` | via `startMainContextPump` |
160
- | blocking `GLib.MainLoop.run()` / `Gio.Application.runAsync()` | ✅ | ✅ | ✅ |
161
- | Promise continuations drain **during** a blocking GLib loop (async DBus replies, `await` chains) | ✅ | ✅ | ✅ |
162
- | Node timers/I/O alive **during** a blocking GLib loop (uv co-pump) | ✅ | — | — |
163
-
164
- Bun reaches full parity with Node on the core surface; Deno passes the same
165
- conformance subset since 2.9 (the marshalling/async N-API quirks that excluded
166
- `arrays`/`async-error` on Deno 2.1 are fixed upstream) — the Node-only co-pump
167
- cases are the remaining, by-design gap. The authoritative full suite runs on Node.
168
-
169
- **Architectures.** CI runs the Bun/Deno legs on x86-64 only, so `linux-arm64` was
170
- measured by hand on a OnePlus 6T / postmarketOS (2026-08-03) against the PUBLISHED
171
- 0.27.0 `prebuilds/linux-arm64/node_gi.node`: **Bun 1.3.14 → 38/38** cross-runtime
172
- files, **Deno 2.9.4 → 35/38 plus the three known non-gating Deno teardown exits,
173
- 0 failed**. Two libc notes from that run:
174
-
175
- - the prebuild declares glibc, yet it **loads and runs on a pure-musl host** (Alpine
176
- / postmarketOS, no glibc, no `gcompat`) under Bun's musl build: musl's dynamic
177
- loader maps the `libc.so.6` / `libm.so.6` `DT_NEEDED` entries onto ITSELF via its
178
- reserved-name list, and every other dependency (`libglib-2.0`,
179
- `libgirepository-2.0`, `libcairo`, `libstdc++`, `libgcc_s`) exists musl-built.
180
- **Do not read a successful load as "every symbol resolved".** Measured with a real
181
- artifact (`@gjsify/sab-native`'s pre-#955 prebuild) on aarch64 musl and in
182
- `alpine:3.24` x86-64: `dlopen` behaves oppositely by mode — `RTLD_NOW` fails at
183
- load, `RTLD_LAZY` **loads** and the process dies at the first call through the
184
- deferred PLT slot. GI opens typelib backers with `G_MODULE_BIND_LAZY`, so on musl a
185
- glibc-only symbol really does hide from a load. Both arches behave identically.
186
- A weak undefined symbol hides the same way. So this binary is clear by audit, not
187
- by its load: 5 of its 579 undefined symbols are weak, and the 3 unresolvable on
188
- musl (`_ITM_{,de}registerTMCloneTable`, `__gmon_start__`) are the NULL-guarded gcc
189
- boilerplate every DSO carries. What defeats the hiding is `dlopen(RTLD_NOW)`, or —
190
- with no musl machine at all — a glibc-floor `SHT_GNU_verneed` audit.
191
- - **Deno publishes no musl build** (`aarch64-unknown-linux-gnu` only), so on such a
192
- host Deno needs a glibc environment (here the `org.gnome.Platform//49` flatpak
193
- runtime: glibc 2.42, gjs 1.86.0). With `node_modules/` already materialised, both
194
- `--node-modules-dir=manual` and Deno's default worked.
195
-
196
- On **Linux** the typelib's bare library leaf needs no help: `Gtk`/`Gdk`/`Adw`/`Pango`/
197
- `Graphene` all resolve and `Gtk.DrawingArea` subclasses correctly with no
198
- `GI_TYPELIB_PATH` and no `LD_LIBRARY_PATH`, both on the musl host and inside the
199
- flatpak runtime, because the system loader's default search path already covers the
200
- distro's own libraries. That is worth stating explicitly because the same step FAILS
201
- on macOS (`Failed to load shared library 'libgtk-4.1.dylib' referenced by the
202
- typelib` → `Gtk.DrawingArea is not a subclassable GObject type`): dyld has no
203
- `ld.so.conf` equivalent, and a plain `node`/`bun`/`deno` binary carries no rpath into
204
- Homebrew's prefix, where `gjs` itself does. A loader-path failure of that shape is
205
- never a node-gi or runtime defect — see `gtk-runtime.js` and the CLI's
206
- `detectNativePackages()` / `buildNativeEnv()` for how the env normally gets wired.
207
-
208
- Promise draining during a blocking loop is cross-runtime because the engine runs
209
- a **microtask checkpoint at every outermost loop-dispatched GLib→JS callback
210
- boundary**: Node's `napi_make_callback` performs it natively; Bun's and Deno's
211
- do not, so on those runtimes the engine invokes the runtime's own drain
212
- primitive (`bun:jsc` `drainMicrotasks` / Deno `core.runMicrotasks`), registered
213
- at addon load (`src/loop.cc` `NodeGiMaybeDrainMicrotasks`). Without it, an
214
- **async** (Promise-returning) DBus method handler exported via
215
- `Gio.DBusExportedObject.wrapJSObject` never sent its reply on Bun/Deno while a
216
- blocking `run()` owned the thread — the client timed out — while sync handlers
217
- worked (regression: `test/dbus-async.test.mjs`). Runtime *timers/I/O* during a
218
- blocking loop remain Node-only (the uv co-pump).
219
-
220
- ## Build & test
221
-
222
- ```bash
223
- npm install # prebuild if one is staged, else node-gyp (scripts/install.mjs)
224
- npm test # node --test (full suite, Node — authoritative)
225
- npm run test:gc # node --test --expose-gc (toggle-ref GC-stress leg)
226
- npm run test:bun # conformance subset on Bun (needs `bun`)
227
- npm run test:deno # conformance subset on Deno (needs `deno`)
228
- npm run build:prebuild # node-gyp rebuild + stage prebuilds/<platform>-<arch>/
229
- # or rebuild explicitly:
230
- npm run rebuild
231
- ```
232
-
233
- The load order prefers a staged `prebuilds/<platform>-<arch>/node_gi.node` over
234
- `build/Release` (the consumer/Deno install path). Local verification always runs
235
- the **just-built** addon instead: the Node test scripts pin `NODE_GI_NATIVE=build`
236
- and the bun/deno runner defaults to it — without that, a stale staged prebuild
237
- silently shadows your build. CI's cross-runtime job sets `NODE_GI_NATIVE=prebuild`
238
- to keep validating the prebuild load path with a freshly staged binary.
239
- `NODE_GI_NATIVE` accepts `build`, `prebuild`, or an explicit path to a
240
- `node_gi.node`. That candidate order lives in `native-paths.js` and is shared
241
- with the `install` script, so the binary the guard decides not to rebuild is
242
- by construction the binary the loader looks for.
243
-
244
- In a checkout `prebuilds/` is gitignored, so `npm install` here always runs the
245
- source build — as do the `npm install --foreground-scripts` steps in
246
- `node-gi.yml` and the `node-gi-prebuild-*` legs in `release.yml`, which need
247
- `build/Release/node_gi.node` for `scripts/stage-prebuild.mjs`. Set
248
- `NODE_GI_BUILD_FROM_SOURCE=1` to force it regardless of a staged prebuild.
249
-
250
- Two debug-only env vars instrument the toggle-ref / teardown machinery (both
251
- parsed once at first use, zero cost when unset — never set them in production):
252
-
253
- - `NODE_GI_TOGGLE_DEBUG=1` — stderr tracing of the GC bridge: owner-env claim,
254
- drain-TSFN create/release, shutdown-flag flips, teardown enqueue/drop, drain
255
- runs/skips (with JS-availability), and the C→JS trampoline skips at env
256
- teardown; each line carries the emitting thread.
257
- - `NODE_GI_TOGGLE_TEARDOWN_DELAY_MS=<n>` — test-only latency seam (clamped to
258
- 10s): the drain defers queued idle teardowns younger than `n` ms (re-waking
259
- itself), which deterministically parks teardowns — with a pending drain wake —
260
- across the event loop's exit. That is the regression vehicle for the
261
- env-cleanup drain race (`test/gc-cross-thread.test.mjs`, "teardown drain
262
- during env cleanup never aborts").
263
-
264
- ## Conformance (golden-diff)
265
-
266
- The exactness oracle for GJS parity: small self-contained `gi://` programs
267
- under `conformance/programs/*.conf.mjs` run UNCHANGED on all four runtimes —
268
- gjs natively (`gjs -m`, ambient `print`), node/bun/deno via a lightweight
269
- generated runtime twin (the `globals.js` shim + `requireGi`, no bundler) — and
270
- every runtime's **stdout must be byte-identical to the committed golden**
271
- (`conformance/golden/<name>.txt`). **gjs is the reference**: the goldens ARE
272
- the gjs output, and a gjs↔golden drift fails loudly (either GJS changed or the
273
- golden is stale — never paper over it).
59
+ ## Install
274
60
 
275
61
  ```bash
276
- npm run test:conformance # full matrix (gjs × node × bun × deno)
277
- node scripts/conformance.mjs --runtimes=gjs,node # runtime subset (gjs/node never auto-skip)
278
- node scripts/conformance.mjs --filter=variant # program subset
279
- node scripts/conformance.mjs --update-golden # regenerate goldens from gjs
62
+ npm install @gjsify/node-gi
280
63
  ```
281
64
 
282
- Adding a program: drop `conformance/programs/<name>.conf.mjs` — default
283
- imports of the exact shape `import Gio from 'gi://Gio?version=2.0';` only
284
- (regex-rewritable to `requireGi`), output via the GJS-ambient `print()`,
285
- strictly deterministic (no versions, paths, hostnames, timing; the runner sets
286
- `LC_ALL=C`), ends cleanly — then `--update-golden`, eyeball the golden for
287
- determinism, and commit both. Every feature PR extends this suite.
288
-
289
- The ledger contract (`conformance/ledger.json`) is strict: every known-failing
290
- program×runtime combo is a **committed entry**
291
- `{ "program", "runtime", "reason", "issue"? }` — a failing combo *not* in the
292
- ledger fails the run, and a passing combo still *in* the ledger fails as a
293
- stale entry (remove it). Exit 0 means zero unexpected results; there are no
294
- silent exclusions (bun/deno merely report `skipped` when not installed — gjs
295
- and node never skip).
296
-
297
- ### Tier B — GJS installed-tests port
298
-
299
- The breadth oracle: GJS's own installed-tests
300
- (`refs/gjs/installed-tests/js/testGIMarshalling.js`) encode GJS's marshalling
301
- behavior against the purpose-built `GIMarshallingTests-1.0` typelib.
302
- `gimarshalling/testGIMarshalling.port.mjs` is a near-verbatim port of that
303
- file to `node:test` via a minimal jasmine shim
304
- (`gimarshalling/jasmine-shim.mjs`), mapping the WHOLE upstream surface:
305
- already-green sections run live, everything else is a `describeSkip` stub
306
- naming the upstream section. Assertions are never weakened.
65
+ **On macOS and Windows the GTK runtime bundle is a MANUAL install today.**
66
+ [`@gjsify/gtk-runtime-darwin-arm64`](../gtk-runtime-darwin-arm64),
67
+ [`@gjsify/gtk-runtime-darwin-x64`](../gtk-runtime-darwin-x64) and
68
+ [`@gjsify/gtk-runtime-win32-x64`](../gtk-runtime-win32-x64) are published, and
69
+ node-gi finds whichever one is present in the tree — but **no package declares
70
+ them as a dependency**, so nothing installs them for you. Install the one for
71
+ your platform alongside node-gi:
307
72
 
308
73
  ```bash
309
- npm run test:gimarshalling # builds the pinned typelibs if missing, then runs the port
74
+ npm install @gjsify/gtk-runtime-win32-x64 # Windows x64
75
+ npm install @gjsify/gtk-runtime-darwin-arm64 # macOS Apple silicon
76
+ npm install @gjsify/gtk-runtime-darwin-x64 # macOS Intel
310
77
  ```
311
78
 
312
- `scripts/build-gi-test-typelibs.mjs` builds the test typelibs reproducibly
313
- from GNOME's gobject-introspection-tests project at the **pinned revision**
314
- GJS itself tests against (`PINNED_REV`, copied from
315
- `refs/gjs/subprojects/gobject-introspection-tests.wrap`) into the gitignored
316
- `.gi-tests/` (meson + ninja required; cairo disabled; Regress builds too).
317
- The launcher (`scripts/gimarshalling.mjs`) sets `GI_TYPELIB_PATH` /
318
- `LD_LIBRARY_PATH` before spawning `node --test` — dlopen cannot pick up late
319
- env changes — and pins `NODE_GI_NATIVE=build`. The port files are named
320
- `*.port.mjs` so the default `npm test` glob never picks them up.
79
+ All three declare `os`/`cpu`, so npm/yarn/pnpm skip them off-platform. Making them
80
+ `optionalDependencies` of this package (which would remove the manual step
81
+ without any consumer-side platform branching) is pending two decisions outside
82
+ this package — the ADR-0003 dependency-direction rule between node-gi's tier
83
+ and the bundles', and `os`/`cpu` filtering in `gjsify install`'s native
84
+ backend, which currently places foreign-platform optional deps.
321
85
 
322
- **Skip contract (strict, mirrors the tier-A ledger):** every skipped
323
- spec/suite carries a reason — a phase-2.x roadmap item from the taxonomy at
324
- the top of the port file (e.g. `phase 2.1 BigInt-64-bit`), an upstream issue
325
- URL, or a `FIDELITY-BUG: …` note — and the reason is reported in the
326
- `node:test` output (`# SKIP <reason>`). A bare skip throws (`pending()`,
327
- `itSkip`, `describeSkip` all require the reason; `xit` must chain
328
- `.pend(reason)`). Later marshalling PRs un-skip their sections — this port is
329
- phase 2's acceptance gate.
86
+ ## Usage
330
87
 
331
- ## Usage (milestone 1)
332
-
333
- ```js
334
- import {
335
- requireNamespace, listInfoNames, callFunction,
336
- newObject, getProperty, setProperty, callMethod,
337
- connectSignal, emitSignal,
338
- } from '@gjsify/node-gi';
339
-
340
- requireNamespace('GLib', '2.0');
341
- console.log(listInfoNames('GLib').includes('MainLoop')); // true
342
- console.log(callFunction('GLib', 'get_host_name')); // namespace function
343
-
344
- requireNamespace('Gio', '2.0');
345
- const action = newObject('Gio', 'SimpleAction', { name: 'greet', enabled: true });
346
- console.log(getProperty(action, 'name')); // 'greet' (GValue round-trip)
347
- console.log(callMethod(action, 'get_name')); // 'greet' (interface method)
348
- callMethod(action, 'set_enabled', [false]); // method with an IN argument
349
-
350
- const c = newObject('Gio', 'Cancellable', {});
351
- connectSignal(c, 'cancelled', () => console.log('cancelled'));
352
- emitSignal(c, 'cancelled');
353
- ```
354
-
355
- Register a GObject subclass and construct it (inherited properties + methods):
356
-
357
- ```js
358
- import { registerClass, constructType, callMethod } from '@gjsify/node-gi';
359
-
360
- const MyAction = registerClass('MyAction', 'Gio', 'SimpleAction');
361
- const a = constructType(MyAction, { name: 'greet', enabled: true });
362
- console.log(callMethod(a, 'get_name')); // 'greet' (inherited GAction method)
363
- ```
364
-
365
- Declare custom properties and signals on the subclass:
366
-
367
- ```js
368
- import { registerClass, constructType, getProperty, setProperty,
369
- connectSignal, emitSignal } from '@gjsify/node-gi';
370
-
371
- const Counter = registerClass('Counter', 'GObject', 'Object', {
372
- properties: [{ name: 'count', type: 'int', default: 0, minimum: 0, maximum: 100 }],
373
- signals: [{ name: 'changed', paramTypes: ['int'] }],
374
- });
375
-
376
- const c = constructType(Counter, { count: 1 });
377
- connectSignal(c, 'notify::count', (pspec) => console.log('changed:', pspec.name));
378
- connectSignal(c, 'changed', (n) => console.log('count is now', n));
379
- setProperty(c, 'count', 5); // fires notify::count
380
- emitSignal(c, 'changed', [5]);
381
- console.log(getProperty(c, 'count')); // 5
382
- ```
383
-
384
- Override a parent GObject vfunc with a JS function (the override runs as a method
385
- on the instance — `this` is the GObject handle). Chain-up to the parent vfunc
386
- lands in a later drop, so an override fully replaces the inherited implementation:
387
-
388
- ```js
389
- import { registerClass, constructType, getProperty } from '@gjsify/node-gi';
390
-
391
- const Greeter = registerClass('Greeter', 'Gio', 'SimpleAction', {
392
- vfuncs: {
393
- // GObject's `constructed` vfunc — runs once, after construct properties are
394
- // set. `name` is a CONSTRUCT_ONLY property already available on `this`.
395
- constructed() {
396
- console.log('constructed:', getProperty(this, 'name'));
397
- },
398
- },
399
- });
400
-
401
- constructType(Greeter, { name: 'greet' }); // logs "constructed: greet"
402
- ```
403
-
404
- ### L1 — GJS-shaped surface (`@gjsify/node-gi/gi`)
405
-
406
- The ergonomic layer the gjsify `--app node` build rewrites `gi://` imports onto.
407
- This is the same code you would write under GJS:
88
+ `requireGi` hands you a GJS-shaped namespace: the same code you would write
89
+ under GJS, property accessors, methods, signals, enums and constructors
90
+ included.
408
91
 
409
92
  ```js
410
93
  import { requireGi } from '@gjsify/node-gi/gi';
@@ -414,757 +97,95 @@ console.log(GLib.get_host_name());
414
97
 
415
98
  const Gio = requireGi('Gio', '2.0');
416
99
  const action = new Gio.SimpleAction({ name: 'greet', enabled: true });
417
- console.log(action.name); // 'greet' (property accessor)
418
- console.log(action.get_name()); // 'greet' (method)
419
- action.enabled = false; // property set → set_property
420
-
421
- const c = new Gio.Cancellable();
422
- c.connect('cancelled', () => console.log('cancelled'));
423
- c.cancel(); // fires the signal
100
+ console.log(action.name); // 'greet' — property accessor
101
+ console.log(action.get_name()); // 'greet' — introspected method
102
+ action.enabled = false; // property set → set_property
424
103
 
425
- // enums, flags and constants (GJS-style UPPER_CASE members)
426
- console.log(GLib.PRIORITY_DEFAULT); // 0
427
- console.log(Gio.BusType.SESSION); // 2
428
- console.log(Gio.ApplicationFlags.HANDLES_OPEN); // 4
104
+ const cancellable = new Gio.Cancellable();
105
+ cancellable.connect('cancelled', () => console.log('cancelled'));
106
+ cancellable.cancel(); // fires the signal
429
107
 
430
- // constructor/static methods + camelCase aliases
108
+ console.log(Gio.BusType.SESSION); // 2 — enums, flags and constants
431
109
  const file = Gio.File.new_for_path('/usr/bin/gjs');
432
- console.log(file.get_path()); // '/usr/bin/gjs'
433
- console.log(file.getBasename()); // 'gjs' (camelCase alias)
434
-
435
- // mainloop: a blocking GLib loop, with Node's libuv kept alive underneath
436
- const loop = GLib.MainLoop.new(null, false);
437
- setTimeout(() => loop.quit(), 100); // a libuv timer that fires during run()
438
- loop.run(); // blocks like under GJS; returns on quit()
439
-
440
- // GI callbacks: a JS function passed where a GI callback is expected. The
441
- // GLib source fires from the loop; returning false (G_SOURCE_REMOVE) stops it.
442
- const ticker = GLib.MainLoop.new(null, false);
443
- let n = 0;
444
- GLib.timeout_add(GLib.PRIORITY_DEFAULT, 50, () => {
445
- if (++n >= 3) { ticker.quit(); return false; }
446
- return true;
447
- });
448
- ticker.run();
449
- ```
450
-
451
- The mainloop bridge (`startMainLoop`) is auto-attached the first time `requireGi`
452
- loads a namespace, so `GLib.MainLoop.run()` / `Gio.Application.run()` block as
453
- they do under GJS while Node's timers, I/O and signal handlers keep running.
454
-
455
- A blocking loop is **optional**: the same call arms the uv-driven auto-pump, so
456
- pending GLib sources dispatch from Node's own event loop too — async Gio work
457
- awaited at top level behaves exactly as under `gjs -m`:
458
-
459
- ```js
460
- import { requireGi } from '@gjsify/node-gi/gi';
461
-
462
- const Gio = requireGi('Gio', '2.0');
463
- const file = Gio.File.new_for_path('/etc/hostname');
464
- // No GLib.MainLoop.run() anywhere: the GTask completion dispatches from Node's
465
- // loop, the in-flight op keeps the process alive, then Node exits normally.
466
- const [ok, contents] = await new Promise((resolve, reject) => {
467
- file.load_contents_async(null, (_source, res) => {
468
- try { resolve(file.load_contents_finish(res)); } catch (e) { reject(e); }
469
- });
470
- });
471
- ```
472
-
473
- Process-lifetime semantics follow Node conventions: an in-flight async op
474
- (a pending `GAsyncReadyCallback`) and an armed GLib timeout keep the process
475
- alive — like Node's own pending I/O and timers — so a REPEATING GLib timeout
476
- keeps the process running like `setInterval` (under `gjs -m` the process would
477
- instead exit once the module settles; remove the source to release the process).
478
- A *passive* fd source with no pending op (e.g. only a listening
479
- `Gio.SocketService`) does not keep the process alive on its own, and a
480
- purely-sync program still exits immediately.
481
-
482
- #### Class prototypes and the lookup order
483
-
484
- `Ns.Class.prototype` is a real prototype: reading a name on it MATERIALIZES the
485
- introspected method behind that name (the JS twin of the `resolve` hook GJS
486
- installs on every GObject prototype), and every wrapper resolves its members
487
- through the prototype of its runtime GType — a `registerClass` subclass's own
488
- prototype, otherwise the introspected class's — before any native fallback. So
489
- instrumenting a method works the way it does on GJS:
490
-
491
- ```js
492
- const proto = Gio.ZlibDecompressor.prototype;
493
- const original = proto.convert; // the introspected method
494
- proto.convert = function (...args) { // instrument it
495
- calls++;
496
- return original.apply(this, args);
497
- };
498
- const d = new Gio.ZlibDecompressor({ format: Gio.ZlibCompressorFormat.GZIP });
499
- d.convert === proto.convert; // true — and the wrapper IS called
500
- proto.convert = original; // put it back
501
- ```
502
-
503
- Until 0.39 an instance ignored its prototype: the assignment stuck, `d.convert`
504
- still reached the native method, and a spy-based test reported itself installed
505
- while measuring nothing. Byte-compared against GJS by the `prototype-chain`
506
- conformance program.
507
-
508
- Lookup order on a wrapper: own JS field → the class prototype chain →
509
- `runAsync` / the GObject.js shims → GObject property → inherited `Object.prototype`
510
- member → a `Gio._promisify` registration → introspected method → `undefined`.
511
-
512
- Deliberate divergences from GJS, all measured:
513
-
514
- - class prototypes are NOT chained to their base classes, so `instanceof` goes
515
- through `g_type_is_a` (see below) and an interface method materializes once per
516
- implementing class instead of being one shared function;
517
- - GObject **properties** are not surfaced as prototype accessors — they resolve on
518
- the instance only;
519
- - a private concrete GType (`GLocalFile`) resolves to its nearest INTROSPECTABLE
520
- ancestor rather than to the interface GJS would use, which is why
521
- `Gio._promisify` also keeps a per-class registry.
522
-
523
- #### `GLib.Variant` (build + unpack, GJS semantics)
524
-
525
- `new GLib.Variant(signature, value)` recursively builds a GVariant from a type
526
- signature, and the wrapper exposes the GJS unpack flavours — the contract
527
- GAction / GSettings / DBus payloads expect:
528
-
529
- ```js
530
- const GLib = requireGi('GLib', '2.0');
531
-
532
- new GLib.Variant('s', 'hi').deepUnpack(); // 'hi'
533
- new GLib.Variant('as', ['a', 'b']).deepUnpack(); // ['a', 'b']
534
- new GLib.Variant('(si)', ['x', 1]).deepUnpack(); // ['x', 1]
535
-
536
- const v = new GLib.Variant('a{sv}', {
537
- name: new GLib.Variant('s', 'Ada'),
538
- age: new GLib.Variant('i', 36),
539
- });
540
- v.get_type_string(); // 'a{sv}'
541
- v.deepUnpack(); // { name: Variant, age: Variant } (one level; values stay Variants)
542
- v.recursiveUnpack(); // { name: 'Ada', age: 36 } (fully plain JS)
543
- v.unpack(); // single level; children stay Variants
544
- ```
545
-
546
- Supported type strings: the basics `b y n q i u x t h d s o g`, `v` (variant),
547
- `m*` (maybe), `a*` arrays (incl. the `as` strv + `ay` bytestring fast-paths and
548
- `a{..}` dictionaries), `(...)` tuples and `{kv}` dict-entries. Built Variants
549
- round-trip as GObject arguments/properties/signal values — e.g. a
550
- `Gio.SimpleAction` state:
551
-
552
- ```js
553
- const Gio = requireGi('Gio', '2.0');
554
- const action = Gio.SimpleAction.new_stateful('counter', null, new GLib.Variant('i', 0));
555
- action.get_state().deepUnpack(); // 0
556
- action.change_state(new GLib.Variant('i', 5));
557
- action.get_state().deepUnpack(); // 5
558
- ```
559
-
560
- #### `GObject.registerClass` (GJS-shaped decorator)
561
-
562
- `requireGi('GObject')` carries the GJS runtime statics — `registerClass`,
563
- `ParamSpec`, `ParamFlags`, `SignalFlags` — layered over the introspected
564
- namespace, so you subclass a GObject the same way you would under GJS:
565
-
566
- ```js
567
- const GObject = requireGi('GObject', '2.0');
568
-
569
- const Counter = GObject.registerClass(
570
- {
571
- GTypeName: 'Counter',
572
- Properties: {
573
- // CONSTRUCT so the value is set before vfunc_constructed runs.
574
- count: GObject.ParamSpec.int(
575
- 'count', 'Count', 'A counter',
576
- GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT,
577
- 0, 100, 0,
578
- ),
579
- },
580
- Signals: { 'changed': { param_types: ['int'] } },
581
- },
582
- class Counter extends GObject.Object {
583
- increment() { this.count += 1; this.emit('changed', this.count); }
584
- vfunc_constructed() { /* runs during construction; `this` is the instance */ }
585
- },
586
- );
587
-
588
- const c = new Counter({ count: 5 });
589
- c.connect('changed', (n) => console.log('now', n));
590
- c.increment(); // logs "now 6"
591
- console.log(c.count); // 6 (custom property)
110
+ console.log(file.get_path()); // '/usr/bin/gjs'
592
111
  ```
593
112
 
594
- `new Counter(props)` constructs the GObject (`constructType`) and wraps it so the
595
- user class's own prototype methods resolve FIRST, then the GObject property/signal
596
- surface. `registerClass(class)` (no meta) is also accepted; the GTypeName then
597
- defaults to the class name. The parent namespace/type is read from the class's
598
- `extends` (its `$gtypeName`), so it works for both `GObject.Object` and real GI
599
- classes (`class extends Gio.SimpleAction { … }`).
600
-
601
- Caveats (this is the no-toggle-ref object model): the user class's JS constructor
602
- body is not run — GObject-idiomatic init belongs in `vfunc_constructed`;
603
- instances are Proxies over a native handle (but `instanceof` still works — it is
604
- resolved through the GObject type system, see the `instanceof` note below);
605
- **plain (non-GObject-property) JS instance fields do NOT cross the
606
- vfunc↔instance boundary** — inside a vfunc, `this` is a distinct wrapper over the
607
- same GObject (the native engine mints a fresh handle per call, so there is no
608
- shared per-instance JS object yet), so use GObject **properties** for any state
609
- that must be visible both inside a vfunc and on the instance (those live in C and
610
- are consistent; the unified instance identity arrives with the toggle-ref work);
611
- a JS↔GObject reference cycle on a custom instance leaks (the same cycle-leak
612
- caveat the signal/vfunc layer carries).
613
-
614
- #### `GObject` conveniences (signals, `GObject.Value`, `Object.new`)
615
-
616
- `requireGi('GObject')` carries the GJS `GObject.js` convenience surface on top of
617
- introspection:
618
-
619
- ```js
620
- const GObject = requireGi('GObject', '2.0');
621
- const Gio = requireGi('Gio', '2.0');
113
+ An existing GJS program needs no edit: built with `gjsify build --app node`, an
114
+ `import Gio from 'gi://Gio?version=2.0'` is rewritten onto `requireGi` for you,
115
+ and the ambient GJS globals (`print`, `imports`, `ARGV`) are injected when the
116
+ bundled output still references them.
622
117
 
623
- // By-function signal ops — node-gi connects through private closures, so the
624
- // (function → handler id) mapping is recorded at connect() time.
625
- const action = new Gio.SimpleAction({ name: 'a', enabled: true });
626
- const onChange = () => { /* … */ };
627
- action.connect('notify::enabled', onChange);
628
- GObject.signal_handlers_block_by_func(action, onChange); // → count blocked
629
- GObject.signal_handlers_unblock_by_func(action, onChange); // → count unblocked
630
- GObject.signal_handlers_disconnect_by_func(action, onChange); // → count disconnected
631
- action.block_signal_handler(id); action.unblock_signal_handler(id); // by id
632
- action.stop_emission_by_name('notify::enabled'); // from within a handler
633
-
634
- // GObject.Value — an explicit GValue you can build, set, read, copy and pass IN.
635
- const v = new GObject.Value();
636
- v.init(GObject.TYPE_INT); v.set_int(42); v.get_int(); // → 42
637
- const s = new GObject.Value(GObject.TYPE_STRING, 'hi'); // 2-arg convenience
638
- v instanceof GObject.Value; // → true
639
-
640
- // Construct a GObject from a runtime GType.
641
- const made = GObject.Object.new(Gio.SimpleAction.$gtype, { name: 'made' });
642
- ```
643
-
644
- Also present: `GObject.ParamFlags` / `SignalFlags` (full introspected bitfields),
645
- the fundamental `GObject.TYPE_*` GTypes, `GObject.AccumulatorType`, and
646
- `GObject.signal_connect` / `signal_connect_after` / `signal_emit_by_name`.
647
-
648
- `bind_property_full` / `BindingGroup.bind_full` work with **real JS transform
649
- functions** (the engine marshals them as C `GBindingTransformFunc` trampolines,
650
- the same architecture gjs uses via GjsPrivate — see `src/private.cc`): a
651
- transform-to converts the bound value, returning `[false, …]` leaves the target
652
- unchanged, a bidirectional transform-from converts back, and `null` transforms
653
- give a plain copy binding. Verified byte-identical to gjs by the
654
- `gclosure-in-args` conformance program. The related raw primitives
655
- `GObject.signal_connect_closure` / `GObject.source_set_closure` accept a plain
656
- JS function wherever a `GObject.Closure` IN-argument is expected (the engine
657
- marshals it as a real GClosure).
658
-
659
- **Kept-throw** (a clear, actionable error, not a crash): `ParamSpec.enum`
660
- / `flags` / `char` / `uchar` / `long` / `ulong` / `param` are not yet buildable (the
661
- native param-spec builder covers int/uint/int64/uint64/double/float/string/boolean/
662
- object/boxed).
663
-
664
- #### `GLib` conveniences (`log_structured`, one-shot idle/timeout)
665
-
666
- `requireGi('GLib')` carries `GLib.log_structured(domain, level, fields)` (packs
667
- string / `Uint8Array` / `GLib.Variant` fields into an `a{sv}` and hands it to
668
- `g_log_variant`) and the one-shot source helpers `idle_add_once` /
669
- `timeout_add_once` / `timeout_add_seconds_once` (the callback runs once, then the
670
- source is auto-removed).
671
-
672
- `GLib.log_set_writer_func(fn)` installs a **JS `GLogWriterFunc`** as the
673
- process structured-log writer, with gjs semantics (node-gi ships the same
674
- thread-guarded C wrapper gjs routes through GjsPrivate — `src/private.cc`): the
675
- writer receives `(logLevel, fields)` where `fields` is a plain object whose
676
- values are `Uint8Array`s of the field bytes (`null` for empty fields) —
677
- byte-for-byte the shape gjs's `{...stringFields.recursiveUnpack()}` produces —
678
- and its returned `GLib.LogWriterOutput` drives the handled/unhandled fallback.
679
- `GLib.log_set_writer_default()` detaches the JS writer (later logs fall back to
680
- `g_log_writer_default`). Verified byte-identical to gjs by the `log-writer`
681
- conformance program. Two contracts, identical under gjs: the underlying
682
- `g_log_set_writer_func` may only ever be called ONCE per process — a second
683
- `GLib.log_set_writer_func(fn)` call aborts inside GLib itself (install one
684
- writer per process; `log_set_writer_default()` detaches the JS side but cannot
685
- re-arm a new install) — and an off-thread log falls back to the default writer
686
- in C (JS is never entered from a foreign thread).
687
-
688
- #### `Gio.DBus` (client proxy, name owning + object export)
689
-
690
- `requireGi('Gio')` carries the GJS DBus surface — both halves. The **client**
691
- half: `Gio.DBusProxy.makeProxyWrapper(interfaceXml)` parses the interface XML and
692
- returns a proxy constructor whose instances expose each method as `NameSync` (sync),
693
- `NameRemote` (raw async callback) and `NameAsync` (Promise), each property as a
694
- getter/setter, and each signal via `connectSignal` / `disconnectSignal` (the same
695
- pure-JS `_signals` mixin GJS uses). `Gio.DBus.session` / `Gio.DBus.system` are the
696
- bus getters; `Gio.DBus.own_name` / `unown_name` / `watch_name` / `unwatch_name`
697
- own and watch bus names. The **export** half
698
- (`Gio.DBusExportedObject.wrapJSObject` — exporting a JS object AS a DBus
699
- service) is described below the example.
700
-
701
- ```js
702
- const Gio = requireGi('Gio', '2.0');
703
-
704
- const Proxy = Gio.DBusProxy.makeProxyWrapper(`<node>
705
- <interface name="org.freedesktop.DBus">
706
- <method name="GetId"><arg type="s" direction="out"/></method>
707
- <signal name="NameOwnerChanged"><arg type="s"/><arg type="s"/><arg type="s"/></signal>
708
- </interface>
709
- </node>`);
710
-
711
- const proxy = new Proxy(Gio.DBus.session, 'org.freedesktop.DBus', '/org/freedesktop/DBus');
712
- const [busId] = proxy.GetIdSync(); // synchronous method
713
- proxy.GetIdRemote((result, error) => { /* … */ }); // async, raw callback
714
- const [id2] = await proxy.GetIdAsync(); // async, Promise (drains after run())
715
- proxy.connectSignal('NameOwnerChanged', (p, sender, [name, oldOwner, newOwner]) => { /* … */ });
716
-
717
- const id = Gio.DBus.own_name(Gio.BusType.SESSION, 'org.example.App',
718
- Gio.BusNameOwnerFlags.NONE, null, (conn, name) => { /* acquired */ }, null);
719
- ```
720
-
721
- Async replies / signals / name callbacks dispatch from the default main context —
722
- either a blocking `GLib.MainLoop.run()` or, with no loop anywhere, the uv-driven
723
- auto-pump (an `await proxy.GetIdAsync()` at top level settles like any other
724
- async Gio op). A `NameAsync` **Promise** `.then` drains *while* a node-gi loop
725
- blocks on all three runtimes (the reply's GI callback settles the Promise and
726
- the microtask checkpoint at that loop-dispatched boundary runs the
727
- continuation). One Node-only divergence remains: when the blocking `run()` is
728
- itself entered inside a live async scope (module top-level evaluation, an
729
- `await`, `node:test`), V8 refuses the nested checkpoint (node-gtk #442/#121) —
730
- the reply still fires and settles the Promise, so it resolves once `run()`
731
- returns; defer the blocking run to a macrotask (what `runAsync` does) or drive
732
- the method through the raw `NameRemote` callback. Bun/Deno do not share that
733
- nesting restriction: their registered drain primitives run even under a
734
- top-level blocking `run()`.
735
-
736
- **Object export** (`Gio.DBusExportedObject.wrapJSObject` — exporting a JS
737
- object AS a DBus service) works with GJS semantics. GJS builds it on
738
- `GjsPrivate.DBusImplementation` (a GJS-internal C type, absent on a plain
739
- Node/GI host); node-gi instead drives the **introspectable**
740
- `g_dbus_connection_register_object_with_closures2` (GLib ≥ 2.84) — the
741
- method-call / get-property / set-property vtable slots are plain JS functions
742
- the engine marshals as real **GClosure IN-arguments**:
743
-
744
- ```js
745
- const service = {
746
- Level: 7, // property (read via the interface XML signature)
747
- Echo(s) { return `echo:${s}`; }, // method (an Async variant + Promise return also work)
748
- Boom() { throw new Error('kaboom'); }, // a throw becomes a DBus error (org.gnome.gjs.JSError.*)
749
- };
750
- const impl = Gio.DBusExportedObject.wrapJSObject(interfaceXml, service);
751
- impl.export(Gio.DBus.session, '/org/example/App');
752
- impl.emit_signal('Pinged', new GLib.Variant('(s)', ['ping!']));
753
- impl.emit_property_changed('Level', new GLib.Variant('i', 8)); // updates proxy caches
754
- impl.unexport(); // releases the registration + its closures
755
- ```
756
-
757
- The impl surface matches GJS (`export` / `unexport` / `unexport_from_connection`
758
- / `emit_signal` / `emit_property_changed` / `flush` / `get_object_path`, plus
759
- node-gi's usual camelCase aliases). The full round-trip — exported method,
760
- property get/set through `org.freedesktop.DBus.Properties`, a throwing method
761
- returning a DBus error, `emit_signal`, `emit_property_changed` updating a
762
- proxy's cached property, and unexport — runs byte-identical to gjs
763
- (`dbus/export-scenario.mjs`, cross-checked against `gjs -m` under
764
- `dbus-run-session` by `npm run test:dbus`, which passes
765
- `--config-file=test/session.conf` — a plain `unix:tmpdir=/tmp` bus, because
766
- dbus-run-session's DEFAULT config cannot start on macOS, where Homebrew's dbus
767
- listens on launchd). Lifetime: the registration ref+sinks
768
- its closures, so the service object lives exactly as long as the registration
769
- (surviving GC with no JS references) and becomes collectable after
770
- `unexport()` — guarded by the `--expose-gc` leg of the dbus suite. A method
771
- handler receives the GJS-appended trailing `Gio.UnixFDList` argument (`null`
772
- when the call carries no fds — verified against gjs; an actual fd-carrying
773
- call arrives as a wrapped `UnixFDList` but deeper fd extraction is untested).
774
- As under GJS, a **sync** self-call from the exporting process deadlocks the
775
- shared main loop that must also service the incoming request — use the async
776
- `NameRemote` forms.
777
-
778
- ### GJS ambient globals (`@gjsify/node-gi/globals`)
779
-
780
- GJS source relies on globals that exist implicitly under `gjs` — `print`,
781
- `printerr`, `log`, `logError`, `ARGV`, and the legacy `imports` object. Importing
782
- `@gjsify/node-gi/globals` (a side effect) installs Node-backed equivalents that
783
- route through the same backend:
784
-
785
- ```js
786
- import '@gjsify/node-gi/globals';
787
-
788
- print('hello', 1, true); // → stdout, GJS String()-join
789
- const GLib = imports.gi.GLib; // legacy imports.gi (honours .versions)
790
- imports.gi.versions.Gtk = '4.0';
791
- console.log(imports.gettext.gettext('x')); // no-translation passthrough
792
-
793
- // Legacy script modules many older GJS sources use:
794
- const emitter = {};
795
- imports.signals.addSignalMethods(emitter); // the pure-JS Signals mixin
796
- emitter.connect('ready', () => imports.mainloop.quit());
797
- imports.mainloop.timeout_add(50, () => { emitter.emit('ready'); return false; });
798
- imports.mainloop.run(); // thin GLib.MainLoop wrapper
799
-
800
- // imports.byteArray — the legacy byte-array module, with GJS semantics
801
- // (fromString/toString are zero-terminated + fatal-decode; fromGBytes/toGBytes
802
- // round-trip GLib.Bytes; fromArray wraps in the legacy ByteArray class). This
803
- // is the seam @gjsify/utils' cli()/gbytesToUint8Array — and through them
804
- // @gjsify/os + @gjsify/child_process — read GLib subprocess output with:
805
- const [ok, out] = imports.gi.GLib.spawn_command_line_sync('echo hi');
806
- console.log(imports.byteArray.toString(out)); // 'hi\n'
807
- const bytes = imports.byteArray.toGBytes(Uint8Array.of(1, 2, 3));
808
- imports.byteArray.fromGBytes(bytes); // Uint8Array [1, 2, 3] (a copy)
809
- ```
810
-
811
- A follow-up `--app node` build step will inject this automatically for any
812
- bundle that references those globals (so `const Gtk = imports.gi.Gtk` /
813
- `print(...)` GJS source runs unmodified on Node); today it is an explicit import.
814
-
815
- The remaining GJS-compatible surface (`import GLib from 'gi://GLib?version=2.0'`,
816
- `const GLib = imports.gi.GLib`, the core overrides, `_promisify`, the legacy
817
- `imports.*` modules) is layered on top of this engine in the gjsify bundler
818
- integration and subsequent drops.
819
-
820
- ### cairo (`@gjsify/node-gi/cairo`)
821
-
822
- cairo is a **foreign struct** in GObject-Introspection: GI does not know the
823
- layout of `cairo_t` / `cairo_surface_t` / `cairo_pattern_t`, so it delegates their
824
- marshalling to a module. GJS ships a native cairo binding + a foreign-struct
825
- registration so that a GI function taking/returning a cairo pointer (e.g. a
826
- `Gtk.DrawingArea` draw-func's `cairo_t`) round-trips to/from the JS cairo objects.
827
- node-gi ports that seam: the same drawing code runs on GJS (native cairo) and Node
828
- (this binding). An npm `cairo` package cannot stand in — a foreign cairo argument
829
- must marshal through the SAME module the engine's foreign-struct seam knows about.
830
-
831
- ```js
832
- import cairo from '@gjsify/node-gi/cairo'; // bare `cairo` on the --app node build
833
- import { requireGi } from '@gjsify/node-gi/gi';
118
+ No explicit main loop is required for async work — `requireGi` arms the
119
+ GLib↔event-loop bridge on the first namespace load, so an `await`ed Gio async
120
+ op settles from the runtime's own loop, while a blocking `GLib.MainLoop.run()`
121
+ keeps Node's timers and I/O alive the way it does under `gjs -m`.
834
122
 
835
- // Headless drawing — read the pixels back with getData().
836
- const surface = new cairo.ImageSurface(cairo.Format.ARGB32, 64, 48);
837
- const cr = new cairo.Context(surface);
838
- cr.setSourceRGB(0.8, 0.1, 0.1);
839
- cr.rectangle(8, 8, 20, 16);
840
- cr.fill();
841
- cr.$dispose();
842
- surface.flush();
843
- const pixels = surface.getData(); // Uint8Array (stride * height), ARGB32
123
+ The whole surface — `GObject.registerClass`, `GLib.Variant`, the DBus client and
124
+ export halves, the legacy `imports.*` modules, cairo, and the measured
125
+ divergences from GJS — is
126
+ [docs/node-gi-gjs-surface.md](../../../docs/node-gi-gjs-surface.md).
844
127
 
845
- // The foreign seam: a GI function taking a cairo_t marshals the Context through.
846
- const PangoCairo = requireGi('PangoCairo', '1.0');
847
- const layout = PangoCairo.create_layout(new cairo.Context(surface));
848
-
849
- // A Gtk.DrawingArea draw-func receives a cairo_t → a live cairo.Context:
850
- // area.set_draw_func((_area, ctx, w, h) => { ctx.setSourceRGB(1, 0, 0); … });
851
- ```
852
-
853
- Ported this slice: `cairo.Context` (drawing + transform ops incl.
854
- `identityMatrix` and the `userToDevice[Distance]` / `deviceToUser[Distance]`
855
- point transforms, state getters, `setDash`/`getDashCount` (+ a net-new
856
- `getDash`), `inFill`/`inStroke`, `newSubPath`, `copyPath`/`copyPathFlat`/
857
- `appendPath` (owned `cairo.Path` handles), `getSource` with concrete-subclass
858
- fan-out, `$dispose`), `cairo.Surface` + `cairo.ImageSurface` (`getData`/
859
- `getWidth`/`getHeight`/`getStride`/`getFormat`/`flush`/`writeToPNG`/
860
- `createFromPNG`), the patterns — `cairo.SolidPattern`,
861
- `cairo.LinearGradient`/`cairo.RadialGradient` (`addColorStopRGB[A]` via the
862
- shared `cairo.Gradient` base), `cairo.SurfacePattern`
863
- (`setExtend`/`getExtend`/`setFilter`/`getFilter`) — the opaque `cairo.Path`,
864
- and the enums (`Format`, `Operator`, `Content`, `Extend`, `Filter`, …). This is
865
- the full surface `@gjsify/canvas2d-core` draws through (headless Canvas 2D).
866
- The native binding paints **byte-for-byte identically to GJS** (verified
867
- pixel-for-pixel against `gjs -m`, incl. gradients / repeating surface patterns /
868
- dashed strokes / path round-trips — `test/cairo-canvas2d.test.mjs`). Deferred:
869
- region objects, the PDF/SVG/PS surfaces, and the text/font ops
870
- (`showText`/`selectFontFace` — canvas2d text rides PangoCairo instead).
871
-
872
- Building on that seam, the **LIVE `@gjsify/canvas2d` `Canvas2DBridge`** — a
873
- `Gtk.DrawingArea` that wraps an `HTMLCanvasElement` 2D context and blits its Cairo
874
- surface onto the widget each frame — realizes, draws and blits UNCHANGED on
875
- node-gi under a display: an app draws via the standard `canvas.getContext('2d')`
876
- DOM API in `bridge.onReady`, the GTK draw_func fires, the bridge blits
877
- (`cr.setSourceSurface` + `cr.paint`) and the rAF (`add_tick_callback`) path ticks.
878
- The same source builds `--app gjs` and `--app node` and prints byte-identical
879
- output, pixels read back off the canvas included — `test/canvas2d-bridge.test.mjs`
880
- + `fixtures/canvas2d-bridge-app.ts`. It is a **local/dev verification** (see the
881
- run recipe in the test header), NOT wired into CI: the LIVE bridge pulls the whole
882
- `@gjsify/canvas2d` gi:// graph, so it needs the full gjsify workspace built with a
883
- current-source `@gjsify/cli` (the bare-`cairo`→`@gjsify/node-gi/cairo` and
884
- register-inline fixes the published CLI predates) plus a display and the addon — a
885
- heavyweight from-scratch rebuild not worth gating a minimal CI container on. The
886
- test self-skips in the default `npm test` (no display). One node-only note: a
887
- mapped `Gtk.DrawingArea`'s live `GdkFrameClock` stays an active GLib source after
888
- `app.quit()`, so — matching the documented lifetime divergence — a node-gi GTK
889
- program that must terminate exits explicitly (`process.exit(0)`), whereas `gjs -m`
890
- exits on module completion.
891
-
892
- ### The live `@gjsify/event-bridge` dispatches DOM events on node-gi
893
-
894
- The GTK→DOM event bridge (`@gjsify/event-bridge`'s `attachEventControllers`) —
895
- which attaches GTK4 `EventControllerMotion`/`GestureClick`/`EventControllerScroll`/
896
- `EventControllerKey`/`EventControllerFocus` to a widget and dispatches W3C DOM
897
- events (Mouse/Pointer/Keyboard/Wheel/FocusEvent) — runs UNCHANGED on node-gi. The
898
- shared fixture presents a `Gtk.DrawingArea`, attaches the controllers, and drives a
899
- SYNTHESIZED event through each live `Gtk.EventController*` via `emit(signal, …)`
900
- (the same path the GJS `event-bridge.spec.ts` drives), then asserts the dispatched
901
- DOM event's type / coords / `getModifierState` / key / code. The `Gdk.ModifierType`
902
- flags and `Gdk.keyval_name`/`Gdk.keyval_to_unicode` marshalling produce
903
- byte-identical DOM events under node-gi and `gjs -m` — the same source builds
904
- `--app gjs` and `--app node` and prints the committed golden
905
- (`test/event-bridge.test.mjs` + `fixtures/event-bridge-app.ts`). Every golden line
906
- is deterministic + display-independent (coords clamp to a fixed 400x300 allocation;
907
- key/code/modifiers derive from the Gdk marshalling), so byte-parity is stable.
128
+ ## Runtimes (Node / Bun / Deno)
908
129
 
909
- **`instanceof` across the GObject hierarchy (GJS parity):** `instanceof` for GObject
910
- wrapper classes is wired through the GObject type system — each per-GType wrapper
911
- carries a `Symbol.hasInstance` that resolves via `g_type_is_a` (native
912
- `isInstanceOf`), so `new Gtk.EventControllerMotion() instanceof
913
- Gtk.EventControllerMotion` is `true`, and so is a base class (`… instanceof
914
- Gtk.EventController`), an implemented interface (`simpleAction instanceof Gio.Action`)
915
- and a `registerClass` subclass against its leaf / base / interface — while a sibling
916
- type, an unrelated class, a boxed/`Variant` handle, `null` or a plain object stay
917
- `false`. `test/instanceof.test.mjs` + the cross-runtime golden
918
- `conformance/programs/instanceof-hierarchy.conf.mjs` (gjs/node/bun/deno byte-identical)
919
- guard it. The event-bridge fixture retrieves controllers by ADD ORDER off
920
- `widget.observe_controllers()` as a stylistic choice (identity is preserved and
921
- `emit()` resolves the signal by the live GType) — no longer forced by a gap.
922
- (A second gap the fixture originally routed around — `new
923
- Gdk.Rectangle()` threw `no static method 'new'` — is FIXED: `new <BoxedStruct>()`
924
- now zero-allocates with GJS `gi/boxed.cpp` semantics when the struct has no `new`
925
- constructor (`Graphene.Rect`/`Point`, `Gdk.Rectangle`, `Gdk.RGBA` — the
926
- `@gjsify/devtools` screenshot chain), routes to `new` when it exists, and throws
927
- a clear error for args without one; `test/struct-construct.test.mjs` guards it,
928
- gjs-parity included. The fixture still reads the presented window's real
929
- allocation — simpler and display-truthful.)
130
+ The engine is a **Node-API** addon, so the same binary loads and runs on **Node,
131
+ Bun and Deno** — Node-API is their common native ABI (no separate bindings, no Rust
132
+ rewrite). `index.js` detects the runtime and prefers a shipped
133
+ `prebuilds/<platform>-<arch>/node_gi.node` over a local `build/` (Deno runs no
134
+ postinstall build, so a prebuild is its only install path — stage one with
135
+ `npm run build:prebuild`).
930
136
 
931
- **Run it (needs a display + a built workspace + the `gjsify` CLI; self-skips
932
- otherwise):**
137
+ | Capability | Node | Bun | Deno |
138
+ |---|:--:|:--:|:--:|
139
+ | introspection, marshalling, enums, variants | ✅ | ✅ | ✅ |
140
+ | GObject create / properties / signals | ✅ | ✅ | ✅ |
141
+ | `registerClass` / subclass / vfunc chain-up | ✅ | ✅ | ✅ |
142
+ | toggle-ref GC + cross-thread teardown | ✅ | ✅ | ✅ |
143
+ | GLib async with NO mainloop (timeouts, GIO async, DBus) | ✅ auto | via `startMainContextPump` | via `startMainContextPump` |
144
+ | blocking `GLib.MainLoop.run()` / `Gio.Application.runAsync()` | ✅ | ✅ | ✅ |
145
+ | Promise continuations drain **during** a blocking GLib loop (async DBus replies, `await` chains) | ✅ | ✅ | ✅ |
146
+ | Node timers/I/O alive **during** a blocking GLib loop (uv co-pump) | ✅ | — | — |
933
147
 
934
- ```sh
935
- # node-gi (--app node) — the authoritative check vs the committed golden:
936
- export GJSIFY_BIN="$(git rev-parse --show-toplevel)/packages/infra/cli/lib/index.js"
937
- xvfb-run -a dbus-run-session -- \
938
- env GSK_RENDERER=cairo GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 GTK_A11Y=none \
939
- NODE_GI_NATIVE=build NODE_GI_EB_SKIP_GJS=1 GJSIFY_BIN="$GJSIFY_BIN" \
940
- node --test test/event-bridge.test.mjs
941
- # Drop NODE_GI_EB_SKIP_GJS to additionally re-prove the golden IS gjs's own output
942
- # (builds + runs --app gjs; needs the workspace CLI, not the published one).
943
- ```
148
+ Two libuv-coupled subsystems are what make that portable: the toggle-ref
149
+ teardown drain uses a `napi_threadsafe_function` (core Node-API) rather than a raw
150
+ `uv_async_t`, and the main loop runs both directions — Node co-pumps libuv during
151
+ a blocking GLib loop and dispatches pending GLib sources from its own loop when
152
+ none runs, while Bun/Deno iterate the default GLib context from a runtime timer
153
+ (`startMainContextPump` from `@gjsify/node-gi/gi`, auto-armed by `requireGi`).
944
154
 
945
- `GJSIFY_BIN` must point at the WORKSPACE-built `@gjsify/cli` (`packages/infra/cli/lib/index.js`),
946
- not the published `@gjsify/cli` — the fixture is built with current source, which
947
- carries this session's bundler fixes the published `0.18.0` predates.
948
- ### WebGL / `Gtk.GLArea` (the gwebgl seam)
155
+ Bun reaches full parity with Node on the core surface; Deno passes the same
156
+ conformance subset since 2.9 (the marshalling/async N-API quirks that excluded
157
+ `arrays`/`async-error` on Deno 2.1 are fixed upstream) — the Node-only co-pump
158
+ cases are the remaining, by-design gap. The authoritative full suite runs on Node.
949
159
 
950
- **Definitive: a `Gtk.GLArea` realizes and hands JS a LIVE, CURRENT GL context
951
- under node-gi on a headless software-GL display.** Verified end-to-end by
952
- `test/webgl-glarea.test.mjs` + the ONE dual-runtime source
953
- `fixtures/webgl-glarea-app.ts`: a presented `Gtk.ApplicationWindow` holding a
954
- `Gtk.GLArea` configured exactly like `@gjsify/webgl`'s `WebGLBridge`
955
- (`set_use_es(true)`, `set_required_version(3, 2)`, depth + stencil) realizes
956
- with `get_error() === null`, an **OpenGL ES 3.2** context
957
- (`Gdk.GLContext.get_current()` non-null in both `realize` and `render`), and
958
- the `gwebgl` Vala bridge (`new Gwebgl.WebGLRenderingContextBase()` — the native
959
- class `@gjsify/webgl` wraps) works through it: `getString(GL_VERSION/…)`, a
960
- `getParameterx` **GVariant** round-trip, and a real WebGL draw —
961
- `clearColor(1,0,0,1)` + `clear` + `readPixels` reading back the exact
962
- `255,0,0,255` pixel. The committed golden is byte-identical between `gjs -m`
963
- and `node` (the gjs gold-standard leg re-proves it wherever `gjs` is present;
964
- `NODE_GI_WEBGL_SKIP_GJS=1` skips that leg).
160
+ **The measured detail lives one hop away:** the hand-measured `linux-arm64` /
161
+ musl / Bun / Deno numbers, the darwin dyld loader gap (why
162
+ `Failed to load shared library 'libgtk-4.1.dylib' referenced by the typelib` is
163
+ never a node-gi defect), and the GTK runtime bundles are
164
+ [docs/node-gi-platform-notes.md](../../../docs/node-gi-platform-notes.md).
965
165
 
966
- The GL/display env the golden is pinned to (software GL, no GPU needed):
166
+ ## Build & test
967
167
 
968
168
  ```bash
969
- # X11 (Xvfb or a real display) + mesa llvmpipe + GTK compositing off GL:
970
- xvfb-run -a dbus-run-session -- \
971
- env GSK_RENDERER=cairo GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 GTK_A11Y=none \
972
- NODE_GI_NATIVE=build node --test test/webgl-glarea.test.mjs
973
- # GL under llvmpipe: "OpenGL ES 3.2 Mesa …" / "llvmpipe (LLVM …)".
974
- ```
975
-
976
- **The FULL `@gjsify/webgl` `WebGLBridge` runs too** (same test file, second
977
- fixture `fixtures/webgl-bridge-app.ts`): the complete TS WebGL stack UNCHANGED —
978
- `WebGLBridge` (a `registerClass` `Gtk.GLArea` subclass), `onReady` handing out
979
- `HTMLCanvasElement` + `WebGLRenderingContext` (constants GHashTable, the
980
- `_init()` `getParameterx` GVariant reads, eager WebGL1+2 context construction),
981
- and browser-standard `clearColor(0,0,1,1)` + `clear` + `readPixels` reading the
982
- blue clear back (`bridge-pixel(0,0): 0,0,255,255`), byte-identical gjs ↔ node.
983
- Shader/buffer/texture breadth (a three.js triangle/teapot) is the remaining
984
- follow-up — the seam + context stack are proven.
985
-
986
- The tests self-skip without a display, without a `gjsify` CLI, or without the
987
- committed `Gwebgl-0.1` prebuild (`packages/framework/webgl-linux-*/prebuilds/`,
988
- which the test itself puts on `GI_TYPELIB_PATH`/`LD_LIBRARY_PATH`); the bridge
989
- test additionally skips when `@gjsify/webgl` is not built. The fixture build
990
- needs a WORKSPACE-built `@gjsify/cli` (point `GJSIFY_BIN` at
991
- `packages/infra/cli/lib/index.js` after
992
- `gjsify workspace @gjsify/cli build --with-dependencies`), like
993
- `canvas2d-bridge`; the gjs gold-standard leg additionally needs the workspace
994
- register libs built (`--app gjs` force-inlines `<pkg>/register`). Two engine
995
- gaps this spike fixed on the way (headless regression coverage in
996
- `test/gerror-return.test.mjs`): GError-typed RETURNS
997
- (`Gtk.GLArea.get_error()` — `GI_TYPE_TAG_ERROR` → a field-readable GLib.Error
998
- boxed) and literal-first method-name resolution (Vala GIRs carry camelCase
999
- names — Gwebgl's `getString` — which the unconditional camelCase→snake_case
1000
- alias destroyed; the engine now resolves the literal name first, alias second).
1001
-
1002
- ### Excalibur.js renders through WebGL on node-gi (the GTK-bridge capstone)
1003
-
1004
- **Definitive: a REAL WebGL game engine — Excalibur 0.32, the engine behind the
1005
- `excalibur-jelly-jumper` showcase and the PixelRPG map-editor — boots, runs its
1006
- clock, and renders frames through `@gjsify/webgl`'s `WebGLBridge` UNCHANGED
1007
- under node-gi** (`test/excalibur-webgl.test.mjs` + the ONE dual-runtime source
1008
- `fixtures/excalibur-webgl-app.ts`). `new ex.Engine({ canvasElement })` builds
1009
- against the bridge's `HTMLCanvasElement` (WebGL2 context), `engine.start()`
1010
- resolves, the engine's real render pipeline runs (shader compile/link,
1011
- `bufferData`, VAOs, `vertexAttribPointer`, `drawArrays`/`drawElements`,
1012
- `clearBufferfv` at `RenderTarget.blitToScreen`) for 5 frames, and the committed
1013
- golden asserts the pixels read back off the GL framebuffer — the screen-centered
1014
- blue Actor and the red engine clear color — **byte-identical between `gjs -m`
1015
- and `node`**. The DOM surface (document/HTMLCanvasElement/ResizeObserver/
1016
- matchMedia/XHR) comes from the SAME `@gjsify/*` registers the gjs build injects,
1017
- via the `--app node` explicit-`--globals` reverse-bridge injection.
1018
-
1019
- Excalibur's real GL + engine usage exposed four core gaps, all fixed at the
1020
- engine (each with regression coverage):
1021
-
1022
- - **`GVariant 'ay'` rejects `null`** (`src/variant.cc`) — GJS packs
1023
- `new GLib.Variant('ay', null)` as the EMPTY byte array (`GLib.Bytes(null)`);
1024
- node-gi threw. Exposing call: Excalibur's `texImage2D(..., null)`
1025
- blank-texture allocation at renderer init (`Uint8ArrayToVariant(null)`).
1026
- - **Unknown members must be `undefined`, not a throw-on-call thunk**
1027
- (`src/calls.cc` `hasMethod` + the L1 wrapper `get`) — real consumers
1028
- feature-detect optional native methods (`typeof gl.clearBufferfv ===
1029
- 'function'` gates `@gjsify/webgl`'s clearBuffer emulation, hit at
1030
- `blitToScreen`); the old always-a-function proxy made that detection lie,
1031
- then threw mid-frame. `hasMethod(handle, name)` resolves through the SAME
1032
- literal-first/snake-alias walk `callMethod` uses.
1033
- - **Signal dispatch now runs the microtask checkpoint at its boundary**
1034
- (`src/signals.cc`, `napi_make_callback`) — GJS drains the promise-job queue
1035
- when the outermost JS frame exits; promise chains resolved inside a
1036
- loop-dispatched signal handler (Excalibur's whole `engine.start()` boot,
1037
- queued from the GLArea `render`/`onReady` dispatch) previously lingered
1038
- until the libuv↔GLib bridge's prepare-phase drain.
1039
- - **The uv co-pump source must not outrank GTK painting** (`src/loop.cc`) —
1040
- at the default `G_PRIORITY_DEFAULT` a busy Node loop STARVED
1041
- `GDK_PRIORITY_REDRAW`: Excalibur's `requestIdleCallback` polyfill (a
1042
- self-re-arming 1 ms `setTimeout`, run perpetually by its GarbageCollector)
1043
- kept the UvLoopSource ready on every GLib iteration, so ticks/renders/rAF
1044
- froze while plain GLib timeouts kept firing. The source now sits below
1045
- redraw (`G_PRIORITY_HIGH_IDLE + 30`): rendering outranks Node timers,
1046
- browser-like, and Node I/O still runs in every frame gap.
1047
-
1048
- Run it (a LOCAL/dev verification like the other display suites — self-skips
1049
- without a display / CLI / prebuild / built workspace):
1050
-
1051
- ```sh
1052
- export GJSIFY_BIN="$(git rev-parse --show-toplevel)/packages/infra/cli/lib/index.js"
1053
- xvfb-run -a dbus-run-session -- \
1054
- env -u FORCE_COLOR GSK_RENDERER=cairo GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 \
1055
- GTK_A11Y=none NODE_GI_NATIVE=build GJSIFY_BIN="$GJSIFY_BIN" \
1056
- node --test test/excalibur-webgl.test.mjs
1057
- # Drop NODE_GI_EXCALIBUR_SKIP_GJS to additionally re-prove the golden IS gjs's
1058
- # own byte-output (builds + runs --app gjs).
1059
- ```
1060
-
1061
- The FULL `excalibur-jelly-jumper` showcase builds `--app node`
1062
- (`gjsify run build:node`; `gjsify.example.runtimes` includes `node`) and gets
1063
- remarkably far on node-gi: the GTK window presents, the devtools control plane
1064
- exports over DBus, `Gst.init` runs and every `ex.Sound` constructs its
1065
- Gst-backed `AudioContext` (this exposed + fixed the nullable-array `null`
1066
- marshalling — `Gst.init(null)`), and Excalibur boots into resource loading.
1067
- The remaining blocker is POLYFILL ROUTING, not marshalling: on Node the
1068
- GLOBAL `fetch` is the native undici one (the register convention never
1069
- overrides an existing native), and `@excaliburjs/plugin-tiled`'s fileLoader
1070
- feeds it the root-relative `/res/…` paths that only OUR GJS fetch/XHR resolve
1071
- against the program dir — undici rejects them (`Failed to parse URL`), the
1072
- Tiled map never loads, and scene init fails. Making the reverse bridge route
1073
- `fetch` (and friends) to the `@gjsify/*` polyfills over the runtime natives is
1074
- the follow-up that unlocks the full game. (`jsdom` — plugin-tiled's node-side
1075
- DOMParser fallback — is aliased to `@gjsify/empty` in `build:node`, mirroring
1076
- the plugin's own `"browser": { "jsdom": false }`.)
1077
-
1078
- ### A real Adwaita WINDOW realizes + renders (the GTK-GUI capstone)
1079
-
1080
- Beyond the display-free conformance: an UNCHANGED `Adw.Application` +
1081
- `Adw.ApplicationWindow` (HeaderBar / WindowTitle / StatusPage) not only
1082
- constructs + presents but **realizes and RENDERS a surface** through the GSK
1083
- renderer on node-gi — the same in-process capture path `@gjsify/devtools`'
1084
- `Screenshot` uses: `Gtk.WidgetPaintable` → `Gtk.Snapshot.to_node()` →
1085
- `Gsk.Renderer.render_texture` → `Gdk.Texture.save_to_png_bytes`. A non-empty PNG
1086
- is the unambiguous proof that a `GdkSurface` was allocated + a GSK render tree
1087
- rasterised — not reachable by any headless program. Guarded by
1088
- `test/windowing.test.mjs` + `test/windowing-interactive.test.mjs` (an
1089
- `Adw.ApplicationWindow` that RESPONDS to a `Gio.SimpleAction` + a
1090
- `Gtk.Button::clicked` through the node-gi signal chain) + `test/widgets.test.mjs`
1091
- (the Adwaita widget breadth below) — all self-skip without a display on Linux (the
1092
- win32/darwin GDK backend supplies its own display, so they run there), wired into
1093
- the Linux `gtk-smoke` + the Windows windowing CI jobs. The
1094
- `showcases/gtk/node-gi-window` showcase runs the SAME single source on both GJS
1095
- and Node and screenshots the live window over the `org.gjsify.Devtools` DBus
1096
- surface.
1097
-
1098
- This exposed one core gap, fixed at the engine:
1099
-
1100
- - **Non-GObject GObject-fundamentals wrap through their introspected ref/unref,
1101
- not `WrapGObject`** (`src/object.cc` `MakeFundamentalHandle` + the
1102
- `src/marshal.cc` return branch). `Gtk.Snapshot.to_node()` returns a
1103
- `GskRenderNode` — introspected as OBJECT_INFO but a GObject FUNDAMENTAL
1104
- (`gi_object_info_get_fundamental`), ref-counted via `gsk_render_node_ref/unref`,
1105
- NOT `g_object_ref`, with `G_IS_OBJECT` FALSE. Routing it through `WrapGObject`
1106
- ran the toggle-ref/qdata dance on a non-GObject → a cascade of
1107
- `g_object_*: assertion 'G_IS_OBJECT (object)' failed` criticals AND a leaked ref.
1108
- It now gets a type-tagged External carrying the raw pointer + the introspected
1109
- unref func as the finalizer hint (`isFundamentalHandle` / L1 `wrapFundamental`,
1110
- an opaque round-trippable pass-through) — GParamSpec + GValue keep their
1111
- dedicated branches, this catches the rest.
1112
-
1113
- ### Windows: the FULL-windowing GTK runtime bundle
1114
-
1115
- The batteries-included [`@gjsify/gtk-runtime-win32-x64`](../gtk-runtime-win32-x64)
1116
- bundle has two closures: the DEFAULT display-free set (loadable DLLs + typelibs)
1117
- and the `--windowing` SUPERSET that adds the runtime DATA a real GTK window needs
1118
- on Windows — the gdk-pixbuf loaders + `loaders.cache`, compiled GSettings schemas
1119
- (`gschemas.compiled`), the Adwaita/hicolor icon themes + `icon-theme.cache`, and
1120
- Fontconfig config/cache. node-gi's loader (`gtk-runtime.js`
1121
- `maybeWireGtkWindowingEnv`) detects the windowing data via the `gschemas.compiled`
1122
- marker and wires the env (`GSETTINGS_SCHEMA_DIR` / `GDK_PIXBUF_MODULE_FILE` /
1123
- `XDG_DATA_DIRS` / `FONTCONFIG_*`); a display-free bundle carries no marker, so the
1124
- wiring is a strict no-op and that load is byte-unchanged.
1125
-
1126
- The bundle is not pulled in automatically — `npm install
1127
- @gjsify/gtk-runtime-win32-x64` alongside node-gi (see
1128
- [Requirements](#requirements)). `resolveGtkRuntimeBundle()` finds it wherever the
1129
- package manager placed it: an explicit `GJSIFY_GTK_RUNTIME` dir, a staged
1130
- `prebuilds/<target>/gtk/`, the sibling monorepo checkout, or
1131
- `require.resolve('@gjsify/gtk-runtime-<target>')` — the last of which covers a
1132
- hoisted `node_modules/@gjsify/gtk-runtime-win32-x64`, so no loader change is
1133
- needed if the dependency edge is ever declared.
1134
-
1135
- ### Adwaita widget breadth realizes + reacts + renders
1136
-
1137
- Beyond one window's chrome: a representative slice of the REAL Libadwaita widget
1138
- set constructs, RENDERS and REACTS on node-gi. `test/widgets.test.mjs` builds an
1139
- `Adw.PreferencesPage` / `Adw.PreferencesGroup` of `Adw.ActionRow`, `Adw.SwitchRow`,
1140
- `Adw.EntryRow`, `Adw.ComboRow` (a `Gtk.StringList` model), `Adw.SpinRow` (a
1141
- `Gtk.Adjustment`) and `Adw.ExpanderRow`, plus a `Gtk.ListBox` and a dismissible
1142
- `Adw.Toast` via an `Adw.ToastOverlay`. Two tiers, robust on a runner whose surface
1143
- may or may not realize:
1144
-
1145
- - **DumpTree** — the widget tree contains every expected type (`AdwSwitchRow`,
1146
- `AdwComboRow`, `AdwEntryRow`, `AdwSpinRow`, `AdwExpanderRow`, `AdwPreferencesPage`,
1147
- `GtkListBox`, …), read via the runtime GType (`$typeName`) the `@gjsify/devtools`
1148
- DumpTree uses — so each class constructs + parents correctly through node-gi.
1149
- - **Interaction** — toggling the switch, changing the combo selection, moving the
1150
- spin value, expanding the expander and setting the entry text all drive
1151
- OBSERVABLE property changes AND fire their paired `notify::<prop>` handlers, and a
1152
- toast add → `dismiss()` fires `::dismissed`. This surfaces the `Gtk.StringList` /
1153
- `Gtk.Adjustment` model marshalling (GListModel + object construct props),
1154
- `notify::` property signals and the boxed-model paths — all display-independent, so
1155
- the interaction + DumpTree tier holds headless on Windows CI.
1156
- - **Render** — when the surface realizes, the whole preferences surface rasterises
1157
- through the GSK renderer (a non-empty PNG), the strong proof the broad widget set
1158
- renders, not just constructs.
1159
-
1160
- The same widgets back the `showcases/gtk/node-gi-window` "Settings" view (an
1161
- `Adw.ViewStack` page reachable from the bottom `Adw.ViewSwitcherBar`, beside the
1162
- counter). No engine change and no windowing-bundle change were needed: the rows are
1163
- core GTK4/Adwaita widgets backed by the already-bundled `gtk-4-*.dll` / `libadwaita-1`
1164
- and the full Adwaita icon theme + compiled schemas the `--windowing` bundle already
1165
- ships.
1166
-
1167
- Scoped out of the capstone fixture itself (deliberately): the Gst audio
1168
- DECODE/playback path (`decodeAudioData`, `autoaudiosink`) — construction is
1169
- proven by the showcase run above, the streaming pipeline is its own follow-up
1170
- surface.
169
+ npm install # prebuild if one is staged, else node-gyp (scripts/install.mjs)
170
+ npm test # node --test — the full suite on Node, the authoritative one
171
+ npm run test:bun # conformance subset on Bun (needs `bun`)
172
+ npm run test:deno # conformance subset on Deno (needs `deno`)
173
+ npm run test:conformance # the gjs × node × bun × deno golden-diff matrix
174
+ ```
175
+
176
+ **In a checkout a staged prebuild shadows your local build**, so every `test*`
177
+ script pins `NODE_GI_NATIVE=build`; an ad-hoc probe must too. The full
178
+ contributor picture — the load order and its env vars, the toggle-ref debug
179
+ seams, the golden-diff conformance contract, the GJS installed-tests port, and
180
+ the display capstones with the exact environment each one needs — is
181
+ [docs/node-gi-verification.md](../../../docs/node-gi-verification.md).
182
+
183
+ ## Further reading
184
+
185
+ | | |
186
+ |---|---|
187
+ | the GJS-compatible surface, in full | [docs/node-gi-gjs-surface.md](../../../docs/node-gi-gjs-surface.md) |
188
+ | build, test, conformance, display capstones | [docs/node-gi-verification.md](../../../docs/node-gi-verification.md) |
189
+ | cross-runtime, darwin/win32 loading, GTK bundles | [docs/node-gi-platform-notes.md](../../../docs/node-gi-platform-notes.md) |
190
+ | the engineering rules for this tree | [packages/node-gi/AGENTS.md](../AGENTS.md) |
191
+ | scope + tier decision | [ADR 0005](../../../docs/adr/0005-node-gi-scope.md) |