@gjsify/rolldown-plugin-gjsify 0.22.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,8 +10,11 @@
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';
17
+ import { napiNodeAddonPlugin } from '../plugins/napi-node-addon.js';
15
18
  import { deepkitPlugin } from '@gjsify/rolldown-plugin-deepkit';
16
19
  import blueprintPlugin from '@gjsify/vite-plugin-blueprint';
17
20
  import { getAliasesForGjs } from '../utils/alias.js';
@@ -22,35 +25,38 @@ import { cssAsStringPlugin } from '../plugins/css-as-string.js';
22
25
  import { shebangPlugin, resolveShebangLine, inputShebangStripPlugin } from '../plugins/shebang.js';
23
26
  import { wrapInputWithSideEffects } from '../utils/entry-wrapper.js';
24
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
+ */
25
37
  function resolveShim(shimName) {
26
38
  // Preferred: relative to this module's directory. Works under the
27
39
  // normal Node consumer flow where `_shimDir` = `<pkg>/lib/app/`.
28
40
  const relative = resolve(_shimDir, `../shims/${shimName}.js`);
29
- let fs = null;
30
- try {
31
- // eslint-disable-next-line @typescript-eslint/no-require-imports
32
- fs = require('node:fs');
33
- }
34
- catch {
35
- return relative;
36
- }
37
- if (fs.existsSync(relative))
41
+ if (existsSync(relative))
38
42
  return relative;
39
43
  // Fallback: when the orchestrator is bundled into a single .mjs
40
44
  // (GJS-CLI self-host loop) `_shimDir` collapses to the bundle's
41
- // own directory and the relative lookup misses. createRequire's
42
- // resolver is `exports`-map-aware (Phase C), so the published
43
- // subpath export `./shims/<name>` works under both Node and GJS
44
- // without further walking. Each shim MUST be a `./shims/<name>`
45
- // 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.
46
49
  try {
47
- // eslint-disable-next-line @typescript-eslint/no-require-imports
48
- const Module = require('node:module');
49
- const require_ = Module.createRequire(import.meta.url);
50
- return require_.resolve(`@gjsify/rolldown-plugin-gjsify/shims/${shimName}`);
50
+ return createRequire(import.meta.url).resolve(`@gjsify/rolldown-plugin-gjsify/shims/${shimName}`);
51
51
  }
52
- catch {
53
- 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 });
54
60
  }
55
61
  }
56
62
  export const setupForGjs = async (input) => {
@@ -71,7 +77,12 @@ export const setupForGjs = async (input) => {
71
77
  //
72
78
  // `exactExternal` filters out register subpaths so a user external
73
79
  // entry can never override the force-inline carve-out below.
74
- 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
+ ];
75
86
  const external = createGjsExternalsPredicate(userExternal);
76
87
  const format = input.pluginOptions.format ?? 'esm';
77
88
  const exclude = input.pluginOptions.exclude ?? [];
@@ -196,6 +207,17 @@ export const setupForGjs = async (input) => {
196
207
  ...flattenAliases(aliasMap),
197
208
  },
198
209
  }),
210
+ // Transparent N-API `.node`-addon loader — the forward mirror of
211
+ // `gjsGiNodePlugin`. Its `order:'pre'` resolveId claims a native-addon
212
+ // acquisition (`bindings`/`node-gyp-build`/a direct `.node`/a napi-rs
213
+ // platform sibling) and rewrites it to a virtual module returning
214
+ // `loadAddon('<abs .node>')` from `@gjsify/napi`. Placed AFTER aliasPlugin
215
+ // (so a user alias that pins an addon's native entry applies first) and
216
+ // BEFORE externalsPlugin (so the addon acquisition is claimed before the
217
+ // externals policy sees it). `@gjsify/napi` is BUNDLED, not external.
218
+ // Inert unless such a specifier is in the graph — every other id passes
219
+ // through. Always-on for `--app gjs`; never registered for node/browser/ns.
220
+ napiNodeAddonPlugin(),
199
221
  // Enforce the full `--app gjs` externals policy (gi:// prefix,
200
222
  // exact names, register-subpath force-inline) via resolveId —
201
223
  // the only form BOTH engines honour (the native engine drops
@@ -259,7 +281,7 @@ function flattenAliases(map) {
259
281
  export function createGjsExternalsPredicate(userExternal = []) {
260
282
  const exact = ['cairo', 'gettext', 'system', ...userExternal];
261
283
  return (id) => {
262
- if (isRegisterSubpath(id))
284
+ if (isRegisterSubpath(id) || isGjsifyShim(id))
263
285
  return false;
264
286
  if (id.startsWith('gi://'))
265
287
  return true;
@@ -268,6 +290,36 @@ export function createGjsExternalsPredicate(userExternal = []) {
268
290
  return false;
269
291
  };
270
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
+ }
271
323
  /**
272
324
  * Recognize the `/register` and `/register/<feature>` subpath shapes that
273
325
  * `--globals auto` injects into the bundle as side-effect imports.
@@ -1,6 +1,6 @@
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
- export { setupForNode, enableGjsRegistersForNode } from './node.js';
3
+ export { setupForNode, enableGjsRegistersForNode, isGjsSourceBuild } from './node.js';
4
4
  export type { NodeBuildConfig, NodeFactoryInput } from './node.js';
5
5
  export { setupForBrowser } from './browser.js';
6
6
  export type { BrowserBuildConfig, BrowserFactoryInput } from './browser.js';
package/lib/app/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { setupForGjs, isRegisterSubpath, createGjsExternalsPredicate } from './gjs.js';
2
- export { setupForNode, enableGjsRegistersForNode } from './node.js';
1
+ export { setupForGjs, isRegisterSubpath, isGjsifyShim, createGjsExternalsPredicate } from './gjs.js';
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/app/node.d.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  import type { RolldownOptions, RolldownPluginOption } from 'rolldown';
2
2
  import type { PluginOptions } from '../types/plugin-options.js';
3
3
  /**
4
- * Reverse-bridge register routing: a node build with an EXPLICIT `--globals`
5
- * register-inject stub (`pluginOptions.autoGlobalsInject`, set by the CLI for
6
- * `--app node` when the user names globals — e.g. `--globals auto,dom`) is a
7
- * genuine GJS source that needs the REAL `@gjsify/*` register bodies (document,
8
- * HTMLCanvasElement, matchMedia, …) running over `@gjsify/node-gi`, not the
9
- * default `@gjsify/empty` stubs that keep cross-platform node bundles loadable
4
+ * Reverse-bridge register routing: a node build of a GENUINE GJS SOURCE needs
5
+ * the REAL `@gjsify/*` register bodies (document, HTMLCanvasElement, the `'2d'`
6
+ * context factory, matchMedia, …) running over `@gjsify/node-gi`, not the
7
+ * default `@gjsify/empty` stubs that keep CROSS-PLATFORM node bundles loadable
10
8
  * on plain Node.
11
9
  *
10
+ * Applied by `setupForNode` when `isGjsSourceBuild` is true — see the comment
11
+ * on that flag for why BOTH reverse-bridge signals qualify, not just the
12
+ * explicit `--globals` stub.
13
+ *
12
14
  * Two adjustments over the standard node alias map:
13
15
  * 1. every `<pkg>/register…` → `@gjsify/empty` entry is DROPPED so the
14
16
  * register import resolves to its real body;
@@ -18,10 +20,42 @@ import type { PluginOptions } from '../types/plugin-options.js';
18
20
  * specifiers resolve on node exactly as they do on gjs.
19
21
  *
20
22
  * Applied to the BASE map only — `pluginOptions.aliases` / user aliases still
21
- * override. Without the inject stub the map is untouched (the plain-Node
23
+ * override. On a non-reverse-bridge build the map is untouched (the plain-Node
22
24
  * loadability guarantee for cross-platform packages is not regressed).
23
25
  */
24
26
  export declare function enableGjsRegistersForNode(baseAliases: Record<string, string>): Record<string, string>;
27
+ /**
28
+ * Is this `--app node` build a GENUINE GJS SOURCE going through the node-gi
29
+ * reverse bridge, rather than the node target of a cross-platform package?
30
+ *
31
+ * Two signals qualify, and BOTH must gate the same things:
32
+ * - `nodeGiGlobalsInject` — the CLI's post-tree-shake detection found bare GJS
33
+ * ambient globals (`print`/`imports`/`ARGV`/…) in the bundled output;
34
+ * - `registerInject` (the `--globals` auto/explicit inject stub) — the user
35
+ * asked for GJS registers on the node target explicitly.
36
+ *
37
+ * This predicate exists as ONE named function because the two consumers used to
38
+ * ask the question separately and disagree: `emitGirs` took the union while the
39
+ * register-alias lift took only the explicit stub. A reverse-bridge build then
40
+ * got its `@girs/*` routed through to `requireGi` while a `/register`
41
+ * side-effect import in the SAME graph was still emptied to `@gjsify/empty` —
42
+ * silently dropping `@gjsify/dom-elements/register/canvas`'s `'2d'` context
43
+ * factory, so `@gjsify/canvas2d`'s `Canvas2DBridge` never fired `onReady`.
44
+ *
45
+ * A build with NEITHER signal is byte-unchanged: the `@gjsify/empty` register
46
+ * routing and the `@girs/*` emptying both stay, so a cross-platform package's
47
+ * node bundle keeps loading on plain Node without node-gi installed.
48
+ *
49
+ * KNOWN NARROWNESS (pre-dates this predicate, tracked in STATUS.md): the
50
+ * `nodeGiGlobalsInject` detector keys on BARE ambient globals, so a genuine GJS
51
+ * source that uses `gi://` but logs via `console.log` and passes no `--globals`
52
+ * is not recognised — its `@girs/*` AND its registers are both emptied. The two
53
+ * are now at parity; widening the signal itself is the separate fix.
54
+ */
55
+ export declare function isGjsSourceBuild(options: {
56
+ nodeGiGlobalsInject?: boolean;
57
+ registerInject?: string | undefined;
58
+ }): boolean;
25
59
  export interface NodeBuildConfig {
26
60
  options: RolldownOptions;
27
61
  plugins: RolldownPluginOption[];
package/lib/app/node.js CHANGED
@@ -50,14 +50,16 @@ const NODE_GI_BARE_MODULE_SPECIFIERS = Object.values(ALIASES_GJS_FOR_NODE);
50
50
  /** Matches a register subpath in an alias key: `<pkg>/register` or `<pkg>/register/<feature>`. */
51
51
  const REGISTER_SUBPATH_RE = /\/register(\/|$)/;
52
52
  /**
53
- * Reverse-bridge register routing: a node build with an EXPLICIT `--globals`
54
- * register-inject stub (`pluginOptions.autoGlobalsInject`, set by the CLI for
55
- * `--app node` when the user names globals — e.g. `--globals auto,dom`) is a
56
- * genuine GJS source that needs the REAL `@gjsify/*` register bodies (document,
57
- * HTMLCanvasElement, matchMedia, …) running over `@gjsify/node-gi`, not the
58
- * default `@gjsify/empty` stubs that keep cross-platform node bundles loadable
53
+ * Reverse-bridge register routing: a node build of a GENUINE GJS SOURCE needs
54
+ * the REAL `@gjsify/*` register bodies (document, HTMLCanvasElement, the `'2d'`
55
+ * context factory, matchMedia, …) running over `@gjsify/node-gi`, not the
56
+ * default `@gjsify/empty` stubs that keep CROSS-PLATFORM node bundles loadable
59
57
  * on plain Node.
60
58
  *
59
+ * Applied by `setupForNode` when `isGjsSourceBuild` is true — see the comment
60
+ * on that flag for why BOTH reverse-bridge signals qualify, not just the
61
+ * explicit `--globals` stub.
62
+ *
61
63
  * Two adjustments over the standard node alias map:
62
64
  * 1. every `<pkg>/register…` → `@gjsify/empty` entry is DROPPED so the
63
65
  * register import resolves to its real body;
@@ -67,7 +69,7 @@ const REGISTER_SUBPATH_RE = /\/register(\/|$)/;
67
69
  * specifiers resolve on node exactly as they do on gjs.
68
70
  *
69
71
  * Applied to the BASE map only — `pluginOptions.aliases` / user aliases still
70
- * override. Without the inject stub the map is untouched (the plain-Node
72
+ * override. On a non-reverse-bridge build the map is untouched (the plain-Node
71
73
  * loadability guarantee for cross-platform packages is not regressed).
72
74
  */
73
75
  export function enableGjsRegistersForNode(baseAliases) {
@@ -83,6 +85,37 @@ export function enableGjsRegistersForNode(baseAliases) {
83
85
  }
84
86
  return out;
85
87
  }
88
+ /**
89
+ * Is this `--app node` build a GENUINE GJS SOURCE going through the node-gi
90
+ * reverse bridge, rather than the node target of a cross-platform package?
91
+ *
92
+ * Two signals qualify, and BOTH must gate the same things:
93
+ * - `nodeGiGlobalsInject` — the CLI's post-tree-shake detection found bare GJS
94
+ * ambient globals (`print`/`imports`/`ARGV`/…) in the bundled output;
95
+ * - `registerInject` (the `--globals` auto/explicit inject stub) — the user
96
+ * asked for GJS registers on the node target explicitly.
97
+ *
98
+ * This predicate exists as ONE named function because the two consumers used to
99
+ * ask the question separately and disagree: `emitGirs` took the union while the
100
+ * register-alias lift took only the explicit stub. A reverse-bridge build then
101
+ * got its `@girs/*` routed through to `requireGi` while a `/register`
102
+ * side-effect import in the SAME graph was still emptied to `@gjsify/empty` —
103
+ * silently dropping `@gjsify/dom-elements/register/canvas`'s `'2d'` context
104
+ * factory, so `@gjsify/canvas2d`'s `Canvas2DBridge` never fired `onReady`.
105
+ *
106
+ * A build with NEITHER signal is byte-unchanged: the `@gjsify/empty` register
107
+ * routing and the `@girs/*` emptying both stay, so a cross-platform package's
108
+ * node bundle keeps loading on plain Node without node-gi installed.
109
+ *
110
+ * KNOWN NARROWNESS (pre-dates this predicate, tracked in STATUS.md): the
111
+ * `nodeGiGlobalsInject` detector keys on BARE ambient globals, so a genuine GJS
112
+ * source that uses `gi://` but logs via `console.log` and passes no `--globals`
113
+ * is not recognised — its `@girs/*` AND its registers are both emptied. The two
114
+ * are now at parity; widening the signal itself is the separate fix.
115
+ */
116
+ export function isGjsSourceBuild(options) {
117
+ return Boolean(options.nodeGiGlobalsInject || options.registerInject);
118
+ }
86
119
  export const setupForNode = async (input) => {
87
120
  const userExternal = input.userExternal ?? [];
88
121
  // node-datachannel is a native C++ addon that cannot be bundled — its
@@ -151,9 +184,38 @@ export const setupForNode = async (input) => {
151
184
  sideEffectImports.push(registerInject);
152
185
  const virtualEntries = wrapInputWithSideEffects(entryPoints, sideEffectImports);
153
186
  const finalInput = virtualEntries.input;
187
+ /**
188
+ * Is this a GENUINE GJS SOURCE built via the node-gi reverse bridge (as
189
+ * opposed to a cross-platform package's plain-Node bundle)?
190
+ *
191
+ * TWO signals answer that ONE question and both must count:
192
+ *
193
+ * - `nodeGiGlobalsInject` — the CLI detected BARE GJS ambient globals
194
+ * (`print`/`imports`/`ARGV`) in the tree-shaken output. Only genuine GJS
195
+ * source has those (the detector deliberately ignores the
196
+ * `globalThis.imports` isomorphic-guard shape).
197
+ * - `autoGlobalsInject` — the user named EXPLICIT `--globals` (e.g.
198
+ * `--globals auto,dom`), i.e. asked for the DOM surface on Node.
199
+ *
200
+ * Both already gate `emptyGirs` below. The register routing used to consult
201
+ * ONLY the explicit stub, and that asymmetry was a
202
+ * bug: a reverse-bridge build got its `@girs/*` value imports routed
203
+ * through to `requireGi` while a `/register` side-effect import in the very
204
+ * same graph was still emptied to `@gjsify/empty`. It surfaced as
205
+ * `@gjsify/canvas2d`'s `Canvas2DBridge` silently losing the `'2d'` context
206
+ * factory (`@gjsify/dom-elements/register/canvas` → empty → `getContext('2d')`
207
+ * null → `onReady` never fires) on a plain `gjsify build … --app node`, with
208
+ * no `--globals` flag anywhere — guarded by the node-gi
209
+ * `test/canvas2d-bridge.test.mjs` golden.
210
+ *
211
+ * A build with NEITHER signal is byte-unchanged: the `@gjsify/empty`
212
+ * register routing stays, so a cross-platform package's node bundle keeps
213
+ * loading on plain Node without node-gi installed.
214
+ */
215
+ const gjsSourceBuild = isGjsSourceBuild({ ...input.pluginOptions, registerInject });
154
216
  const baseAliases = getAliasesForNode({ external });
155
217
  const aliasMap = {
156
- ...(registerInject ? enableGjsRegistersForNode(baseAliases) : baseAliases),
218
+ ...(gjsSourceBuild ? enableGjsRegistersForNode(baseAliases) : baseAliases),
157
219
  ...input.pluginOptions.aliases,
158
220
  ...input.userAliases,
159
221
  };
@@ -252,10 +314,9 @@ export const setupForNode = async (input) => {
252
314
  // `emptyGirs` also lifts for the explicit-register reverse-bridge case:
253
315
  // the injected register bodies (dom-elements, canvas2d, …) are genuine
254
316
  // GJS code whose `@girs/*` value imports must route through to
255
- // `requireGi`, exactly like the ambient-globals case.
256
- gjsImportsEmptyPlugin({
257
- emptyGirs: !(input.pluginOptions.nodeGiGlobalsInject || registerInject),
258
- }),
317
+ // `requireGi`, exactly like the ambient-globals case. Same predicate as
318
+ // the register-alias lift above — one question, one flag.
319
+ gjsImportsEmptyPlugin({ emptyGirs: !gjsSourceBuild }),
259
320
  aliasPlugin({ entries: flattenAliases(aliasMap) }),
260
321
  // Blueprint (.blp → XML string): the reverse bridge runs REAL GTK on
261
322
  // Node, so a GJS app entry (composite-template windows) must build for
package/lib/index.d.ts CHANGED
@@ -11,9 +11,13 @@ 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';
19
+ export { napiNodeAddonPlugin, resolveAddonPath, nearestPackageRoot, classifySpecifier, directNodeShim, nodeGypBuildShim, bindingsShim, napiRsShim, ADDON_FILTER_RE, isNapiRsPackageJson, isNapiRsSibling, detectNapiRsEntry, AddonNotBuiltError, } from './plugins/napi-node-addon.js';
20
+ export type { NapiNodeAddonPluginOptions, AddonPackageJson } from './plugins/napi-node-addon.js';
17
21
  export { platformResolvePlugin, detectNativescriptPlatform, nativescriptPlatformDefines, } from './plugins/platform-resolve.js';
18
22
  export type { PlatformResolvePluginOptions, NativescriptPlatform } from './plugins/platform-resolve.js';
19
23
  export * from './plugin.js';
package/lib/index.js CHANGED
@@ -8,8 +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';
14
+ export { napiNodeAddonPlugin, resolveAddonPath, nearestPackageRoot, classifySpecifier, directNodeShim, nodeGypBuildShim, bindingsShim, napiRsShim, ADDON_FILTER_RE, isNapiRsPackageJson, isNapiRsSibling, detectNapiRsEntry, AddonNotBuiltError, } from './plugins/napi-node-addon.js';
13
15
  export { platformResolvePlugin, detectNativescriptPlatform, nativescriptPlatformDefines, } from './plugins/platform-resolve.js';
14
16
  export * from './plugin.js';
15
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.