@gjsify/node-gi 0.22.0 → 0.24.1

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
@@ -678,6 +678,16 @@ imports.signals.addSignalMethods(emitter); // the pure-JS Signals mixin
678
678
  emitter.connect('ready', () => imports.mainloop.quit());
679
679
  imports.mainloop.timeout_add(50, () => { emitter.emit('ready'); return false; });
680
680
  imports.mainloop.run(); // thin GLib.MainLoop wrapper
681
+
682
+ // imports.byteArray — the legacy byte-array module, with GJS semantics
683
+ // (fromString/toString are zero-terminated + fatal-decode; fromGBytes/toGBytes
684
+ // round-trip GLib.Bytes; fromArray wraps in the legacy ByteArray class). This
685
+ // is the seam @gjsify/utils' cli()/gbytesToUint8Array — and through them
686
+ // @gjsify/os + @gjsify/child_process — read GLib subprocess output with:
687
+ const [ok, out] = imports.gi.GLib.spawn_command_line_sync('echo hi');
688
+ console.log(imports.byteArray.toString(out)); // 'hi\n'
689
+ const bytes = imports.byteArray.toGBytes(Uint8Array.of(1, 2, 3));
690
+ imports.byteArray.fromGBytes(bytes); // Uint8Array [1, 2, 3] (a copy)
681
691
  ```
682
692
 
683
693
  A follow-up `--app node` build step will inject this automatically for any
package/gi.d.ts CHANGED
@@ -93,7 +93,7 @@ export interface GObjectNamespace extends GiNamespace {
93
93
  ParamSpec: ParamSpecFactories;
94
94
  /**
95
95
  * The FULL introspected `GParamFlags` bitfield (READABLE/WRITABLE/READWRITE/
96
- * CONSTRUCT/CONSTRUCT_ONLY/LAX_VALIDATION/STATIC_*/EXPLICIT_NOTIFY/DEPRECATED/
96
+ * CONSTRUCT/CONSTRUCT_ONLY/LAX_VALIDATION/STATIC_.../EXPLICIT_NOTIFY/DEPRECATED/
97
97
  * …) — the named members below are the common convenience bits; the index
98
98
  * signature covers the remaining introspected members.
99
99
  */
package/gi.js CHANGED
@@ -985,7 +985,10 @@ let pumpRefCount = 0;
985
985
  * @returns {() => void}
986
986
  */
987
987
  export function startMainContextPump() {
988
- if (native.isNodeRuntime) return () => {};
988
+ // Node: the libuv↔GLib bridge already co-pumps. GJS: the host loop IS
989
+ // GLib's default main context — a JS-timer pump would only iterate the
990
+ // context recursively from within its own dispatch.
991
+ if (native.isNodeRuntime || native.RUNTIME === 'gjs') return () => {};
989
992
  pumpRefCount++;
990
993
  if (pumpTimer === null) {
991
994
  // ~4 ms cadence: low latency without busy-spinning. Each tick drains every
@@ -1011,7 +1014,7 @@ export function startMainContextPump() {
1011
1014
 
1012
1015
  /** Decrement the main-context pump reference count; clears the timer at zero. */
1013
1016
  export function stopMainContextPump() {
1014
- if (native.isNodeRuntime || pumpRefCount === 0) return;
1017
+ if (native.isNodeRuntime || native.RUNTIME === 'gjs' || pumpRefCount === 0) return;
1015
1018
  pumpRefCount--;
1016
1019
  if (pumpRefCount === 0 && pumpTimer !== null) {
1017
1020
  clearInterval(pumpTimer);
package/globals.d.ts CHANGED
@@ -15,6 +15,19 @@ export interface GjsImports {
15
15
  system: SystemModule;
16
16
  /** `imports.gettext` — the `@gjsify/node-gi/gettext` module (no-translation passthrough). */
17
17
  gettext: GettextModule;
18
+ /** `imports.byteArray` — the legacy GJS byte-array module (GJS semantics: zero-terminated, fatal decode). */
19
+ byteArray: {
20
+ ByteArray: new (arg?: number | Uint8Array) => {
21
+ length: number;
22
+ toString(encoding?: string): string;
23
+ toGBytes(): unknown;
24
+ };
25
+ fromArray(array: Iterable<number>): unknown;
26
+ fromGBytes(bytes: unknown): Uint8Array;
27
+ fromString(string: string, encoding?: string): Uint8Array;
28
+ toGBytes(array: Uint8Array): unknown;
29
+ toString(byteArray: Uint8Array, encoding?: string): string;
30
+ };
18
31
  versions: Record<string, unknown>;
19
32
  }
20
33
 
package/globals.js CHANGED
@@ -22,6 +22,7 @@ import cairo from './cairo.js';
22
22
  // Legacy `imports.signals` (the pure-JS Signals mixin) + `imports.mainloop` (the
23
23
  // GLib.MainLoop convenience layer) — the GJS script modules many older sources use.
24
24
  import * as signalsModule from './overrides/_signals.js';
25
+ import { createByteArray } from './overrides/byte-array.js';
25
26
  import { createMainloop } from './overrides/mainloop.js';
26
27
  import { createPackage } from './overrides/package.js';
27
28
 
@@ -109,6 +110,11 @@ function makeImports() {
109
110
  system,
110
111
  gettext,
111
112
  cairo,
113
+ // `imports.byteArray` — the legacy GJS byte-array module
114
+ // (fromGBytes/toGBytes/fromString/toString/fromArray/ByteArray), the seam
115
+ // `@gjsify/utils`' gbytesToUint8Array/cli() read subprocess output through.
116
+ // GLib is only pulled in lazily (toGBytes), so seeding it here is free.
117
+ byteArray: createByteArray(requireGi),
112
118
  signals: { addSignalMethods, _connect, _connectAfter, _disconnect, _emit, _signalHandlerIsConnected, _disconnectAll },
113
119
  // GJS's `imports.searchPath` — `imports.package.init` unshifts the app's
114
120
  // moduledir onto it. Inert on node-gi (ESM + gi://), but present + safe so
package/index.js CHANGED
@@ -49,19 +49,25 @@ const require = createRequire(import.meta.url);
49
49
  const here = dirname(fileURLToPath(import.meta.url)); // package root
50
50
 
51
51
  /**
52
- * Which JS runtime we are on. The Node-API addon loads on all three, but the
52
+ * Which JS runtime we are on. The Node-API addon loads on all four: Node, Bun
53
+ * and Deno implement Node-API natively; under GJS the `@gjsify/napi` shim is
54
+ * the N-API host and the addon is loaded through it (`loadNative` below). The
53
55
  * libuv-backed main-loop bridge (startMainLoop) is Node-only — Deno exports no
54
56
  * libuv symbols and Bun panics on uv_backend_fd — so Bun/Deno use the portable
55
- * GLib-iteration pump instead (see gi.js). Detection order matters: Bun and Deno
56
- * both expose a `process` shim, so probe their own globals first.
57
- * @type {'bun' | 'deno' | 'node'}
57
+ * GLib-iteration pump instead (see gi.js), and GJS needs NEITHER: the host
58
+ * loop already IS GLib's default main context. Detection order matters: GJS is
59
+ * probed first via its own globals (`imports` + `print` — no other runtime
60
+ * defines both), then Bun/Deno (both expose a `process` shim), then Node.
61
+ * @type {'bun' | 'deno' | 'gjs' | 'node'}
58
62
  */
59
63
  export const RUNTIME =
60
- typeof globalThis.Bun !== 'undefined'
61
- ? 'bun'
62
- : typeof globalThis.Deno !== 'undefined'
63
- ? 'deno'
64
- : 'node';
64
+ typeof globalThis.imports !== 'undefined' && typeof globalThis.print === 'function'
65
+ ? 'gjs'
66
+ : typeof globalThis.Bun !== 'undefined'
67
+ ? 'bun'
68
+ : typeof globalThis.Deno !== 'undefined'
69
+ ? 'deno'
70
+ : 'node';
65
71
 
66
72
  /** Whether we are on Node.js (the only runtime with the libuv main-loop bridge). */
67
73
  export const isNodeRuntime = RUNTIME === 'node';
@@ -84,9 +90,37 @@ function nativeCandidates() {
84
90
  return [prebuild, release, debug];
85
91
  }
86
92
 
93
+ // GJS host mode: the addon is loaded through the @gjsify/napi shim, not
94
+ // `require()`. The loader native is `globalThis.__gjsifyNapiLoadAddon`,
95
+ // installed by `GjsifyNapi.init()`; if another consumer (the @gjsify/napi L1)
96
+ // already captured + deleted it, init() re-defines it (idempotent). The
97
+ // GjsifyNapi typelib must be resolvable (GI_TYPELIB_PATH/LD_LIBRARY_PATH →
98
+ // the shim prebuild).
99
+ function gjsLoadAddon(path) {
100
+ let load = globalThis.__gjsifyNapiLoadAddon;
101
+ if (typeof load !== 'function') {
102
+ const GjsifyNapi = globalThis.imports?.gi?.GjsifyNapi;
103
+ if (!GjsifyNapi || !GjsifyNapi.init()) {
104
+ throw new Error(
105
+ '@gjsify/node-gi: GJS host mode needs the @gjsify/napi shim — the GjsifyNapi ' +
106
+ 'typelib is not loadable (point GI_TYPELIB_PATH and LD_LIBRARY_PATH at its prebuild).',
107
+ );
108
+ }
109
+ load = globalThis.__gjsifyNapiLoadAddon;
110
+ if (typeof load !== 'function') {
111
+ throw new Error('@gjsify/node-gi: GjsifyNapi.init() did not install the addon loader.');
112
+ }
113
+ // Capture-then-delete, the same no-side-channel discipline as the L1.
114
+ delete globalThis.__gjsifyNapiLoadAddon;
115
+ }
116
+ return load(path);
117
+ }
118
+
87
119
  function loadNative() {
88
120
  for (const candidate of nativeCandidates()) {
89
- if (existsSync(candidate)) return require(candidate);
121
+ if (existsSync(candidate)) {
122
+ return RUNTIME === 'gjs' ? gjsLoadAddon(candidate) : require(candidate);
123
+ }
90
124
  }
91
125
  throw new Error(
92
126
  `@gjsify/node-gi: native addon not found for ${RUNTIME} on ${process.platform}-${process.arch}. ` +
@@ -94,7 +128,8 @@ function loadNative() {
94
128
  'Run `node-gyp rebuild` in ' +
95
129
  here +
96
130
  ' (requires a C++ toolchain and the girepository-2.0 / glib-2.0 development headers), ' +
97
- 'or install a package build that ships a prebuild for your platform.',
131
+ 'or install a package build that ships a prebuild for your platform. ' +
132
+ 'Under a bundled GJS app (import.meta anchors at the bundle), set NODE_GI_NATIVE to the addon path.',
98
133
  );
99
134
  }
100
135
 
@@ -133,7 +168,10 @@ try {
133
168
  // matching Node's checkpoint and GJS (SpiderMonkey drains the promise-job queue
134
169
  // when the last JS frame exits). Node never registers — its checkpoint already
135
170
  // runs natively, so the Node path is byte-identical to before.
136
- if (!isNodeRuntime) {
171
+ // GJS never registers: SpiderMonkey/GJS drains the promise-job queue from its
172
+ // own main-loop source whenever the last JS frame exits — the exact semantic
173
+ // this checkpoint recreates for Bun/Deno.
174
+ if (!isNodeRuntime && RUNTIME !== 'gjs') {
137
175
  try {
138
176
  let drain = null;
139
177
  if (RUNTIME === 'bun') {
@@ -0,0 +1,197 @@
1
+ // SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
2
+ // SPDX-FileCopyrightText: 2017 Philip Chimento <philip.chimento@gmail.com>
3
+ //
4
+ // Adapted from GJS (refs/gjs/modules/script/byteArray.js + the native
5
+ // refs/gjs/gjs/byteArray.cpp `imports._byteArrayNative` functions). Copyright
6
+ // (c) 2017 Philip Chimento. MIT OR LGPL-2.0-or-later.
7
+ // Modifications: the legacy `imports.byteArray` module ported to
8
+ // @gjsify/node-gi. The native `fromGBytes`/`fromString`/`toString` are
9
+ // reimplemented in JS over the engine's boxed-GBytes surface and
10
+ // TextEncoder/TextDecoder, preserving GJS semantics:
11
+ // - toString/fromString are ZERO-TERMINATED (data truncates at the first NUL
12
+ // byte — gjs/text-encoding.cpp `zero_terminated_length`), and decoding is
13
+ // FATAL (invalid sequences throw a TypeError, like gjs's fatal=true).
14
+ // - fromGBytes/fromString return a COPY as a plain Uint8Array carrying the
15
+ // legacy own `toString(encoding)` method gjs tacks on
16
+ // (`define_legacy_tostring`), minus gjs's once-per-callsite deprecation
17
+ // warning (stderr-only noise a byte-golden never sees).
18
+ // - toGBytes routes through `new GLib.Bytes(array)` exactly as the gjs
19
+ // script module does; GLib is loaded lazily so building `imports` never
20
+ // pulls the namespace in.
21
+
22
+ // Truncate a byte array at the first NUL, per gjs's ZERO_TERMINATED handling
23
+ // (gjs/text-encoding.cpp zero_terminated_length).
24
+ function zeroTerminated(u8) {
25
+ const nul = u8.indexOf(0);
26
+ return nul === -1 ? u8 : u8.subarray(0, nul);
27
+ }
28
+
29
+ // gjs routes non-UTF-8 encodings through iconv; Node's TextDecoder (decode
30
+ // side) accepts WHATWG labels which cover the practical iconv set, so decoding
31
+ // just passes the label through. The ENCODE side has no general converter on
32
+ // Node — map the labels gjs consumers actually use onto Buffer encodings and
33
+ // throw a clear error for the rest.
34
+ const ENCODE_LABELS = new Map([
35
+ ['latin1', 'latin1'],
36
+ ['iso-8859-1', 'latin1'],
37
+ ['iso8859-1', 'latin1'],
38
+ ['ascii', 'ascii'],
39
+ ['us-ascii', 'ascii'],
40
+ ['utf-16', 'utf16le'],
41
+ ['utf-16le', 'utf16le'],
42
+ ['utf16le', 'utf16le'],
43
+ ]);
44
+
45
+ function isUtf8Label(encoding) {
46
+ const label = String(encoding).toLowerCase();
47
+ return label === 'utf-8' || label === 'utf8';
48
+ }
49
+
50
+ // The legacy own `toString(encoding)` gjs defines on arrays returned by
51
+ // fromString/fromGBytes/fromArray (byteArray.cpp define_legacy_tostring).
52
+ function defineLegacyToString(u8, toString) {
53
+ Object.defineProperty(u8, 'toString', {
54
+ value(encoding = 'utf-8') {
55
+ return toString(u8, encoding);
56
+ },
57
+ writable: true,
58
+ enumerable: false,
59
+ configurable: true,
60
+ });
61
+ return u8;
62
+ }
63
+
64
+ /**
65
+ * Build the legacy `imports.byteArray` module bound to the L1 backend.
66
+ * @param {(ns: string, version?: string) => Record<string, any>} requireGi
67
+ * @returns {{ ByteArray: any, fromArray: Function, fromGBytes: Function, fromString: Function, toGBytes: Function, toString: Function }}
68
+ */
69
+ export function createByteArray(requireGi) {
70
+ /**
71
+ * @param {Uint8Array} byteArray bytes to decode
72
+ * @param {string} [encoding] a WHATWG/iconv label, default UTF-8
73
+ * @returns {string} the decoded string, stopping at the first NUL byte
74
+ */
75
+ function toString(byteArray, encoding = 'utf-8') {
76
+ // Plain Error, not TypeError — gjs raises these argument failures via
77
+ // gjs_throw (Error); only the GBytes typecheck below is a TypeError.
78
+ if (!(byteArray instanceof Uint8Array)) throw new Error('Argument to decode() must be a Uint8Array');
79
+ const data = zeroTerminated(byteArray);
80
+ if (data.length === 0) return '';
81
+ // fatal:true mirrors gjs's fatal decode (invalid sequences throw).
82
+ return new TextDecoder(encoding, { fatal: true }).decode(data);
83
+ }
84
+
85
+ /**
86
+ * @param {string} string the string to encode
87
+ * @param {string} [encoding] a WHATWG/iconv label, default UTF-8
88
+ * @returns {Uint8Array} the encoded bytes, truncated at the first NUL byte
89
+ */
90
+ function fromString(string, encoding = 'utf-8') {
91
+ if (typeof string !== 'string') throw new Error('Argument to fromString() must be a string');
92
+ let encoded;
93
+ if (isUtf8Label(encoding)) {
94
+ encoded = new TextEncoder().encode(string);
95
+ } else {
96
+ const bufferEncoding = ENCODE_LABELS.get(String(encoding).toLowerCase());
97
+ if (!bufferEncoding) throw new Error(`Unsupported encoding for fromString(): ${encoding}`);
98
+ const buf = Buffer.from(string, bufferEncoding);
99
+ encoded = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
100
+ }
101
+ const data = zeroTerminated(encoded);
102
+ // Copy into a fresh plain Uint8Array (gjs returns a standalone buffer, and
103
+ // Buffer.from may hand out a view into Node's shared slab).
104
+ const out = new Uint8Array(data.length);
105
+ out.set(data);
106
+ return defineLegacyToString(out, toString);
107
+ }
108
+
109
+ /**
110
+ * @param {any} bytes a GLib.Bytes boxed handle
111
+ * @returns {Uint8Array} a copy of the bytes as a plain Uint8Array
112
+ */
113
+ function fromGBytes(bytes) {
114
+ // The engine's boxed GBytes proxy carries `toArray()` (gi.js wrapBoxed) —
115
+ // the same convenience gjs adds to GLib.Bytes.prototype. Its absence means
116
+ // the value is not a GBytes, matching gjs's G_TYPE_BYTES typecheck throw.
117
+ if (!bytes || typeof bytes.toArray !== 'function') {
118
+ throw new TypeError('Argument to fromGBytes() must be a GLib.Bytes');
119
+ }
120
+ const data = bytes.toArray();
121
+ // toArray() already returns a copy of the GBytes data; re-view it as a
122
+ // PLAIN Uint8Array (gjs returns Uint8Array, not Node's Buffer subclass).
123
+ const out = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
124
+ return defineLegacyToString(out, toString);
125
+ }
126
+
127
+ /**
128
+ * @param {Uint8Array} array the Uint8Array to convert to GLib.Bytes
129
+ * @returns {any} a GLib.Bytes boxed handle
130
+ */
131
+ function toGBytes(array) {
132
+ if (!(array instanceof Uint8Array)) throw new Error('Argument to ByteArray.toGBytes() must be a Uint8Array');
133
+ const GLib = requireGi('GLib', '2.0');
134
+ return new GLib.Bytes(array);
135
+ }
136
+
137
+ // The legacy ByteArray wrapper class, ported verbatim from
138
+ // refs/gjs/modules/script/byteArray.js (backwards compatibility only).
139
+ const ByteArray = class ByteArray {
140
+ constructor(arg = 0) {
141
+ if (arg instanceof Uint8Array) this._array = arg;
142
+ else this._array = new Uint8Array(arg);
143
+ return new Proxy(this, ByteArray);
144
+ }
145
+
146
+ static get(target, prop, receiver) {
147
+ if (!Number.isNaN(Number.parseInt(prop))) return Reflect.get(target._array, prop);
148
+ return Reflect.get(target, prop, receiver);
149
+ }
150
+
151
+ static set(target, prop, val, receiver) {
152
+ const ix = Number.parseInt(prop);
153
+ if (!Number.isNaN(ix)) {
154
+ if (ix >= target._array.length) {
155
+ const newArray = new Uint8Array(ix + 1);
156
+ newArray.set(target._array);
157
+ target._array = newArray;
158
+ }
159
+ return Reflect.set(target._array, prop, val);
160
+ }
161
+ return Reflect.set(target, prop, val, receiver);
162
+ }
163
+
164
+ get length() {
165
+ return this._array.length;
166
+ }
167
+
168
+ set length(newLength) {
169
+ if (newLength === this._array.length) return;
170
+ if (newLength < this._array.length) {
171
+ this._array = new Uint8Array(this._array.buffer, 0, newLength);
172
+ return;
173
+ }
174
+ const newArray = new Uint8Array(newLength);
175
+ newArray.set(this._array);
176
+ this._array = newArray;
177
+ }
178
+
179
+ toString(encoding = 'UTF-8') {
180
+ return toString(this._array, encoding);
181
+ }
182
+
183
+ toGBytes() {
184
+ return toGBytes(this._array);
185
+ }
186
+ };
187
+
188
+ /**
189
+ * @param {Iterable<number>} array an iterable to convert into a ByteArray wrapper
190
+ * @returns {InstanceType<ByteArray>}
191
+ */
192
+ function fromArray(array) {
193
+ return new ByteArray(Uint8Array.from(array));
194
+ }
195
+
196
+ return { ByteArray, fromArray, fromGBytes, fromString, toGBytes, toString };
197
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/node-gi",
3
- "version": "0.22.0",
3
+ "version": "0.24.1",
4
4
  "description": "GObject-Introspection runtime for Node.js — load gi:// namespaces (GLib/GObject/Gio/…) with GJS-compatible semantics. Vendored + rewritten from node-gtk under MIT.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,6 +58,12 @@
58
58
  "prebuilds"
59
59
  ],
60
60
  "gjsify": {
61
+ "platforms": [
62
+ "linux-x64",
63
+ "linux-arm64",
64
+ "darwin-arm64",
65
+ "win32-x64"
66
+ ],
61
67
  "runtimes": {
62
68
  "gjs": "none",
63
69
  "node": "polyfill",
@@ -79,6 +85,7 @@
79
85
  "test:dbus": "dbus-run-session -- env NODE_GI_NATIVE=build node --test --expose-gc test/dbus.test.mjs test/dbus-async.test.mjs",
80
86
  "test:bun": "node scripts/cross-runtime.mjs bun",
81
87
  "test:conformance": "node scripts/conformance.mjs",
88
+ "test:conformance:gjs-napi": "node scripts/conformance.mjs --runtimes=gjs-napi",
82
89
  "test:gimarshalling": "node scripts/gimarshalling.mjs",
83
90
  "test:deno": "node scripts/cross-runtime.mjs deno",
84
91
  "test:cairo": "NODE_GI_NATIVE=build node --test test/cairo.test.mjs",
Binary file
Binary file
package/src/marshal.cc CHANGED
@@ -153,6 +153,43 @@ BoxedHandle* TryGetBoxedHandle(Napi::Value v) {
153
153
  return ext.Data();
154
154
  }
155
155
 
156
+ // The pointer to hand a callee for a boxed/struct IN argument, honouring the
157
+ // arg's TRANSFER annotation.
158
+ //
159
+ // A transfer-full (or -container) IN arg is ADOPTED by the callee: it will free
160
+ // the pointer. Handing over the very block this handle still owns makes both
161
+ // sides free it — the finalizer's g_boxed_free then hits already-released
162
+ // memory (`free(): invalid pointer`, SIGABRT). The canonical case is
163
+ // `GstWebRTC.WebRTCSessionDescription.new(type, sdp)`, whose `sdp` param is
164
+ // `(transfer full)`: the fresh `GstSDPMessage` from
165
+ // `GstSdp.SDPMessage.new_from_text()` (an OUT `(transfer full)`, so our handle
166
+ // owns it) got freed once by `gst_webrtc_session_description_free` and again by
167
+ // its own finalizer.
168
+ //
169
+ // gjs solves this by COPYING on transfer — refs/gjs/gi/wrapperutils.h
170
+ // `GIWrapperBase::transfer_to_gi_argument` (`GI_DIRECTION_IN &&
171
+ // transfer != GI_TRANSFER_NOTHING` → `Instance::copy_ptr`), with
172
+ // refs/gjs/gi/struct.h `StructInstance::copy_ptr` = `g_variant_ref` for
173
+ // GVariant else refs/gjs/gi/boxed.h `BoxedInstance::copy_ptr` = `g_boxed_copy`.
174
+ // Mirror that: the callee owns an independent instance, the handle keeps (and
175
+ // still owns) its own. Transfer-NOTHING is a plain borrow — unchanged.
176
+ //
177
+ // A handle with no copy function (a plain, non-boxed C struct — G_TYPE_INVALID,
178
+ // or a `rawOwned` caller-allocates OUT block) cannot be duplicated. gjs throws
179
+ // there; we instead RELINQUISH ownership so the finalizer stays out of the
180
+ // callee's way — a leak-free hand-over is impossible either way, and not
181
+ // double-freeing is strictly safer than the alternative.
182
+ gpointer TransferBoxedIn(BoxedHandle* h, GITransfer transfer) {
183
+ if (h == nullptr) return nullptr;
184
+ if (transfer == GI_TRANSFER_NOTHING || h->ptr == nullptr) return h->ptr;
185
+ if (h->gtype == G_TYPE_VARIANT) return g_variant_ref(static_cast<GVariant*>(h->ptr));
186
+ if (h->gtype != G_TYPE_INVALID && G_TYPE_IS_BOXED(h->gtype))
187
+ return g_boxed_copy(h->gtype, h->ptr);
188
+ h->owns = false;
189
+ h->rawOwned = false;
190
+ return h->ptr;
191
+ }
192
+
156
193
  // Read a boxed handle's pointer if `v` is one (tag-checked; no deref of ptr).
157
194
  bool TryGetBoxedPtr(Napi::Value v, gpointer* out) {
158
195
  if (!v.IsExternal()) return false;
@@ -440,7 +477,7 @@ bool JsToGIArgument(Napi::Env env, Napi::Value v, GITypeInfo* type, GIArgument*
440
477
  .ThrowAsJavaScriptException();
441
478
  return false;
442
479
  }
443
- out->v_pointer = h->ptr;
480
+ out->v_pointer = TransferBoxedIn(h, transfer);
444
481
  handled = true;
445
482
  }
446
483
  }
package/src/object.cc CHANGED
@@ -258,7 +258,32 @@ Napi::Value GValueToJs(Napi::Env env, const GValue* v) {
258
258
  // NOT special-cased here: gjs leaves it as a GLib.Bytes boxed handle
259
259
  // (value.cpp has no GBytes case → the generic boxed branch), which is what the
260
260
  // MakeBoxedHandle below already produces (verified: a GBytes signal param →
261
- // `instanceof GLib.Bytes`). A GStrv-as-array read stays a follow-up.
261
+ // `instanceof GLib.Bytes`).
262
+ //
263
+ // G_TYPE_STRV (a NULL-terminated gchar**, e.g. Gtk.Widget:css-classes,
264
+ // Gio.ThemedIcon:names, or any Vala `public string[] x { get; }` property —
265
+ // valac emits g_param_spec_boxed(…, G_TYPE_STRV) for those). This is the READ
266
+ // half of the pair JsToGValue's GStrv branch opened: without it a GStrv
267
+ // property fell through to MakeBoxedHandle and JS got an opaque boxed handle
268
+ // instead of an array — SILENTLY, because a handle is a truthy object whose
269
+ // `.length` is undefined, so `arr ?? []` keeps it and every index/`for` over it
270
+ // yields nothing (this is how @gjsify/http lost every request header on the
271
+ // node-gi bridge: `bridgeReq.header_pairs` became a handle and the pair loop
272
+ // ran zero times). Mirrors GJS gjs_value_from_g_value_internal
273
+ // (refs/gjs/gi/value.cpp:1082 `gtype == G_TYPE_STRV` → gjs_array_from_strv).
274
+ // NULL → an EMPTY ARRAY, not null: GJS explicitly excludes G_TYPE_STRV from its
275
+ // "pointer-valued NULL → JS null" pre-check (value.cpp:1023) and documents the
276
+ // choice in gjs_array_from_strv (arg.cpp:2267) — "clients would need to always
277
+ // check for both an empty array and null" otherwise. g_value_get_boxed BORROWS;
278
+ // every string is copied into JS, so nothing is owned here.
279
+ if (G_VALUE_HOLDS(v, G_TYPE_STRV)) {
280
+ const gchar* const* strv = static_cast<const gchar* const*>(g_value_get_boxed(v));
281
+ guint len = 0;
282
+ while (strv != nullptr && strv[len] != nullptr) len++;
283
+ Napi::Array arr = Napi::Array::New(env, len);
284
+ for (guint i = 0; i < len; i++) arr.Set(i, Napi::String::New(env, strv[i]));
285
+ return arr;
286
+ }
262
287
  if (G_VALUE_HOLDS(v, G_TYPE_BYTE_ARRAY)) {
263
288
  GByteArray* ba = static_cast<GByteArray*>(g_value_get_boxed(v));
264
289
  if (ba == nullptr) return env.Null();
package/system.js CHANGED
@@ -20,25 +20,61 @@
20
20
  // returning the same shape GJS does — they exist so GJS source that calls them
21
21
  // keeps running unmodified, not to reproduce SpiderMonkey internals.
22
22
 
23
+ // The RUNTIME's own `process`, bound ONCE at module evaluation.
24
+ //
25
+ // `globalThis.process` is NOT a safe source for this module: a GJS program run
26
+ // through the reverse bridge routinely installs the gjsify `@gjsify/process`
27
+ // polyfill over it (`@gjsify/node-globals/register` defines it unconditionally),
28
+ // and that polyfill's `exit()` takes its GJS path whenever `imports.system.exit`
29
+ // exists — i.e. it delegates back into THIS module. Reading `globalThis.process`
30
+ // in `exit()` closes that loop into an infinite, GLib-idle-driven recursion
31
+ // (exit → polyfill → system.exit → polyfill → …) that spins at 100 % CPU and
32
+ // never terminates. `getBuiltinModule('node:process')` (Node ≥ 22.3 / Bun /
33
+ // Deno 2) returns the real builtin regardless of what the global currently is;
34
+ // the module-evaluation capture is the fallback for hosts without it — this
35
+ // module is loaded from `@gjsify/node-gi/globals`, before any bundle body (and
36
+ // therefore before any register side effect) runs.
37
+ const runtimeProcess = (() => {
38
+ const hostProcess = typeof process !== 'undefined' ? process : undefined;
39
+ try {
40
+ const builtin = hostProcess?.getBuiltinModule?.('node:process');
41
+ if (builtin && typeof builtin.exit === 'function') return builtin;
42
+ } catch {
43
+ // getBuiltinModule missing or refusing the specifier — use the captured one.
44
+ }
45
+ return hostProcess;
46
+ })();
47
+
48
+ const runtimeExit = typeof runtimeProcess?.exit === 'function' ? runtimeProcess.exit.bind(runtimeProcess) : undefined;
49
+
23
50
  // `programArgs` mirrors GJS's ARGV: the script arguments, excluding the
24
51
  // interpreter (argv[0]) and the script path (argv[1]).
25
52
  function readProgramArgs() {
26
- return typeof process !== 'undefined' && Array.isArray(process.argv) ? process.argv.slice(2) : [];
53
+ return Array.isArray(runtimeProcess?.argv) ? runtimeProcess.argv.slice(2) : [];
27
54
  }
28
55
 
29
56
  // `programInvocationName` / `programPath` track the running script — Node's
30
57
  // `process.argv[1]`.
31
58
  function readProgramInvocationName() {
32
- return (typeof process !== 'undefined' && process.argv[1]) || '';
59
+ return runtimeProcess?.argv?.[1] || '';
33
60
  }
34
61
 
35
62
  function readProgramPath() {
36
- return (typeof process !== 'undefined' && process.argv[1]) || null;
63
+ return runtimeProcess?.argv?.[1] || null;
37
64
  }
38
65
 
39
- /** Exit the process (GJS `System.exit`). */
66
+ /**
67
+ * Exit the process (GJS `System.exit`).
68
+ *
69
+ * Terminal by contract: under gjs this is the `exit()` syscall — it never
70
+ * returns and nothing a program installs can intercept it. The Node/Bun/Deno
71
+ * equivalent is the RUNTIME's own `process.exit`, resolved above; routing
72
+ * through `globalThis.process` instead is what produced the never-exiting
73
+ * reverse-bridge process (see the `runtimeProcess` note and
74
+ * `conformance/programs/system-exit-lifetime.conf.mjs`).
75
+ */
40
76
  export function exit(code) {
41
- if (typeof process !== 'undefined') process.exit(code ?? 0);
77
+ if (runtimeExit !== undefined) runtimeExit(code ?? 0);
42
78
  }
43
79
 
44
80
  /** Trigger a garbage collection if the host exposed `globalThis.gc` (`--expose-gc`). */