@gjsify/node-gi 0.44.0 → 0.46.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.
@@ -0,0 +1,510 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Put EVERY staged prebuild typelib on GI's search path, not only the GTK bundle's.
3
+ //
4
+ // THE GAP THIS CLOSES. `@gjsify/*` packages that ship a native library plus its
5
+ // GObject-Introspection typelib declare `"gjsify": { "prebuilds": "<dir>" }` and stage
6
+ // the artifacts under `<dir>/<os>-<arch>/` (ADR 0017). The CLI already finds them —
7
+ // `detectNativePackages()` walks `node_modules` and composes `GI_TYPELIB_PATH` for
8
+ // `gjsify run` — but a bundle started as plain `node app.node.mjs` never goes through
9
+ // the CLI, so it got nothing. The GTK runtime bundle was the one exception, activated
10
+ // env-free by `activateBundledGtkRuntime`; every OTHER prebuilt typelib was reachable
11
+ // only by exporting an environment variable by hand.
12
+ //
13
+ // ADR 0021 makes launcher-free prebuild resolution the rule rather than a courtesy, so
14
+ // the exception was the bug. This module generalises what the GTK bundle already had,
15
+ // using the same two env-free primitives: `prependSearchPath` for the typelib and
16
+ // `prependLibraryPath` for the library it names.
17
+ //
18
+ // MEASURED, and it is what motivated this file. `@gjsify/webkit-native` ships
19
+ // `WebKit-6.0.typelib` + `libgjsifywebkit.dylib` — Apple's WKWebView behind the
20
+ // WebKitGTK 6.0 API (ADR 0022) and the ONLY WebKit any darwin host has. Under Node on
21
+ // macOS, `requireGi('WebKit', '6.0')` failed with "Typelib file for namespace 'WebKit',
22
+ // version '6.0' not found" while the typelib sat installed in `node_modules`, two
23
+ // directories away. Nothing was missing but the search path. That matters beyond one
24
+ // package: ADR 0024 § 4 puts macOS and Windows applications on Node + node-gi, so the
25
+ // runtime that could not see these typelibs is the only runtime those platforms have.
26
+ //
27
+ // ## Why this duplicates `detect-native-packages.ts` instead of importing it
28
+ //
29
+ // That file is the source of truth for prebuild resolution and this one deliberately
30
+ // mirrors its two decisions rather than inventing a second answer. It cannot be
31
+ // imported: node-gi lives OUTSIDE the npm workspace (ADR 0031) and must load with no
32
+ // dependency on the CLI. So the shapes are copied, and named here so a reader can
33
+ // diff them:
34
+ //
35
+ // * `prebuildDirCandidates()` — the directory names to probe, most-specific first.
36
+ // A single `${platform}-${arch}` is NOT enough: `hostPlatformTokens()` calls itself
37
+ // "the SINGLE definition of the musl preference order … so it cannot drift", and
38
+ // the root AGENTS.md keeps the retired `linux-x86_64` spelling readable so
39
+ // pre-rename tarballs still load. Hardcoding one spelling broke both.
40
+ // * `resolvePlatformSibling()` — the SECOND pass, for a package that declares
41
+ // `gjsify.prebuilds` while the artifacts live in a per-target companion
42
+ // (`<name>-<token>`). Walking up from node-gi never enters that package's own
43
+ // `node_modules`, so pass one cannot see a companion nested under it.
44
+ //
45
+ // A LIMIT BOTH RESOLVERS SHARE, measured rather than assumed, because the obvious
46
+ // reading of the paragraph above is wrong. `checkPackage()` requires
47
+ // `gjsify.prebuilds` to be a string before a package is a candidate at all — and the
48
+ // FACADE does not declare it. `@gjsify/webkit-native` declares `gjsify.platforms` plus
49
+ // two `optionalDependencies` and ships `"files": []`, and it is not an exception:
50
+ // counted across the tree, 65 packages declare `gjsify.prebuilds` and all but two ship
51
+ // the directory too, so every one of them is a per-target COMPANION that pass one
52
+ // resolves directly. Eleven facades — rolldown-native, lightningcss-native,
53
+ // oxfmt-native, webgl, webrtc-native, tls-native, http2-native, sab-native,
54
+ // terminal-native, http-soup-bridge, webkit-native — declare the split through
55
+ // `optionalDependencies` and none declares the key.
56
+ //
57
+ // Two consequences, and neither is answered here:
58
+ //
59
+ // * The second pass has NO reachable input today, in this module or in the CLI. It
60
+ // is kept because it is the CLI's shape and the split rollout is ongoing, not
61
+ // because anything currently reaches it — do not read its presence as coverage.
62
+ // * When npm nests a companion under its facade (a version conflict, or pnpm with
63
+ // hoisting off), neither resolver finds the typelib: there is no declaring package
64
+ // to start the walk from. Verified on the darwin VM against a real nested install
65
+ // — `requireGi('WebKit', '6.0')` fails there under both. The caller sees GI's own
66
+ // "Typelib file … not found", which names the namespace but not this cause.
67
+ //
68
+ // That is a gap in the SHARED contract — a facade that declared `gjsify.prebuilds`
69
+ // would close it for both resolvers at once — and it belongs there, not in a third
70
+ // mechanism keyed off `optionalDependencies` here. The ordinary hoisted layout, which
71
+ // is what npm produces absent a conflict, resolves through pass one, and that is what
72
+ // the acceptance below measures.
73
+ //
74
+ // A THIRD LAYOUT, and this one is about the ANCHOR rather than about a facade.
75
+ // `startDir` defaults to `packageRoot`, which is `import.meta.url` — and Node's loader
76
+ // RESOLVES SYMLINKS by default. Where the application reaches this package through a
77
+ // link (`npm link`, a pnpm store, a workspace `node_modules` entry), that anchor lands
78
+ // on the link TARGET, outside the application's tree, and the upward walk never sees
79
+ // the application's `node_modules` at all. Measured on linux-x64, three arms of one
80
+ // tree: node-gi copied in resolves; node-gi symlinked in fails with GI's own "Typelib
81
+ // file … not found"; the same symlinked tree under `node --preserve-symlinks`
82
+ // resolves — which is what pins the cause to the anchor and not to the walk.
83
+ //
84
+ // Left as a recorded limit rather than patched here, because the single anchor is a
85
+ // deliberate choice (a `process.cwd()` second anchor would make which typelibs a
86
+ // LIBRARY loads depend on the shell's working directory) and undoing it in passing
87
+ // would trade a narrow gap for a broad one. The shape a fix would take, if it is
88
+ // wanted, is the CLI's own precedent: `runGjsBundle()` anchors at
89
+ // `dirname(bundlePath)`, so the equivalent here is the application ENTRY —
90
+ // `process.argv[1]`'s directory — which is the application's tree by construction and
91
+ // is not the cwd. That is a decision about scope, so it is named rather than taken.
92
+
93
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
94
+ import { dirname, join, resolve } from 'node:path';
95
+ // `packageRoot` rather than a second `dirname(fileURLToPath(import.meta.url))` here:
96
+ // native-paths.js exists to be the ONE definition of this package's own root, and a
97
+ // second copy of the expression is a second chance to spell it wrong — which is
98
+ // exactly what happened to this one (`new URL(…).pathname`, "/C:/…" on Windows)
99
+ // while the copy in native-paths.js was right the whole time.
100
+ import { packageRoot } from './native-paths.js';
101
+
102
+ /**
103
+ * Mirrors NODE_ARCH_TO_LEGACY_UNAME in detect-native-packages.ts. ALL FOUR rows, not
104
+ * the two this host happens to use: a short table reads as complete and the missing
105
+ * rows fail as "typelib not found" on the arch nobody here tests on.
106
+ */
107
+ const LEGACY_UNAME_ARCH = { x64: 'x86_64', arm64: 'aarch64', arm: 'armv7', ia32: 'i686' };
108
+
109
+ /** Mirrors ARCH_ALIASES: spellings that name the same arch, folded onto the node one. */
110
+ const ARCH_ALIASES = { x86_64: 'x64', amd64: 'x64', aarch64: 'arm64' };
111
+
112
+ /** Mirrors MUSL_SUFFIX. Linux-only by construction, as the grammar has it. */
113
+ const MUSL_SUFFIX = '-musl';
114
+
115
+ /**
116
+ * Mirrors `canonicalPlatformToken()`: fold the ARCH half onto the node spelling so a
117
+ * package's own declared token compares equal to a host token.
118
+ *
119
+ * Load-bearing rather than tidiness. Without it the declared-spelling probe below
120
+ * compares two RAW strings, which can only ever match a token the probe beside it
121
+ * already adds — so the whole declaration branch was dead code, and a package
122
+ * declaring + staging `linux-amd64` resolved in the CLI and not here.
123
+ */
124
+ function canonicalPlatformToken(token) {
125
+ const isMusl = token.endsWith(MUSL_SUFFIX);
126
+ const base = isMusl ? token.slice(0, -MUSL_SUFFIX.length) : token;
127
+ const dash = base.indexOf('-');
128
+ if (dash < 0) return token;
129
+ const os = base.slice(0, dash);
130
+ const arch = base.slice(dash + 1);
131
+ if (arch === '') return token;
132
+ return `${os}-${ARCH_ALIASES[arch] ?? arch}${isMusl ? MUSL_SUFFIX : ''}`;
133
+ }
134
+
135
+ /**
136
+ * Directory names to probe for a host, most-specific first.
137
+ *
138
+ * The declared-spelling probe of the CLI's version is folded into the caller, which
139
+ * has the manifest in hand; what is reproduced here is the host-token order and the
140
+ * legacy fallback, which is where the drift would be invisible.
141
+ */
142
+ function targetCandidates(platform, arch, musl) {
143
+ const canonical = `${platform}-${arch}`;
144
+ const out = musl && platform === 'linux' ? [`${canonical}${MUSL_SUFFIX}`, canonical] : [canonical];
145
+ const legacy = `${platform}-${LEGACY_UNAME_ARCH[arch] ?? arch}`;
146
+ if (!out.includes(legacy)) out.push(legacy);
147
+ return out;
148
+ }
149
+
150
+ /**
151
+ * Mirrors `resolveHostLibc()`: decide a host's C library from two independently
152
+ * gathered facts. PURE, and exported so a test can pin the decision — the CLI keeps
153
+ * it that way for the same reason, since neither probe answers everywhere.
154
+ *
155
+ * TWO probes, and the SECOND is what makes the default correct:
156
+ *
157
+ * 1. `process.report.…glibcVersionRuntime` — present iff the running process is
158
+ * linked against glibc, and authoritative when it answers. Under GJS it cannot:
159
+ * `@gjsify/process` implements no `report` at all. MEASURED rather than assumed
160
+ * for the other two, because the obvious guess is wrong — bun 1.3.14 and deno
161
+ * 2.1.1 on linux-x64 BOTH answer it (`"2.43"` / `"2.38"`), so the case for probe 2
162
+ * rests on the GJS host and on any build where the field is simply absent, not on
163
+ * a blanket "only node has it".
164
+ * 2. musl's dynamic loader, `/lib/ld-musl-<arch>.so.1`. A fact about the SYSTEM
165
+ * rather than about the process, so it answers wherever probe 1 declines.
166
+ *
167
+ * NEITHER answering means glibc, which is a claim about the evidence and not a guess:
168
+ * probe 2 finding no musl loader means the host has no musl. Reading probe 1's silence
169
+ * as "musl" — which is what this did before, having copied only the first half — makes
170
+ * a host on glibc prefer a `-musl` directory, and a musl-linked library staged there
171
+ * cannot load on the platform it would be chosen for. Silent wrong artifact, not a
172
+ * loud refusal.
173
+ *
174
+ * @param {{platform: string, glibcVersionRuntime?: string, muslLoaderPresent?: boolean}} input
175
+ * @returns {boolean} whether to offer `-musl` directories; false off Linux, where the
176
+ * axis does not exist (npm's own `libc` field is Linux-only).
177
+ */
178
+ export function resolveHostMusl(input) {
179
+ if (input.platform !== 'linux') return false;
180
+ if (typeof input.glibcVersionRuntime === 'string' && input.glibcVersionRuntime.length > 0) return false;
181
+ return input.muslLoaderPresent === true;
182
+ }
183
+
184
+ /** Gather the two host facts {@link resolveHostMusl} decides from. */
185
+ function hostIsMusl(platform) {
186
+ if (platform !== 'linux') return false;
187
+ let glibcVersionRuntime;
188
+ try {
189
+ // Two ways probe 1 declines: a missing `report` short-circuits to undefined,
190
+ // while a bare `process` (a GJS host with no polyfill registered) or a partial
191
+ // `getReport` shim THROWS. Both mean the same thing here.
192
+ const header = process.report?.getReport()?.header;
193
+ if (typeof header?.glibcVersionRuntime === 'string') glibcVersionRuntime = header.glibcVersionRuntime;
194
+ } catch {
195
+ // Probe 2 still answers.
196
+ }
197
+ let muslLoaderPresent = false;
198
+ try {
199
+ // Read the directory rather than testing per-arch loader names: those
200
+ // (`ld-musl-x86_64`, `ld-musl-aarch64`, …) are a THIRD arch vocabulary, and
201
+ // this file already carries the two the repo keeps. Throws on a host with no
202
+ // `/lib` at all, which is the same answer as finding no loader in one.
203
+ muslLoaderPresent = readdirSync('/lib').some((f) => f.startsWith('ld-musl-'));
204
+ } catch {
205
+ // No `/lib` to read ⇒ no musl loader installed.
206
+ }
207
+ return resolveHostMusl({ platform, glibcVersionRuntime, muslLoaderPresent });
208
+ }
209
+
210
+ /** `@scope/name` + token -> `@scope/name-token`, as `platformPackageName` spells it. */
211
+ function platformPackageName(name, token) {
212
+ return `${name}-${token}`;
213
+ }
214
+
215
+ /**
216
+ * Whether a package name is a GTK runtime bundle, whose typelibs belong to
217
+ * `gtkSource()` and to nothing else (see the skip in the walk below).
218
+ *
219
+ * The UNSCOPED half, and a prefix rather than a substring: the family this module
220
+ * hands over is `gtk-runtime` and `gtk-runtime-<target>`, under any scope. A
221
+ * substring test reaches further than the claim — `@acme/vendored-gtk-runtime-helper`
222
+ * is somebody else's package, and silently dropping its prebuilds is a defect
223
+ * reported as "the typelib is installed and not found", the very complaint this
224
+ * module exists to answer.
225
+ */
226
+ function isGtkRuntimePackage(name) {
227
+ const unscoped = name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name;
228
+ return unscoped === 'gtk-runtime' || unscoped.startsWith('gtk-runtime-');
229
+ }
230
+
231
+ const REAL_FS = {
232
+ readDir: (p) => {
233
+ try {
234
+ // A symlink counts as a directory here because that is how npm and pnpm
235
+ // place a workspace or hoisted package, and the walk must follow it.
236
+ // Nothing else asks `isDirectory` about a FILE — the typelib test matches
237
+ // on the name alone, so a symlinked `*.typelib` is not lost to this.
238
+ return readdirSync(p, { withFileTypes: true }).map((e) => ({
239
+ name: e.name,
240
+ isDirectory: e.isDirectory() || e.isSymbolicLink(),
241
+ }));
242
+ } catch {
243
+ return [];
244
+ }
245
+ },
246
+ readJson: (p) => {
247
+ try {
248
+ return JSON.parse(readFileSync(p, 'utf8'));
249
+ } catch {
250
+ return null;
251
+ }
252
+ },
253
+ isDirectory: (p) => {
254
+ try {
255
+ return statSync(p).isDirectory();
256
+ } catch {
257
+ return false;
258
+ }
259
+ },
260
+ };
261
+
262
+ /** Every `node_modules` from `startDir` up to the filesystem root. */
263
+ function nodeModulesChain(startDir, fs) {
264
+ const dirs = [];
265
+ let current = resolve(startDir);
266
+ for (;;) {
267
+ const candidate = join(current, 'node_modules');
268
+ if (fs.isDirectory(candidate)) dirs.push(candidate);
269
+ const parent = dirname(current);
270
+ if (parent === current) break;
271
+ current = parent;
272
+ }
273
+ return dirs;
274
+ }
275
+
276
+ /**
277
+ * Package directories inside one `node_modules`, scopes expanded one level, SORTED
278
+ * by package name.
279
+ *
280
+ * The sort is not cosmetic. The up-walk fixes the order BETWEEN `node_modules`
281
+ * levels, but within one level this is a `readdir`, and a directory read order is
282
+ * not a defined order — it is the filesystem's (ext4 hashes it, and the hash moves
283
+ * when the directory is rewritten). Two packages that both stage a typelib would
284
+ * therefore reach GI's search path in an order that can differ between two machines
285
+ * with byte-identical installs, and change on one machine after a reinstall. A
286
+ * search path is first-match-wins, so that is a difference in WHICH typelib loads,
287
+ * reproducing on one machine and not on the other. Sorting costs one comparison per
288
+ * package and makes the answer a function of the tree alone.
289
+ */
290
+ function packageDirsIn(nodeModules, fs) {
291
+ const out = [];
292
+ for (const entry of fs.readDir(nodeModules)) {
293
+ if (!entry.isDirectory || entry.name.startsWith('.')) continue;
294
+ const full = join(nodeModules, entry.name);
295
+ if (!entry.name.startsWith('@')) {
296
+ out.push({ dir: full, name: entry.name });
297
+ continue;
298
+ }
299
+ for (const scoped of fs.readDir(full)) {
300
+ if (scoped.isDirectory && !scoped.name.startsWith('.')) {
301
+ out.push({ dir: join(full, scoped.name), name: `${entry.name}/${scoped.name}` });
302
+ }
303
+ }
304
+ }
305
+ // Plain code-unit order, not `localeCompare`: package names are ASCII by npm's
306
+ // own rule, and a locale-sensitive collation would hand the determinism this
307
+ // sort exists to establish straight back to the environment.
308
+ return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
309
+ }
310
+
311
+ /** The staged directory of `pkgDir` for this host, or null. */
312
+ function stagedDirFor(pkgDir, manifest, tokens, fs) {
313
+ const declaredDir = manifest?.gjsify?.prebuilds;
314
+ if (typeof declaredDir !== 'string' || declaredDir === '') return null;
315
+ const declaredPlatforms = manifest?.gjsify?.platforms;
316
+ const names = [];
317
+ // The package's OWN declared spelling first, per host token — what makes a
318
+ // pre-rename tarball load without guessing.
319
+ for (const token of tokens) {
320
+ if (Array.isArray(declaredPlatforms)) {
321
+ for (const declared of declaredPlatforms) {
322
+ if (typeof declared !== 'string') continue;
323
+ if (canonicalPlatformToken(declared) === token && !names.includes(declared)) {
324
+ names.push(declared);
325
+ }
326
+ }
327
+ }
328
+ if (!names.includes(token)) names.push(token);
329
+ }
330
+ for (const name of names) {
331
+ const dir = join(pkgDir, declaredDir, name);
332
+ if (!fs.isDirectory(dir)) continue;
333
+ // Name only: a staged typelib may be a symlink, which `readDir` reports as a
334
+ // directory so the package walk can follow one. Nothing is ever a DIRECTORY
335
+ // named `*.typelib`, so the suffix alone is the honest test. A package staging
336
+ // only a `.node` addon or a bare dylib is skipped — those directories would be
337
+ // noise on GI's search path for no namespace.
338
+ if (fs.readDir(dir).some((e) => e.name.endsWith('.typelib'))) return dir;
339
+ }
340
+ return null;
341
+ }
342
+
343
+ /**
344
+ * The second pass: a facade declares the prebuilds, a per-target companion carries
345
+ * them. Restart the walk from the DECLARING package's own directory, which is the
346
+ * whole trick — `resolvePlatformSibling` in the CLI exists for the same reason and its
347
+ * comment says so.
348
+ */
349
+ function siblingStagedDir(pkgDir, pkgName, tokens, fs) {
350
+ let dir = resolve(pkgDir);
351
+ for (;;) {
352
+ for (const token of tokens) {
353
+ const siblingName = platformPackageName(pkgName, token);
354
+ const siblingDir = join(dir, 'node_modules', ...siblingName.split('/'));
355
+ const manifest = fs.readJson(join(siblingDir, 'package.json'));
356
+ const staged = stagedDirFor(siblingDir, manifest, tokens, fs);
357
+ if (staged !== null) return staged;
358
+ }
359
+ const parent = resolve(dir, '..');
360
+ if (parent === dir) return null;
361
+ dir = parent;
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Directories holding a staged typelib for this host, nearest install first.
367
+ *
368
+ * PURE apart from the injected `fs`, and takes the target as parameters, so the darwin
369
+ * and win32 branches are exercisable from a Linux host — the discipline
370
+ * `detect-native-packages.ts` already states.
371
+ *
372
+ * COST, and it is NOT cheap. Two parts, and the accounting has to name both or the
373
+ * second one grows unwatched: ONE `package.json` read per package in the tree, PLUS
374
+ * the second pass's probes for every package that declares prebuilds and resolves
375
+ * nothing locally — those walk to the filesystem root, per host token, opening a
376
+ * companion manifest that is not there. Measured on a 1691-package install
377
+ * (linux-x64, warm page cache): 1691 + 848 = 2539 reads, ~40 ms; ~306 ms cold. It is
378
+ * paid before any namespace is required, on every addon load, and by an application
379
+ * with no native prebuild at all. Note what the split says: while no facade declares
380
+ * `gjsify.prebuilds` (see the header), those 848 probes find nothing by construction.
381
+ *
382
+ * A cheaper pre-filter was tried and is recorded here because it looked right and was
383
+ * not. Skipping a package that has no `prebuilds/` directory (a cheap `stat`) cut the
384
+ * reads to 109 — and broke the second pass entirely, because a FACADE is exactly the
385
+ * package that declares prebuilds while shipping none, so skipping it skips the
386
+ * sibling walk that is the whole point. A package is a facade only according to its
387
+ * MANIFEST, so the manifest is what has to be read.
388
+ *
389
+ * NOT `@gjsify/webkit-native`, which an earlier revision of this paragraph named as
390
+ * that facade while the module header said the opposite about the same package, in
391
+ * the same file. The manifest settles it — `packages/framework/webkit-native/
392
+ * package.json` declares `gjsify.platforms` and no `gjsify.prebuilds` — and the
393
+ * header is the side that was right. Read it for what that costs.
394
+ *
395
+ * Narrowing the scan to `@gjsify/*` would cut it honestly, but it is a different
396
+ * contract — "any package declaring `gjsify.prebuilds`" is what the CLI implements —
397
+ * and it should be decided rather than smuggled in as an optimisation.
398
+ */
399
+ export function discoverPrebuiltTypelibDirs({ startDir, platform, arch, musl, fs = REAL_FS }) {
400
+ const tokens = targetCandidates(platform, arch, musl ?? hostIsMusl(platform));
401
+ const found = [];
402
+ const seen = new Set();
403
+
404
+ for (const nodeModules of nodeModulesChain(startDir, fs)) {
405
+ for (const { dir: pkgDir, name } of packageDirsIn(nodeModules, fs)) {
406
+ // Which GTK a process uses is a POLICY decision (ADR 0023) applied in
407
+ // gtk-runtime.js against `gtkSource()`. Its bundle is not staged under a
408
+ // declared `gjsify.prebuilds` at all — it lives at `<pkg>/gtk/` — so this
409
+ // walk would not reach it either way. The skip is kept because the two are
410
+ // one decision apart: a gtk-runtime package that ever DID declare prebuilds
411
+ // would be prepended from here as well, and #920 is what that costs — a
412
+ // bundle's typelibs on the path of a process whose addon is linked against
413
+ // the host's GTK, surfacing as methods resolving onto the wrong entries.
414
+ // No ORDERING fixes it: the case that matters is `gtkSource() === 'system'`,
415
+ // where the GTK activation prepends NOTHING, so there is nothing for a
416
+ // later or earlier prepend to lose to. Excluding the package is the remedy.
417
+ //
418
+ // Matched on the UNSCOPED name's prefix, not `includes`: this claims
419
+ // authority over `@gjsify/gtk-runtime-<target>` and its unscoped spelling,
420
+ // and a substring test would also silently swallow an unrelated
421
+ // `@acme/vendored-gtk-runtime-helper` that this module has no policy about.
422
+ if (isGtkRuntimePackage(name)) continue;
423
+
424
+ const manifest = fs.readJson(join(pkgDir, 'package.json'));
425
+ if (manifest?.gjsify?.prebuilds === undefined) continue;
426
+
427
+ const staged =
428
+ stagedDirFor(pkgDir, manifest, tokens, fs) ??
429
+ siblingStagedDir(pkgDir, manifest.name ?? name, tokens, fs);
430
+ if (staged === null || seen.has(staged)) continue;
431
+ seen.add(staged);
432
+ found.push(staged);
433
+ }
434
+ }
435
+ return found;
436
+ }
437
+
438
+ let activated = null; // memoize: idempotent, like the GTK activation beside it
439
+
440
+ /** TEST-ONLY: allow a spec to run the activation again. */
441
+ export function resetNativePrebuildsForTests() {
442
+ activated = null;
443
+ }
444
+
445
+ /**
446
+ * Prepend every discovered prebuild directory to GI's typelib AND library search
447
+ * paths.
448
+ *
449
+ * Both paths, because a typelib is only half an answer: `WebKit-6.0.typelib` names
450
+ * `libgjsifywebkit.dylib`, which sits beside it and which dyld would not find either.
451
+ * `gi_repository_prepend_library_path()` is GI's own mechanism and captures nothing at
452
+ * process launch, so this works identically on node, bun and deno with no re-exec and
453
+ * no environment variable — the same reasoning `activateGiLibraryPath` records.
454
+ *
455
+ * Never fatal. An addon predating either binding, an unreadable `node_modules`, or a
456
+ * package with no prebuild for this target all leave the search path exactly as it
457
+ * was; the caller then fails at `requireGi` with GI's own message, which is the
458
+ * behaviour before this module existed.
459
+ *
460
+ * @param {{ prependSearchPath?: (p: string) => void, prependLibraryPath?: (p: string) => void }} native
461
+ * @param {object} [options] test seam; defaults to this module's location and the host
462
+ * @returns {string[]} the directories handed to GI (empty when there was nothing to add)
463
+ */
464
+ export function activateNativePrebuilds(native, options = {}) {
465
+ if (activated !== null) return activated;
466
+ activated = [];
467
+ if (typeof native?.prependSearchPath !== 'function') return activated;
468
+
469
+ const {
470
+ // This package's own directory, which inside an installed application IS that
471
+ // application's tree — so the upward walk sees every `node_modules` above it.
472
+ // Deliberately NOT merged with `process.cwd()` the way the GJS-side helper in
473
+ // `gi-search-path.ts` does: that one serves a globally installed CLI sitting
474
+ // away from the project, while a cwd anchor here would make which typelibs a
475
+ // library loads depend on the shell's working directory. ADR 0021 § The Node host.
476
+ startDir = packageRoot,
477
+ platform = process.platform,
478
+ arch = process.arch,
479
+ musl,
480
+ fs = REAL_FS,
481
+ } = options;
482
+
483
+ const dirs = discoverPrebuiltTypelibDirs({ startDir, platform, arch, musl, fs });
484
+ // LAST wins with a prepend, so walking in reverse leaves `dirs`' own order —
485
+ // nearest install first — intact in GI's search path.
486
+ for (const dir of [...dirs].reverse()) {
487
+ try {
488
+ native.prependSearchPath(dir);
489
+ } catch {
490
+ // A stubbed/old addon without the binding — this directory did not reach
491
+ // GI, so it must not appear in the returned claim either.
492
+ continue;
493
+ }
494
+ // Recorded as soon as the TYPELIB path took it, before the library path is
495
+ // attempted. The two are separate bindings and an addon can carry one without
496
+ // the other; folding both into one try/catch made a directory GI had already
497
+ // accepted vanish from the report because the SECOND call threw. The return
498
+ // value is what a caller diagnoses from, so "reported == handed" has to hold
499
+ // per path, not per pair.
500
+ activated.unshift(dir);
501
+ try {
502
+ if (typeof native.prependLibraryPath === 'function') native.prependLibraryPath(dir);
503
+ } catch {
504
+ // Typelib found, library path unchanged: GI still resolves the namespace
505
+ // and only a g_module_open of the backing library can fail — the state
506
+ // before this module existed, and never a reason to drop the typelib dir.
507
+ }
508
+ }
509
+ return activated;
510
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/node-gi",
3
- "version": "0.44.0",
3
+ "version": "0.46.0",
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",
@@ -56,6 +56,7 @@
56
56
  "cairo.js",
57
57
  "cairo.d.ts",
58
58
  "native-paths.js",
59
+ "native-prebuilds.js",
59
60
  "overrides",
60
61
  "src",
61
62
  "binding.gyp",
Binary file
Binary file
Binary file
Binary file
Binary file
package/src/addon.cc CHANGED
@@ -110,6 +110,7 @@ static Napi::Object Init(Napi::Env env, Napi::Object exports) {
110
110
  exports.Set("getBoxedField", Napi::Function::New(env, GetBoxedField));
111
111
  exports.Set("setBoxedField", Napi::Function::New(env, SetBoxedField));
112
112
  exports.Set("boxedTypeName", Napi::Function::New(env, BoxedTypeName));
113
+ exports.Set("__boxedAddress", Napi::Function::New(env, BoxedAddress));
113
114
  exports.Set("isParamSpecHandle", Napi::Function::New(env, IsParamSpecHandle));
114
115
  exports.Set("isFundamentalHandle", Napi::Function::New(env, IsFundamentalHandle));
115
116
  exports.Set("paramSpecProp", Napi::Function::New(env, ParamSpecProp));