@gjsify/node-gi 0.13.0 → 0.20.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
@@ -9,11 +9,34 @@ It loads `gi://` namespaces (GLib, GObject, Gio, …) via `libgirepository` and
9
9
  exposes them with GJS-compatible semantics, so the same source builds and runs
10
10
  on both GJS and Node via `gjsify build --app {gjs,node}`.
11
11
 
12
+ > **Status: product (Tier 2 —
13
+ > [ADR 0005](../../../docs/adr/0005-node-gi-scope.md)).** node-gi graduated
14
+ > from Tier 3 on 2026-07-14 once the four gate items landed: the
15
+ > toggle-ref/multi-env teardown crash fixed, vfunc OUT/INOUT chain-up, the
16
+ > GTK/Cairo layer, and a second real consumer (`@gjsify/sqlite`'s suite runs on
17
+ > node-gi). Best-effort now: tested, released on the train, breaking changes ship
18
+ > with a minor + changelog note. **Dependency isolation is still an invariant:**
19
+ > no Tier-1/2 `@gjsify/*` package may take a hard dependency (`dependencies` /
20
+ > `optionalDependencies`) on `@gjsify/node-gi` — the reverse bridge would double
21
+ > the runtime test matrix. The sanctioned seams stay a devDependency
22
+ > (`--runtime node` dev flows, the `@gjsify/sqlite` consumer) and the conditional
23
+ > `--app node` build injection — enforced by `scripts/audit-runtimes.mjs` in CI.
24
+
12
25
  > **Status: milestone 1 (headless core) — in progress.** The native engine over
13
26
  > the modern `girepository-2.0` API now does: resolve the default repository,
14
27
  > `require` a namespace, enumerate its infos; call namespace-level functions and
15
28
  > **instance methods** (own + implemented-interface methods, up the parent chain)
16
- > with value marshalling (numbers, booleans, strings, GObjects, enums/flags);
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, …)`);
17
40
  > construct GObjects and read/write properties (GValue round-trip); connect /
18
41
  > emit / disconnect signals (incl. detailed names like `notify::prop`); and
19
42
  > **register GObject subclasses** (subtype + construct-by-type, inheriting the
@@ -25,11 +48,20 @@ on both GJS and Node via `gjsify build --app {gjs,node}`.
25
48
  > access, `action.get_name()` methods, `.connect()/.emit()/.disconnect()`, and
26
49
  > enums / flags / constants (`Gio.BusType.SESSION`, `GLib.PRIORITY_DEFAULT`);
27
50
  > constructor/static methods (`Gio.File.new_for_path(...)`); and both snake_case
28
- > and camelCase accessors. A **libuv↔GLib mainloop bridge** (`startMainLoop`,
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`,
29
57
  > auto-attached by `requireGi`) nests Node's libuv loop inside the GLib loop, so a
30
58
  > blocking `GLib.MainLoop.run()` keeps Node's timers/I/O alive — including the
31
59
  > **boxed/struct slice** that needs (`GLib.MainLoop.new(...)` → a boxed handle →
32
- > `.run()`/`.quit()`). **JS functions marshal as GI callbacks** via an ffi
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
33
65
  > closure (`GLib.timeout_add`/`idle_add` fire from the loop, the boolean return
34
66
  > drives source continuation; the hidden user_data/destroy slots are auto-filled).
35
67
  > register GObject subclasses (subtype + construct-by-type, inheriting the
@@ -59,17 +91,168 @@ vendored as-is — gjsify ships its own dual (GJS + Node) example/test infra.
59
91
  (Fedora: `glib2-devel gobject-introspection-devel gcc-c++`;
60
92
  Debian/Ubuntu: `libglib2.0-dev libgirepository-2.0-dev g++`)
61
93
  - At runtime, the target libraries' typelibs must be installed (same as `gi://`
62
- under GJS).
94
+ under GJS) — **except on macOS arm64**, where the optional
95
+ [`@gjsify/gtk-runtime-darwin-arm64`](../gtk-runtime-darwin-arm64) bundle ships a
96
+ relocated GTK/GI runtime so the display-free surface works with no Homebrew GTK
97
+ (Phase 2). node-gi auto-detects the bundle at load and prepends its typelib dir
98
+ to the GIRepository search path (`gtk-runtime.js`).
99
+ - **Node.js ≥ 20, Bun ≥ 1.3, or Deno ≥ 2** — the addon is Node-API, so one binary
100
+ runs on all three (see [Runtimes](#runtimes-node--bun--deno)).
101
+
102
+ ## Runtimes (Node / Bun / Deno)
103
+
104
+ The engine is a **Node-API** addon, so the same binary loads and runs on **Node,
105
+ Bun and Deno** — Node-API is their common native ABI (no separate bindings, no Rust
106
+ rewrite). `index.js` detects the runtime and prefers a shipped
107
+ `prebuilds/<platform>-<arch>/node_gi.node` over a local `build/` (Deno runs no
108
+ postinstall build, so a prebuild is its only install path — stage one with
109
+ `npm run build:prebuild`).
110
+
111
+ Two libuv-coupled subsystems are portable across all three:
112
+
113
+ - **GC bridge** — the toggle-ref teardown drain uses a `napi_threadsafe_function`
114
+ (core Node-API), not node-gtk's raw `uv_async_t` (Deno exports no libuv symbols;
115
+ Bun panics on `uv_async_init`).
116
+ - **Main loop** — Node runs BOTH directions: the uv-nesting bridge co-pumps
117
+ Node's own event loop during a blocking GLib loop, and the **uv-driven
118
+ auto-pump** dispatches pending GLib sources from Node's loop when NO blocking
119
+ GLib loop runs (async Gio + `await` just works, no explicit loop). Bun/Deno
120
+ co-pump the non-blocking case by hand, iterating the default GLib context from
121
+ a runtime timer (`startMainContextPump` from `@gjsify/node-gi/gi`), so GIO
122
+ async callbacks / GLib timeouts / DBus fire while the runtime's own loop stays
123
+ live.
124
+
125
+ | Capability | Node | Bun | Deno |
126
+ |---|:--:|:--:|:--:|
127
+ | introspection, marshalling, enums, variants | ✅ | ✅ | ✅ |
128
+ | GObject create / properties / signals | ✅ | ✅ | ✅ |
129
+ | `registerClass` / subclass / vfunc chain-up | ✅ | ✅ | ✅ |
130
+ | toggle-ref GC + cross-thread teardown | ✅ | ✅ | ✅ |
131
+ | GLib async with NO mainloop (timeouts, GIO async, DBus) | ✅ auto | via `startMainContextPump` | via `startMainContextPump` |
132
+ | blocking `GLib.MainLoop.run()` / `Gio.Application.runAsync()` | ✅ | ✅ | ✅ |
133
+ | Promise continuations drain **during** a blocking GLib loop (async DBus replies, `await` chains) | ✅ | ✅ | ✅ |
134
+ | Node timers/I/O alive **during** a blocking GLib loop (uv co-pump) | ✅ | — | — |
135
+
136
+ Bun reaches full parity with Node on the core surface; Deno passes the same
137
+ conformance subset since 2.9 (the marshalling/async N-API quirks that excluded
138
+ `arrays`/`async-error` on Deno 2.1 are fixed upstream) — the Node-only co-pump
139
+ cases are the remaining, by-design gap. The authoritative full suite runs on Node.
140
+
141
+ Promise draining during a blocking loop is cross-runtime because the engine runs
142
+ a **microtask checkpoint at every outermost loop-dispatched GLib→JS callback
143
+ boundary**: Node's `napi_make_callback` performs it natively; Bun's and Deno's
144
+ do not, so on those runtimes the engine invokes the runtime's own drain
145
+ primitive (`bun:jsc` `drainMicrotasks` / Deno `core.runMicrotasks`), registered
146
+ at addon load (`src/loop.cc` `NodeGiMaybeDrainMicrotasks`). Without it, an
147
+ **async** (Promise-returning) DBus method handler exported via
148
+ `Gio.DBusExportedObject.wrapJSObject` never sent its reply on Bun/Deno while a
149
+ blocking `run()` owned the thread — the client timed out — while sync handlers
150
+ worked (regression: `test/dbus-async.test.mjs`). Runtime *timers/I/O* during a
151
+ blocking loop remain Node-only (the uv co-pump).
63
152
 
64
153
  ## Build & test
65
154
 
66
155
  ```bash
67
156
  npm install # builds the native addon via node-gyp (install script)
68
- npm test # node --test (smoke tests)
157
+ npm test # node --test (full suite, Node — authoritative)
158
+ npm run test:gc # node --test --expose-gc (toggle-ref GC-stress leg)
159
+ npm run test:bun # conformance subset on Bun (needs `bun`)
160
+ npm run test:deno # conformance subset on Deno (needs `deno`)
161
+ npm run build:prebuild # node-gyp rebuild + stage prebuilds/<platform>-<arch>/
69
162
  # or rebuild explicitly:
70
163
  npm run rebuild
71
164
  ```
72
165
 
166
+ The load order prefers a staged `prebuilds/<platform>-<arch>/node_gi.node` over
167
+ `build/Release` (the consumer/Deno install path). Local verification always runs
168
+ the **just-built** addon instead: the Node test scripts pin `NODE_GI_NATIVE=build`
169
+ and the bun/deno runner defaults to it — without that, a stale staged prebuild
170
+ silently shadows your build. CI's cross-runtime job sets `NODE_GI_NATIVE=prebuild`
171
+ to keep validating the prebuild load path with a freshly staged binary.
172
+ `NODE_GI_NATIVE` accepts `build`, `prebuild`, or an explicit path to a
173
+ `node_gi.node`.
174
+
175
+ Two debug-only env vars instrument the toggle-ref / teardown machinery (both
176
+ parsed once at first use, zero cost when unset — never set them in production):
177
+
178
+ - `NODE_GI_TOGGLE_DEBUG=1` — stderr tracing of the GC bridge: owner-env claim,
179
+ drain-TSFN create/release, shutdown-flag flips, teardown enqueue/drop, drain
180
+ runs/skips (with JS-availability), and the C→JS trampoline skips at env
181
+ teardown; each line carries the emitting thread.
182
+ - `NODE_GI_TOGGLE_TEARDOWN_DELAY_MS=<n>` — test-only latency seam (clamped to
183
+ 10s): the drain defers queued idle teardowns younger than `n` ms (re-waking
184
+ itself), which deterministically parks teardowns — with a pending drain wake —
185
+ across the event loop's exit. That is the regression vehicle for the
186
+ env-cleanup drain race (`test/gc-cross-thread.test.mjs`, "teardown drain
187
+ during env cleanup never aborts").
188
+
189
+ ## Conformance (golden-diff)
190
+
191
+ The exactness oracle for GJS parity: small self-contained `gi://` programs
192
+ under `conformance/programs/*.conf.mjs` run UNCHANGED on all four runtimes —
193
+ gjs natively (`gjs -m`, ambient `print`), node/bun/deno via a lightweight
194
+ generated runtime twin (the `globals.js` shim + `requireGi`, no bundler) — and
195
+ every runtime's **stdout must be byte-identical to the committed golden**
196
+ (`conformance/golden/<name>.txt`). **gjs is the reference**: the goldens ARE
197
+ the gjs output, and a gjs↔golden drift fails loudly (either GJS changed or the
198
+ golden is stale — never paper over it).
199
+
200
+ ```bash
201
+ npm run test:conformance # full matrix (gjs × node × bun × deno)
202
+ node scripts/conformance.mjs --runtimes=gjs,node # runtime subset (gjs/node never auto-skip)
203
+ node scripts/conformance.mjs --filter=variant # program subset
204
+ node scripts/conformance.mjs --update-golden # regenerate goldens from gjs
205
+ ```
206
+
207
+ Adding a program: drop `conformance/programs/<name>.conf.mjs` — default
208
+ imports of the exact shape `import Gio from 'gi://Gio?version=2.0';` only
209
+ (regex-rewritable to `requireGi`), output via the GJS-ambient `print()`,
210
+ strictly deterministic (no versions, paths, hostnames, timing; the runner sets
211
+ `LC_ALL=C`), ends cleanly — then `--update-golden`, eyeball the golden for
212
+ determinism, and commit both. Every feature PR extends this suite.
213
+
214
+ The ledger contract (`conformance/ledger.json`) is strict: every known-failing
215
+ program×runtime combo is a **committed entry**
216
+ `{ "program", "runtime", "reason", "issue"? }` — a failing combo *not* in the
217
+ ledger fails the run, and a passing combo still *in* the ledger fails as a
218
+ stale entry (remove it). Exit 0 means zero unexpected results; there are no
219
+ silent exclusions (bun/deno merely report `skipped` when not installed — gjs
220
+ and node never skip).
221
+
222
+ ### Tier B — GJS installed-tests port
223
+
224
+ The breadth oracle: GJS's own installed-tests
225
+ (`refs/gjs/installed-tests/js/testGIMarshalling.js`) encode GJS's marshalling
226
+ behavior against the purpose-built `GIMarshallingTests-1.0` typelib.
227
+ `gimarshalling/testGIMarshalling.port.mjs` is a near-verbatim port of that
228
+ file to `node:test` via a minimal jasmine shim
229
+ (`gimarshalling/jasmine-shim.mjs`), mapping the WHOLE upstream surface:
230
+ already-green sections run live, everything else is a `describeSkip` stub
231
+ naming the upstream section. Assertions are never weakened.
232
+
233
+ ```bash
234
+ npm run test:gimarshalling # builds the pinned typelibs if missing, then runs the port
235
+ ```
236
+
237
+ `scripts/build-gi-test-typelibs.mjs` builds the test typelibs reproducibly
238
+ from GNOME's gobject-introspection-tests project at the **pinned revision**
239
+ GJS itself tests against (`PINNED_REV`, copied from
240
+ `refs/gjs/subprojects/gobject-introspection-tests.wrap`) into the gitignored
241
+ `.gi-tests/` (meson + ninja required; cairo disabled; Regress builds too).
242
+ The launcher (`scripts/gimarshalling.mjs`) sets `GI_TYPELIB_PATH` /
243
+ `LD_LIBRARY_PATH` before spawning `node --test` — dlopen cannot pick up late
244
+ env changes — and pins `NODE_GI_NATIVE=build`. The port files are named
245
+ `*.port.mjs` so the default `npm test` glob never picks them up.
246
+
247
+ **Skip contract (strict, mirrors the tier-A ledger):** every skipped
248
+ spec/suite carries a reason — a phase-2.x roadmap item from the taxonomy at
249
+ the top of the port file (e.g. `phase 2.1 BigInt-64-bit`), an upstream issue
250
+ URL, or a `FIDELITY-BUG: …` note — and the reason is reported in the
251
+ `node:test` output (`# SKIP <reason>`). A bare skip throws (`pending()`,
252
+ `itSkip`, `describeSkip` all require the reason; `xit` must chain
253
+ `.pend(reason)`). Later marshalling PRs un-skip their sections — this port is
254
+ phase 2's acceptance gate.
255
+
73
256
  ## Usage (milestone 1)
74
257
 
75
258
  ```js
@@ -194,6 +377,286 @@ The mainloop bridge (`startMainLoop`) is auto-attached the first time `requireGi
194
377
  loads a namespace, so `GLib.MainLoop.run()` / `Gio.Application.run()` block as
195
378
  they do under GJS while Node's timers, I/O and signal handlers keep running.
196
379
 
380
+ A blocking loop is **optional**: the same call arms the uv-driven auto-pump, so
381
+ pending GLib sources dispatch from Node's own event loop too — async Gio work
382
+ awaited at top level behaves exactly as under `gjs -m`:
383
+
384
+ ```js
385
+ import { requireGi } from '@gjsify/node-gi/gi';
386
+
387
+ const Gio = requireGi('Gio', '2.0');
388
+ const file = Gio.File.new_for_path('/etc/hostname');
389
+ // No GLib.MainLoop.run() anywhere: the GTask completion dispatches from Node's
390
+ // loop, the in-flight op keeps the process alive, then Node exits normally.
391
+ const [ok, contents] = await new Promise((resolve, reject) => {
392
+ file.load_contents_async(null, (_source, res) => {
393
+ try { resolve(file.load_contents_finish(res)); } catch (e) { reject(e); }
394
+ });
395
+ });
396
+ ```
397
+
398
+ Process-lifetime semantics follow Node conventions: an in-flight async op
399
+ (a pending `GAsyncReadyCallback`) and an armed GLib timeout keep the process
400
+ alive — like Node's own pending I/O and timers — so a REPEATING GLib timeout
401
+ keeps the process running like `setInterval` (under `gjs -m` the process would
402
+ instead exit once the module settles; remove the source to release the process).
403
+ A *passive* fd source with no pending op (e.g. only a listening
404
+ `Gio.SocketService`) does not keep the process alive on its own, and a
405
+ purely-sync program still exits immediately.
406
+
407
+ #### `GLib.Variant` (build + unpack, GJS semantics)
408
+
409
+ `new GLib.Variant(signature, value)` recursively builds a GVariant from a type
410
+ signature, and the wrapper exposes the GJS unpack flavours — the contract
411
+ GAction / GSettings / DBus payloads expect:
412
+
413
+ ```js
414
+ const GLib = requireGi('GLib', '2.0');
415
+
416
+ new GLib.Variant('s', 'hi').deepUnpack(); // 'hi'
417
+ new GLib.Variant('as', ['a', 'b']).deepUnpack(); // ['a', 'b']
418
+ new GLib.Variant('(si)', ['x', 1]).deepUnpack(); // ['x', 1]
419
+
420
+ const v = new GLib.Variant('a{sv}', {
421
+ name: new GLib.Variant('s', 'Ada'),
422
+ age: new GLib.Variant('i', 36),
423
+ });
424
+ v.get_type_string(); // 'a{sv}'
425
+ v.deepUnpack(); // { name: Variant, age: Variant } (one level; values stay Variants)
426
+ v.recursiveUnpack(); // { name: 'Ada', age: 36 } (fully plain JS)
427
+ v.unpack(); // single level; children stay Variants
428
+ ```
429
+
430
+ Supported type strings: the basics `b y n q i u x t h d s o g`, `v` (variant),
431
+ `m*` (maybe), `a*` arrays (incl. the `as` strv + `ay` bytestring fast-paths and
432
+ `a{..}` dictionaries), `(...)` tuples and `{kv}` dict-entries. Built Variants
433
+ round-trip as GObject arguments/properties/signal values — e.g. a
434
+ `Gio.SimpleAction` state:
435
+
436
+ ```js
437
+ const Gio = requireGi('Gio', '2.0');
438
+ const action = Gio.SimpleAction.new_stateful('counter', null, new GLib.Variant('i', 0));
439
+ action.get_state().deepUnpack(); // 0
440
+ action.change_state(new GLib.Variant('i', 5));
441
+ action.get_state().deepUnpack(); // 5
442
+ ```
443
+
444
+ #### `GObject.registerClass` (GJS-shaped decorator)
445
+
446
+ `requireGi('GObject')` carries the GJS runtime statics — `registerClass`,
447
+ `ParamSpec`, `ParamFlags`, `SignalFlags` — layered over the introspected
448
+ namespace, so you subclass a GObject the same way you would under GJS:
449
+
450
+ ```js
451
+ const GObject = requireGi('GObject', '2.0');
452
+
453
+ const Counter = GObject.registerClass(
454
+ {
455
+ GTypeName: 'Counter',
456
+ Properties: {
457
+ // CONSTRUCT so the value is set before vfunc_constructed runs.
458
+ count: GObject.ParamSpec.int(
459
+ 'count', 'Count', 'A counter',
460
+ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT,
461
+ 0, 100, 0,
462
+ ),
463
+ },
464
+ Signals: { 'changed': { param_types: ['int'] } },
465
+ },
466
+ class Counter extends GObject.Object {
467
+ increment() { this.count += 1; this.emit('changed', this.count); }
468
+ vfunc_constructed() { /* runs during construction; `this` is the instance */ }
469
+ },
470
+ );
471
+
472
+ const c = new Counter({ count: 5 });
473
+ c.connect('changed', (n) => console.log('now', n));
474
+ c.increment(); // logs "now 6"
475
+ console.log(c.count); // 6 (custom property)
476
+ ```
477
+
478
+ `new Counter(props)` constructs the GObject (`constructType`) and wraps it so the
479
+ user class's own prototype methods resolve FIRST, then the GObject property/signal
480
+ surface. `registerClass(class)` (no meta) is also accepted; the GTypeName then
481
+ defaults to the class name. The parent namespace/type is read from the class's
482
+ `extends` (its `$gtypeName`), so it works for both `GObject.Object` and real GI
483
+ classes (`class extends Gio.SimpleAction { … }`).
484
+
485
+ Caveats (this is the no-toggle-ref object model): the user class's JS constructor
486
+ body is not run — GObject-idiomatic init belongs in `vfunc_constructed`;
487
+ instances are Proxies over a native handle (but `instanceof` still works — it is
488
+ resolved through the GObject type system, see the `instanceof` note below);
489
+ **plain (non-GObject-property) JS instance fields do NOT cross the
490
+ vfunc↔instance boundary** — inside a vfunc, `this` is a distinct wrapper over the
491
+ same GObject (the native engine mints a fresh handle per call, so there is no
492
+ shared per-instance JS object yet), so use GObject **properties** for any state
493
+ that must be visible both inside a vfunc and on the instance (those live in C and
494
+ are consistent; the unified instance identity arrives with the toggle-ref work);
495
+ a JS↔GObject reference cycle on a custom instance leaks (the same cycle-leak
496
+ caveat the signal/vfunc layer carries); and multi-level registered subclass
497
+ chains (registering a subclass of a registered subclass) are not yet supported.
498
+
499
+ #### `GObject` conveniences (signals, `GObject.Value`, `Object.new`)
500
+
501
+ `requireGi('GObject')` carries the GJS `GObject.js` convenience surface on top of
502
+ introspection:
503
+
504
+ ```js
505
+ const GObject = requireGi('GObject', '2.0');
506
+ const Gio = requireGi('Gio', '2.0');
507
+
508
+ // By-function signal ops — node-gi connects through private closures, so the
509
+ // (function → handler id) mapping is recorded at connect() time.
510
+ const action = new Gio.SimpleAction({ name: 'a', enabled: true });
511
+ const onChange = () => { /* … */ };
512
+ action.connect('notify::enabled', onChange);
513
+ GObject.signal_handlers_block_by_func(action, onChange); // → count blocked
514
+ GObject.signal_handlers_unblock_by_func(action, onChange); // → count unblocked
515
+ GObject.signal_handlers_disconnect_by_func(action, onChange); // → count disconnected
516
+ action.block_signal_handler(id); action.unblock_signal_handler(id); // by id
517
+ action.stop_emission_by_name('notify::enabled'); // from within a handler
518
+
519
+ // GObject.Value — an explicit GValue you can build, set, read, copy and pass IN.
520
+ const v = new GObject.Value();
521
+ v.init(GObject.TYPE_INT); v.set_int(42); v.get_int(); // → 42
522
+ const s = new GObject.Value(GObject.TYPE_STRING, 'hi'); // 2-arg convenience
523
+ v instanceof GObject.Value; // → true
524
+
525
+ // Construct a GObject from a runtime GType.
526
+ const made = GObject.Object.new(Gio.SimpleAction.$gtype, { name: 'made' });
527
+ ```
528
+
529
+ Also present: `GObject.ParamFlags` / `SignalFlags` (full introspected bitfields),
530
+ the fundamental `GObject.TYPE_*` GTypes, `GObject.AccumulatorType`, and
531
+ `GObject.signal_connect` / `signal_connect_after` / `signal_emit_by_name`.
532
+
533
+ `bind_property_full` / `BindingGroup.bind_full` work with **real JS transform
534
+ functions** (the engine marshals them as C `GBindingTransformFunc` trampolines,
535
+ the same architecture gjs uses via GjsPrivate — see `src/private.cc`): a
536
+ transform-to converts the bound value, returning `[false, …]` leaves the target
537
+ unchanged, a bidirectional transform-from converts back, and `null` transforms
538
+ give a plain copy binding. Verified byte-identical to gjs by the
539
+ `gclosure-in-args` conformance program. The related raw primitives
540
+ `GObject.signal_connect_closure` / `GObject.source_set_closure` accept a plain
541
+ JS function wherever a `GObject.Closure` IN-argument is expected (the engine
542
+ marshals it as a real GClosure).
543
+
544
+ **Kept-throw** (a clear, actionable error, not a crash): `ParamSpec.enum`
545
+ / `flags` / `char` / `uchar` / `long` / `ulong` / `param` are not yet buildable (the
546
+ native param-spec builder covers int/uint/int64/uint64/double/float/string/boolean/
547
+ object/boxed).
548
+
549
+ #### `GLib` conveniences (`log_structured`, one-shot idle/timeout)
550
+
551
+ `requireGi('GLib')` carries `GLib.log_structured(domain, level, fields)` (packs
552
+ string / `Uint8Array` / `GLib.Variant` fields into an `a{sv}` and hands it to
553
+ `g_log_variant`) and the one-shot source helpers `idle_add_once` /
554
+ `timeout_add_once` / `timeout_add_seconds_once` (the callback runs once, then the
555
+ source is auto-removed).
556
+
557
+ `GLib.log_set_writer_func(fn)` installs a **JS `GLogWriterFunc`** as the
558
+ process structured-log writer, with gjs semantics (node-gi ships the same
559
+ thread-guarded C wrapper gjs routes through GjsPrivate — `src/private.cc`): the
560
+ writer receives `(logLevel, fields)` where `fields` is a plain object whose
561
+ values are `Uint8Array`s of the field bytes (`null` for empty fields) —
562
+ byte-for-byte the shape gjs's `{...stringFields.recursiveUnpack()}` produces —
563
+ and its returned `GLib.LogWriterOutput` drives the handled/unhandled fallback.
564
+ `GLib.log_set_writer_default()` detaches the JS writer (later logs fall back to
565
+ `g_log_writer_default`). Verified byte-identical to gjs by the `log-writer`
566
+ conformance program. Two contracts, identical under gjs: the underlying
567
+ `g_log_set_writer_func` may only ever be called ONCE per process — a second
568
+ `GLib.log_set_writer_func(fn)` call aborts inside GLib itself (install one
569
+ writer per process; `log_set_writer_default()` detaches the JS side but cannot
570
+ re-arm a new install) — and an off-thread log falls back to the default writer
571
+ in C (JS is never entered from a foreign thread).
572
+
573
+ #### `Gio.DBus` (client proxy, name owning + object export)
574
+
575
+ `requireGi('Gio')` carries the GJS DBus surface — both halves. The **client**
576
+ half: `Gio.DBusProxy.makeProxyWrapper(interfaceXml)` parses the interface XML and
577
+ returns a proxy constructor whose instances expose each method as `NameSync` (sync),
578
+ `NameRemote` (raw async callback) and `NameAsync` (Promise), each property as a
579
+ getter/setter, and each signal via `connectSignal` / `disconnectSignal` (the same
580
+ pure-JS `_signals` mixin GJS uses). `Gio.DBus.session` / `Gio.DBus.system` are the
581
+ bus getters; `Gio.DBus.own_name` / `unown_name` / `watch_name` / `unwatch_name`
582
+ own and watch bus names. The **export** half
583
+ (`Gio.DBusExportedObject.wrapJSObject` — exporting a JS object AS a DBus
584
+ service) is described below the example.
585
+
586
+ ```js
587
+ const Gio = requireGi('Gio', '2.0');
588
+
589
+ const Proxy = Gio.DBusProxy.makeProxyWrapper(`<node>
590
+ <interface name="org.freedesktop.DBus">
591
+ <method name="GetId"><arg type="s" direction="out"/></method>
592
+ <signal name="NameOwnerChanged"><arg type="s"/><arg type="s"/><arg type="s"/></signal>
593
+ </interface>
594
+ </node>`);
595
+
596
+ const proxy = new Proxy(Gio.DBus.session, 'org.freedesktop.DBus', '/org/freedesktop/DBus');
597
+ const [busId] = proxy.GetIdSync(); // synchronous method
598
+ proxy.GetIdRemote((result, error) => { /* … */ }); // async, raw callback
599
+ const [id2] = await proxy.GetIdAsync(); // async, Promise (drains after run())
600
+ proxy.connectSignal('NameOwnerChanged', (p, sender, [name, oldOwner, newOwner]) => { /* … */ });
601
+
602
+ const id = Gio.DBus.own_name(Gio.BusType.SESSION, 'org.example.App',
603
+ Gio.BusNameOwnerFlags.NONE, null, (conn, name) => { /* acquired */ }, null);
604
+ ```
605
+
606
+ Async replies / signals / name callbacks dispatch from the default main context —
607
+ either a blocking `GLib.MainLoop.run()` or, with no loop anywhere, the uv-driven
608
+ auto-pump (an `await proxy.GetIdAsync()` at top level settles like any other
609
+ async Gio op). A `NameAsync` **Promise** `.then` drains *while* a node-gi loop
610
+ blocks on all three runtimes (the reply's GI callback settles the Promise and
611
+ the microtask checkpoint at that loop-dispatched boundary runs the
612
+ continuation). One Node-only divergence remains: when the blocking `run()` is
613
+ itself entered inside a live async scope (module top-level evaluation, an
614
+ `await`, `node:test`), V8 refuses the nested checkpoint (node-gtk #442/#121) —
615
+ the reply still fires and settles the Promise, so it resolves once `run()`
616
+ returns; defer the blocking run to a macrotask (what `runAsync` does) or drive
617
+ the method through the raw `NameRemote` callback. Bun/Deno do not share that
618
+ nesting restriction: their registered drain primitives run even under a
619
+ top-level blocking `run()`.
620
+
621
+ **Object export** (`Gio.DBusExportedObject.wrapJSObject` — exporting a JS
622
+ object AS a DBus service) works with GJS semantics. GJS builds it on
623
+ `GjsPrivate.DBusImplementation` (a GJS-internal C type, absent on a plain
624
+ Node/GI host); node-gi instead drives the **introspectable**
625
+ `g_dbus_connection_register_object_with_closures2` (GLib ≥ 2.84) — the
626
+ method-call / get-property / set-property vtable slots are plain JS functions
627
+ the engine marshals as real **GClosure IN-arguments**:
628
+
629
+ ```js
630
+ const service = {
631
+ Level: 7, // property (read via the interface XML signature)
632
+ Echo(s) { return `echo:${s}`; }, // method (an Async variant + Promise return also work)
633
+ Boom() { throw new Error('kaboom'); }, // a throw becomes a DBus error (org.gnome.gjs.JSError.*)
634
+ };
635
+ const impl = Gio.DBusExportedObject.wrapJSObject(interfaceXml, service);
636
+ impl.export(Gio.DBus.session, '/org/example/App');
637
+ impl.emit_signal('Pinged', new GLib.Variant('(s)', ['ping!']));
638
+ impl.emit_property_changed('Level', new GLib.Variant('i', 8)); // updates proxy caches
639
+ impl.unexport(); // releases the registration + its closures
640
+ ```
641
+
642
+ The impl surface matches GJS (`export` / `unexport` / `unexport_from_connection`
643
+ / `emit_signal` / `emit_property_changed` / `flush` / `get_object_path`, plus
644
+ node-gi's usual camelCase aliases). The full round-trip — exported method,
645
+ property get/set through `org.freedesktop.DBus.Properties`, a throwing method
646
+ returning a DBus error, `emit_signal`, `emit_property_changed` updating a
647
+ proxy's cached property, and unexport — runs byte-identical to gjs
648
+ (`dbus/export-scenario.mjs`, cross-checked against `gjs -m` under
649
+ `dbus-run-session` by `npm run test:dbus`). Lifetime: the registration ref+sinks
650
+ its closures, so the service object lives exactly as long as the registration
651
+ (surviving GC with no JS references) and becomes collectable after
652
+ `unexport()` — guarded by the `--expose-gc` leg of the dbus suite. A method
653
+ handler receives the GJS-appended trailing `Gio.UnixFDList` argument (`null`
654
+ when the call carries no fds — verified against gjs; an actual fd-carrying
655
+ call arrives as a wrapped `UnixFDList` but deeper fd extraction is untested).
656
+ As under GJS, a **sync** self-call from the exporting process deadlocks the
657
+ shared main loop that must also service the incoming request — use the async
658
+ `NameRemote` forms.
659
+
197
660
  ### GJS ambient globals (`@gjsify/node-gi/globals`)
198
661
 
199
662
  GJS source relies on globals that exist implicitly under `gjs` — `print`,
@@ -208,6 +671,13 @@ print('hello', 1, true); // → stdout, GJS String()-join
208
671
  const GLib = imports.gi.GLib; // legacy imports.gi (honours .versions)
209
672
  imports.gi.versions.Gtk = '4.0';
210
673
  console.log(imports.gettext.gettext('x')); // no-translation passthrough
674
+
675
+ // Legacy script modules many older GJS sources use:
676
+ const emitter = {};
677
+ imports.signals.addSignalMethods(emitter); // the pure-JS Signals mixin
678
+ emitter.connect('ready', () => imports.mainloop.quit());
679
+ imports.mainloop.timeout_add(50, () => { emitter.emit('ready'); return false; });
680
+ imports.mainloop.run(); // thin GLib.MainLoop wrapper
211
681
  ```
212
682
 
213
683
  A follow-up `--app node` build step will inject this automatically for any
@@ -218,3 +688,346 @@ The remaining GJS-compatible surface (`import GLib from 'gi://GLib?version=2.0'`
218
688
  `const GLib = imports.gi.GLib`, the core overrides, `_promisify`, the legacy
219
689
  `imports.*` modules) is layered on top of this engine in the gjsify bundler
220
690
  integration and subsequent drops.
691
+
692
+ ### cairo (`@gjsify/node-gi/cairo`)
693
+
694
+ cairo is a **foreign struct** in GObject-Introspection: GI does not know the
695
+ layout of `cairo_t` / `cairo_surface_t` / `cairo_pattern_t`, so it delegates their
696
+ marshalling to a module. GJS ships a native cairo binding + a foreign-struct
697
+ registration so that a GI function taking/returning a cairo pointer (e.g. a
698
+ `Gtk.DrawingArea` draw-func's `cairo_t`) round-trips to/from the JS cairo objects.
699
+ node-gi ports that seam: the same drawing code runs on GJS (native cairo) and Node
700
+ (this binding). An npm `cairo` package cannot stand in — a foreign cairo argument
701
+ must marshal through the SAME module the engine's foreign-struct seam knows about.
702
+
703
+ ```js
704
+ import cairo from '@gjsify/node-gi/cairo'; // bare `cairo` on the --app node build
705
+ import { requireGi } from '@gjsify/node-gi/gi';
706
+
707
+ // Headless drawing — read the pixels back with getData().
708
+ const surface = new cairo.ImageSurface(cairo.Format.ARGB32, 64, 48);
709
+ const cr = new cairo.Context(surface);
710
+ cr.setSourceRGB(0.8, 0.1, 0.1);
711
+ cr.rectangle(8, 8, 20, 16);
712
+ cr.fill();
713
+ cr.$dispose();
714
+ surface.flush();
715
+ const pixels = surface.getData(); // Uint8Array (stride * height), ARGB32
716
+
717
+ // The foreign seam: a GI function taking a cairo_t marshals the Context through.
718
+ const PangoCairo = requireGi('PangoCairo', '1.0');
719
+ const layout = PangoCairo.create_layout(new cairo.Context(surface));
720
+
721
+ // A Gtk.DrawingArea draw-func receives a cairo_t → a live cairo.Context:
722
+ // area.set_draw_func((_area, ctx, w, h) => { ctx.setSourceRGB(1, 0, 0); … });
723
+ ```
724
+
725
+ Ported this slice: `cairo.Context` (drawing + transform ops incl.
726
+ `identityMatrix` and the `userToDevice[Distance]` / `deviceToUser[Distance]`
727
+ point transforms, state getters, `setDash`/`getDashCount` (+ a net-new
728
+ `getDash`), `inFill`/`inStroke`, `newSubPath`, `copyPath`/`copyPathFlat`/
729
+ `appendPath` (owned `cairo.Path` handles), `getSource` with concrete-subclass
730
+ fan-out, `$dispose`), `cairo.Surface` + `cairo.ImageSurface` (`getData`/
731
+ `getWidth`/`getHeight`/`getStride`/`getFormat`/`flush`/`writeToPNG`/
732
+ `createFromPNG`), the patterns — `cairo.SolidPattern`,
733
+ `cairo.LinearGradient`/`cairo.RadialGradient` (`addColorStopRGB[A]` via the
734
+ shared `cairo.Gradient` base), `cairo.SurfacePattern`
735
+ (`setExtend`/`getExtend`/`setFilter`/`getFilter`) — the opaque `cairo.Path`,
736
+ and the enums (`Format`, `Operator`, `Content`, `Extend`, `Filter`, …). This is
737
+ the full surface `@gjsify/canvas2d-core` draws through (headless Canvas 2D).
738
+ The native binding paints **byte-for-byte identically to GJS** (verified
739
+ pixel-for-pixel against `gjs -m`, incl. gradients / repeating surface patterns /
740
+ dashed strokes / path round-trips — `test/cairo-canvas2d.test.mjs`). Deferred:
741
+ region objects, the PDF/SVG/PS surfaces, and the text/font ops
742
+ (`showText`/`selectFontFace` — canvas2d text rides PangoCairo instead).
743
+
744
+ Building on that seam, the **LIVE `@gjsify/canvas2d` `Canvas2DBridge`** — a
745
+ `Gtk.DrawingArea` that wraps an `HTMLCanvasElement` 2D context and blits its Cairo
746
+ surface onto the widget each frame — realizes, draws and blits UNCHANGED on
747
+ node-gi under a display: an app draws via the standard `canvas.getContext('2d')`
748
+ DOM API in `bridge.onReady`, the GTK draw_func fires, the bridge blits
749
+ (`cr.setSourceSurface` + `cr.paint`) and the rAF (`add_tick_callback`) path ticks.
750
+ The same source builds `--app gjs` and `--app node` and prints byte-identical
751
+ output, pixels read back off the canvas included — `test/canvas2d-bridge.test.mjs`
752
+ + `fixtures/canvas2d-bridge-app.ts`. It is a **local/dev verification** (see the
753
+ run recipe in the test header), NOT wired into CI: the LIVE bridge pulls the whole
754
+ `@gjsify/canvas2d` gi:// graph, so it needs the full gjsify workspace built with a
755
+ current-source `@gjsify/cli` (the bare-`cairo`→`@gjsify/node-gi/cairo` and
756
+ register-inline fixes the published CLI predates) plus a display and the addon — a
757
+ heavyweight from-scratch rebuild not worth gating a minimal CI container on. The
758
+ test self-skips in the default `npm test` (no display). One node-only note: a
759
+ mapped `Gtk.DrawingArea`'s live `GdkFrameClock` stays an active GLib source after
760
+ `app.quit()`, so — matching the documented lifetime divergence — a node-gi GTK
761
+ program that must terminate exits explicitly (`process.exit(0)`), whereas `gjs -m`
762
+ exits on module completion.
763
+
764
+ ### The live `@gjsify/event-bridge` dispatches DOM events on node-gi
765
+
766
+ The GTK→DOM event bridge (`@gjsify/event-bridge`'s `attachEventControllers`) —
767
+ which attaches GTK4 `EventControllerMotion`/`GestureClick`/`EventControllerScroll`/
768
+ `EventControllerKey`/`EventControllerFocus` to a widget and dispatches W3C DOM
769
+ events (Mouse/Pointer/Keyboard/Wheel/FocusEvent) — runs UNCHANGED on node-gi. The
770
+ shared fixture presents a `Gtk.DrawingArea`, attaches the controllers, and drives a
771
+ SYNTHESIZED event through each live `Gtk.EventController*` via `emit(signal, …)`
772
+ (the same path the GJS `event-bridge.spec.ts` drives), then asserts the dispatched
773
+ DOM event's type / coords / `getModifierState` / key / code. The `Gdk.ModifierType`
774
+ flags and `Gdk.keyval_name`/`Gdk.keyval_to_unicode` marshalling produce
775
+ byte-identical DOM events under node-gi and `gjs -m` — the same source builds
776
+ `--app gjs` and `--app node` and prints the committed golden
777
+ (`test/event-bridge.test.mjs` + `fixtures/event-bridge-app.ts`). Every golden line
778
+ is deterministic + display-independent (coords clamp to a fixed 400x300 allocation;
779
+ key/code/modifiers derive from the Gdk marshalling), so byte-parity is stable.
780
+
781
+ **`instanceof` across the GObject hierarchy (GJS parity):** `instanceof` for GObject
782
+ wrapper classes is wired through the GObject type system — each per-GType wrapper
783
+ carries a `Symbol.hasInstance` that resolves via `g_type_is_a` (native
784
+ `isInstanceOf`), so `new Gtk.EventControllerMotion() instanceof
785
+ Gtk.EventControllerMotion` is `true`, and so is a base class (`… instanceof
786
+ Gtk.EventController`), an implemented interface (`simpleAction instanceof Gio.Action`)
787
+ and a `registerClass` subclass against its leaf / base / interface — while a sibling
788
+ type, an unrelated class, a boxed/`Variant` handle, `null` or a plain object stay
789
+ `false`. `test/instanceof.test.mjs` + the cross-runtime golden
790
+ `conformance/programs/instanceof-hierarchy.conf.mjs` (gjs/node/bun/deno byte-identical)
791
+ guard it. The event-bridge fixture retrieves controllers by ADD ORDER off
792
+ `widget.observe_controllers()` as a stylistic choice (identity is preserved and
793
+ `emit()` resolves the signal by the live GType) — no longer forced by a gap.
794
+ (A second gap the fixture originally routed around — `new
795
+ Gdk.Rectangle()` threw `no static method 'new'` — is FIXED: `new <BoxedStruct>()`
796
+ now zero-allocates with GJS `gi/boxed.cpp` semantics when the struct has no `new`
797
+ constructor (`Graphene.Rect`/`Point`, `Gdk.Rectangle`, `Gdk.RGBA` — the
798
+ `@gjsify/devtools` screenshot chain), routes to `new` when it exists, and throws
799
+ a clear error for args without one; `test/struct-construct.test.mjs` guards it,
800
+ gjs-parity included. The fixture still reads the presented window's real
801
+ allocation — simpler and display-truthful.)
802
+
803
+ **Run it (needs a display + a built workspace + the `gjsify` CLI; self-skips
804
+ otherwise):**
805
+
806
+ ```sh
807
+ # node-gi (--app node) — the authoritative check vs the committed golden:
808
+ export GJSIFY_BIN="$(git rev-parse --show-toplevel)/packages/infra/cli/lib/index.js"
809
+ xvfb-run -a dbus-run-session -- \
810
+ env GSK_RENDERER=cairo GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 GTK_A11Y=none \
811
+ NODE_GI_NATIVE=build NODE_GI_EB_SKIP_GJS=1 GJSIFY_BIN="$GJSIFY_BIN" \
812
+ node --test test/event-bridge.test.mjs
813
+ # Drop NODE_GI_EB_SKIP_GJS to additionally re-prove the golden IS gjs's own output
814
+ # (builds + runs --app gjs; needs the workspace CLI, not the published one).
815
+ ```
816
+
817
+ `GJSIFY_BIN` must point at the WORKSPACE-built `@gjsify/cli` (`packages/infra/cli/lib/index.js`),
818
+ not the published `@gjsify/cli` — the fixture is built with current source, which
819
+ carries this session's bundler fixes the published `0.18.0` predates.
820
+ ### WebGL / `Gtk.GLArea` (the gwebgl seam)
821
+
822
+ **Definitive: a `Gtk.GLArea` realizes and hands JS a LIVE, CURRENT GL context
823
+ under node-gi on a headless software-GL display.** Verified end-to-end by
824
+ `test/webgl-glarea.test.mjs` + the ONE dual-runtime source
825
+ `fixtures/webgl-glarea-app.ts`: a presented `Gtk.ApplicationWindow` holding a
826
+ `Gtk.GLArea` configured exactly like `@gjsify/webgl`'s `WebGLBridge`
827
+ (`set_use_es(true)`, `set_required_version(3, 2)`, depth + stencil) realizes
828
+ with `get_error() === null`, an **OpenGL ES 3.2** context
829
+ (`Gdk.GLContext.get_current()` non-null in both `realize` and `render`), and
830
+ the `gwebgl` Vala bridge (`new Gwebgl.WebGLRenderingContextBase()` — the native
831
+ class `@gjsify/webgl` wraps) works through it: `getString(GL_VERSION/…)`, a
832
+ `getParameterx` **GVariant** round-trip, and a real WebGL draw —
833
+ `clearColor(1,0,0,1)` + `clear` + `readPixels` reading back the exact
834
+ `255,0,0,255` pixel. The committed golden is byte-identical between `gjs -m`
835
+ and `node` (the gjs gold-standard leg re-proves it wherever `gjs` is present;
836
+ `NODE_GI_WEBGL_SKIP_GJS=1` skips that leg).
837
+
838
+ The GL/display env the golden is pinned to (software GL, no GPU needed):
839
+
840
+ ```bash
841
+ # X11 (Xvfb or a real display) + mesa llvmpipe + GTK compositing off GL:
842
+ xvfb-run -a dbus-run-session -- \
843
+ env GSK_RENDERER=cairo GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 GTK_A11Y=none \
844
+ NODE_GI_NATIVE=build node --test test/webgl-glarea.test.mjs
845
+ # GL under llvmpipe: "OpenGL ES 3.2 Mesa …" / "llvmpipe (LLVM …)".
846
+ ```
847
+
848
+ **The FULL `@gjsify/webgl` `WebGLBridge` runs too** (same test file, second
849
+ fixture `fixtures/webgl-bridge-app.ts`): the complete TS WebGL stack UNCHANGED —
850
+ `WebGLBridge` (a `registerClass` `Gtk.GLArea` subclass), `onReady` handing out
851
+ `HTMLCanvasElement` + `WebGLRenderingContext` (constants GHashTable, the
852
+ `_init()` `getParameterx` GVariant reads, eager WebGL1+2 context construction),
853
+ and browser-standard `clearColor(0,0,1,1)` + `clear` + `readPixels` reading the
854
+ blue clear back (`bridge-pixel(0,0): 0,0,255,255`), byte-identical gjs ↔ node.
855
+ Shader/buffer/texture breadth (a three.js triangle/teapot) is the remaining
856
+ follow-up — the seam + context stack are proven.
857
+
858
+ The tests self-skip without a display, without a `gjsify` CLI, or without the
859
+ committed `Gwebgl-0.1` prebuild (`packages/framework/webgl/prebuilds/linux-*`,
860
+ which the test itself puts on `GI_TYPELIB_PATH`/`LD_LIBRARY_PATH`); the bridge
861
+ test additionally skips when `@gjsify/webgl` is not built. The fixture build
862
+ needs a WORKSPACE-built `@gjsify/cli` (point `GJSIFY_BIN` at
863
+ `packages/infra/cli/lib/index.js` after
864
+ `gjsify workspace @gjsify/cli build --with-dependencies`), like
865
+ `canvas2d-bridge`; the gjs gold-standard leg additionally needs the workspace
866
+ register libs built (`--app gjs` force-inlines `<pkg>/register`). Two engine
867
+ gaps this spike fixed on the way (headless regression coverage in
868
+ `test/gerror-return.test.mjs`): GError-typed RETURNS
869
+ (`Gtk.GLArea.get_error()` — `GI_TYPE_TAG_ERROR` → a field-readable GLib.Error
870
+ boxed) and literal-first method-name resolution (Vala GIRs carry camelCase
871
+ names — Gwebgl's `getString` — which the unconditional camelCase→snake_case
872
+ alias destroyed; the engine now resolves the literal name first, alias second).
873
+
874
+ ### Excalibur.js renders through WebGL on node-gi (the GTK-bridge capstone)
875
+
876
+ **Definitive: a REAL WebGL game engine — Excalibur 0.32, the engine behind the
877
+ `excalibur-jelly-jumper` showcase and the PixelRPG map-editor — boots, runs its
878
+ clock, and renders frames through `@gjsify/webgl`'s `WebGLBridge` UNCHANGED
879
+ under node-gi** (`test/excalibur-webgl.test.mjs` + the ONE dual-runtime source
880
+ `fixtures/excalibur-webgl-app.ts`). `new ex.Engine({ canvasElement })` builds
881
+ against the bridge's `HTMLCanvasElement` (WebGL2 context), `engine.start()`
882
+ resolves, the engine's real render pipeline runs (shader compile/link,
883
+ `bufferData`, VAOs, `vertexAttribPointer`, `drawArrays`/`drawElements`,
884
+ `clearBufferfv` at `RenderTarget.blitToScreen`) for 5 frames, and the committed
885
+ golden asserts the pixels read back off the GL framebuffer — the screen-centered
886
+ blue Actor and the red engine clear color — **byte-identical between `gjs -m`
887
+ and `node`**. The DOM surface (document/HTMLCanvasElement/ResizeObserver/
888
+ matchMedia/XHR) comes from the SAME `@gjsify/*` registers the gjs build injects,
889
+ via the `--app node` explicit-`--globals` reverse-bridge injection.
890
+
891
+ Excalibur's real GL + engine usage exposed four core gaps, all fixed at the
892
+ engine (each with regression coverage):
893
+
894
+ - **`GVariant 'ay'` rejects `null`** (`src/variant.cc`) — GJS packs
895
+ `new GLib.Variant('ay', null)` as the EMPTY byte array (`GLib.Bytes(null)`);
896
+ node-gi threw. Exposing call: Excalibur's `texImage2D(..., null)`
897
+ blank-texture allocation at renderer init (`Uint8ArrayToVariant(null)`).
898
+ - **Unknown members must be `undefined`, not a throw-on-call thunk**
899
+ (`src/calls.cc` `hasMethod` + the L1 wrapper `get`) — real consumers
900
+ feature-detect optional native methods (`typeof gl.clearBufferfv ===
901
+ 'function'` gates `@gjsify/webgl`'s clearBuffer emulation, hit at
902
+ `blitToScreen`); the old always-a-function proxy made that detection lie,
903
+ then threw mid-frame. `hasMethod(handle, name)` resolves through the SAME
904
+ literal-first/snake-alias walk `callMethod` uses.
905
+ - **Signal dispatch now runs the microtask checkpoint at its boundary**
906
+ (`src/signals.cc`, `napi_make_callback`) — GJS drains the promise-job queue
907
+ when the outermost JS frame exits; promise chains resolved inside a
908
+ loop-dispatched signal handler (Excalibur's whole `engine.start()` boot,
909
+ queued from the GLArea `render`/`onReady` dispatch) previously lingered
910
+ until the libuv↔GLib bridge's prepare-phase drain.
911
+ - **The uv co-pump source must not outrank GTK painting** (`src/loop.cc`) —
912
+ at the default `G_PRIORITY_DEFAULT` a busy Node loop STARVED
913
+ `GDK_PRIORITY_REDRAW`: Excalibur's `requestIdleCallback` polyfill (a
914
+ self-re-arming 1 ms `setTimeout`, run perpetually by its GarbageCollector)
915
+ kept the UvLoopSource ready on every GLib iteration, so ticks/renders/rAF
916
+ froze while plain GLib timeouts kept firing. The source now sits below
917
+ redraw (`G_PRIORITY_HIGH_IDLE + 30`): rendering outranks Node timers,
918
+ browser-like, and Node I/O still runs in every frame gap.
919
+
920
+ Run it (a LOCAL/dev verification like the other display suites — self-skips
921
+ without a display / CLI / prebuild / built workspace):
922
+
923
+ ```sh
924
+ export GJSIFY_BIN="$(git rev-parse --show-toplevel)/packages/infra/cli/lib/index.js"
925
+ xvfb-run -a dbus-run-session -- \
926
+ env -u FORCE_COLOR GSK_RENDERER=cairo GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 \
927
+ GTK_A11Y=none NODE_GI_NATIVE=build GJSIFY_BIN="$GJSIFY_BIN" \
928
+ node --test test/excalibur-webgl.test.mjs
929
+ # Drop NODE_GI_EXCALIBUR_SKIP_GJS to additionally re-prove the golden IS gjs's
930
+ # own byte-output (builds + runs --app gjs).
931
+ ```
932
+
933
+ The FULL `excalibur-jelly-jumper` showcase builds `--app node`
934
+ (`gjsify run build:node`; `gjsify.example.runtimes` includes `node`) and gets
935
+ remarkably far on node-gi: the GTK window presents, the devtools control plane
936
+ exports over DBus, `Gst.init` runs and every `ex.Sound` constructs its
937
+ Gst-backed `AudioContext` (this exposed + fixed the nullable-array `null`
938
+ marshalling — `Gst.init(null)`), and Excalibur boots into resource loading.
939
+ The remaining blocker is POLYFILL ROUTING, not marshalling: on Node the
940
+ GLOBAL `fetch` is the native undici one (the register convention never
941
+ overrides an existing native), and `@excaliburjs/plugin-tiled`'s fileLoader
942
+ feeds it the root-relative `/res/…` paths that only OUR GJS fetch/XHR resolve
943
+ against the program dir — undici rejects them (`Failed to parse URL`), the
944
+ Tiled map never loads, and scene init fails. Making the reverse bridge route
945
+ `fetch` (and friends) to the `@gjsify/*` polyfills over the runtime natives is
946
+ the follow-up that unlocks the full game. (`jsdom` — plugin-tiled's node-side
947
+ DOMParser fallback — is aliased to `@gjsify/empty` in `build:node`, mirroring
948
+ the plugin's own `"browser": { "jsdom": false }`.)
949
+
950
+ ### A real Adwaita WINDOW realizes + renders (the GTK-GUI capstone)
951
+
952
+ Beyond the display-free conformance: an UNCHANGED `Adw.Application` +
953
+ `Adw.ApplicationWindow` (HeaderBar / WindowTitle / StatusPage) not only
954
+ constructs + presents but **realizes and RENDERS a surface** through the GSK
955
+ renderer on node-gi — the same in-process capture path `@gjsify/devtools`'
956
+ `Screenshot` uses: `Gtk.WidgetPaintable` → `Gtk.Snapshot.to_node()` →
957
+ `Gsk.Renderer.render_texture` → `Gdk.Texture.save_to_png_bytes`. A non-empty PNG
958
+ is the unambiguous proof that a `GdkSurface` was allocated + a GSK render tree
959
+ rasterised — not reachable by any headless program. Guarded by
960
+ `test/windowing.test.mjs` + `test/windowing-interactive.test.mjs` (an
961
+ `Adw.ApplicationWindow` that RESPONDS to a `Gio.SimpleAction` + a
962
+ `Gtk.Button::clicked` through the node-gi signal chain) + `test/widgets.test.mjs`
963
+ (the Adwaita widget breadth below) — all self-skip without a display on Linux (the
964
+ win32/darwin GDK backend supplies its own display, so they run there), wired into
965
+ the Linux `gtk-smoke` + the Windows windowing CI jobs. The
966
+ `showcases/gtk/node-gi-window` showcase runs the SAME single source on both GJS
967
+ and Node and screenshots the live window over the `org.gjsify.Devtools` DBus
968
+ surface.
969
+
970
+ This exposed one core gap, fixed at the engine:
971
+
972
+ - **Non-GObject GObject-fundamentals wrap through their introspected ref/unref,
973
+ not `WrapGObject`** (`src/object.cc` `MakeFundamentalHandle` + the
974
+ `src/marshal.cc` return branch). `Gtk.Snapshot.to_node()` returns a
975
+ `GskRenderNode` — introspected as OBJECT_INFO but a GObject FUNDAMENTAL
976
+ (`gi_object_info_get_fundamental`), ref-counted via `gsk_render_node_ref/unref`,
977
+ NOT `g_object_ref`, with `G_IS_OBJECT` FALSE. Routing it through `WrapGObject`
978
+ ran the toggle-ref/qdata dance on a non-GObject → a cascade of
979
+ `g_object_*: assertion 'G_IS_OBJECT (object)' failed` criticals AND a leaked ref.
980
+ It now gets a type-tagged External carrying the raw pointer + the introspected
981
+ unref func as the finalizer hint (`isFundamentalHandle` / L1 `wrapFundamental`,
982
+ an opaque round-trippable pass-through) — GParamSpec + GValue keep their
983
+ dedicated branches, this catches the rest.
984
+
985
+ ### Windows: the FULL-windowing GTK runtime bundle
986
+
987
+ The batteries-included [`@gjsify/gtk-runtime-win32-x64`](../gtk-runtime-win32-x64)
988
+ bundle has two closures: the DEFAULT display-free set (loadable DLLs + typelibs)
989
+ and the `--windowing` SUPERSET that adds the runtime DATA a real GTK window needs
990
+ on Windows — the gdk-pixbuf loaders + `loaders.cache`, compiled GSettings schemas
991
+ (`gschemas.compiled`), the Adwaita/hicolor icon themes + `icon-theme.cache`, and
992
+ Fontconfig config/cache. node-gi's loader (`gtk-runtime.js`
993
+ `maybeWireGtkWindowingEnv`) detects the windowing data via the `gschemas.compiled`
994
+ marker and wires the env (`GSETTINGS_SCHEMA_DIR` / `GDK_PIXBUF_MODULE_FILE` /
995
+ `XDG_DATA_DIRS` / `FONTCONFIG_*`); a display-free bundle carries no marker, so the
996
+ wiring is a strict no-op and that load is byte-unchanged.
997
+
998
+ ### Adwaita widget breadth realizes + reacts + renders
999
+
1000
+ Beyond one window's chrome: a representative slice of the REAL Libadwaita widget
1001
+ set constructs, RENDERS and REACTS on node-gi. `test/widgets.test.mjs` builds an
1002
+ `Adw.PreferencesPage` / `Adw.PreferencesGroup` of `Adw.ActionRow`, `Adw.SwitchRow`,
1003
+ `Adw.EntryRow`, `Adw.ComboRow` (a `Gtk.StringList` model), `Adw.SpinRow` (a
1004
+ `Gtk.Adjustment`) and `Adw.ExpanderRow`, plus a `Gtk.ListBox` and a dismissible
1005
+ `Adw.Toast` via an `Adw.ToastOverlay`. Two tiers, robust on a runner whose surface
1006
+ may or may not realize:
1007
+
1008
+ - **DumpTree** — the widget tree contains every expected type (`AdwSwitchRow`,
1009
+ `AdwComboRow`, `AdwEntryRow`, `AdwSpinRow`, `AdwExpanderRow`, `AdwPreferencesPage`,
1010
+ `GtkListBox`, …), read via the runtime GType (`$typeName`) the `@gjsify/devtools`
1011
+ DumpTree uses — so each class constructs + parents correctly through node-gi.
1012
+ - **Interaction** — toggling the switch, changing the combo selection, moving the
1013
+ spin value, expanding the expander and setting the entry text all drive
1014
+ OBSERVABLE property changes AND fire their paired `notify::<prop>` handlers, and a
1015
+ toast add → `dismiss()` fires `::dismissed`. This surfaces the `Gtk.StringList` /
1016
+ `Gtk.Adjustment` model marshalling (GListModel + object construct props),
1017
+ `notify::` property signals and the boxed-model paths — all display-independent, so
1018
+ the interaction + DumpTree tier holds headless on Windows CI.
1019
+ - **Render** — when the surface realizes, the whole preferences surface rasterises
1020
+ through the GSK renderer (a non-empty PNG), the strong proof the broad widget set
1021
+ renders, not just constructs.
1022
+
1023
+ The same widgets back the `showcases/gtk/node-gi-window` "Settings" view (an
1024
+ `Adw.ViewStack` page reachable from the bottom `Adw.ViewSwitcherBar`, beside the
1025
+ counter). No engine change and no windowing-bundle change were needed: the rows are
1026
+ core GTK4/Adwaita widgets backed by the already-bundled `gtk-4-*.dll` / `libadwaita-1`
1027
+ and the full Adwaita icon theme + compiled schemas the `--windowing` bundle already
1028
+ ships.
1029
+
1030
+ Scoped out of the capstone fixture itself (deliberately): the Gst audio
1031
+ DECODE/playback path (`decodeAudioData`, `autoaudiosink`) — construction is
1032
+ proven by the showcase run above, the streaming pipeline is its own follow-up
1033
+ surface.