@gjsify/rolldown-plugin-gjsify 0.23.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.
@@ -4,10 +4,14 @@
4
4
  // (they appear transitively via `@gjsify/unit` and similar packages with
5
5
  // GJS-specific code paths). Bare Node specifiers + their `node:*` prefix
6
6
  // variants are routed to `@gjsify/<X>` via the curated
7
- // `ALIASES_NODE_FOR_BROWSER` table — the dynamic per-runtimes-triplet
8
- // resolver (`getDerivedAliasesSync`) finishes the routing in a second pass
9
- // (`@gjsify/<X>` → `@gjsify/<X>/globals` / `@gjsify/empty` depending on the
10
- // slot declaration).
7
+ // `ALIASES_NODE_FOR_BROWSER` table, whose VALUES already carry the
8
+ // per-runtimes-triplet slot routing (`@gjsify/<X>` → `@gjsify/<X>/browser` /
9
+ // `/globals` / `@gjsify/empty`): that table is exported through
10
+ // `withDerivedSlotRouting` in `@gjsify/resolve-npm`. Composition happens THERE,
11
+ // not as a resolver second pass here — `aliasPlugin` resolves an alias target
12
+ // in exactly one hop, because the map it receives is merged flat across tiers
13
+ // and a chain would re-route user `--alias` targets too (full argument in
14
+ // `withDerivedSlotRouting`'s doc comment and `plugins/alias.ts`).
11
15
  import { aliasPlugin } from '../plugins/alias.js';
12
16
  import { deepkitPlugin } from '@gjsify/rolldown-plugin-deepkit';
13
17
  import blueprintPlugin from '@gjsify/vite-plugin-blueprint';
package/lib/app/gjs.d.ts CHANGED
@@ -55,6 +55,30 @@ export declare const setupForGjs: (input: GjsFactoryInput) => Promise<GjsBuildCo
55
55
  * in `auto-globals.spec.ts` — canonical contract, change-detector status.
56
56
  */
57
57
  export declare function createGjsExternalsPredicate(userExternal?: string[]): (id: string) => boolean;
58
+ /**
59
+ * Recognize the shims this plugin INJECTS into a `--app gjs` bundle
60
+ * (`shims/module-resolve`, `shims/console-gjs`, `shims/unicorn-magic`, …), in
61
+ * both bare-specifier and resolved-disk-path form.
62
+ *
63
+ * They must be force-inlined for exactly the reason `/register` subpaths are,
64
+ * and the failure is the same one: GJS has no `require` and its ESM loader has
65
+ * neither a node_modules walker nor `exports`-map support, so an EXTERNALIZED
66
+ * shim aborts the program at load:
67
+ *
68
+ * Error: Calling `require` for
69
+ * "@gjsify/rolldown-plugin-gjsify/shims/module-resolve" in an environment
70
+ * that doesn't expose the `require` function
71
+ *
72
+ * The shim is not optional — the bundler injected it because the bundle needs
73
+ * it — so externalizing it can only ever produce an unloadable artifact. The
74
+ * carve-out short-circuits BEFORE the user-external check so `bundler.external`
75
+ * cannot override it, and it matches by SHAPE rather than an explicit list, so
76
+ * a shim added later is covered without touching this predicate.
77
+ *
78
+ * Scope is deliberately narrow: only OUR shim directory. A consumer package
79
+ * that happens to have a `shims/` folder is unaffected.
80
+ */
81
+ export declare function isGjsifyShim(id: string): boolean;
58
82
  /**
59
83
  * Recognize the `/register` and `/register/<feature>` subpath shapes that
60
84
  * `--globals auto` injects into the bundle as side-effect imports.
package/lib/app/gjs.js CHANGED
@@ -10,6 +10,8 @@
10
10
  // handled separately by `setupLib`.
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { dirname, resolve } from 'node:path';
13
+ import { existsSync } from 'node:fs';
14
+ import { createRequire } from 'node:module';
13
15
  import { aliasPlugin } from '../plugins/alias.js';
14
16
  import { externalsPlugin } from '../plugins/externals.js';
15
17
  import { napiNodeAddonPlugin } from '../plugins/napi-node-addon.js';
@@ -23,35 +25,38 @@ import { cssAsStringPlugin } from '../plugins/css-as-string.js';
23
25
  import { shebangPlugin, resolveShebangLine, inputShebangStripPlugin } from '../plugins/shebang.js';
24
26
  import { wrapInputWithSideEffects } from '../utils/entry-wrapper.js';
25
27
  const _shimDir = dirname(fileURLToPath(import.meta.url));
28
+ /**
29
+ * Resolve one of this package's bundled shims to an absolute path.
30
+ *
31
+ * `createRequire` is a NAMED IMPORT from `node:module`, never a bare
32
+ * `require(...)` — this source is ESM (see AGENTS.md "Our source is ESM —
33
+ * no bare `require`"). It is used here for its RESOLVER, which is
34
+ * `exports`-map aware, and only to answer "where does this subpath live";
35
+ * nothing is loaded through it.
36
+ */
26
37
  function resolveShim(shimName) {
27
38
  // Preferred: relative to this module's directory. Works under the
28
39
  // normal Node consumer flow where `_shimDir` = `<pkg>/lib/app/`.
29
40
  const relative = resolve(_shimDir, `../shims/${shimName}.js`);
30
- let fs = null;
31
- try {
32
- // eslint-disable-next-line @typescript-eslint/no-require-imports
33
- fs = require('node:fs');
34
- }
35
- catch {
36
- return relative;
37
- }
38
- if (fs.existsSync(relative))
41
+ if (existsSync(relative))
39
42
  return relative;
40
43
  // Fallback: when the orchestrator is bundled into a single .mjs
41
44
  // (GJS-CLI self-host loop) `_shimDir` collapses to the bundle's
42
- // own directory and the relative lookup misses. createRequire's
43
- // resolver is `exports`-map-aware (Phase C), so the published
44
- // subpath export `./shims/<name>` works under both Node and GJS
45
- // without further walking. Each shim MUST be a `./shims/<name>`
46
- // subpath export in package.json for this fallback to resolve.
45
+ // own directory and the relative lookup misses. The published subpath
46
+ // export `./shims/<name>` resolves under both Node and GJS without
47
+ // further walking. Each shim MUST be a `./shims/<name>` subpath export
48
+ // in package.json for this fallback to resolve.
47
49
  try {
48
- // eslint-disable-next-line @typescript-eslint/no-require-imports
49
- const Module = require('node:module');
50
- const require_ = Module.createRequire(import.meta.url);
51
- return require_.resolve(`@gjsify/rolldown-plugin-gjsify/shims/${shimName}`);
50
+ return createRequire(import.meta.url).resolve(`@gjsify/rolldown-plugin-gjsify/shims/${shimName}`);
52
51
  }
53
- catch {
54
- return relative;
52
+ catch (cause) {
53
+ // Both lookups failed — the shim is genuinely absent. Returning the
54
+ // unverified `relative` here would hand a non-existent path to the
55
+ // alias map, and the failure would resurface much later as an
56
+ // unrelated "could not resolve" naming a path nobody wrote. Name the
57
+ // actual problem, at the site that knows what it was looking for.
58
+ throw new Error(`@gjsify/rolldown-plugin-gjsify: cannot locate the "${shimName}" shim. ` +
59
+ `Tried ${relative} and the "./shims/${shimName}" subpath export.`, { cause });
55
60
  }
56
61
  }
57
62
  export const setupForGjs = async (input) => {
@@ -72,7 +77,12 @@ export const setupForGjs = async (input) => {
72
77
  //
73
78
  // `exactExternal` filters out register subpaths so a user external
74
79
  // entry can never override the force-inline carve-out below.
75
- const exactExternal = ['cairo', 'gettext', 'system', ...userExternal.filter((id) => !isRegisterSubpath(id))];
80
+ const exactExternal = [
81
+ 'cairo',
82
+ 'gettext',
83
+ 'system',
84
+ ...userExternal.filter((id) => !isRegisterSubpath(id) && !isGjsifyShim(id)),
85
+ ];
76
86
  const external = createGjsExternalsPredicate(userExternal);
77
87
  const format = input.pluginOptions.format ?? 'esm';
78
88
  const exclude = input.pluginOptions.exclude ?? [];
@@ -271,7 +281,7 @@ function flattenAliases(map) {
271
281
  export function createGjsExternalsPredicate(userExternal = []) {
272
282
  const exact = ['cairo', 'gettext', 'system', ...userExternal];
273
283
  return (id) => {
274
- if (isRegisterSubpath(id))
284
+ if (isRegisterSubpath(id) || isGjsifyShim(id))
275
285
  return false;
276
286
  if (id.startsWith('gi://'))
277
287
  return true;
@@ -280,6 +290,36 @@ export function createGjsExternalsPredicate(userExternal = []) {
280
290
  return false;
281
291
  };
282
292
  }
293
+ /**
294
+ * Recognize the shims this plugin INJECTS into a `--app gjs` bundle
295
+ * (`shims/module-resolve`, `shims/console-gjs`, `shims/unicorn-magic`, …), in
296
+ * both bare-specifier and resolved-disk-path form.
297
+ *
298
+ * They must be force-inlined for exactly the reason `/register` subpaths are,
299
+ * and the failure is the same one: GJS has no `require` and its ESM loader has
300
+ * neither a node_modules walker nor `exports`-map support, so an EXTERNALIZED
301
+ * shim aborts the program at load:
302
+ *
303
+ * Error: Calling `require` for
304
+ * "@gjsify/rolldown-plugin-gjsify/shims/module-resolve" in an environment
305
+ * that doesn't expose the `require` function
306
+ *
307
+ * The shim is not optional — the bundler injected it because the bundle needs
308
+ * it — so externalizing it can only ever produce an unloadable artifact. The
309
+ * carve-out short-circuits BEFORE the user-external check so `bundler.external`
310
+ * cannot override it, and it matches by SHAPE rather than an explicit list, so
311
+ * a shim added later is covered without touching this predicate.
312
+ *
313
+ * Scope is deliberately narrow: only OUR shim directory. A consumer package
314
+ * that happens to have a `shims/` folder is unaffected.
315
+ */
316
+ export function isGjsifyShim(id) {
317
+ // Bare/fully-qualified: `@gjsify/rolldown-plugin-gjsify/shims/<name>`.
318
+ if (/@gjsify\/rolldown-plugin-gjsify\/shims\/[^?]+$/.test(id))
319
+ return true;
320
+ // Resolved disk path: `…/rolldown-plugin-gjsify/{lib,src}/shims/<name>.js`.
321
+ return /[\\/]rolldown-plugin-gjsify[\\/](?:lib|src)[\\/]shims[\\/]/.test(id);
322
+ }
283
323
  /**
284
324
  * Recognize the `/register` and `/register/<feature>` subpath shapes that
285
325
  * `--globals auto` injects into the bundle as side-effect imports.
@@ -1,4 +1,4 @@
1
- export { setupForGjs, isRegisterSubpath, createGjsExternalsPredicate } from './gjs.js';
1
+ export { setupForGjs, isRegisterSubpath, isGjsifyShim, createGjsExternalsPredicate } from './gjs.js';
2
2
  export type { GjsBuildConfig, GjsFactoryInput } from './gjs.js';
3
3
  export { setupForNode, enableGjsRegistersForNode, isGjsSourceBuild } from './node.js';
4
4
  export type { NodeBuildConfig, NodeFactoryInput } from './node.js';
package/lib/app/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { setupForGjs, isRegisterSubpath, createGjsExternalsPredicate } from './gjs.js';
1
+ export { setupForGjs, isRegisterSubpath, isGjsifyShim, createGjsExternalsPredicate } from './gjs.js';
2
2
  export { setupForNode, enableGjsRegistersForNode, isGjsSourceBuild } from './node.js';
3
3
  export { setupForBrowser } from './browser.js';
4
4
  export { setupForNativescript } from './nativescript.js';
@@ -16,10 +16,21 @@
16
16
  // GJS-specific code paths that never execute on NS.
17
17
  //
18
18
  // Bare Node specifiers + their `node:*` prefix variants are routed to
19
- // `@gjsify/<X>` via the curated `ALIASES_NODE_FOR_NATIVESCRIPT` table — the
20
- // dynamic per-runtimes-triplet resolver (`getDerivedAliasesSync`) finishes
21
- // the routing in a second pass (`@gjsify/<X>` → `@gjsify/<X>/globals` /
22
- // `@gjsify/empty` depending on the slot declaration).
19
+ // `@gjsify/<X>` via the curated `ALIASES_NODE_FOR_NATIVESCRIPT` table. Unlike
20
+ // the browser table, this one is NOT passed through `withDerivedSlotRouting`:
21
+ // slot routing would only change 4 of its 122 entries and all four are
22
+ // regressions — `fs`/`fs/promises` (+ their `node:` forms) deliberately point
23
+ // at `@gjsify/native-fs-bridge`, which declares `nativescript: "native"` in the
24
+ // "this package IS the native implementation" sense and therefore routes to a
25
+ // `globals.mjs` it does not ship → `@gjsify/empty`. The slot vocabulary's
26
+ // `native` means "the RUNTIME provides this, use its value", so the bridge
27
+ // packages are mis-declared; that is tracked in STATUS.md and must be settled
28
+ // before this table is composed. A direct `import … from
29
+ // '@gjsify/native-fs-bridge'` already hits the derived map (plain key hit) and
30
+ // is emptied TODAY — same root cause, listed in the same TODO.
31
+ //
32
+ // Derived `@gjsify/<X>` entries are merged into the map below, so an import by
33
+ // PACKAGE NAME still routes per its declared slot in a single hop.
23
34
  //
24
35
  // No `cssAsStringPlugin` (NativeScript ships its own CSS pipeline as part
25
36
  // of `@nativescript/core`) and no `blueprintPlugin` (Blueprint is GTK-only).
package/lib/index.d.ts CHANGED
@@ -11,10 +11,12 @@ export { textLoaderPlugin } from './plugins/text-loader.js';
11
11
  export type { TextLoaderPluginOptions, LoaderKind } from './plugins/text-loader.js';
12
12
  export { shebangPlugin, GJS_SHEBANG, NODE_SHEBANG, expandEnvTemplate, resolveShebangLine } from './plugins/shebang.js';
13
13
  export type { ShebangPluginOptions } from './plugins/shebang.js';
14
+ export { aliasPlugin } from './plugins/alias.js';
15
+ export type { AliasPluginOptions } from './plugins/alias.js';
14
16
  export { gjsImportsEmptyPlugin } from './plugins/gjs-imports-empty.js';
15
17
  export { externalsPlugin } from './plugins/externals.js';
16
18
  export type { ExternalsPredicate, ExternalsPluginOptions } from './plugins/externals.js';
17
- export { napiNodeAddonPlugin, resolveAddonPath, nearestPackageRoot, classifySpecifier, directNodeShim, nodeGypBuildShim, bindingsShim, napiRsShim, isNapiRsPackageJson, detectNapiRsEntry, AddonNotBuiltError, } from './plugins/napi-node-addon.js';
19
+ export { napiNodeAddonPlugin, resolveAddonPath, nearestPackageRoot, classifySpecifier, directNodeShim, nodeGypBuildShim, bindingsShim, napiRsShim, ADDON_FILTER_RE, isNapiRsPackageJson, isNapiRsSibling, detectNapiRsEntry, AddonNotBuiltError, } from './plugins/napi-node-addon.js';
18
20
  export type { NapiNodeAddonPluginOptions, AddonPackageJson } from './plugins/napi-node-addon.js';
19
21
  export { platformResolvePlugin, detectNativescriptPlatform, nativescriptPlatformDefines, } from './plugins/platform-resolve.js';
20
22
  export type { PlatformResolvePluginOptions, NativescriptPlatform } from './plugins/platform-resolve.js';
package/lib/index.js CHANGED
@@ -8,9 +8,10 @@ export { processStubPlugin, GJS_PROCESS_STUB, composeBanner } from './plugins/pr
8
8
  export { cssAsStringPlugin } from './plugins/css-as-string.js';
9
9
  export { textLoaderPlugin } from './plugins/text-loader.js';
10
10
  export { shebangPlugin, GJS_SHEBANG, NODE_SHEBANG, expandEnvTemplate, resolveShebangLine } from './plugins/shebang.js';
11
+ export { aliasPlugin } from './plugins/alias.js';
11
12
  export { gjsImportsEmptyPlugin } from './plugins/gjs-imports-empty.js';
12
13
  export { externalsPlugin } from './plugins/externals.js';
13
- export { napiNodeAddonPlugin, resolveAddonPath, nearestPackageRoot, classifySpecifier, directNodeShim, nodeGypBuildShim, bindingsShim, napiRsShim, isNapiRsPackageJson, detectNapiRsEntry, AddonNotBuiltError, } from './plugins/napi-node-addon.js';
14
+ export { napiNodeAddonPlugin, resolveAddonPath, nearestPackageRoot, classifySpecifier, directNodeShim, nodeGypBuildShim, bindingsShim, napiRsShim, ADDON_FILTER_RE, isNapiRsPackageJson, isNapiRsSibling, detectNapiRsEntry, AddonNotBuiltError, } from './plugins/napi-node-addon.js';
14
15
  export { platformResolvePlugin, detectNativescriptPlatform, nativescriptPlatformDefines, } from './plugins/platform-resolve.js';
15
16
  export * from './plugin.js';
16
17
  import { gjsifyPlugin } from './plugin.js';
@@ -20,6 +20,38 @@
20
20
  // resolve through the "import" condition (Rolldown's default), bypassing the
21
21
  // `cjs-compat.cjs` shim that unwraps named-export ESM modules to their
22
22
  // constructor — breaking `util.inherits(Child, Stream)` patterns.
23
+ //
24
+ // ── Two invariants this plugin holds ────────────────────────────────────────
25
+ //
26
+ // 1. **Gjsify-generated modules are NOT aliased.** An import whose importer is
27
+ // a `\0gjsify-*` virtual module (`isGjsifyVirtualModuleId`) is left alone.
28
+ // We wrote that module's source; it names the RUNTIME module it needs and
29
+ // must get exactly that. The motivating failure: `gjsGiNodePlugin`'s
30
+ // `import { createRequire } from 'node:module'` was rewritten onto the
31
+ // `@gjsify/module` polyfill by the consumer harness's
32
+ // `--alias node:module=@gjsify/module`, so the bundle called the polyfill's
33
+ // `createRequire` at top level before its GLib proxy existed
34
+ // (`TypeError: … reading 'filename_from_uri'`) — and the polyfill's own
35
+ // Gio-backed loader can only reach GLib THROUGH the module it was being
36
+ // asked to load. The guard is importer-based rather than a per-specifier
37
+ // carve-out so it covers every current and future virtual module (entry
38
+ // wrapper, gi-node, napi-addon, gjs-imports-empty).
39
+ //
40
+ // 2. **An alias target is TERMINAL — exactly one hop, never a chain.** The
41
+ // `entries` map handed in is already MERGED across tiers (derived slot
42
+ // routing → curated `ALIASES_*` tables → per-target overrides → user
43
+ // `--alias`, later wins, see `app/*.ts`), so the tier a value came from is
44
+ // no longer recoverable here. Re-feeding a target through the table would
45
+ // therefore apply slot routing to USER aliases too, which is precisely what
46
+ // must not happen: `scripts/node-gi-consumer-harness.mjs` builds
47
+ // `--alias node:<x>=@gjsify/<x>` to put the POLYFILL under test behind a
48
+ // Node builtin, and 34 of those polyfills declare `runtimes.node: "native"`
49
+ // — a second hop would silently route them back to `@gjsify/<x>/globals`
50
+ // (i.e. Node's own builtin) and the suite would measure nothing. The
51
+ // "second pass" of the slot-routing model is a COMPOSITION concern, and it
52
+ // is resolved where the tiers are still distinguishable — see
53
+ // `withDerivedSlotRouting` in `@gjsify/resolve-npm`.
54
+ import { isGjsifyVirtualModuleId } from '../utils/virtual-module-id.js';
23
55
  export function aliasPlugin(options) {
24
56
  const entries = options.entries;
25
57
  return {
@@ -27,6 +59,10 @@ export function aliasPlugin(options) {
27
59
  resolveId: {
28
60
  order: 'pre',
29
61
  async handler(source, importer, extraOptions) {
62
+ // Invariant 1 — never rewrite an import that WE generated.
63
+ if (isGjsifyVirtualModuleId(importer)) {
64
+ return null;
65
+ }
30
66
  if (!Object.prototype.hasOwnProperty.call(entries, source)) {
31
67
  return null;
32
68
  }
@@ -121,7 +121,32 @@ async function tryLoadNativeBundler() {
121
121
  }
122
122
  }
123
123
  async function loadNpmBundler() {
124
- const { bundleAsync } = await import('lightningcss');
124
+ // INDIRECT specifier, for the same reason `tryLoadNativeBundler` uses one:
125
+ // keep this acquisition out of the BUILD graph so it is resolved at
126
+ // runtime only. npm `lightningcss` is a napi-rs package, and a static
127
+ // `import('lightningcss')` here put its generated loader into the module
128
+ // graph of every `--app gjs` bundle — including the CLI's own committed
129
+ // `dist/cli.gjs.mjs`.
130
+ //
131
+ // That is what made the committed bundle ENVIRONMENT-DEPENDENT. With
132
+ // `@gjsify/napi` resolvable, `napiNodeAddonPlugin` rewrites the loader to a
133
+ // `loadAddon()` shim and inlines it; without it the plugin declines and the
134
+ // import stays external. Two different module graphs, so the minifier
135
+ // assigns different variable names, so the artifact stops reproducing from
136
+ // its own source (`verify-committed-bundles` red, first difference at byte
137
+ // 275: `r=` vs `a=` for `globalThis.imports.gi.GLib`). A toolchain artifact
138
+ // must not depend on whether an optional package happened to be installed
139
+ // when it was built.
140
+ //
141
+ // Nothing is lost under GJS: the documented GJS backend is
142
+ // `@gjsify/lightningcss-native` (above), and this npm fallback was never
143
+ // actually usable there — its `.node` cannot load under GJS without a
144
+ // build-time rewrite, and the only arches where that rewrite is possible
145
+ // (`@gjsify/napi`: linux-x64, darwin-arm64) are arches where the native
146
+ // bridge exists anyway. On Node this is unchanged: `lightningcss` is a real
147
+ // `dependency` of this package, so the runtime resolve finds it.
148
+ const specifier = 'lightningcss';
149
+ const { bundleAsync } = (await import(/* @vite-ignore */ specifier));
125
150
  return async (filename, targets) => {
126
151
  const result = await bundleAsync({
127
152
  filename,
@@ -27,7 +27,11 @@
27
27
  // Portability note (same as `gjs-imports-empty.ts`): the `filter` is a Rolldown
28
28
  // fast-path; the internal guard in the handler is the load-bearing check so the
29
29
  // plugin is correct even when the filter does not pre-filter (e.g. under Vite).
30
- const GI_NODE_VIRTUAL_PREFIX = '\0gjsify-gi-node:';
30
+ import { GJSIFY_VIRTUAL_PREFIX } from '../utils/virtual-module-id.js';
31
+ // Derived from the shared prefix so `isGjsifyVirtualModuleId` recognises these
32
+ // ids — that is what keeps the `node:module` import below bound to the RUNTIME's
33
+ // `createRequire` even when the build carries a user `--alias node:module=…`.
34
+ const GI_NODE_VIRTUAL_PREFIX = `${GJSIFY_VIRTUAL_PREFIX}gi-node:`;
31
35
  /** Parse a `gi://Ns?version=X` specifier into its namespace + optional version. */
32
36
  export function parseGiSpecifier(source) {
33
37
  if (!source.startsWith('gi://'))
@@ -31,7 +31,8 @@
31
31
  // dev server) the handler may receive ALL ids. The internal guard below makes
32
32
  // the plugin correct regardless of whether the filter pre-filtered — it is
33
33
  // the load-bearing check; the `filter` is a defense-in-depth optimization.
34
- const GJSIMPORTS_VIRTUAL_ID = '\0gjsify-empty-gjs-import';
34
+ import { GJSIFY_VIRTUAL_PREFIX } from '../utils/virtual-module-id.js';
35
+ const GJSIMPORTS_VIRTUAL_ID = `${GJSIFY_VIRTUAL_PREFIX}empty-gjs-import`;
35
36
  export function gjsImportsEmptyPlugin(options = {}) {
36
37
  const emptyGirs = options.emptyGirs ?? true;
37
38
  // When `@girs/*` is carved out, only `gi://*` reaches the empty redirect.
@@ -1,4 +1,13 @@
1
1
  import type { Plugin } from 'rolldown';
2
+ /**
3
+ * Fast-path filter (superset of what `claimSpecifier` claims). Handler re-checks.
4
+ *
5
+ * Exported so `napi-node-addon.spec.ts` can assert its Rust-compatibility (see
6
+ * {@link BARE_SPECIFIER_RE}) — a JS-only construct here disables the plugin
7
+ * entirely under `@gjsify/rolldown-native`, and no build-level test would see
8
+ * it, because every addon gate drives the Node CLI entry.
9
+ */
10
+ export declare const ADDON_FILTER_RE: RegExp;
2
11
  /** Options for {@link napiNodeAddonPlugin}. */
3
12
  export interface NapiNodeAddonPluginOptions {
4
13
  /**
@@ -54,6 +63,8 @@ export declare function classifySpecifier(source: string): {
54
63
  export interface AddonPackageJson {
55
64
  name?: string;
56
65
  main?: string;
66
+ module?: string;
67
+ exports?: unknown;
57
68
  browser?: string;
58
69
  napi?: unknown;
59
70
  optionalDependencies?: Record<string, string>;
@@ -65,11 +76,13 @@ export interface AddonPackageJson {
65
76
  * (a) a top-level `napi` config OBJECT (`{ binaryName, targets, … }`) — the
66
77
  * napi-rs CLI's own build block. An ordinary npm package never declares a
67
78
  * `napi` object at the manifest root.
68
- * (b) at least one `optionalDependencies` entry named `<self>-<triple>` — the
69
- * napi-rs prebuilt-binary convention (`@node-rs/argon2` ships
70
- * `@node-rs/argon2-linux-x64-gnu`, …). The key MUST start with the
71
- * package's own name plus `-` AND end in a platform triple, so a normal
72
- * optional dep (`fsevents`) can't match.
79
+ * (b) at least one `optionalDependencies` entry that is a platform SIBLING
80
+ * (see {@link isNapiRsSibling}) — the napi-rs prebuilt-binary convention,
81
+ * under either naming scheme: `<self>-<triple>` (`@node-rs/argon2` ships
82
+ * `@node-rs/argon2-linux-x64-gnu`) or `<napi.packageName>-<triple>`
83
+ * (`rolldown` ships `@rolldown/binding-linux-x64-gnu`). The key MUST
84
+ * carry a known prefix AND end in a platform triple, so a normal optional
85
+ * dep (`fsevents`) can't match.
73
86
  *
74
87
  * Neither fires on a normal package, so pairing this with the "resolved file IS
75
88
  * the package's own native main entry" check (see {@link detectNapiRsEntry})
@@ -88,6 +101,13 @@ export declare function detectNapiRsEntry(entryFile: string): {
88
101
  pkgRoot: string;
89
102
  pkg: AddonPackageJson;
90
103
  } | null;
104
+ /**
105
+ * Is `dep` a platform sibling of `pkg` — `<prefix>-<triple>` for one of the
106
+ * prefixes above? Both halves must hold: a bare prefix match would claim an
107
+ * ordinary dependency, and a bare triple match would claim an unrelated
108
+ * package's binaries. Exported for the unit tests.
109
+ */
110
+ export declare function isNapiRsSibling(pkg: AddonPackageJson, dep: string): boolean;
91
111
  /**
92
112
  * Transparent `.node`-addon loader for `gjsify build --app gjs`.
93
113
  *
@@ -38,9 +38,12 @@
38
38
  // LOCATE the `.node` but cannot rescue that body. So when the entry of a napi-rs
39
39
  // package resolves, we replace the WHOLE module with
40
40
  // `module.exports = loadAddon('<abs platform .node>')` (the `napi-rs-entry`
41
- // kind) — the platform `.node` is the current-triple sibling package
42
- // (`@scope/pkg-<triple>`, only the host's optionalDependency is installed) or a
43
- // local `pkg.<triple>.node`. Detection is CONSERVATIVE (package.json signal +
41
+ // kind) — the platform `.node` is the current-triple sibling package (only the
42
+ // host's optionalDependency is installed) or a local `pkg.<triple>.node`. The
43
+ // sibling is named `<self>-<triple>` OR `<napi.packageName>-<triple>`; both
44
+ // schemes ship in the wild and `isNapiRsSibling` knows both, which is what
45
+ // makes `rolldown`/`oxfmt`/`oxlint` (all `@<scope>/binding-<triple>`) reachable
46
+ // and not just `lightningcss`/`@node-rs/*`. Detection is CONSERVATIVE (package.json signal +
44
47
  // the file must be the package's own native `main` entry + a real host `.node`
45
48
  // must resolve) so it never rewrites an unrelated package's entry; when no
46
49
  // current-platform `.node` resolves we fall through to normal resolution rather
@@ -66,7 +69,8 @@
66
69
  // plugin is correct even when the filter does not pre-filter.
67
70
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
68
71
  import { dirname, isAbsolute, join, resolve } from 'node:path';
69
- const NAPI_ADDON_VIRTUAL_PREFIX = '\0gjsify-napi-addon:';
72
+ import { GJSIFY_VIRTUAL_PREFIX } from '../utils/virtual-module-id.js';
73
+ const NAPI_ADDON_VIRTUAL_PREFIX = `${GJSIFY_VIRTUAL_PREFIX}napi-addon:`;
70
74
  /** Bare specifier the shims import — resolved + bundled from the consumer graph. */
71
75
  const NAPI_BARE_SPECIFIER = '@gjsify/napi';
72
76
  /**
@@ -78,8 +82,41 @@ const NAPI_BARE_SPECIFIER = '@gjsify/napi';
78
82
  * `this.resolve` probes on ordinary deps.
79
83
  */
80
84
  const NAPI_RS_TRIPLE_RE = /-(?:linux|darwin|win32|freebsd|openbsd|sunos|android|aix)-(?:x64|arm64|arm|ia32|ppc64|s390x|riscv64|loong64)(?:-(?:gnu|musl|msvc|eabi|eabihf|androideabi|gnueabihf))?$/;
81
- /** Fast-path filter (superset of what `claimSpecifier` claims). Handler re-checks. */
82
- const ADDON_FILTER_RE = new RegExp([
85
+ /**
86
+ * A BARE package specifier (`lightningcss`, `@rolldown/binding-…`) — no leading
87
+ * `.`/`/`/`\`, no protocol (`node:fs`, `gi://Gtk`, `data:…`, `C:\…`), not a
88
+ * `\0` virtual id.
89
+ *
90
+ * Needed because a napi-rs generated loader acquires its binary through a
91
+ * specifier the bundler CANNOT see: `require(`lightningcss-${parts.join('-')}`)`
92
+ * / `require(`../lightningcss.${parts.join('-')}.node`)` are template literals
93
+ * computed at runtime. So neither the sibling nor the direct-`.node`
94
+ * interception can fire, and the only lever is replacing the loader ENTRY —
95
+ * which the caller reaches by its BARE name. Matching only path-shaped ids (as
96
+ * before) meant `import { transform } from 'lightningcss'` was never even
97
+ * offered to the handler, and the addon gate had to hand-alias every bare
98
+ * specifier to its native entry to get coverage.
99
+ *
100
+ * PORTABILITY, and it is load-bearing: this source feeds `ADDON_FILTER_RE`,
101
+ * which `@gjsify/rolldown-native` hands to the Rust core as an `idFilter`
102
+ * STRING. Rust's `regex` crate has no lookaround and no `\0` escape, and it
103
+ * rejects the WHOLE combined filter rather than the offending branch — which
104
+ * silently disables every interception under the GJS engine while npm
105
+ * `rolldown` on Node keeps working. So: no `(?!…)`, and NUL is spelled `\x00`.
106
+ * The asymmetry is invisible to the addon gate, which drives the Node CLI
107
+ * entry, so only a real `--app gjs` build under `gjs` catches a regression here.
108
+ */
109
+ // oxlint-disable-next-line no-control-regex -- the NUL is the point: it marks a bundler VIRTUAL id (`\0gjsify-…`), which is never a package specifier and must not be probed.
110
+ const BARE_SPECIFIER_RE = /^[^.\\/:\s\x00][^:\s\x00]*$/;
111
+ /**
112
+ * Fast-path filter (superset of what `claimSpecifier` claims). Handler re-checks.
113
+ *
114
+ * Exported so `napi-node-addon.spec.ts` can assert its Rust-compatibility (see
115
+ * {@link BARE_SPECIFIER_RE}) — a JS-only construct here disables the plugin
116
+ * entirely under `@gjsify/rolldown-native`, and no build-level test would see
117
+ * it, because every addon gate drives the Node CLI entry.
118
+ */
119
+ export const ADDON_FILTER_RE = new RegExp([
83
120
  /\.node$/.source, // direct .node
84
121
  /^node-gyp-build(?:\/index\.js)?$/.source, // node-gyp-build helper
85
122
  /^bindings(?:\/bindings\.js)?$/.source, // bindings helper
@@ -89,6 +126,9 @@ const ADDON_FILTER_RE = new RegExp([
89
126
  // `index.*`, not every `.js`) — the handler's `detectNapiRsEntry` gate
90
127
  // (package.json signal + native-main match) is the load-bearing check.
91
128
  /[/\\]index\.[cm]?js$/.source,
129
+ // A bare specifier, so the handler can resolve it and test the RESOLVED
130
+ // file. Broad by design; the handler's fast rejects keep it cheap.
131
+ BARE_SPECIFIER_RE.source,
92
132
  ].join('|'));
93
133
  function isMusl(platform) {
94
134
  // Match node-gyp-build's isAlpine() probe.
@@ -351,11 +391,13 @@ export function classifySpecifier(source) {
351
391
  * (a) a top-level `napi` config OBJECT (`{ binaryName, targets, … }`) — the
352
392
  * napi-rs CLI's own build block. An ordinary npm package never declares a
353
393
  * `napi` object at the manifest root.
354
- * (b) at least one `optionalDependencies` entry named `<self>-<triple>` — the
355
- * napi-rs prebuilt-binary convention (`@node-rs/argon2` ships
356
- * `@node-rs/argon2-linux-x64-gnu`, …). The key MUST start with the
357
- * package's own name plus `-` AND end in a platform triple, so a normal
358
- * optional dep (`fsevents`) can't match.
394
+ * (b) at least one `optionalDependencies` entry that is a platform SIBLING
395
+ * (see {@link isNapiRsSibling}) — the napi-rs prebuilt-binary convention,
396
+ * under either naming scheme: `<self>-<triple>` (`@node-rs/argon2` ships
397
+ * `@node-rs/argon2-linux-x64-gnu`) or `<napi.packageName>-<triple>`
398
+ * (`rolldown` ships `@rolldown/binding-linux-x64-gnu`). The key MUST
399
+ * carry a known prefix AND end in a platform triple, so a normal optional
400
+ * dep (`fsevents`) can't match.
359
401
  *
360
402
  * Neither fires on a normal package, so pairing this with the "resolved file IS
361
403
  * the package's own native main entry" check (see {@link detectNapiRsEntry})
@@ -364,11 +406,10 @@ export function classifySpecifier(source) {
364
406
  export function isNapiRsPackageJson(pkg) {
365
407
  if (pkg && typeof pkg.napi === 'object' && pkg.napi !== null)
366
408
  return true;
367
- const self = pkg?.name;
368
409
  const opt = pkg?.optionalDependencies;
369
- if (self && opt) {
410
+ if (opt) {
370
411
  for (const dep of Object.keys(opt)) {
371
- if (dep.startsWith(`${self}-`) && NAPI_RS_TRIPLE_RE.test(dep))
412
+ if (isNapiRsSibling(pkg, dep))
372
413
  return true;
373
414
  }
374
415
  }
@@ -399,13 +440,79 @@ export function detectNapiRsEntry(entryFile) {
399
440
  const pkg = readPackageJsonSafe(pkgRoot);
400
441
  if (pkg === null || !isNapiRsPackageJson(pkg))
401
442
  return null;
402
- // The resolved file must be the package's OWN native main entry — reject a
403
- // `browser`/wasm fallback or any deep module inside the package.
404
- const mainEntry = resolve(pkgRoot, pkg.main ?? 'index.js');
405
- if (resolve(entryFile) !== mainEntry)
443
+ // The resolved file must be one of the package's OWN native entry points —
444
+ // reject a `browser`/wasm fallback or any deep module inside the package.
445
+ const target = stripJsExt(resolve(entryFile));
446
+ const isNativeEntry = nativeEntrySpecs(pkg).some((spec) => stripJsExt(resolve(pkgRoot, spec)) === target);
447
+ if (!isNativeEntry)
406
448
  return null;
407
449
  return { pkgRoot, pkg };
408
450
  }
451
+ /** Drop a trailing `.js` / `.mjs` / `.cjs` — see {@link nativeEntrySpecs}. */
452
+ function stripJsExt(p) {
453
+ return p.replace(/\.[cm]?js$/, '');
454
+ }
455
+ /**
456
+ * Every path a napi-rs manifest names as its NATIVE entry — `main`, `module`,
457
+ * and the `exports["."]` target(s) — with the `browser` condition deliberately
458
+ * excluded (that is the wasm / pure-JS fallback, which must keep resolving
459
+ * normally: it needs no addon and works on GJS as-is).
460
+ *
461
+ * Both halves of this matter, and each was a real miss:
462
+ *
463
+ * - `main` ALONE is not enough. A dual package declares the CJS twin in
464
+ * `main` while a bundler resolves the ESM one — `lightningcss` says
465
+ * `main: "node/index.js"` and ships `node/index.mjs` beside it, which is
466
+ * what `--app gjs` actually loads. Comparing only against `main` rejected
467
+ * the very file being bundled, so the rewrite silently never happened and
468
+ * the generated loader's runtime `require(\`lightningcss-${…}\`)` shipped
469
+ * into the bundle.
470
+ * - Matching is therefore extension-INSENSITIVE (`stripJsExt`): the `.js` /
471
+ * `.mjs` / `.cjs` twins of a declared entry are the same generated loader.
472
+ * It stays narrow because the path itself must still be one the manifest
473
+ * names — a deep module inside the package never matches.
474
+ *
475
+ * Defaults to `index.js` (node's own default `main`) when the manifest names
476
+ * nothing at all.
477
+ */
478
+ function nativeEntrySpecs(pkg) {
479
+ const specs = [];
480
+ const add = (v) => {
481
+ if (typeof v === 'string' && v && !specs.includes(v))
482
+ specs.push(v);
483
+ };
484
+ add(pkg.main);
485
+ add(pkg.module);
486
+ // `exports["."]` is a string, or a condition object whose values are
487
+ // strings / nested condition objects. Walk it, skipping `browser`.
488
+ const walk = (node, depth) => {
489
+ if (typeof node === 'string')
490
+ return add(node);
491
+ if (depth > 4 || node === null || typeof node !== 'object' || Array.isArray(node))
492
+ return;
493
+ for (const [condition, value] of Object.entries(node)) {
494
+ if (condition === 'browser' || condition === 'types')
495
+ continue;
496
+ walk(value, depth + 1);
497
+ }
498
+ };
499
+ const exportsField = pkg.exports;
500
+ if (typeof exportsField === 'string') {
501
+ add(exportsField);
502
+ }
503
+ else if (exportsField !== null && typeof exportsField === 'object' && !Array.isArray(exportsField)) {
504
+ const map = exportsField;
505
+ // Two legal shapes: a SUBPATH map (keys start with `.`) — then only `.`
506
+ // is the package entry — or a bare CONDITION object, which IS the entry.
507
+ // Distinguishing them matters: walking a subpath map wholesale would
508
+ // make `./some-subpath` a candidate entry.
509
+ const isSubpathMap = Object.keys(map).some((k) => k.startsWith('.'));
510
+ walk(isSubpathMap ? map['.'] : map, 0);
511
+ }
512
+ if (specs.length === 0)
513
+ specs.push('index.js');
514
+ return specs;
515
+ }
409
516
  /** napi-rs binaryName (from `napi.binaryName`/`napi.name`, else the unscoped pkg name). */
410
517
  function napiBinaryName(pkg) {
411
518
  const napi = pkg.napi;
@@ -419,13 +526,66 @@ function napiBinaryName(pkg) {
419
526
  return pkg.name.replace(/^@[^/]+\//, '');
420
527
  return null;
421
528
  }
529
+ /**
530
+ * The name PREFIXES a napi-rs package's platform siblings can carry, most
531
+ * specific first. A sibling is always `<prefix>-<triple>`.
532
+ *
533
+ * Two conventions are in the wild, and only knowing both covers the toolchain
534
+ * this project builds on:
535
+ *
536
+ * - `<self>-<triple>` — the sibling is named after the package itself
537
+ * (`lightningcss` → `lightningcss-linux-x64-gnu`; `@node-rs/argon2` →
538
+ * `@node-rs/argon2-linux-x64-gnu`). This was the only shape handled.
539
+ * - `<napi.packageName>-<triple>` — napi-rs lets a package publish its
540
+ * binaries under a SEPARATE scope, declared in the manifest's own napi
541
+ * block. `rolldown` ships `@rolldown/binding-linux-x64-gnu`, `oxfmt`
542
+ * ships `@oxfmt/binding-linux-x64-gnu`, `oxlint` ships
543
+ * `@oxlint/binding-linux-x64-gnu` — none of which starts with the
544
+ * package's own name, so the `<self>-` test missed every one of them.
545
+ *
546
+ * That gap is why the three packages at the centre of this build chain
547
+ * (bundler, formatter, linter) could not load transparently under GJS while
548
+ * `lightningcss` could: `isNapiRsPackageJson` recognises all four (they all
549
+ * carry a `napi` config object), so the ENTRY is detected — but with no
550
+ * sibling resolvable, `resolveNapiRsEntryAddon` returns null and the rewrite
551
+ * is skipped, leaving the generated loader's CJS body to ship into the bundle
552
+ * and throw. Conservative by construction either way: a prefix only matters
553
+ * when it also resolves to a real `.node`.
554
+ *
555
+ * `napi.packageName` is read defensively — an arbitrary manifest may put
556
+ * anything there, and a non-string must not poison the prefix list.
557
+ */
558
+ function napiSiblingPrefixes(pkg) {
559
+ const prefixes = [];
560
+ const napi = pkg.napi;
561
+ if (napi && typeof napi === 'object') {
562
+ const { packageName } = napi;
563
+ if (typeof packageName === 'string' && packageName)
564
+ prefixes.push(packageName);
565
+ }
566
+ if (typeof pkg.name === 'string' && pkg.name && !prefixes.includes(pkg.name))
567
+ prefixes.push(pkg.name);
568
+ return prefixes;
569
+ }
570
+ /**
571
+ * Is `dep` a platform sibling of `pkg` — `<prefix>-<triple>` for one of the
572
+ * prefixes above? Both halves must hold: a bare prefix match would claim an
573
+ * ordinary dependency, and a bare triple match would claim an unrelated
574
+ * package's binaries. Exported for the unit tests.
575
+ */
576
+ export function isNapiRsSibling(pkg, dep) {
577
+ if (!NAPI_RS_TRIPLE_RE.test(dep))
578
+ return false;
579
+ return napiSiblingPrefixes(pkg).some((prefix) => dep.startsWith(`${prefix}-`));
580
+ }
422
581
  /**
423
582
  * The napi-rs short platform triple for the CURRENT host (`linux-x64-gnu`,
424
583
  * `darwin-arm64`, `win32-x64-msvc`, …) — the tail napi-rs stamps into a sibling
425
584
  * package name (`@node-rs/argon2-linux-x64-gnu`) and a local binary
426
- * (`argon2.linux-x64-gnu.node`). Used ONLY for the deterministic local-file
427
- * fallback; the sibling path never needs it (npm installs only the matching
428
- * optionalDependency). Returns null for a host napi-rs doesn't name.
585
+ * (`argon2.linux-x64-gnu.node`). Selects BOTH the sibling package and the
586
+ * local-file fallback — see `resolveNapiRsEntryAddon` for why the sibling path
587
+ * cannot just take whatever resolves. Returns null for a host napi-rs doesn't
588
+ * name, and the caller then widens rather than guessing.
429
589
  */
430
590
  function hostNapiRsTriple() {
431
591
  const platform = process.platform;
@@ -477,24 +637,29 @@ function rawAddonPath(id) {
477
637
  * DOES NOT rewrite, so we never shim over a missing file.
478
638
  */
479
639
  async function resolveNapiRsEntryAddon(ctx, pkgRoot, pkg, importer) {
480
- const self = pkg.name;
481
- const opt = pkg.optionalDependencies ?? {};
482
- if (self) {
483
- for (const dep of Object.keys(opt)) {
484
- if (!dep.startsWith(`${self}-`) || !NAPI_RS_TRIPLE_RE.test(dep))
485
- continue;
486
- const resolved = await ctx.resolve(dep, importer, { skipSelf: true });
487
- if (!resolved)
488
- continue;
489
- const abs = rawAddonPath(resolved.id);
490
- if (abs.endsWith('.node') && existsSync(abs))
491
- return abs;
492
- }
640
+ const siblings = Object.keys(pkg.optionalDependencies ?? {}).filter((dep) => isNapiRsSibling(pkg, dep));
641
+ const triple = hostNapiRsTriple();
642
+ // HOST TRIPLE FIRST, and alone whenever we can name it. The older code took
643
+ // the first sibling that resolved, on the premise that "npm installs only
644
+ // the host's optionalDependency, so the one that resolves is the host's".
645
+ // That premise does not hold here: `gjsify install` materialises EVERY
646
+ // platform package, so `lightningcss`'s `darwin-x64` sibling resolves on a
647
+ // Linux box and won — baking a Mach-O `.dylib`-bearing `.node` into a linux
648
+ // GJS bundle, which `loadAddon` can only fail on at runtime. Selecting by
649
+ // the host triple is also what node-gyp-build and napi-rs' own generated
650
+ // loaders do, so this matches the binary Node would have loaded.
651
+ const ordered = triple === null ? siblings : siblings.filter((dep) => dep.endsWith(`-${triple}`));
652
+ for (const dep of ordered) {
653
+ const resolved = await ctx.resolve(dep, importer, { skipSelf: true });
654
+ if (!resolved)
655
+ continue;
656
+ const abs = rawAddonPath(resolved.id);
657
+ if (abs.endsWith('.node') && existsSync(abs))
658
+ return abs;
493
659
  }
494
660
  // Local in-package binary (`<binaryName>.<host-triple>.node`) — deterministic
495
661
  // host-triple match so a wrong-platform local file is never picked.
496
662
  const binaryName = napiBinaryName(pkg);
497
- const triple = hostNapiRsTriple();
498
663
  if (binaryName && triple) {
499
664
  const local = join(pkgRoot, `${binaryName}.${triple}.node`);
500
665
  if (existsSync(local))
@@ -555,28 +720,107 @@ export function napiNodeAddonPlugin(options = {}) {
555
720
  napiEntryCache.set(entryFile, info);
556
721
  return info;
557
722
  }
558
- async function warnIfNapiMissing(ctx, importer) {
559
- if (!warnOnMissingNapi || missingNapiChecked)
560
- return;
561
- missingNapiChecked = true; // check once per build — interception is rare
723
+ // Bare-specifier → entry-file resolution, memoized per (specifier, importer
724
+ // DIRECTORY) since that pair is what node resolution actually depends on.
725
+ // The widened filter offers every bare import here and almost none of them
726
+ // is a native addon, so this cache is what keeps the extra `ctx.resolve` to
727
+ // one per unique pair rather than one per import site.
728
+ const bareEntryCache = new Map();
729
+ /**
730
+ * The FILE a specifier denotes, for napi-rs entry detection:
731
+ * - absolute path → itself (an already-resolved or hand-aliased entry).
732
+ * - bare specifier → resolved through the full chain (so aliases and
733
+ * export conditions apply — under `--app gjs` that is what picks a
734
+ * package's `browser`/wasm fallback over its native entry, and
735
+ * `detectNapiRsEntry` then correctly declines to rewrite it).
736
+ * - anything else (relative paths) → null, unchanged from before.
737
+ */
738
+ async function entryFileFor(ctx, source, importer) {
739
+ if (isAbsolute(source))
740
+ return source;
741
+ if (!BARE_SPECIFIER_RE.test(source))
742
+ return null;
743
+ // Never recurse through our own shims' import.
744
+ if (source === NAPI_BARE_SPECIFIER || source.startsWith(`${NAPI_BARE_SPECIFIER}/`))
745
+ return null;
746
+ const key = `${source}\0${importer === undefined ? '' : dirname(importer)}`;
747
+ const cached = bareEntryCache.get(key);
748
+ if (cached !== undefined)
749
+ return cached;
750
+ let file = null;
562
751
  try {
563
- const found = await ctx.resolve(NAPI_BARE_SPECIFIER, importer, { skipSelf: true });
564
- if (!found) {
565
- warnSafe(ctx, `[gjsify-napi-addon] a native .node addon was intercepted but '${NAPI_BARE_SPECIFIER}' ` +
566
- `is not resolvable — the bundle will fail at load. Install it: gjsify install ${NAPI_BARE_SPECIFIER}`);
567
- }
752
+ const resolved = await ctx.resolve(source, importer, { skipSelf: true });
753
+ // An external / virtual / unresolvable id is not a file on disk.
754
+ if (resolved && isAbsolute(resolved.id) && existsSync(resolved.id))
755
+ file = resolved.id;
568
756
  }
569
757
  catch {
570
- /* best-effort probe */
758
+ /* resolution failure is not our error to raise — fall through */
571
759
  }
760
+ bareEntryCache.set(key, file);
761
+ return file;
762
+ }
763
+ /**
764
+ * Is `@gjsify/napi` resolvable from the consumer graph? This is the GATE on
765
+ * every rewrite, not a diagnostic — installing it (`gjsify install
766
+ * @gjsify/napi`) is what a project uses to opt into napi routing.
767
+ *
768
+ * It used to only WARN, and the warning said the quiet part out loud: "the
769
+ * bundle will fail at load". Emitting a knowingly-unloadable artifact is
770
+ * never better than declining to rewrite — the untouched module at least
771
+ * gets the default resolution, and a package with a JS/wasm fallback then
772
+ * works. Two concrete failures made this a gate rather than a nicety:
773
+ *
774
+ * - The CLI's own `--app gjs` bundle reaches npm `lightningcss` through
775
+ * `css-as-string`'s Node fallback branch (under GJS it deliberately
776
+ * prefers `@gjsify/lightningcss-native`). Rewriting that branch put a
777
+ * `require('@gjsify/napi')` into a bundle whose package does not depend
778
+ * on `@gjsify/napi` at all.
779
+ * - Worse, it made the COMMITTED bundle environment-dependent: with the
780
+ * package present the shim inlines, without it the import goes external,
781
+ * and the two module graphs minify to different variable names. That is
782
+ * a `verify-committed-bundles` failure — the artifact stops reproducing
783
+ * from its own source.
784
+ *
785
+ * Probed once per build (interception is rare) and cached, so the extra
786
+ * resolve costs nothing on the common path.
787
+ */
788
+ let napiAvailable = null;
789
+ async function ensureNapiAvailable(ctx, importer) {
790
+ if (napiAvailable !== null)
791
+ return napiAvailable;
792
+ let found = false;
793
+ try {
794
+ found = Boolean(await ctx.resolve(NAPI_BARE_SPECIFIER, importer, { skipSelf: true }));
795
+ }
796
+ catch {
797
+ /* best-effort probe — treat a resolver throw as "not available" */
798
+ }
799
+ napiAvailable = found;
800
+ if (!found && warnOnMissingNapi && !missingNapiChecked) {
801
+ missingNapiChecked = true;
802
+ warnSafe(ctx, `[gjsify-napi-addon] a native .node addon was found but '${NAPI_BARE_SPECIFIER}' is not ` +
803
+ `resolvable — leaving it to normal resolution. Install it to route the addon through ` +
804
+ `N-API on GJS: gjsify install ${NAPI_BARE_SPECIFIER}`);
805
+ }
806
+ return found;
572
807
  }
573
808
  return {
574
809
  name: 'gjsify-napi-node-addon',
575
810
  resolveId: {
576
811
  order: 'pre',
577
812
  filter: { id: ADDON_FILTER_RE },
578
- async handler(source, importer) {
813
+ async handler(source, rawImporter) {
579
814
  const ctx = this;
815
+ // The two engines disagree on "no importer": npm `rolldown`
816
+ // passes `undefined`, `@gjsify/rolldown-native` passes `null`
817
+ // (its hook payload round-trips through JSON, which has no
818
+ // `undefined`). Every guard below is written `=== undefined`,
819
+ // and `null` passes all of them — `dirname(null)` then threw
820
+ // `The "path" argument must be of type string. Received type
821
+ // object` and took the whole GJS build down as an
822
+ // UNHANDLEABLE_ERROR. Normalise once, at the boundary.
823
+ const importer = typeof rawImporter === 'string' ? rawImporter : undefined;
580
824
  const cls = classifySpecifier(source);
581
825
  if (cls !== null) {
582
826
  // Direct `.node` — resolve the file path itself.
@@ -584,7 +828,8 @@ export function napiNodeAddonPlugin(options = {}) {
584
828
  const abs = await resolveNodeFile(ctx, source, importer);
585
829
  if (abs === null)
586
830
  return null; // unresolvable — let the default chain error
587
- await warnIfNapiMissing(ctx, importer);
831
+ if (!(await ensureNapiAvailable(ctx, importer)))
832
+ return null;
588
833
  return { id: encodeVirtual('direct', abs) };
589
834
  }
590
835
  // napi-rs platform sibling — confirm it resolves to a `.node`.
@@ -592,7 +837,8 @@ export function napiNodeAddonPlugin(options = {}) {
592
837
  const resolved = await ctx.resolve(source, importer, { skipSelf: true });
593
838
  if (!resolved || !resolved.id.endsWith('.node'))
594
839
  return null; // not a native sibling
595
- await warnIfNapiMissing(ctx, importer);
840
+ if (!(await ensureNapiAvailable(ctx, importer)))
841
+ return null;
596
842
  return { id: encodeVirtual('napi-rs', resolved.id) };
597
843
  }
598
844
  // node-gyp-build / bindings — probe the importer's package root.
@@ -601,8 +847,9 @@ export function napiNodeAddonPlugin(options = {}) {
601
847
  const pkgRoot = nearestPackageRoot(importer);
602
848
  if (pkgRoot === null)
603
849
  return null;
850
+ if (!(await ensureNapiAvailable(ctx, importer)))
851
+ return null;
604
852
  const addonPath = resolveAddonPath(pkgRoot, { warn: (m) => warnSafe(ctx, m) }); // throws → build error
605
- await warnIfNapiMissing(ctx, importer);
606
853
  return { id: encodeVirtual(cls.kind, addonPath) };
607
854
  }
608
855
  // napi-rs GENERATED-LOADER ENTRY (`index.*` filter). Replace the
@@ -610,11 +857,21 @@ export function napiNodeAddonPlugin(options = {}) {
610
857
  // of a napi-rs package (package.json signal) AND a current-platform
611
858
  // `.node` resolves. Otherwise fall through (never a shim over a
612
859
  // missing file, never an unrelated package's entry rewritten).
613
- const entry = detectNapiRsEntryCached(source);
860
+ // The specifier may be the entry PATH (an internal import, or a
861
+ // caller that already aliased it) or the package's BARE name —
862
+ // the ordinary `import { transform } from 'lightningcss'`. For
863
+ // the bare form the entry file is only known after resolution,
864
+ // so resolve first and test the resolved id. `entryFileFor`
865
+ // memoizes both the resolve and the rejection.
866
+ const entryFile = await entryFileFor(ctx, source, importer);
867
+ if (entryFile === null)
868
+ return null;
869
+ const entry = detectNapiRsEntryCached(entryFile);
614
870
  if (entry !== null) {
615
- const addonPath = await resolveNapiRsEntryAddon(ctx, entry.pkgRoot, entry.pkg, source);
871
+ const addonPath = await resolveNapiRsEntryAddon(ctx, entry.pkgRoot, entry.pkg, entryFile);
616
872
  if (addonPath !== null) {
617
- await warnIfNapiMissing(ctx, importer);
873
+ if (!(await ensureNapiAvailable(ctx, importer)))
874
+ return null;
618
875
  return { id: encodeVirtual('napi-rs-entry', addonPath) };
619
876
  }
620
877
  }
@@ -319,7 +319,12 @@ export async function detectAutoGlobals(analysisOptions, pluginOptions, gjsifyPl
319
319
  ?.GJSIFY_DEBUG_AUTO_GLOBALS) {
320
320
  const path = `/tmp/gjsify-auto-globals-failed-chunk-${i}.mjs`;
321
321
  try {
322
- // eslint-disable-next-line @typescript-eslint/no-require-imports
322
+ // Dynamic `import()` on purpose: this is a debug-only
323
+ // branch and the module graph should not carry
324
+ // `node:fs` for it. (It is NOT a `require` — it never
325
+ // was; the rule-disable comment that used to sit here
326
+ // named a rule that cannot apply to an import
327
+ // expression.)
323
328
  const fs = await import('node:fs');
324
329
  fs.writeFileSync(path, code);
325
330
  console.error(`[gjsify-auto-globals] parse failed on chunk #${i} — wrote ${path} for inspection`);
@@ -7,6 +7,7 @@
7
7
  // first, then re-exports the real entry. `\0`-prefixed ids are Rollup's
8
8
  // convention for synthetic modules — Rolldown treats them as not-from-disk and
9
9
  // skips the default loader.
10
+ import { GJSIFY_VIRTUAL_PREFIX } from './virtual-module-id.js';
10
11
  /**
11
12
  * If there are side-effect imports to land alongside the user's entry, wrap each
12
13
  * entry in a virtual module that imports them first then re-exports the entry.
@@ -22,7 +23,7 @@ export function wrapInputWithSideEffects(input, sideEffects, opts = {}) {
22
23
  return { input, plugin: null };
23
24
  }
24
25
  const userEntries = new Map(); // virtualId → realPath
25
- const PREFIX = '\0gjsify-entry:';
26
+ const PREFIX = `${GJSIFY_VIRTUAL_PREFIX}entry:`;
26
27
  function wrap(realPath) {
27
28
  const id = PREFIX + realPath;
28
29
  userEntries.set(id, realPath);
@@ -6,3 +6,4 @@ export * from './merge.js';
6
6
  export { detectFreeGlobals } from './detect-free-globals.js';
7
7
  export { resolveGlobalsList, writeRegisterInjectFile } from './scan-globals.js';
8
8
  export { inlineStaticReads } from './inline-static-reads.js';
9
+ export { GJSIFY_VIRTUAL_PREFIX, isGjsifyVirtualModuleId } from './virtual-module-id.js';
@@ -6,3 +6,4 @@ export * from './merge.js';
6
6
  export { detectFreeGlobals } from './detect-free-globals.js';
7
7
  export { resolveGlobalsList, writeRegisterInjectFile } from './scan-globals.js';
8
8
  export { inlineStaticReads } from './inline-static-reads.js';
9
+ export { GJSIFY_VIRTUAL_PREFIX, isGjsifyVirtualModuleId } from './virtual-module-id.js';
@@ -0,0 +1,14 @@
1
+ /** Shared prefix of every gjsify-generated (synthetic) module id. */
2
+ export declare const GJSIFY_VIRTUAL_PREFIX = "\0gjsify-";
3
+ /**
4
+ * Is `id` a module gjsify's own plugins generated (as opposed to a user/npm
5
+ * source file)?
6
+ *
7
+ * Used by `aliasPlugin` to scope the alias tables to REAL source: generated
8
+ * module bodies name the runtime module they need explicitly and must not be
9
+ * re-pointed by a user alias or a slot-routing entry.
10
+ *
11
+ * @param id A Rolldown module id — typically a `resolveId` `importer`, which is
12
+ * `undefined` for entry modules.
13
+ */
14
+ export declare function isGjsifyVirtualModuleId(id: string | undefined | null): boolean;
@@ -0,0 +1,36 @@
1
+ // The ONE prefix every synthetic module id emitted by a gjsify plugin carries.
2
+ //
3
+ // Rollup/Rolldown convention is that a `\0`-prefixed id is not-from-disk; every
4
+ // gjsify plugin that emits one additionally namespaces it with `gjsify-`
5
+ // (`\0gjsify-entry:`, `\0gjsify-gi-node:`, `\0gjsify-napi-addon:`,
6
+ // `\0gjsify-empty-gjs-import`). Deriving those ids from THIS constant turns that
7
+ // naming habit into an enforced invariant, which matters because the alias layer
8
+ // keys off it: a module whose SOURCE we generated must bind to the RUNTIME's real
9
+ // modules, never to whatever a user `--alias` retargets a specifier onto.
10
+ //
11
+ // Concretely (the bug that motivated this): `gjsGiNodePlugin`'s virtual module
12
+ // emits `import { createRequire } from 'node:module'`. Under the node-gi consumer
13
+ // harness — which builds `--alias node:module=@gjsify/module` to put the polyfill
14
+ // UNDER TEST behind the specifier — that import was rewritten onto the polyfill
15
+ // itself, so the bundle called `@gjsify/module`'s `createRequire` at top level
16
+ // before its lazy GLib namespace proxy existed (`TypeError: Cannot read
17
+ // properties of undefined (reading 'filename_from_uri')`), and past that hit a
18
+ // genuine cycle: the polyfill's Gio-backed CJS loader needs GLib, and GLib is
19
+ // reached through `@gjsify/node-gi/gi` — which is what the virtual module was
20
+ // loading in the first place.
21
+ /** Shared prefix of every gjsify-generated (synthetic) module id. */
22
+ export const GJSIFY_VIRTUAL_PREFIX = '\0gjsify-';
23
+ /**
24
+ * Is `id` a module gjsify's own plugins generated (as opposed to a user/npm
25
+ * source file)?
26
+ *
27
+ * Used by `aliasPlugin` to scope the alias tables to REAL source: generated
28
+ * module bodies name the runtime module they need explicitly and must not be
29
+ * re-pointed by a user alias or a slot-routing entry.
30
+ *
31
+ * @param id A Rolldown module id — typically a `resolveId` `importer`, which is
32
+ * `undefined` for entry modules.
33
+ */
34
+ export function isGjsifyVirtualModuleId(id) {
35
+ return typeof id === 'string' && id.startsWith(GJSIFY_VIRTUAL_PREFIX);
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/rolldown-plugin-gjsify",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
4
  "description": "Rolldown / Rollup / Vite plugin orchestrator for GJS, Node, and Browser targets",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -55,11 +55,11 @@
55
55
  ],
56
56
  "license": "MIT",
57
57
  "dependencies": {
58
- "@gjsify/console": "^0.23.0",
59
- "@gjsify/resolve-npm": "^0.23.0",
60
- "@gjsify/rolldown-plugin-deepkit": "^0.23.0",
61
- "@gjsify/rolldown-plugin-pnp": "^0.23.0",
62
- "@gjsify/vite-plugin-blueprint": "^0.23.0",
58
+ "@gjsify/console": "^0.24.1",
59
+ "@gjsify/resolve-npm": "^0.24.1",
60
+ "@gjsify/rolldown-plugin-deepkit": "^0.24.1",
61
+ "@gjsify/rolldown-plugin-pnp": "^0.24.1",
62
+ "@gjsify/vite-plugin-blueprint": "^0.24.1",
63
63
  "@rollup/pluginutils": "^5.4.0",
64
64
  "acorn": "^8.17.0",
65
65
  "acorn-walk": "^8.3.5",
@@ -68,7 +68,7 @@
68
68
  "sass": "^1.101.0"
69
69
  },
70
70
  "peerDependencies": {
71
- "@gjsify/lightningcss-native": "^0.23.0",
71
+ "@gjsify/lightningcss-native": "^0.24.1",
72
72
  "rolldown": "^1.1.4"
73
73
  },
74
74
  "peerDependenciesMeta": {