@gjsify/rolldown-plugin-gjsify 0.52.0 → 0.54.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.
Files changed (43) hide show
  1. package/README.md +11 -0
  2. package/lib/app/browser.js +1 -0
  3. package/lib/app/gjs.js +18 -2
  4. package/lib/app/nativescript.js +1 -1
  5. package/lib/app/node.d.ts +1 -1
  6. package/lib/app/node.js +34 -10
  7. package/lib/index.d.ts +5 -1
  8. package/lib/index.js +4 -1
  9. package/lib/plugins/console-assign.d.ts +13 -0
  10. package/lib/plugins/console-assign.js +125 -0
  11. package/lib/plugins/css-as-string.js +128 -44
  12. package/lib/plugins/gi-optional.d.ts +60 -0
  13. package/lib/plugins/gi-optional.js +152 -0
  14. package/lib/plugins/gi-renderer.d.ts +1 -1
  15. package/lib/plugins/gi-renderer.js +1 -1
  16. package/lib/plugins/gi-runtime-paths.js +5 -2
  17. package/lib/plugins/napi-node-addon.d.ts +48 -4
  18. package/lib/plugins/napi-node-addon.js +261 -85
  19. package/lib/plugins/node-native-external.d.ts +20 -0
  20. package/lib/plugins/node-native-external.js +145 -0
  21. package/lib/plugins/rewrite-node-modules-paths.d.ts +18 -1
  22. package/lib/plugins/rewrite-node-modules-paths.js +181 -25
  23. package/lib/plugins/unresolved-workspace-import.js +89 -68
  24. package/lib/shims/addon-resolve.d.ts +19 -0
  25. package/lib/shims/addon-resolve.js +193 -0
  26. package/lib/utils/addon-platform.d.ts +23 -0
  27. package/lib/utils/addon-platform.js +50 -0
  28. package/lib/utils/auto-globals.d.ts +6 -0
  29. package/lib/utils/auto-globals.js +13 -10
  30. package/lib/utils/declare-build-input.d.ts +13 -0
  31. package/lib/utils/declare-build-input.js +37 -0
  32. package/lib/utils/detect-free-globals.d.ts +3 -0
  33. package/lib/utils/detect-free-globals.js +1 -1
  34. package/lib/utils/entry-wrapper.js +6 -1
  35. package/lib/utils/index.d.ts +1 -0
  36. package/lib/utils/index.js +1 -0
  37. package/lib/utils/inline-static-reads.d.ts +15 -1
  38. package/lib/utils/inline-static-reads.js +16 -8
  39. package/lib/utils/scan-globals.d.ts +2 -2
  40. package/lib/utils/scan-globals.js +20 -14
  41. package/lib/utils/zip-path.d.ts +9 -0
  42. package/lib/utils/zip-path.js +12 -0
  43. package/package.json +13 -9
@@ -28,9 +28,14 @@
28
28
  // own native `main` + a real host `.node` must resolve — and falls through to
29
29
  // normal resolution otherwise, never shimming over a missing file.
30
30
  //
31
- // `resolveAddonPath()` replicates node-gyp-build's OWN selection algorithm
32
- // (build/Release → build/Debug → prebuilds/<platform>-<arch>/<best tag>) so the
33
- // GJS build routes the SAME binary Node would load.
31
+ // What is baked into the bundle is the addon's PACKAGE IDENTITY, never a path
32
+ // (ADR 0084): `enumerateAddonTargets()` records every `.node` the addon package
33
+ // ships, keyed by platform, and the run-time resolver
34
+ // (`shims/addon-resolve.ts`) picks the entry for the host the bundle finds
35
+ // itself on and resolves it through the bundle's own location. Per-tuple tag
36
+ // selection stays node-gyp-build's (`selectPrebuildFile`), so the entry for the
37
+ // build host is the binary Node would load; `resolveAddonPath()` remains the
38
+ // single-file probe of that same order, kept as the public, build-host answer.
34
39
  //
35
40
  // The shims import `@gjsify/napi` by BARE SPECIFIER: it is a `gjs:polyfill`
36
41
  // package that bundles normally, and its native typelib is auto-added to
@@ -42,8 +47,9 @@
42
47
  // Portability (same as `gjs-gi-node.ts`): the `filter` is a Rolldown fast path;
43
48
  // the handler's internal guard is the load-bearing check.
44
49
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
45
- import { dirname, isAbsolute, join, resolve } from 'node:path';
50
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
46
51
  import { GJSIFY_VIRTUAL_PREFIX } from '../utils/virtual-module-id.js';
52
+ import { addonPlatformKey, normalizeNapiRsTriple } from '../utils/addon-platform.js';
47
53
  const NAPI_ADDON_VIRTUAL_PREFIX = `${GJSIFY_VIRTUAL_PREFIX}napi-addon:`;
48
54
  /** Bare specifier the shims import — resolved + bundled from the consumer graph. */
49
55
  const NAPI_BARE_SPECIFIER = '@gjsify/napi';
@@ -99,10 +105,28 @@ function isMusl(platform) {
99
105
  function hostTarget() {
100
106
  const platform = process.env.npm_config_platform || process.platform;
101
107
  const arch = process.env.npm_config_arch || process.arch;
102
- const abi = process.versions ? process.versions.modules : undefined;
108
+ return makeTarget(platform, arch, hostLibc(platform), process.versions ? process.versions.modules : undefined);
109
+ }
110
+ /**
111
+ * The host WITHOUT the `npm_config_*` override — the machine that is running
112
+ * the build, which is what `build/Release` and `build/Debug` are compiled FOR.
113
+ *
114
+ * Separate from {@link hostTarget} because a cross-build sets the override: a
115
+ * linux-arm64 bundle built on x64 selects its PREBUILDS for arm64 (that is the
116
+ * override's whole purpose, and it is what makes ADR 0084's cross-build clause
117
+ * work) while the binaries under `build/` are still x64. Keying those by the
118
+ * overridden target would have the bundle load an x64 `.node` on an arm64 host
119
+ * — a wrong claim rather than a missing one, which is the harder failure.
120
+ */
121
+ function buildHostTarget() {
122
+ return makeTarget(process.platform, process.arch, hostLibc(process.platform), process.versions ? process.versions.modules : undefined);
123
+ }
124
+ function hostLibc(platform) {
125
+ return process.env.LIBC === 'musl' || isMusl(platform) ? 'musl' : 'glibc';
126
+ }
127
+ function makeTarget(platform, arch, libc, abi) {
103
128
  const uv = ((process.versions && process.versions.uv) || '').split('.')[0] || '';
104
129
  const armv = process.env.ARM_VERSION || (arch === 'arm64' ? '8' : '') || '';
105
- const libc = process.env.LIBC === 'musl' || isMusl(platform) ? 'musl' : 'glibc';
106
130
  return { platform, arch, libc, abi, uv, armv, runtime: 'node' };
107
131
  }
108
132
  function readdirSafe(dir) {
@@ -168,21 +192,33 @@ function runtimeAgnostic(tags) {
168
192
  return tags.runtime === 'node' && tags.napi === true;
169
193
  }
170
194
  /**
171
- * Resolve the best `prebuilds/<platform>-<arch>/<file>.node` for `pkgRoot`,
172
- * ported from node-gyp-build's `resolve(dir)` — tolerant of a missing `abi`.
195
+ * node-gyp-build's PER-TUPLE selection: the tags that match `host`, best first.
196
+ *
197
+ * ONE selector, and the reason it must be one: it is the whole correctness of
198
+ * the ADDON TABLE. The table is what the bundle loads at run time, so a tuple
199
+ * answered by anything weaker than node-gyp-build's own algorithm points the
200
+ * bundle at a binary Node would never load — and the failure is a `dlopen` at
201
+ * LAUNCH, on a user's machine, with the fix's own test suite green because the
202
+ * test asserts the table's SHAPE rather than its contents. Measured before this
203
+ * was extracted, on a `prebuilds/linux-x64/` holding one file of each kind:
204
+ *
205
+ * electron.node + node.node → table: electron.node (node-gyp-build: node.node)
206
+ * node.abi115.node + node.node → table: node.abi115.node (node-gyp-build: node.node)
207
+ *
208
+ * Both because the previous enumeration sorted by SPECIFICITY alone: `electron`
209
+ * and `abi115` each score 1 and `node` scores 0, so the foreign-runtime binary
210
+ * outranked the right one. The filter below is node-gyp-build's `parseTags` +
211
+ * `matches`, ported verbatim, tolerant of a missing `abi`.
212
+ *
213
+ * The FILE NAME is the final tiebreak and is ours, not node-gyp-build's: its
214
+ * comparator ends in a `0`, so a same-specificity pair is decided by `readdir`
215
+ * order — which is a property of the FILESYSTEM, not of the tree. The table is
216
+ * `JSON.stringify`'d into the bundle, so an unsorted tie made the artifact's
217
+ * bytes vary by where it was built. Compared with `<`/`>`, never
218
+ * `localeCompare`: that would trade one filesystem dependency for a locale one.
173
219
  */
174
- function resolvePrebuild(pkgRoot, host) {
175
- const prebuildsDir = join(pkgRoot, 'prebuilds');
176
- const tuple = readdirSafe(prebuildsDir)
177
- .map(parseTuple)
178
- .filter((t) => t !== null && t.platform === host.platform && t.architectures.includes(host.arch))
179
- // Prefer single-arch prebuilds over multi-arch (compareTuples).
180
- .sort((a, b) => a.architectures.length - b.architectures.length)[0];
181
- if (!tuple)
182
- return null;
183
- const tupleDir = join(prebuildsDir, tuple.name);
184
- const winner = readdirSafe(tupleDir)
185
- .map(parseTags)
220
+ function selectPrebuildFile(files, host) {
221
+ return (files
186
222
  .filter((t) => {
187
223
  if (t === null)
188
224
  return false;
@@ -208,8 +244,24 @@ function resolvePrebuild(pkgRoot, host) {
208
244
  return a.abi ? -1 : 1;
209
245
  if (a.specificity !== b.specificity)
210
246
  return a.specificity > b.specificity ? -1 : 1;
211
- return 0;
212
- })[0];
247
+ return a.file < b.file ? -1 : a.file > b.file ? 1 : 0;
248
+ })[0] ?? null);
249
+ }
250
+ /**
251
+ * Resolve the best `prebuilds/<platform>-<arch>/<file>.node` for `pkgRoot`,
252
+ * ported from node-gyp-build's `resolve(dir)` — tolerant of a missing `abi`.
253
+ */
254
+ function resolvePrebuild(pkgRoot, host) {
255
+ const prebuildsDir = join(pkgRoot, 'prebuilds');
256
+ const tuple = readdirSafe(prebuildsDir)
257
+ .map(parseTuple)
258
+ .filter((t) => t !== null && t.platform === host.platform && t.architectures.includes(host.arch))
259
+ // Prefer single-arch prebuilds over multi-arch (compareTuples).
260
+ .sort((a, b) => a.architectures.length - b.architectures.length)[0];
261
+ if (!tuple)
262
+ return null;
263
+ const tupleDir = join(prebuildsDir, tuple.name);
264
+ const winner = selectPrebuildFile(readdirSafe(tupleDir).map(parseTags), host);
213
265
  return winner ? join(tupleDir, winner.file) : null;
214
266
  }
215
267
  /** A native addon package root has no resolvable compiled `.node`. */
@@ -220,6 +272,74 @@ export class AddonNotBuiltError extends Error {
220
272
  this.name = 'AddonNotBuiltError';
221
273
  }
222
274
  }
275
+ /**
276
+ * Enumerate every `.node` an addon package ships, keyed by platform, as a
277
+ * `<package>/<subpath>` spec per entry. ADR 0084: the build ENUMERATES, it
278
+ * does not select — the bundle picks the right entry at RUN time from the
279
+ * host it finds itself on.
280
+ *
281
+ * The table has:
282
+ * - One entry per `prebuilds/<tuple>/` directory, keyed by the tuple's
283
+ * platform and EVERY architecture it declares, and per libc variant. Each
284
+ * entry is chosen by {@link selectPrebuildFile} — node-gyp-build's own
285
+ * algorithm, run for a synthetic host of that tuple — so the entry for the
286
+ * build host is exactly the binary Node would load, and a foreign platform's
287
+ * entry is the best one that host could load rather than the best-looking
288
+ * file in the directory.
289
+ * - One entry per `build/Release` and `build/Debug`, keyed by the BUILD host
290
+ * ({@link buildHostTarget}, deliberately not the `npm_config_*` override
291
+ * those files are not built for), overriding the prebuilds entry for the
292
+ * same key: node-gyp-build's order has build/Release winning.
293
+ *
294
+ * Returns an empty record when no `.node` exists anywhere — the caller then
295
+ * throws {@link AddonNotBuiltError} as a build-time gate.
296
+ */
297
+ export function enumerateAddonTargets(pkgRoot, pkg) {
298
+ const targets = {};
299
+ const pkgName = typeof pkg.name === 'string' && pkg.name ? pkg.name : null;
300
+ if (!pkgName)
301
+ return targets;
302
+ // 1. prebuilds/<tuple>/<best tag>, per tuple, per architecture, per libc.
303
+ // `readdirSafe` is sorted: the table is `JSON.stringify`'d into the
304
+ // bundle, so its key ORDER is part of the artifact's bytes and must come
305
+ // from the tree rather than from the filesystem's directory order.
306
+ const prebuildsDir = join(pkgRoot, 'prebuilds');
307
+ const tuples = readdirSafe(prebuildsDir)
308
+ .sort()
309
+ .map(parseTuple)
310
+ .filter((t) => t !== null);
311
+ for (const tuple of tuples) {
312
+ const files = readdirSafe(join(prebuildsDir, tuple.name)).map(parseTags);
313
+ if (!files.some((t) => t !== null))
314
+ continue;
315
+ for (const arch of tuple.architectures) {
316
+ for (const libc of ['glibc', 'musl']) {
317
+ // `abi: undefined` on purpose: the only prebuilds a FOREIGN
318
+ // platform can be served are the runtime-agnostic ones. An
319
+ // `abi<N>.node` matching this build's Node would be selected
320
+ // otherwise, and it is not loadable by that host's runtime at
321
+ // all — the wrong binary beats a missing one.
322
+ const best = selectPrebuildFile(files, makeTarget(tuple.platform, arch, libc, undefined));
323
+ if (best === null)
324
+ continue;
325
+ targets[addonPlatformKey(tuple.platform, arch, libc)] =
326
+ `${pkgName}/${subpathOf(pkgRoot, join(prebuildsDir, tuple.name, best.file))}`;
327
+ }
328
+ }
329
+ }
330
+ // 2. build/Release + build/Debug for the BUILD host (overrides prebuilds).
331
+ const host = buildHostTarget();
332
+ const hostKey = addonPlatformKey(host.platform, host.arch, host.libc);
333
+ for (const flavor of ['Release', 'Debug']) {
334
+ const dir = join(pkgRoot, 'build', flavor);
335
+ const hit = firstNodeFile(dir);
336
+ if (hit) {
337
+ targets[hostKey] = `${pkgName}/${subpathOf(pkgRoot, join(dir, hit.file))}`;
338
+ break; // Release wins
339
+ }
340
+ }
341
+ return targets;
342
+ }
223
343
  /**
224
344
  * Locate the compiled `.node` for an addon package root, matching node-gyp-build's
225
345
  * probe order: `build/Release` → `build/Debug` →
@@ -259,47 +379,53 @@ export function nearestPackageRoot(importerFile) {
259
379
  }
260
380
  return null;
261
381
  }
382
+ /** The addon-resolve shim specifier — resolved by the consumer's build. */
383
+ const ADDON_RESOLVE_SHIM = '@gjsify/rolldown-plugin-gjsify/shims/addon-resolve';
262
384
  /** Direct `.node` import → the addon's exports (ESM default). */
263
- export function directNodeShim(addonPath) {
385
+ export function directNodeShim(addonTable) {
264
386
  return (`import { loadAddon } from ${JSON.stringify(NAPI_BARE_SPECIFIER)};\n` +
265
- `export default loadAddon(${JSON.stringify(addonPath)});\n`);
387
+ `import { __gjsifyAddonResolve } from ${JSON.stringify(ADDON_RESOLVE_SHIM)};\n` +
388
+ `export default loadAddon(__gjsifyAddonResolve(${addonTable}));\n`);
266
389
  }
267
390
  /** `node-gyp-build` replacement — a callable `load(dir)` carrying `.path()`. */
268
- export function nodeGypBuildShim(addonPath) {
391
+ export function nodeGypBuildShim(addonTable) {
269
392
  return (`const { loadAddon } = require(${JSON.stringify(NAPI_BARE_SPECIFIER)});\n` +
270
- `function load() { return loadAddon(${JSON.stringify(addonPath)}); }\n` +
271
- `load.path = function () { return ${JSON.stringify(addonPath)}; };\n` +
393
+ `const { __gjsifyAddonResolve } = require(${JSON.stringify(ADDON_RESOLVE_SHIM)});\n` +
394
+ `function load() { return loadAddon(__gjsifyAddonResolve(${addonTable})); }\n` +
395
+ `load.path = function () { return __gjsifyAddonResolve(${addonTable}); };\n` +
272
396
  `load.resolve = load.path;\n` +
273
397
  `module.exports = load;\n`);
274
398
  }
275
399
  /** `bindings` replacement — a callable `bindings(name)` returning the addon. */
276
- export function bindingsShim(addonPath) {
400
+ export function bindingsShim(addonTable) {
277
401
  return (`const { loadAddon } = require(${JSON.stringify(NAPI_BARE_SPECIFIER)});\n` +
278
- `function bindings() { return loadAddon(${JSON.stringify(addonPath)}); }\n` +
402
+ `const { __gjsifyAddonResolve } = require(${JSON.stringify(ADDON_RESOLVE_SHIM)});\n` +
403
+ `function bindings() { return loadAddon(__gjsifyAddonResolve(${addonTable})); }\n` +
279
404
  `module.exports = bindings;\n`);
280
405
  }
281
406
  /** napi-rs sibling → the raw native exports as the module value. */
282
- export function napiRsShim(addonPath) {
407
+ export function napiRsShim(addonTable) {
283
408
  return (`const { loadAddon } = require(${JSON.stringify(NAPI_BARE_SPECIFIER)});\n` +
284
- `module.exports = loadAddon(${JSON.stringify(addonPath)});\n`);
409
+ `const { __gjsifyAddonResolve } = require(${JSON.stringify(ADDON_RESOLVE_SHIM)});\n` +
410
+ `module.exports = loadAddon(__gjsifyAddonResolve(${addonTable}));\n`);
285
411
  }
286
- function shimFor(kind, addonPath) {
412
+ function shimFor(kind, addonTable) {
287
413
  switch (kind) {
288
414
  case 'direct':
289
- return directNodeShim(addonPath);
415
+ return directNodeShim(addonTable);
290
416
  case 'node-gyp-build':
291
- return nodeGypBuildShim(addonPath);
417
+ return nodeGypBuildShim(addonTable);
292
418
  case 'bindings':
293
- return bindingsShim(addonPath);
419
+ return bindingsShim(addonTable);
294
420
  case 'napi-rs':
295
421
  case 'napi-rs-entry':
296
422
  // Same body: `napi-rs-entry` replaces the whole GENERATED loader,
297
423
  // `napi-rs` a directly-imported platform sibling.
298
- return napiRsShim(addonPath);
424
+ return napiRsShim(addonTable);
299
425
  }
300
426
  }
301
- function encodeVirtual(kind, addonPath) {
302
- return `${NAPI_ADDON_VIRTUAL_PREFIX}${kind}:${addonPath}`;
427
+ function encodeVirtual(kind, addonTable) {
428
+ return `${NAPI_ADDON_VIRTUAL_PREFIX}${kind}:${addonTable}`;
303
429
  }
304
430
  function decodeVirtual(id) {
305
431
  if (!id.startsWith(NAPI_ADDON_VIRTUAL_PREFIX))
@@ -309,8 +435,8 @@ function decodeVirtual(id) {
309
435
  if (sep === -1)
310
436
  return null;
311
437
  const kind = rest.slice(0, sep);
312
- const addonPath = rest.slice(sep + 1);
313
- return { kind, addonPath };
438
+ const addonTable = rest.slice(sep + 1);
439
+ return { kind, addonTable };
314
440
  }
315
441
  /** Classify a specifier for interception — pure decision logic, no filesystem. */
316
442
  export function classifySpecifier(source) {
@@ -568,59 +694,84 @@ export function hostNapiRsTriple() {
568
694
  return null;
569
695
  }
570
696
  }
571
- /** Decode a napi virtual id back to its raw `.node` path (safety net for a resolve hit). */
572
- function rawAddonPath(id) {
573
- const decoded = decodeVirtual(id);
574
- return decoded ? decoded.addonPath : id;
697
+ /**
698
+ * The path of `abs` inside `pkgRoot`, as a `/`-separated MODULE SUBPATH.
699
+ *
700
+ * `relative()` answers in the HOST's separator, so on win32 the table carried
701
+ * `pkg/prebuilds\\win32-x64\\node.napi.node` while every other value in it — and
702
+ * the resolver's own `splitPackageSpec`, which splits on `/` — is `/`-separated.
703
+ * `join` happened to absorb the difference, so the bundle still loaded; what did
704
+ * not survive is the table as BYTES: the same tree then serialised differently
705
+ * per platform, which is the reproducibility `verify-committed-bundles` reads.
706
+ */
707
+ function subpathOf(pkgRoot, abs) {
708
+ return relative(pkgRoot, abs).split('\\').join('/');
709
+ }
710
+ /**
711
+ * The `<package>/<subpath>` spec for an absolute `.node` file path — the part
712
+ * after the LAST `node_modules/` segment, which is what the runtime resolver
713
+ * feeds to `createRequire(...).resolve`. Always a module SPECIFIER, so always
714
+ * `/`-separated.
715
+ *
716
+ * A path under no `node_modules` (a direct import of a locally built `.node`)
717
+ * has no package identity to record, so the path itself is the spec and the
718
+ * resolver returns it unchanged. That is the one case ADR 0084's premise does
719
+ * not reach, and it is not a regression: before this ADR the absolute path was
720
+ * baked in and the bundle loaded the file. It only stops being RELOCATABLE, so
721
+ * the build says so once rather than shipping it silently.
722
+ */
723
+ function packageSpecFor(absPath, warn) {
724
+ const normalized = process.platform === 'win32' ? absPath.replaceAll('\\', '/') : absPath;
725
+ const marker = 'node_modules/';
726
+ const idx = normalized.lastIndexOf(marker);
727
+ if (idx >= 0)
728
+ return normalized.slice(idx + marker.length);
729
+ warn?.(`[gjsify-napi-addon] '${absPath}' is not inside a node_modules, so the bundle carries its ` +
730
+ 'ABSOLUTE path and only loads where it was built. Move the addon into a package (or ' +
731
+ 'install one that ships it) for a bundle that travels.');
732
+ return normalized;
575
733
  }
576
734
  /**
577
- * Resolve the current-platform compiled `.node` for a napi-rs generated-loader
578
- * package: the current-triple sibling package (whose own `main` IS the `.node`),
579
- * then a local `pkg.<triple>.node`. `skipSelf` bypasses this plugin's own
580
- * napi-rs-candidate interception so a raw `.node` id comes back. Returns null when
581
- * no current-platform binary is present, so the caller never shims a missing file.
735
+ * Enumerate every napi-rs platform sibling of `pkg` that resolves to a `.node`,
736
+ * keyed by the sibling's platform triple. ADR 0084: the build ENUMERATES every
737
+ * installed sibling, so the bundle picks the right one at RUN time.
738
+ *
739
+ * Returns null when no sibling resolves — the caller then falls through to
740
+ * normal resolution (never a shim over nothing).
582
741
  */
583
- async function resolveNapiRsEntryAddon(ctx, pkgRoot, pkg, importer) {
742
+ async function enumerateNapiRsEntryTargets(ctx, pkgRoot, pkg, importer) {
584
743
  const siblings = Object.keys(pkg.optionalDependencies ?? {}).filter((dep) => isNapiRsSibling(pkg, dep));
585
- const triple = hostNapiRsTriple();
586
- // HOST TRIPLE ONLY whenever we can name it — never "the first sibling that
587
- // resolves". `gjsify install` materialises EVERY platform package, so on a Linux
588
- // box `lightningcss`'s `darwin-x64` sibling also resolves, and taking it bakes a
589
- // Mach-O `.node` into a linux GJS bundle that `loadAddon` can only fail on at
590
- // runtime. Host-triple selection is what node-gyp-build and napi-rs' own
591
- // generated loaders do, so this matches the binary Node would have loaded.
592
- const ordered = triple === null ? siblings : siblings.filter((dep) => dep.endsWith(`-${triple}`));
593
- for (const dep of ordered) {
594
- // This resolve is a QUESTION ("is a host-triple binary installed?") that can
595
- // THROW instead of answering: `skipSelf` skips only THIS plugin, so the
596
- // `unresolved-workspace-import` guard still runs at `order:'post'` and
597
- // (correctly, for a real import) makes an unresolvable bare `@gjsify/*`
598
- // FATAL. A misread sibling name would then kill the build instead of
599
- // declining — measured on darwin. `isNapiRsSibling` is the fix; catching here
600
- // keeps the class from ever being fatal again.
744
+ const targets = {};
745
+ for (const dep of siblings) {
601
746
  let resolved = null;
602
747
  try {
603
748
  resolved = await ctx.resolve(dep, importer, { skipSelf: true });
604
749
  }
605
- catch (err) {
606
- warnSafe(ctx, `[gjsify-napi] probing the platform sibling "${dep}" of "${pkg.name ?? pkgRoot}" failed; not rewriting its entry (${err instanceof Error ? err.message.split('\n')[0] : String(err)})`);
750
+ catch {
607
751
  continue;
608
752
  }
609
- if (!resolved)
753
+ if (!resolved || !resolved.id.endsWith('.node') || !existsSync(resolved.id))
610
754
  continue;
611
- const abs = rawAddonPath(resolved.id);
612
- if (abs.endsWith('.node') && existsSync(abs))
613
- return abs;
755
+ const spec = packageSpecFor(resolved.id, (m) => warnSafe(ctx, m));
756
+ // Extract the triple from the sibling name (`<prefix>-<triple>`).
757
+ const match = dep.match(NAPI_RS_TRIPLE_RE);
758
+ const triple = match ? match[0].slice(1) : null;
759
+ if (!triple)
760
+ continue;
761
+ const key = normalizeNapiRsTriple(triple);
762
+ targets[key] = spec;
614
763
  }
615
- // Local in-package binary (`<binaryName>.<host-triple>.node`) — deterministic
616
- // host-triple match so a wrong-platform local file is never picked.
764
+ // Local in-package binary (`<binaryName>.<triple>.node`) — host triple only.
765
+ const triple = hostNapiRsTriple();
617
766
  const binaryName = napiBinaryName(pkg);
618
767
  if (binaryName && triple) {
619
768
  const local = join(pkgRoot, `${binaryName}.${triple}.node`);
620
- if (existsSync(local))
621
- return local;
769
+ if (existsSync(local)) {
770
+ const spec = packageSpecFor(local, (m) => warnSafe(ctx, m));
771
+ targets[normalizeNapiRsTriple(triple)] = spec;
772
+ }
622
773
  }
623
- return null;
774
+ return Object.keys(targets).length > 0 ? targets : null;
624
775
  }
625
776
  /** Emit a non-fatal warning through the context, if it supports it. */
626
777
  function warnSafe(ctx, msg) {
@@ -658,7 +809,9 @@ async function resolveNodeFile(ctx, source, importer) {
658
809
  */
659
810
  export function napiNodeAddonPlugin(options = {}) {
660
811
  const warnOnMissingNapi = options.warnOnMissingNapi !== false;
812
+ const runtimeResolve = options.runtimeResolve !== false;
661
813
  let missingNapiChecked = false;
814
+ let unanchoredChecked = false;
662
815
  // Memoized per resolved file: the `index.*` filter fires the handler for every
663
816
  // package's index entry on every build pass, so this bounds the package.json
664
817
  // reads to one per unique entry file.
@@ -762,6 +915,21 @@ export function napiNodeAddonPlugin(options = {}) {
762
915
  // `dirname(null)` then took the whole GJS build down as an
763
916
  // UNHANDLEABLE_ERROR. Normalise once, at the boundary.
764
917
  const importer = typeof rawImporter === 'string' ? rawImporter : undefined;
918
+ // One gate ahead of the `@gjsify/napi` gate: without the
919
+ // bundle-URL banner the run-time resolver has nothing to anchor
920
+ // on and would throw at LOAD. Declining leaves the module to
921
+ // normal resolution, which is the same shape as the missing-napi
922
+ // decline below and for the same reason — a knowingly
923
+ // unloadable artifact is worse than an unrewritten one.
924
+ if (!runtimeResolve) {
925
+ if (warnOnMissingNapi && !unanchoredChecked) {
926
+ unanchoredChecked = true;
927
+ warnSafe(ctx, `[gjsify-napi-addon] leaving native addons to normal resolution — the run-time ` +
928
+ `addon resolver needs an ESM single-file build (gjsify build --app gjs), and ` +
929
+ 'this output carries no bundle-URL anchor.');
930
+ }
931
+ return null;
932
+ }
765
933
  const cls = classifySpecifier(source);
766
934
  if (cls !== null) {
767
935
  // Direct `.node` — resolve the file path itself.
@@ -771,7 +939,8 @@ export function napiNodeAddonPlugin(options = {}) {
771
939
  return null; // unresolvable — let the default chain error
772
940
  if (!(await ensureNapiAvailable(ctx, importer)))
773
941
  return null;
774
- return { id: encodeVirtual('direct', abs) };
942
+ const spec = packageSpecFor(abs, (m) => warnSafe(ctx, m));
943
+ return { id: encodeVirtual('direct', JSON.stringify({ '*': spec })) };
775
944
  }
776
945
  // napi-rs platform sibling — confirm it resolves to a `.node`.
777
946
  if (cls.kind === 'napi-rs-candidate') {
@@ -780,7 +949,8 @@ export function napiNodeAddonPlugin(options = {}) {
780
949
  return null; // not a native sibling
781
950
  if (!(await ensureNapiAvailable(ctx, importer)))
782
951
  return null;
783
- return { id: encodeVirtual('napi-rs', resolved.id) };
952
+ const spec = packageSpecFor(resolved.id, (m) => warnSafe(ctx, m));
953
+ return { id: encodeVirtual('napi-rs', JSON.stringify({ '*': spec })) };
784
954
  }
785
955
  // node-gyp-build / bindings — probe the importer's package root.
786
956
  if (importer === undefined)
@@ -790,8 +960,14 @@ export function napiNodeAddonPlugin(options = {}) {
790
960
  return null;
791
961
  if (!(await ensureNapiAvailable(ctx, importer)))
792
962
  return null;
793
- const addonPath = resolveAddonPath(pkgRoot, { warn: (m) => warnSafe(ctx, m) }); // throws → build error
794
- return { id: encodeVirtual(cls.kind, addonPath) };
963
+ const pkg = readPackageJsonSafe(pkgRoot);
964
+ if (pkg === null)
965
+ return null;
966
+ const table = enumerateAddonTargets(pkgRoot, pkg);
967
+ if (Object.keys(table).length === 0) {
968
+ throw new AddonNotBuiltError(pkgRoot);
969
+ }
970
+ return { id: encodeVirtual(cls.kind, JSON.stringify(table)) };
795
971
  }
796
972
  // napi-rs GENERATED-LOADER ENTRY. The specifier may be the entry PATH
797
973
  // (an internal or already-aliased import) or the package's BARE name,
@@ -803,11 +979,11 @@ export function napiNodeAddonPlugin(options = {}) {
803
979
  return null;
804
980
  const entry = detectNapiRsEntryCached(entryFile);
805
981
  if (entry !== null) {
806
- const addonPath = await resolveNapiRsEntryAddon(ctx, entry.pkgRoot, entry.pkg, entryFile);
807
- if (addonPath !== null) {
982
+ const table = await enumerateNapiRsEntryTargets(ctx, entry.pkgRoot, entry.pkg, entryFile);
983
+ if (table !== null) {
808
984
  if (!(await ensureNapiAvailable(ctx, importer)))
809
985
  return null;
810
- return { id: encodeVirtual('napi-rs-entry', addonPath) };
986
+ return { id: encodeVirtual('napi-rs-entry', JSON.stringify(table)) };
811
987
  }
812
988
  }
813
989
  return null;
@@ -817,7 +993,7 @@ export function napiNodeAddonPlugin(options = {}) {
817
993
  const decoded = decodeVirtual(id);
818
994
  if (decoded === null)
819
995
  return null;
820
- return { code: shimFor(decoded.kind, decoded.addonPath), moduleSideEffects: false };
996
+ return { code: shimFor(decoded.kind, decoded.addonTable), moduleSideEffects: false };
821
997
  },
822
998
  };
823
999
  }
@@ -0,0 +1,20 @@
1
+ import type { Plugin } from 'rolldown';
2
+ import { type AddonPackageJson } from './napi-node-addon.js';
3
+ interface NativePackageJson extends AddonPackageJson {
4
+ gypfile?: boolean;
5
+ binary?: unknown;
6
+ dependencies?: Record<string, string>;
7
+ }
8
+ /**
9
+ * Is the package at `pkgRoot` a Node-API addon — one whose code loads a `.node`
10
+ * relative to its own directory? True on any of: `gypfile`, a `binding.gyp`, a
11
+ * node-pre-gyp `binary` block, a dependency on an addon loader, the napi-rs manifest
12
+ * signals, or a `.node` under `prebuilds/` or `build/`. A gjsify native bridge is
13
+ * never one: it ships a GI typelib loaded through `gi://`.
14
+ */
15
+ export declare function isNodeAddonPackage(pkgRoot: string, pkg: NativePackageJson): boolean;
16
+ /** `@scope/name/sub` → `@scope/name`, `name/sub` → `name`; null for anything not bare. */
17
+ export declare function packageNameOf(specifier: string): string | null;
18
+ /** Keeps every import of a Node-API addon package external on `--app node`. */
19
+ export declare function nodeNativeExternalPlugin(): Plugin;
20
+ export {};