@gjsify/rolldown-plugin-gjsify 0.3.21 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/app/gjs.js CHANGED
@@ -18,8 +18,38 @@ import { globToEntryPoints } from '../utils/entry-points.js';
18
18
  import { nodeModulesPathRewritePlugin, getBundleDirFromOutput, } from '../plugins/rewrite-node-modules-paths.js';
19
19
  import { processStubPlugin } from '../plugins/process-stub.js';
20
20
  import { cssAsStringPlugin } from '../plugins/css-as-string.js';
21
- import { shebangPlugin, resolveShebangLine } from '../plugins/shebang.js';
21
+ import { shebangPlugin, resolveShebangLine, inputShebangStripPlugin } from '../plugins/shebang.js';
22
22
  const _shimDir = dirname(fileURLToPath(import.meta.url));
23
+ function resolveConsoleShim() {
24
+ // Preferred: relative to this module's directory. Works under the
25
+ // normal Node consumer flow where `_shimDir` = `<pkg>/lib/app/`.
26
+ const relative = resolve(_shimDir, '../shims/console-gjs.js');
27
+ let fs = null;
28
+ try {
29
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
30
+ fs = require('node:fs');
31
+ }
32
+ catch {
33
+ return relative;
34
+ }
35
+ if (fs.existsSync(relative))
36
+ return relative;
37
+ // Fallback: when the orchestrator is bundled into a single .mjs
38
+ // (GJS-CLI self-host loop) `_shimDir` collapses to the bundle's
39
+ // own directory and the relative lookup misses. createRequire's
40
+ // resolver is `exports`-map-aware (Phase C), so the published
41
+ // subpath export `./shims/console-gjs` works under both Node and
42
+ // GJS without further walking.
43
+ try {
44
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
45
+ const Module = require('node:module');
46
+ const require_ = Module.createRequire(import.meta.url);
47
+ return require_.resolve('@gjsify/rolldown-plugin-gjsify/shims/console-gjs');
48
+ }
49
+ catch {
50
+ return relative;
51
+ }
52
+ }
23
53
  export const setupForGjs = async (input) => {
24
54
  const userExternal = input.userExternal ?? [];
25
55
  // Rolldown's `external` array does not support glob patterns the way
@@ -37,17 +67,32 @@ export const setupForGjs = async (input) => {
37
67
  const format = input.pluginOptions.format ?? 'esm';
38
68
  const exclude = input.pluginOptions.exclude ?? [];
39
69
  const entryPoints = await globToEntryPoints(input.input, exclude);
70
+ // unicorn-magic gates its full API behind the "node" conditional
71
+ // exports. We deliberately omit `node` from conditionNames (some
72
+ // packages ship genuinely Node-only code there — see comment
73
+ // around `conditionNames` below). Route the package to our
74
+ // bundled shim so the API is reachable under --app gjs without
75
+ // turning on the node condition globally.
76
+ const unicornMagicShim = resolve(_shimDir, '../shims/unicorn-magic.js');
40
77
  const aliasMap = {
41
78
  ...getAliasesForGjs({ external }),
79
+ 'unicorn-magic': unicornMagicShim,
42
80
  ...(input.pluginOptions.aliases ?? {}),
43
81
  ...(input.userAliases ?? {}),
44
82
  };
45
83
  // The console shim replaces all `console` references with print()/printerr()-
46
84
  // based implementations that bypass GLib.log_structured() — no prefix,
47
85
  // ANSI codes work. Disabled via `pluginOptions.consoleShim === false`.
86
+ //
87
+ // Path resolution: `resolve(_shimDir, '../shims/...')` works in normal
88
+ // Node consumption (_shimDir = `<pkg>/lib/app/`). When the CLI is
89
+ // bundled into a single .mjs (e.g. the GJS-CLI self-host loop),
90
+ // `import.meta.url` collapses to the bundle's path and the relative
91
+ // resolution lands at a non-existent location. Walk up via
92
+ // createRequire's node_modules-aware resolver as a fallback.
48
93
  const consoleShimEnabled = input.pluginOptions.consoleShim !== false;
49
94
  const consoleShimPath = consoleShimEnabled
50
- ? resolve(_shimDir, '../shims/console-gjs.js')
95
+ ? resolveConsoleShim()
51
96
  : null;
52
97
  // The auto-globals inject stub (when present) is side-effect-imported
53
98
  // via a virtual entry — its register modules write to globalThis, so
@@ -72,6 +117,19 @@ export const setupForGjs = async (input) => {
72
117
  mainFields: format === 'esm' ? ['browser', 'module', 'main'] : ['browser', 'main', 'module'],
73
118
  // ESM: omit 'require' — packages listing 'require' before 'import'
74
119
  // would silently route through their CJS entry.
120
+ //
121
+ // We deliberately do NOT add `'node'` here. Per Node's exports-map
122
+ // spec the resolver iterates keys in DECLARATION ORDER and picks
123
+ // the first one whose name is in `conditionNames` — the order of
124
+ // conditionNames itself is irrelevant. Packages like
125
+ // `cross-fetch-ponyfill` declare `"node"` first in their exports
126
+ // map and ship a Node-only entry that imports `blobFrom`/
127
+ // `fileFrom` (from native `node:fetch`). With `node` enabled,
128
+ // the resolver picks that branch over `browser` and the bundle
129
+ // breaks at link time. Packages that genuinely need their `node`
130
+ // export under GJS (rare — only one known case so far,
131
+ // `unicorn-magic`'s `traversePathUp`) are handled with explicit
132
+ // resolve aliases instead.
75
133
  conditionNames: format === 'esm' ? ['browser', 'import'] : ['browser', 'require', 'import'],
76
134
  },
77
135
  transform: {
@@ -110,6 +168,12 @@ export const setupForGjs = async (input) => {
110
168
  // Virtual-entry plugin runs FIRST so its resolveId/load match the
111
169
  // synthetic input ids that `wrapInputWithSideEffects` produces.
112
170
  ...(virtualEntries.plugin ? [virtualEntries.plugin] : []),
171
+ // Strip leading #! from any input module BEFORE bundling — otherwise
172
+ // a shebang in e.g. the CLI's own entry file ends up embedded
173
+ // mid-chunk after our process-stub banner, and acorn (auto-globals
174
+ // detector) rejects the `#` byte. Final-output shebang is composed
175
+ // by shebangPlugin's renderChunk hook.
176
+ inputShebangStripPlugin(),
113
177
  // random-access-file's 'browser' field maps to a throwing stub; force
114
178
  // the fs-backed Node entry. Implemented via the gjsify alias plugin
115
179
  // as a direct entry-table override.
@@ -19,10 +19,100 @@
19
19
  // flattened to GTK4-CSS-engine-compatible output. Targeting is opt-in —
20
20
  // a missing `targets` keeps the source pristine.
21
21
  //
22
- // `lightningcss` is a regular dependency of this package; the plugin
23
- // imports it lazily so missing-arch installs surface the underlying
24
- // load error instead of crashing every gjsify build.
22
+ // Backend selection (Phase D-2 decision matrix in
23
+ // `docs/poc/lightningcss-decision.md`):
24
+ //
25
+ // 1. `@gjsify/lightningcss-native` when its prebuild is loadable on
26
+ // the running architecture (3-5× faster than the WASM track,
27
+ // ~960× faster cold init). Only relevant when `gjsify build`
28
+ // itself runs under GJS (Phase D-3).
29
+ // 2. npm `lightningcss` for everything else (Node, unsupported
30
+ // arches, dev machines without the prebuild). Existing behavior;
31
+ // keeps the regular dependency on this package.
32
+ //
33
+ // Selection is lazy and silent — the first `.css` load probes for
34
+ // the native bridge once, caches the answer, and routes the rest of
35
+ // the build through the chosen backend. Set the env var
36
+ // `GJSIFY_CSS_BACKEND={native|npm}` to force a specific backend
37
+ // (mainly useful for benchmarking + the integration suite).
25
38
  import { readFile } from 'node:fs/promises';
39
+ let _bundlerPromise = null;
40
+ async function pickBundler() {
41
+ const forced = globalThis.process?.env?.GJSIFY_CSS_BACKEND;
42
+ if (forced === 'npm')
43
+ return loadNpmBundler();
44
+ if (forced === 'native') {
45
+ const native = await tryLoadNativeBundler();
46
+ if (!native)
47
+ throw new Error('GJSIFY_CSS_BACKEND=native but @gjsify/lightningcss-native is not loadable');
48
+ return native;
49
+ }
50
+ const native = await tryLoadNativeBundler();
51
+ return native ?? loadNpmBundler();
52
+ }
53
+ async function tryLoadNativeBundler() {
54
+ // The native bridge only exists under GJS — `imports.gi` marker. Skip
55
+ // the dynamic import entirely on Node so it doesn't even register as a
56
+ // resolved dep, which would inflate the CLI's bundled output.
57
+ const isGjs = typeof globalThis.imports?.gi !== 'undefined';
58
+ if (!isGjs)
59
+ return null;
60
+ try {
61
+ // Indirect specifier so tsc + Rolldown don't try to resolve the
62
+ // optional peer dep at build time. Resolution happens only at
63
+ // runtime under GJS (where the prebuild is installed).
64
+ const specifier = '@gjsify/lightningcss-native';
65
+ const mod = (await import(/* @vite-ignore */ specifier));
66
+ if (!mod.hasNativeLightningcss())
67
+ return null;
68
+ return async (filename, targets) => {
69
+ // The native shim accepts a browserslist string; the npm
70
+ // `lightningcss` Targets struct is bitfield-encoded
71
+ // (`firefox: 60 << 16` etc). Convert by extracting major
72
+ // version per browser key and re-emitting as the equivalent
73
+ // browserslist query.
74
+ const query = targetsToBrowserslist(targets);
75
+ return mod.bundle({
76
+ filename,
77
+ targets: query,
78
+ minify: false,
79
+ sourceMap: false,
80
+ errorRecovery: true,
81
+ });
82
+ };
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ async function loadNpmBundler() {
89
+ const { bundleAsync } = await import('lightningcss');
90
+ return async (filename, targets) => {
91
+ const result = await bundleAsync({
92
+ filename,
93
+ targets,
94
+ minify: false,
95
+ errorRecovery: true,
96
+ });
97
+ return { code: result.code };
98
+ };
99
+ }
100
+ function targetsToBrowserslist(targets) {
101
+ if (!targets)
102
+ return undefined;
103
+ const parts = [];
104
+ for (const [browser, encoded] of Object.entries(targets)) {
105
+ if (typeof encoded !== 'number')
106
+ continue;
107
+ // npm lightningcss encodes versions as `(major << 16) | (minor << 8) | patch`.
108
+ const major = (encoded >>> 16) & 0xff;
109
+ if (major === 0)
110
+ continue;
111
+ const name = browser === 'ios_saf' ? 'ios' : browser;
112
+ parts.push(`${name} >= ${major}`);
113
+ }
114
+ return parts.length ? parts.join(', ') : undefined;
115
+ }
26
116
  export function cssAsStringPlugin(options = {}) {
27
117
  const { targets, bundle = true } = options;
28
118
  return {
@@ -42,12 +132,9 @@ export function cssAsStringPlugin(options = {}) {
42
132
  };
43
133
  }
44
134
  async function loadAndBundleCss(filename, targets) {
45
- const { bundleAsync } = await import('lightningcss');
46
- const result = await bundleAsync({
47
- filename,
48
- targets,
49
- minify: false,
50
- errorRecovery: true,
51
- });
52
- return result.code;
135
+ if (!_bundlerPromise)
136
+ _bundlerPromise = pickBundler();
137
+ const bundler = await _bundlerPromise;
138
+ const { code } = await bundler(filename, targets);
139
+ return code;
53
140
  }
@@ -5,6 +5,8 @@ export interface ShebangPluginOptions {
5
5
  /** Override the shebang line. Defaults to `GJS_SHEBANG`. */
6
6
  line?: string;
7
7
  }
8
+ /** Always-on plugin half: strips input shebangs regardless of output options. */
9
+ export declare function inputShebangStripPlugin(): Plugin;
8
10
  export declare function shebangPlugin(options?: ShebangPluginOptions): Plugin | null;
9
11
  /**
10
12
  * Expand `${env:NAME}` and `${env:NAME:-default}` placeholders against
@@ -6,6 +6,29 @@
6
6
  // the `#` character is only valid as the very first byte of the file under
7
7
  // SpiderMonkey 128+.
8
8
  export const GJS_SHEBANG = '#!/usr/bin/env -S gjs -m';
9
+ /**
10
+ * Strip a leading `#!…\n` from a source module. Rolldown preserves input
11
+ * shebangs verbatim, which ends up embedded mid-chunk after our process-stub
12
+ * banner — acorn (used by the auto-globals detector) then rejects `#` because
13
+ * it's not at byte 0 anymore. Stripping at the transform stage cleans both
14
+ * the analysis bundle and the final bundle; the gjsify-shebang renderChunk
15
+ * step then injects the correct line for the output target.
16
+ */
17
+ const SHEBANG_RE = /^#![^\n]*\n/;
18
+ /** Always-on plugin half: strips input shebangs regardless of output options. */
19
+ export function inputShebangStripPlugin() {
20
+ return {
21
+ name: 'gjsify-input-shebang-strip',
22
+ transform: {
23
+ order: 'pre',
24
+ handler(code) {
25
+ if (!code.startsWith('#!'))
26
+ return null;
27
+ return { code: code.replace(SHEBANG_RE, ''), map: null };
28
+ },
29
+ },
30
+ };
31
+ }
9
32
  export function shebangPlugin(options = {}) {
10
33
  if (!options.enabled)
11
34
  return null;
@@ -0,0 +1,14 @@
1
+ export declare function toPath(urlOrPath: any): any;
2
+ export declare function rootDirectory(pathInput: any): string;
3
+ export declare function traversePathUp(startPath: any): {
4
+ [Symbol.iterator](): Generator<string, void, unknown>;
5
+ };
6
+ export declare function execFile(file: any, arguments_: any, options?: {}): Promise<{
7
+ stdout: string;
8
+ stderr: string;
9
+ }>;
10
+ export declare function execFileSync(file: any, arguments_?: any[], options?: {}): NonSharedBuffer;
11
+ export declare function delay(opts?: {
12
+ seconds?: number;
13
+ milliseconds?: number;
14
+ }): Promise<void>;
@@ -0,0 +1,68 @@
1
+ // Shim for `unicorn-magic` under --app gjs.
2
+ //
3
+ // The upstream package gates the full API (`toPath`, `traversePathUp`,
4
+ // `rootDirectory`, `execFile`, `execFileSync`) behind the `"node"`
5
+ // conditional exports entry. Under --app gjs we intentionally omit
6
+ // the `node` resolve-condition (cross-fetch-ponyfill ships
7
+ // Node-only code under that key — see `app/gjs.ts` conditionNames
8
+ // comment), so a bare `import { toPath } from 'unicorn-magic'` falls
9
+ // back to `default.js` which only exposes `delay`.
10
+ //
11
+ // This shim mirrors the node.js entry verbatim — the underlying
12
+ // `node:url`/`node:path`/`node:child_process`/`node:util` imports
13
+ // route through `@gjsify/{url,path,child_process,util}` under GJS
14
+ // and through real Node-internals under Node. The aliasPlugin
15
+ // points `unicorn-magic` here for --app gjs builds.
16
+ //
17
+ // Source-of-truth: refs/unicorn-magic/node.js (when added — for
18
+ // now mirrored from node_modules/unicorn-magic@0.3.0).
19
+ import { promisify } from 'node:util';
20
+ import { execFile as execFileCallback, execFileSync as execFileSyncOriginal } from 'node:child_process';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ const execFileOriginal = promisify(execFileCallback);
24
+ export function toPath(urlOrPath) {
25
+ return urlOrPath instanceof URL ? fileURLToPath(urlOrPath) : urlOrPath;
26
+ }
27
+ export function rootDirectory(pathInput) {
28
+ return path.parse(toPath(pathInput)).root;
29
+ }
30
+ export function traversePathUp(startPath) {
31
+ return {
32
+ *[Symbol.iterator]() {
33
+ let currentPath = path.resolve(toPath(startPath));
34
+ let previousPath;
35
+ while (previousPath !== currentPath) {
36
+ yield currentPath;
37
+ previousPath = currentPath;
38
+ currentPath = path.resolve(currentPath, '..');
39
+ }
40
+ },
41
+ };
42
+ }
43
+ const TEN_MEGABYTES_IN_BYTES = 10 * 1024 * 1024;
44
+ export async function execFile(file, arguments_, options = {}) {
45
+ return execFileOriginal(file, arguments_, {
46
+ maxBuffer: TEN_MEGABYTES_IN_BYTES,
47
+ ...options,
48
+ });
49
+ }
50
+ export function execFileSync(file, arguments_ = [], options = {}) {
51
+ return execFileSyncOriginal(file, arguments_, {
52
+ maxBuffer: TEN_MEGABYTES_IN_BYTES,
53
+ ...options,
54
+ });
55
+ }
56
+ // Re-export from default.js so the union API (delay + node helpers)
57
+ // stays intact for callers that import both.
58
+ export async function delay(opts = {}) {
59
+ const { seconds, milliseconds } = opts;
60
+ let duration;
61
+ if (typeof seconds === 'number')
62
+ duration = seconds * 1000;
63
+ else if (typeof milliseconds === 'number')
64
+ duration = milliseconds;
65
+ else
66
+ throw new TypeError('Expected an object with either `seconds` or `milliseconds`.');
67
+ return new Promise((resolveFn) => setTimeout(resolveFn, duration));
68
+ }
@@ -1,4 +1,19 @@
1
- import { type InputOptions, type RolldownPluginOption, type TransformOptions } from 'rolldown';
1
+ import type { InputOptions, RolldownPluginOption, TransformOptions } from 'rolldown';
2
+ /**
3
+ * In-memory bundle function — returns the per-entry chunk code strings.
4
+ * Implementations: npm rolldown (Node default), `@gjsify/rolldown-native`
5
+ * (GJS). Pulled out so auto-globals can run under either engine without
6
+ * hardcoding npm rolldown (which can't load under GJS — the Rust prebuild's
7
+ * init code uses `require('node:fs')` synchronously).
8
+ *
9
+ * The default impl below dynamically imports npm rolldown; the CLI
10
+ * overrides this from `actions/build.ts` to route via the same engine the
11
+ * final build uses.
12
+ */
13
+ export type AnalysisBundler = (input: {
14
+ rolldownInput: InputOptions;
15
+ format: 'esm' | 'cjs' | 'iife';
16
+ }) => Promise<string[]>;
2
17
  import type { PluginOptions } from '../types/plugin-options.js';
3
18
  export interface AutoGlobalsResult {
4
19
  /** Global identifiers detected in the bundle */
@@ -68,5 +83,5 @@ type GjsifyPluginFactory = (options: PluginOptions) => RolldownPluginOption | Pr
68
83
  * @param verbose Emit per-iteration debug output to console.
69
84
  * @param options Optional `extraGlobalsList` / `excludeGlobals`.
70
85
  */
71
- export declare function detectAutoGlobals(analysisOptions: AnalysisOptions, pluginOptions: Omit<PluginOptions, 'autoGlobalsInject'>, gjsifyPluginFactory: GjsifyPluginFactory, verbose?: boolean, options?: DetectAutoGlobalsOptions): Promise<AutoGlobalsResult>;
86
+ export declare function detectAutoGlobals(analysisOptions: AnalysisOptions, pluginOptions: Omit<PluginOptions, 'autoGlobalsInject'>, gjsifyPluginFactory: GjsifyPluginFactory, verbose?: boolean, options?: DetectAutoGlobalsOptions, bundler?: AnalysisBundler): Promise<AutoGlobalsResult>;
72
87
  export {};
@@ -17,7 +17,25 @@
17
17
  // We deliberately do NOT minify the analysis builds: minification can
18
18
  // alias `globalThis` to a short variable and defeat MemberExpression
19
19
  // detection in detect-free-globals.ts.
20
- import { rolldown } from 'rolldown';
20
+ const defaultBundler = async ({ rolldownInput, format }) => {
21
+ // Indirect specifier so the GJS bundle doesn't pull npm rolldown in
22
+ // statically. Only reached when the caller doesn't override (Node).
23
+ const specifier = 'rolldown';
24
+ const mod = (await import(/* @vite-ignore */ specifier));
25
+ const build = await mod.rolldown(rolldownInput);
26
+ try {
27
+ const result = await build.generate({ format, minify: false, sourcemap: false });
28
+ const codes = [];
29
+ for (const entry of result.output) {
30
+ if (entry.type === 'chunk')
31
+ codes.push(entry.code);
32
+ }
33
+ return codes;
34
+ }
35
+ finally {
36
+ await build.close();
37
+ }
38
+ };
21
39
  import { detectFreeGlobals } from './detect-free-globals.js';
22
40
  import { resolveGlobalsList, writeRegisterInjectFile } from './scan-globals.js';
23
41
  import { GJS_GLOBALS_MAP } from '@gjsify/resolve-npm/globals-map';
@@ -69,7 +87,7 @@ function detectedToRegisterPaths(detected) {
69
87
  * @param verbose Emit per-iteration debug output to console.
70
88
  * @param options Optional `extraGlobalsList` / `excludeGlobals`.
71
89
  */
72
- export async function detectAutoGlobals(analysisOptions, pluginOptions, gjsifyPluginFactory, verbose, options = {}) {
90
+ export async function detectAutoGlobals(analysisOptions, pluginOptions, gjsifyPluginFactory, verbose, options = {}, bundler = defaultBundler) {
73
91
  const extraRegisterPaths = options.extraGlobalsList
74
92
  ? resolveGlobalsList(options.extraGlobalsList)
75
93
  : new Set();
@@ -101,29 +119,17 @@ export async function detectAutoGlobals(analysisOptions, pluginOptions, gjsifyPl
101
119
  const inputWithInject = currentInject
102
120
  ? appendInjectAsEntry(analysisOptions.input, currentInject)
103
121
  : analysisOptions.input;
104
- const build = await rolldown({
105
- input: inputWithInject,
106
- external: analysisOptions.external,
107
- resolve: analysisOptions.resolve,
108
- transform: analysisOptions.transform,
109
- plugins: [...callerPlugins, gjsifyInstance],
110
- logLevel: 'silent',
122
+ const chunkCodes = await bundler({
123
+ rolldownInput: {
124
+ input: inputWithInject,
125
+ external: analysisOptions.external,
126
+ resolve: analysisOptions.resolve,
127
+ transform: analysisOptions.transform,
128
+ plugins: [...callerPlugins, gjsifyInstance],
129
+ logLevel: 'silent',
130
+ },
131
+ format: analysisOptions.format ?? 'esm',
111
132
  });
112
- const chunkCodes = [];
113
- try {
114
- const result = await build.generate({
115
- format: analysisOptions.format ?? 'esm',
116
- minify: false,
117
- sourcemap: false,
118
- });
119
- for (const entry of result.output) {
120
- if (entry.type === 'chunk')
121
- chunkCodes.push(entry.code);
122
- }
123
- }
124
- finally {
125
- await build.close();
126
- }
127
133
  if (chunkCodes.length === 0) {
128
134
  return { detected: new Set(), injectPath: currentInject };
129
135
  }
@@ -133,9 +139,25 @@ export async function detectAutoGlobals(analysisOptions, pluginOptions, gjsifyPl
133
139
  // top-level declarations: `File`, `Buffer`, …) that acorn can't
134
140
  // parse. Per-chunk parsing keeps each chunk's lexical scope intact.
135
141
  const newDetected = new Set();
136
- for (const code of chunkCodes) {
137
- for (const id of detectFreeGlobals(code))
138
- newDetected.add(id);
142
+ for (let i = 0; i < chunkCodes.length; i++) {
143
+ const code = chunkCodes[i] ?? '';
144
+ try {
145
+ for (const id of detectFreeGlobals(code))
146
+ newDetected.add(id);
147
+ }
148
+ catch (e) {
149
+ if (globalThis.process?.env?.GJSIFY_DEBUG_AUTO_GLOBALS) {
150
+ const path = `/tmp/gjsify-auto-globals-failed-chunk-${i}.mjs`;
151
+ try {
152
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
153
+ const fs = await import('node:fs');
154
+ fs.writeFileSync(path, code);
155
+ console.error(`[gjsify-auto-globals] parse failed on chunk #${i} — wrote ${path} for inspection`);
156
+ }
157
+ catch { /* ignore */ }
158
+ }
159
+ throw e;
160
+ }
139
161
  }
140
162
  // Apply excludeGlobals BEFORE writing the next iteration's inject file.
141
163
  // Otherwise an excluded identifier would still appear in the inject
@@ -93,6 +93,12 @@ export function detectFreeGlobals(code) {
93
93
  const ast = acorn.parse(code, {
94
94
  ecmaVersion: 'latest',
95
95
  sourceType: 'module',
96
+ // Some bundled chunks carry an embedded `#!shebang` line —
97
+ // notably any project bundling its own CLI gets the
98
+ // `#!/usr/bin/env -S gjs -m` shebang hoisted to byte 0.
99
+ // Acorn rejects shebangs by default; allow them so the
100
+ // free-globals analyzer doesn't choke on its own input.
101
+ allowHashBang: true,
96
102
  });
97
103
  // --- Pass 1: collect all declared names across the entire module ---
98
104
  const declaredNames = new Set();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/rolldown-plugin-gjsify",
3
- "version": "0.3.21",
3
+ "version": "0.4.0",
4
4
  "description": "Rolldown / Rollup / Vite plugin orchestrator for GJS, Node, and Browser targets",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -42,10 +42,10 @@
42
42
  ],
43
43
  "license": "MIT",
44
44
  "dependencies": {
45
- "@gjsify/resolve-npm": "^0.3.21",
46
- "@gjsify/rolldown-plugin-deepkit": "^0.3.21",
47
- "@gjsify/rolldown-plugin-pnp": "^0.3.21",
48
- "@gjsify/vite-plugin-blueprint": "^0.3.21",
45
+ "@gjsify/resolve-npm": "^0.4.0",
46
+ "@gjsify/rolldown-plugin-deepkit": "^0.4.0",
47
+ "@gjsify/rolldown-plugin-pnp": "^0.4.0",
48
+ "@gjsify/vite-plugin-blueprint": "^0.4.0",
49
49
  "@rollup/pluginutils": "^5.3.0",
50
50
  "acorn": "^8.16.0",
51
51
  "acorn-walk": "^8.3.5",
@@ -53,9 +53,13 @@
53
53
  "lightningcss": "^1.32.0"
54
54
  },
55
55
  "peerDependencies": {
56
+ "@gjsify/lightningcss-native": "^0.4.0",
56
57
  "rolldown": "^1.0.0-rc.18"
57
58
  },
58
59
  "peerDependenciesMeta": {
60
+ "@gjsify/lightningcss-native": {
61
+ "optional": true
62
+ },
59
63
  "rolldown": {
60
64
  "optional": true
61
65
  }
package/src/app/gjs.ts CHANGED
@@ -26,10 +26,36 @@ import {
26
26
  } from '../plugins/rewrite-node-modules-paths.js';
27
27
  import { processStubPlugin } from '../plugins/process-stub.js';
28
28
  import { cssAsStringPlugin } from '../plugins/css-as-string.js';
29
- import { shebangPlugin, resolveShebangLine } from '../plugins/shebang.js';
29
+ import { shebangPlugin, resolveShebangLine, inputShebangStripPlugin } from '../plugins/shebang.js';
30
30
 
31
31
  const _shimDir = dirname(fileURLToPath(import.meta.url));
32
32
 
33
+ function resolveConsoleShim(): string {
34
+ // Preferred: relative to this module's directory. Works under the
35
+ // normal Node consumer flow where `_shimDir` = `<pkg>/lib/app/`.
36
+ const relative = resolve(_shimDir, '../shims/console-gjs.js');
37
+ let fs: typeof import('node:fs') | null = null;
38
+ try {
39
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
40
+ fs = require('node:fs') as typeof import('node:fs');
41
+ } catch { return relative; }
42
+ if (fs.existsSync(relative)) return relative;
43
+ // Fallback: when the orchestrator is bundled into a single .mjs
44
+ // (GJS-CLI self-host loop) `_shimDir` collapses to the bundle's
45
+ // own directory and the relative lookup misses. createRequire's
46
+ // resolver is `exports`-map-aware (Phase C), so the published
47
+ // subpath export `./shims/console-gjs` works under both Node and
48
+ // GJS without further walking.
49
+ try {
50
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
51
+ const Module = require('node:module') as typeof import('node:module');
52
+ const require_ = Module.createRequire(import.meta.url);
53
+ return require_.resolve('@gjsify/rolldown-plugin-gjsify/shims/console-gjs');
54
+ } catch {
55
+ return relative;
56
+ }
57
+ }
58
+
33
59
  /** Resolved Rolldown configuration template + plugins for `--app gjs`. */
34
60
  export interface GjsBuildConfig {
35
61
  options: RolldownOptions;
@@ -75,8 +101,17 @@ export const setupForGjs = async (input: GjsFactoryInput): Promise<GjsBuildConfi
75
101
  const exclude = input.pluginOptions.exclude ?? [];
76
102
  const entryPoints = await globToEntryPoints(input.input, exclude);
77
103
 
104
+ // unicorn-magic gates its full API behind the "node" conditional
105
+ // exports. We deliberately omit `node` from conditionNames (some
106
+ // packages ship genuinely Node-only code there — see comment
107
+ // around `conditionNames` below). Route the package to our
108
+ // bundled shim so the API is reachable under --app gjs without
109
+ // turning on the node condition globally.
110
+ const unicornMagicShim = resolve(_shimDir, '../shims/unicorn-magic.js');
111
+
78
112
  const aliasMap = {
79
113
  ...getAliasesForGjs({ external }),
114
+ 'unicorn-magic': unicornMagicShim,
80
115
  ...(input.pluginOptions.aliases ?? {}),
81
116
  ...(input.userAliases ?? {}),
82
117
  };
@@ -84,9 +119,16 @@ export const setupForGjs = async (input: GjsFactoryInput): Promise<GjsBuildConfi
84
119
  // The console shim replaces all `console` references with print()/printerr()-
85
120
  // based implementations that bypass GLib.log_structured() — no prefix,
86
121
  // ANSI codes work. Disabled via `pluginOptions.consoleShim === false`.
122
+ //
123
+ // Path resolution: `resolve(_shimDir, '../shims/...')` works in normal
124
+ // Node consumption (_shimDir = `<pkg>/lib/app/`). When the CLI is
125
+ // bundled into a single .mjs (e.g. the GJS-CLI self-host loop),
126
+ // `import.meta.url` collapses to the bundle's path and the relative
127
+ // resolution lands at a non-existent location. Walk up via
128
+ // createRequire's node_modules-aware resolver as a fallback.
87
129
  const consoleShimEnabled = input.pluginOptions.consoleShim !== false;
88
130
  const consoleShimPath = consoleShimEnabled
89
- ? resolve(_shimDir, '../shims/console-gjs.js')
131
+ ? resolveConsoleShim()
90
132
  : null;
91
133
 
92
134
  // The auto-globals inject stub (when present) is side-effect-imported
@@ -113,6 +155,19 @@ export const setupForGjs = async (input: GjsFactoryInput): Promise<GjsBuildConfi
113
155
  mainFields: format === 'esm' ? ['browser', 'module', 'main'] : ['browser', 'main', 'module'],
114
156
  // ESM: omit 'require' — packages listing 'require' before 'import'
115
157
  // would silently route through their CJS entry.
158
+ //
159
+ // We deliberately do NOT add `'node'` here. Per Node's exports-map
160
+ // spec the resolver iterates keys in DECLARATION ORDER and picks
161
+ // the first one whose name is in `conditionNames` — the order of
162
+ // conditionNames itself is irrelevant. Packages like
163
+ // `cross-fetch-ponyfill` declare `"node"` first in their exports
164
+ // map and ship a Node-only entry that imports `blobFrom`/
165
+ // `fileFrom` (from native `node:fetch`). With `node` enabled,
166
+ // the resolver picks that branch over `browser` and the bundle
167
+ // breaks at link time. Packages that genuinely need their `node`
168
+ // export under GJS (rare — only one known case so far,
169
+ // `unicorn-magic`'s `traversePathUp`) are handled with explicit
170
+ // resolve aliases instead.
116
171
  conditionNames: format === 'esm' ? ['browser', 'import'] : ['browser', 'require', 'import'],
117
172
  },
118
173
  transform: {
@@ -153,6 +208,12 @@ export const setupForGjs = async (input: GjsFactoryInput): Promise<GjsBuildConfi
153
208
  // Virtual-entry plugin runs FIRST so its resolveId/load match the
154
209
  // synthetic input ids that `wrapInputWithSideEffects` produces.
155
210
  ...(virtualEntries.plugin ? [virtualEntries.plugin] : []),
211
+ // Strip leading #! from any input module BEFORE bundling — otherwise
212
+ // a shebang in e.g. the CLI's own entry file ends up embedded
213
+ // mid-chunk after our process-stub banner, and acorn (auto-globals
214
+ // detector) rejects the `#` byte. Final-output shebang is composed
215
+ // by shebangPlugin's renderChunk hook.
216
+ inputShebangStripPlugin(),
156
217
  // random-access-file's 'browser' field maps to a throwing stub; force
157
218
  // the fs-backed Node entry. Implemented via the gjsify alias plugin
158
219
  // as a direct entry-table override.
@@ -19,9 +19,22 @@
19
19
  // flattened to GTK4-CSS-engine-compatible output. Targeting is opt-in —
20
20
  // a missing `targets` keeps the source pristine.
21
21
  //
22
- // `lightningcss` is a regular dependency of this package; the plugin
23
- // imports it lazily so missing-arch installs surface the underlying
24
- // load error instead of crashing every gjsify build.
22
+ // Backend selection (Phase D-2 decision matrix in
23
+ // `docs/poc/lightningcss-decision.md`):
24
+ //
25
+ // 1. `@gjsify/lightningcss-native` when its prebuild is loadable on
26
+ // the running architecture (3-5× faster than the WASM track,
27
+ // ~960× faster cold init). Only relevant when `gjsify build`
28
+ // itself runs under GJS (Phase D-3).
29
+ // 2. npm `lightningcss` for everything else (Node, unsupported
30
+ // arches, dev machines without the prebuild). Existing behavior;
31
+ // keeps the regular dependency on this package.
32
+ //
33
+ // Selection is lazy and silent — the first `.css` load probes for
34
+ // the native bridge once, caches the answer, and routes the rest of
35
+ // the build through the chosen backend. Set the env var
36
+ // `GJSIFY_CSS_BACKEND={native|npm}` to force a specific backend
37
+ // (mainly useful for benchmarking + the integration suite).
25
38
 
26
39
  import { readFile } from 'node:fs/promises';
27
40
  import type { Plugin } from 'rolldown';
@@ -44,6 +57,108 @@ export interface CssAsStringOptions {
44
57
  bundle?: boolean;
45
58
  }
46
59
 
60
+ interface BundleResult {
61
+ code: Uint8Array;
62
+ }
63
+
64
+ type Bundler = (filename: string, targets: import('lightningcss').Targets | undefined) => Promise<BundleResult>;
65
+
66
+ let _bundlerPromise: Promise<Bundler> | null = null;
67
+
68
+ async function pickBundler(): Promise<Bundler> {
69
+ const forced = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.GJSIFY_CSS_BACKEND;
70
+
71
+ if (forced === 'npm') return loadNpmBundler();
72
+ if (forced === 'native') {
73
+ const native = await tryLoadNativeBundler();
74
+ if (!native) throw new Error('GJSIFY_CSS_BACKEND=native but @gjsify/lightningcss-native is not loadable');
75
+ return native;
76
+ }
77
+
78
+ const native = await tryLoadNativeBundler();
79
+ return native ?? loadNpmBundler();
80
+ }
81
+
82
+ // Local mirror of the @gjsify/lightningcss-native surface we touch. We
83
+ // can't rely on the published types here because the package is an
84
+ // OPTIONAL peer dep — under Node it's not installed, so `import type`
85
+ // from it would break tsc on every Node consumer. Local interface
86
+ // keeps the type narrow + decouples the plugin's typecheck from
87
+ // whether the prebuild package is installed.
88
+ interface NativeLightningcssSurface {
89
+ hasNativeLightningcss(): boolean;
90
+ bundle(input: {
91
+ filename: string;
92
+ targets?: string;
93
+ minify?: boolean;
94
+ sourceMap?: boolean;
95
+ errorRecovery?: boolean;
96
+ }): { code: Uint8Array; map?: Uint8Array };
97
+ }
98
+
99
+ async function tryLoadNativeBundler(): Promise<Bundler | null> {
100
+ // The native bridge only exists under GJS — `imports.gi` marker. Skip
101
+ // the dynamic import entirely on Node so it doesn't even register as a
102
+ // resolved dep, which would inflate the CLI's bundled output.
103
+ const isGjs = typeof (globalThis as { imports?: { gi?: unknown } }).imports?.gi !== 'undefined';
104
+ if (!isGjs) return null;
105
+
106
+ try {
107
+ // Indirect specifier so tsc + Rolldown don't try to resolve the
108
+ // optional peer dep at build time. Resolution happens only at
109
+ // runtime under GJS (where the prebuild is installed).
110
+ const specifier = '@gjsify/lightningcss-native';
111
+ const mod = (await import(/* @vite-ignore */ specifier)) as NativeLightningcssSurface;
112
+ if (!mod.hasNativeLightningcss()) return null;
113
+ return async (filename, targets) => {
114
+ // The native shim accepts a browserslist string; the npm
115
+ // `lightningcss` Targets struct is bitfield-encoded
116
+ // (`firefox: 60 << 16` etc). Convert by extracting major
117
+ // version per browser key and re-emitting as the equivalent
118
+ // browserslist query.
119
+ const query = targetsToBrowserslist(targets);
120
+ return mod.bundle({
121
+ filename,
122
+ targets: query,
123
+ minify: false,
124
+ sourceMap: false,
125
+ errorRecovery: true,
126
+ });
127
+ };
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ async function loadNpmBundler(): Promise<Bundler> {
134
+ const { bundleAsync } = await import('lightningcss');
135
+ return async (filename, targets) => {
136
+ const result = await bundleAsync({
137
+ filename,
138
+ targets,
139
+ minify: false,
140
+ errorRecovery: true,
141
+ });
142
+ return { code: result.code };
143
+ };
144
+ }
145
+
146
+ function targetsToBrowserslist(
147
+ targets: import('lightningcss').Targets | undefined,
148
+ ): string | undefined {
149
+ if (!targets) return undefined;
150
+ const parts: string[] = [];
151
+ for (const [browser, encoded] of Object.entries(targets) as [string, number | undefined][]) {
152
+ if (typeof encoded !== 'number') continue;
153
+ // npm lightningcss encodes versions as `(major << 16) | (minor << 8) | patch`.
154
+ const major = (encoded >>> 16) & 0xff;
155
+ if (major === 0) continue;
156
+ const name = browser === 'ios_saf' ? 'ios' : browser;
157
+ parts.push(`${name} >= ${major}`);
158
+ }
159
+ return parts.length ? parts.join(', ') : undefined;
160
+ }
161
+
47
162
  export function cssAsStringPlugin(options: CssAsStringOptions = {}): Plugin {
48
163
  const { targets, bundle = true } = options;
49
164
  return {
@@ -67,12 +182,8 @@ async function loadAndBundleCss(
67
182
  filename: string,
68
183
  targets: import('lightningcss').Targets | undefined,
69
184
  ): Promise<Uint8Array> {
70
- const { bundleAsync } = await import('lightningcss');
71
- const result = await bundleAsync({
72
- filename,
73
- targets,
74
- minify: false,
75
- errorRecovery: true,
76
- });
77
- return result.code;
185
+ if (!_bundlerPromise) _bundlerPromise = pickBundler();
186
+ const bundler = await _bundlerPromise;
187
+ const { code } = await bundler(filename, targets);
188
+ return code;
78
189
  }
@@ -16,6 +16,30 @@ export interface ShebangPluginOptions {
16
16
  line?: string;
17
17
  }
18
18
 
19
+ /**
20
+ * Strip a leading `#!…\n` from a source module. Rolldown preserves input
21
+ * shebangs verbatim, which ends up embedded mid-chunk after our process-stub
22
+ * banner — acorn (used by the auto-globals detector) then rejects `#` because
23
+ * it's not at byte 0 anymore. Stripping at the transform stage cleans both
24
+ * the analysis bundle and the final bundle; the gjsify-shebang renderChunk
25
+ * step then injects the correct line for the output target.
26
+ */
27
+ const SHEBANG_RE = /^#![^\n]*\n/;
28
+
29
+ /** Always-on plugin half: strips input shebangs regardless of output options. */
30
+ export function inputShebangStripPlugin(): Plugin {
31
+ return {
32
+ name: 'gjsify-input-shebang-strip',
33
+ transform: {
34
+ order: 'pre' as const,
35
+ handler(code) {
36
+ if (!code.startsWith('#!')) return null;
37
+ return { code: code.replace(SHEBANG_RE, ''), map: null };
38
+ },
39
+ },
40
+ };
41
+ }
42
+
19
43
  export function shebangPlugin(options: ShebangPluginOptions = {}): Plugin | null {
20
44
  if (!options.enabled) return null;
21
45
  const line = options.line ?? GJS_SHEBANG;
@@ -0,0 +1,75 @@
1
+ // Shim for `unicorn-magic` under --app gjs.
2
+ //
3
+ // The upstream package gates the full API (`toPath`, `traversePathUp`,
4
+ // `rootDirectory`, `execFile`, `execFileSync`) behind the `"node"`
5
+ // conditional exports entry. Under --app gjs we intentionally omit
6
+ // the `node` resolve-condition (cross-fetch-ponyfill ships
7
+ // Node-only code under that key — see `app/gjs.ts` conditionNames
8
+ // comment), so a bare `import { toPath } from 'unicorn-magic'` falls
9
+ // back to `default.js` which only exposes `delay`.
10
+ //
11
+ // This shim mirrors the node.js entry verbatim — the underlying
12
+ // `node:url`/`node:path`/`node:child_process`/`node:util` imports
13
+ // route through `@gjsify/{url,path,child_process,util}` under GJS
14
+ // and through real Node-internals under Node. The aliasPlugin
15
+ // points `unicorn-magic` here for --app gjs builds.
16
+ //
17
+ // Source-of-truth: refs/unicorn-magic/node.js (when added — for
18
+ // now mirrored from node_modules/unicorn-magic@0.3.0).
19
+
20
+ import { promisify } from 'node:util';
21
+ import { execFile as execFileCallback, execFileSync as execFileSyncOriginal } from 'node:child_process';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const execFileOriginal = promisify(execFileCallback);
26
+
27
+ export function toPath(urlOrPath) {
28
+ return urlOrPath instanceof URL ? fileURLToPath(urlOrPath) : urlOrPath;
29
+ }
30
+
31
+ export function rootDirectory(pathInput) {
32
+ return path.parse(toPath(pathInput)).root;
33
+ }
34
+
35
+ export function traversePathUp(startPath) {
36
+ return {
37
+ *[Symbol.iterator]() {
38
+ let currentPath = path.resolve(toPath(startPath));
39
+ let previousPath;
40
+
41
+ while (previousPath !== currentPath) {
42
+ yield currentPath;
43
+ previousPath = currentPath;
44
+ currentPath = path.resolve(currentPath, '..');
45
+ }
46
+ },
47
+ };
48
+ }
49
+
50
+ const TEN_MEGABYTES_IN_BYTES = 10 * 1024 * 1024;
51
+
52
+ export async function execFile(file, arguments_, options = {}) {
53
+ return execFileOriginal(file, arguments_, {
54
+ maxBuffer: TEN_MEGABYTES_IN_BYTES,
55
+ ...options,
56
+ });
57
+ }
58
+
59
+ export function execFileSync(file, arguments_ = [], options = {}) {
60
+ return execFileSyncOriginal(file, arguments_, {
61
+ maxBuffer: TEN_MEGABYTES_IN_BYTES,
62
+ ...options,
63
+ });
64
+ }
65
+
66
+ // Re-export from default.js so the union API (delay + node helpers)
67
+ // stays intact for callers that import both.
68
+ export async function delay(opts: { seconds?: number; milliseconds?: number } = {}): Promise<void> {
69
+ const { seconds, milliseconds } = opts;
70
+ let duration: number;
71
+ if (typeof seconds === 'number') duration = seconds * 1000;
72
+ else if (typeof milliseconds === 'number') duration = milliseconds;
73
+ else throw new TypeError('Expected an object with either `seconds` or `milliseconds`.');
74
+ return new Promise<void>((resolveFn) => setTimeout(resolveFn, duration));
75
+ }
@@ -18,7 +18,41 @@
18
18
  // alias `globalThis` to a short variable and defeat MemberExpression
19
19
  // detection in detect-free-globals.ts.
20
20
 
21
- import { rolldown, type InputOptions, type OutputChunk, type RolldownPluginOption, type TransformOptions } from 'rolldown';
21
+ import type { InputOptions, RolldownPluginOption, TransformOptions } from 'rolldown';
22
+
23
+ /**
24
+ * In-memory bundle function — returns the per-entry chunk code strings.
25
+ * Implementations: npm rolldown (Node default), `@gjsify/rolldown-native`
26
+ * (GJS). Pulled out so auto-globals can run under either engine without
27
+ * hardcoding npm rolldown (which can't load under GJS — the Rust prebuild's
28
+ * init code uses `require('node:fs')` synchronously).
29
+ *
30
+ * The default impl below dynamically imports npm rolldown; the CLI
31
+ * overrides this from `actions/build.ts` to route via the same engine the
32
+ * final build uses.
33
+ */
34
+ export type AnalysisBundler = (input: {
35
+ rolldownInput: InputOptions;
36
+ format: 'esm' | 'cjs' | 'iife';
37
+ }) => Promise<string[]>;
38
+
39
+ const defaultBundler: AnalysisBundler = async ({ rolldownInput, format }) => {
40
+ // Indirect specifier so the GJS bundle doesn't pull npm rolldown in
41
+ // statically. Only reached when the caller doesn't override (Node).
42
+ const specifier = 'rolldown';
43
+ const mod = (await import(/* @vite-ignore */ specifier)) as typeof import('rolldown');
44
+ const build = await mod.rolldown(rolldownInput);
45
+ try {
46
+ const result = await build.generate({ format, minify: false, sourcemap: false });
47
+ const codes: string[] = [];
48
+ for (const entry of result.output) {
49
+ if (entry.type === 'chunk') codes.push(entry.code);
50
+ }
51
+ return codes;
52
+ } finally {
53
+ await build.close();
54
+ }
55
+ };
22
56
  import { detectFreeGlobals } from './detect-free-globals.js';
23
57
  import { resolveGlobalsList, writeRegisterInjectFile } from './scan-globals.js';
24
58
  import { GJS_GLOBALS_MAP } from '@gjsify/resolve-npm/globals-map';
@@ -139,6 +173,7 @@ export async function detectAutoGlobals(
139
173
  gjsifyPluginFactory: GjsifyPluginFactory,
140
174
  verbose?: boolean,
141
175
  options: DetectAutoGlobalsOptions = {},
176
+ bundler: AnalysisBundler = defaultBundler,
142
177
  ): Promise<AutoGlobalsResult> {
143
178
  const extraRegisterPaths = options.extraGlobalsList
144
179
  ? resolveGlobalsList(options.extraGlobalsList)
@@ -178,29 +213,18 @@ export async function detectAutoGlobals(
178
213
  ? appendInjectAsEntry(analysisOptions.input, currentInject)
179
214
  : analysisOptions.input;
180
215
 
181
- const build = await rolldown({
182
- input: inputWithInject,
183
- external: analysisOptions.external,
184
- resolve: analysisOptions.resolve,
185
- transform: analysisOptions.transform,
186
- plugins: [...callerPlugins, gjsifyInstance],
187
- logLevel: 'silent',
216
+ const chunkCodes = await bundler({
217
+ rolldownInput: {
218
+ input: inputWithInject,
219
+ external: analysisOptions.external,
220
+ resolve: analysisOptions.resolve,
221
+ transform: analysisOptions.transform,
222
+ plugins: [...callerPlugins, gjsifyInstance],
223
+ logLevel: 'silent',
224
+ },
225
+ format: analysisOptions.format ?? 'esm',
188
226
  });
189
227
 
190
- const chunkCodes: string[] = [];
191
- try {
192
- const result = await build.generate({
193
- format: analysisOptions.format ?? 'esm',
194
- minify: false,
195
- sourcemap: false,
196
- });
197
- for (const entry of result.output) {
198
- if (entry.type === 'chunk') chunkCodes.push((entry as OutputChunk).code);
199
- }
200
- } finally {
201
- await build.close();
202
- }
203
-
204
228
  if (chunkCodes.length === 0) {
205
229
  return { detected: new Set(), injectPath: currentInject };
206
230
  }
@@ -211,8 +235,22 @@ export async function detectAutoGlobals(
211
235
  // top-level declarations: `File`, `Buffer`, …) that acorn can't
212
236
  // parse. Per-chunk parsing keeps each chunk's lexical scope intact.
213
237
  const newDetected = new Set<string>();
214
- for (const code of chunkCodes) {
215
- for (const id of detectFreeGlobals(code)) newDetected.add(id);
238
+ for (let i = 0; i < chunkCodes.length; i++) {
239
+ const code = chunkCodes[i] ?? '';
240
+ try {
241
+ for (const id of detectFreeGlobals(code)) newDetected.add(id);
242
+ } catch (e) {
243
+ if ((globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.GJSIFY_DEBUG_AUTO_GLOBALS) {
244
+ const path = `/tmp/gjsify-auto-globals-failed-chunk-${i}.mjs`;
245
+ try {
246
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
247
+ const fs = await import('node:fs');
248
+ fs.writeFileSync(path, code);
249
+ console.error(`[gjsify-auto-globals] parse failed on chunk #${i} — wrote ${path} for inspection`);
250
+ } catch { /* ignore */ }
251
+ }
252
+ throw e;
253
+ }
216
254
  }
217
255
 
218
256
  // Apply excludeGlobals BEFORE writing the next iteration's inject file.
@@ -101,6 +101,12 @@ export function detectFreeGlobals(code: string): Set<string> {
101
101
  const ast = acorn.parse(code, {
102
102
  ecmaVersion: 'latest',
103
103
  sourceType: 'module',
104
+ // Some bundled chunks carry an embedded `#!shebang` line —
105
+ // notably any project bundling its own CLI gets the
106
+ // `#!/usr/bin/env -S gjs -m` shebang hoisted to byte 0.
107
+ // Acorn rejects shebangs by default; allow them so the
108
+ // free-globals analyzer doesn't choke on its own input.
109
+ allowHashBang: true,
104
110
  });
105
111
 
106
112
  // --- Pass 1: collect all declared names across the entire module ---