@gjsify/rolldown-plugin-gjsify 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +11 -0
  2. package/lib/app/browser.js +1 -0
  3. package/lib/app/gjs.js +18 -2
  4. package/lib/app/nativescript.js +1 -1
  5. package/lib/app/node.d.ts +1 -1
  6. package/lib/app/node.js +34 -10
  7. package/lib/index.d.ts +5 -1
  8. package/lib/index.js +4 -1
  9. package/lib/plugins/console-assign.d.ts +13 -0
  10. package/lib/plugins/console-assign.js +125 -0
  11. package/lib/plugins/css-as-string.js +128 -44
  12. package/lib/plugins/gi-optional.d.ts +60 -0
  13. package/lib/plugins/gi-optional.js +152 -0
  14. package/lib/plugins/gi-renderer.d.ts +1 -1
  15. package/lib/plugins/gi-renderer.js +1 -1
  16. package/lib/plugins/gi-runtime-paths.js +5 -2
  17. package/lib/plugins/napi-node-addon.d.ts +48 -4
  18. package/lib/plugins/napi-node-addon.js +261 -85
  19. package/lib/plugins/node-native-external.d.ts +20 -0
  20. package/lib/plugins/node-native-external.js +145 -0
  21. package/lib/plugins/rewrite-node-modules-paths.d.ts +18 -1
  22. package/lib/plugins/rewrite-node-modules-paths.js +181 -25
  23. package/lib/plugins/unresolved-workspace-import.js +89 -68
  24. package/lib/shims/addon-resolve.d.ts +19 -0
  25. package/lib/shims/addon-resolve.js +193 -0
  26. package/lib/utils/addon-platform.d.ts +23 -0
  27. package/lib/utils/addon-platform.js +50 -0
  28. package/lib/utils/auto-globals.d.ts +6 -0
  29. package/lib/utils/auto-globals.js +13 -10
  30. package/lib/utils/declare-build-input.d.ts +13 -0
  31. package/lib/utils/declare-build-input.js +37 -0
  32. package/lib/utils/detect-free-globals.d.ts +3 -0
  33. package/lib/utils/detect-free-globals.js +1 -1
  34. package/lib/utils/entry-wrapper.js +6 -1
  35. package/lib/utils/index.d.ts +1 -0
  36. package/lib/utils/index.js +1 -0
  37. package/lib/utils/inline-static-reads.d.ts +15 -1
  38. package/lib/utils/inline-static-reads.js +16 -8
  39. package/lib/utils/scan-globals.d.ts +2 -2
  40. package/lib/utils/scan-globals.js +20 -14
  41. package/lib/utils/zip-path.d.ts +9 -0
  42. package/lib/utils/zip-path.js +12 -0
  43. package/package.json +13 -9
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Resolve the addon's `.node` for the RUNNING host from the build-time table.
3
+ *
4
+ * `targets` maps platform keys (`linux-x64`, `linux-x64-musl`, …) to
5
+ * `<package>/<subpath>` specs. The host keys are tried most-specific first
6
+ * (exact, then libc-agnostic, then `*`). The package root is resolved at run
7
+ * time through the bundle-URL anchor, which is what makes the bundle relocatable
8
+ * (ADR 0084).
9
+ *
10
+ * Throws when no entry matches the running host, when the table is empty, and
11
+ * when the addon package is not installed next to the bundle. The last one is
12
+ * a LIMIT, not an oversight: identity-based resolution needs the package to be
13
+ * somewhere on disk, so a bundle shipped with no `node_modules` around it cannot
14
+ * load a third-party addon. It throws rather than returning
15
+ * `<bundle dir>/addons/…` because that directory is a declared layout nothing
16
+ * fills yet, and a path that cannot exist reaches `loadAddon` as a bare ENOENT
17
+ * that names neither the package nor the remedy. Both facts are in the message.
18
+ */
19
+ export declare function __gjsifyAddonResolve(targets: Record<string, string>): string;
@@ -0,0 +1,193 @@
1
+ // Runtime resolver for a GJS bundle's Node-API addon, ADR 0084.
2
+ //
3
+ // The build enumerates every `.node` an addon package ships, keyed by
4
+ // platform (`linux-x64`, `linux-x64-musl`, `darwin-arm64`, …), and bakes the
5
+ // table into the bundle. At RUN time the bundle picks the entry for the host
6
+ // it finds itself on and resolves the addon's package root through the same
7
+ // `__gjsifyBundleUrl` banner + `createRequire` anchor the module-resolve shim
8
+ // uses. The addon is never copied and never leaves its package, so a `.node`
9
+ // that `dlopen`s a sibling `.so` or reads a data file beside itself keeps
10
+ // working.
11
+ //
12
+ // Three correctness constraints shape the implementation:
13
+ //
14
+ // 1. *Location anchor.* The resolver anchors at the BUNDLE's URL, not its
15
+ // own. When a consumer bundles this shim it lives under
16
+ // `node_modules/@gjsify/rolldown-plugin-gjsify/...`, so the path rewriter
17
+ // would rewrite this file's own `import.meta.url` too. We therefore take
18
+ // no `import.meta.url` here and read the anchor from `globalThis.
19
+ // __gjsifyBundleUrl`, captured by a one-line banner at byte 0 of the
20
+ // bundle — the single point where `import.meta.url` is unambiguously the
21
+ // bundle's URL.
22
+ //
23
+ // 2. *exports-map safety.* `createRequire(...).resolve("<pkg>/<deep/path>")`
24
+ // is rejected by strict `"exports"` maps under Node's native
25
+ // `createRequire` (the `--app node` target), and `@gjsify/module` is
26
+ // exports-aware too. So we never resolve the deep path directly: we
27
+ // resolve the PACKAGE ROOT and join the subpath literally, which no
28
+ // `exports` map can block.
29
+ //
30
+ // 3. *libc is a variable, not a probe.* It is read from `process.env.LIBC`,
31
+ // never sniffed off the filesystem. On a musl host with an unset `LIBC`
32
+ // the lookup falls through to the libc-agnostic entry, which is what
33
+ // node-gyp-build's own untagged prebuilds assume; a package shipping a
34
+ // glibc-only and a musl prebuild under one tuple is served the glibc one,
35
+ // which `LIBC=musl` fixes. (The one `package.json` read in this file is on
36
+ // the `exports`-blocked root fallback, not on the libc decision.)
37
+ //
38
+ // The `addons/` layout the resolver names when the package is not installed is
39
+ // the declared destination a packaging step would fill. `gjsify ship` does not
40
+ // fill it yet — see ADR 0084 § Consequences for the measurement.
41
+ //
42
+ // @ts-ignore — `node:{module,url,path,fs}` are resolved by the consumer's
43
+ // `gjsify build` run (aliased to `@gjsify/{module,url,path,fs}`), not by tsc here.
44
+ import { createRequire } from 'node:module';
45
+ // @ts-ignore — see above.
46
+ import { fileURLToPath } from 'node:url';
47
+ // @ts-ignore — see above.
48
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
49
+ // @ts-ignore — see above. Read on the `exports`-blocked fallback only; see
50
+ // `declaresPackage`. The no-filesystem rule above is about libc, not this.
51
+ import { readFileSync } from 'node:fs';
52
+ import { hostAddonKeys, selectAddonTarget } from '../utils/addon-platform.js';
53
+ /** The bundle's own URL (set by the byte-0 banner). Throws only if the banner didn't run. */
54
+ function bundleAnchorUrl() {
55
+ const anchor = globalThis.__gjsifyBundleUrl;
56
+ if (!anchor) {
57
+ throw new Error('gjsify: __gjsifyBundleUrl is not set — the bundle-URL banner did not run. ' +
58
+ 'The addon-resolve shim is only valid in single-file app builds (gjsify build --app gjs).');
59
+ }
60
+ return anchor;
61
+ }
62
+ /**
63
+ * Split a bundled-file spec into its package name and the path within the
64
+ * package. Handles scoped names:
65
+ * "typedoc/dist/lib/app.js" → { pkg: "typedoc", subpath: "dist/lib/app.js" }
66
+ * "@scope/name/sub/file.js" → { pkg: "@scope/name", subpath: "sub/file.js" }
67
+ * "typedoc" → { pkg: "typedoc", subpath: "" }
68
+ */
69
+ function splitPackageSpec(spec) {
70
+ const parts = spec.split('/');
71
+ const segments = spec.startsWith('@') ? 2 : 1;
72
+ return {
73
+ pkg: parts.slice(0, segments).join('/'),
74
+ subpath: parts.slice(segments).join('/'),
75
+ };
76
+ }
77
+ /**
78
+ * Find the on-disk root directory of `pkg`, exports-map-agnostic.
79
+ *
80
+ * `package.json` is resolvable for nearly every package and points straight at
81
+ * the root. When a strict `exports` map blocks it, fall back to the package's
82
+ * main entry (always an export) and derive the root from the
83
+ * `node_modules/<pkg>` boundary in its path.
84
+ *
85
+ * A WORKSPACE-LINKED package resolves to its real path, which carries no
86
+ * `node_modules/<pkg>/` segment, and answering `dirname(main)` there named the
87
+ * directory the entry sits in — `packages/typedoc/dist` — so the subpath was
88
+ * joined onto `dist/` and the addon was reported missing at a path that never
89
+ * existed. The boundary is therefore only a shortcut; the fallback walks up to
90
+ * the nearest ancestor whose own `package.json` declares `pkg`, which is the
91
+ * root by definition. Returns null when `pkg` is not installed.
92
+ */
93
+ function resolvePackageRoot(pkg) {
94
+ try {
95
+ const require = createRequire(bundleAnchorUrl());
96
+ return dirname(require.resolve(`${pkg}/package.json`));
97
+ }
98
+ catch {
99
+ /* strict `exports`: fall through to the entry point */
100
+ }
101
+ let main;
102
+ try {
103
+ main = createRequire(bundleAnchorUrl()).resolve(pkg);
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ const marker = `/node_modules/${pkg}/`;
109
+ const idx = main.lastIndexOf(marker);
110
+ if (idx >= 0)
111
+ return main.slice(0, idx + marker.length - 1);
112
+ for (let dir = dirname(main), i = 0; i < 64; i++) {
113
+ if (declaresPackage(dir, pkg))
114
+ return dir;
115
+ const parent = dirname(dir);
116
+ if (parent === dir)
117
+ break;
118
+ dir = parent;
119
+ }
120
+ return null;
121
+ }
122
+ /** Does the `package.json` in `dir` declare `name: pkg`? The root test, read not inferred. */
123
+ function declaresPackage(dir, pkg) {
124
+ try {
125
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
126
+ return manifest.name === pkg;
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
132
+ /**
133
+ * Resolve the addon's `.node` for the RUNNING host from the build-time table.
134
+ *
135
+ * `targets` maps platform keys (`linux-x64`, `linux-x64-musl`, …) to
136
+ * `<package>/<subpath>` specs. The host keys are tried most-specific first
137
+ * (exact, then libc-agnostic, then `*`). The package root is resolved at run
138
+ * time through the bundle-URL anchor, which is what makes the bundle relocatable
139
+ * (ADR 0084).
140
+ *
141
+ * Throws when no entry matches the running host, when the table is empty, and
142
+ * when the addon package is not installed next to the bundle. The last one is
143
+ * a LIMIT, not an oversight: identity-based resolution needs the package to be
144
+ * somewhere on disk, so a bundle shipped with no `node_modules` around it cannot
145
+ * load a third-party addon. It throws rather than returning
146
+ * `<bundle dir>/addons/…` because that directory is a declared layout nothing
147
+ * fills yet, and a path that cannot exist reaches `loadAddon` as a bare ENOENT
148
+ * that names neither the package nor the remedy. Both facts are in the message.
149
+ */
150
+ export function __gjsifyAddonResolve(targets) {
151
+ const keys = hostAddonKeys(process.platform, process.arch, process.env.LIBC);
152
+ const spec = selectAddonTarget(targets, keys);
153
+ if (spec === null) {
154
+ const known = Object.keys(targets).join(', ') || '(empty)';
155
+ throw new Error(`gjsify-napi-addon: no .node for host '${process.platform}-${process.arch}' ` +
156
+ `(libc: ${process.env.LIBC ?? 'unset'}). Addon table knows: ${known}. ` +
157
+ `Install the platform package or build the addon for this host.`);
158
+ }
159
+ // A `.node` outside every `node_modules` has no package IDENTITY to resolve
160
+ // by, and `packageSpecFor` recorded the path itself. Splitting it as a
161
+ // specifier made the first segment the package name and the rest the subpath,
162
+ // so a direct import of a locally built addon resolved a package named ``
163
+ // and threw naming neither the file nor the remedy. There is nothing to
164
+ // resolve — the build's path is the only answer — so it is returned, and the
165
+ // build warns once that this entry does not travel with the bundle.
166
+ if (isAbsolute(spec))
167
+ return spec;
168
+ const { pkg, subpath } = splitPackageSpec(spec);
169
+ const root = resolvePackageRoot(pkg);
170
+ if (root !== null) {
171
+ if (subpath === '')
172
+ return root;
173
+ // The subpath is the build's own `relative()` today, so this cannot
174
+ // reject a table this plugin wrote. It is here because this is the
175
+ // function that decides which file gets dlopen'd: a table naming `..`
176
+ // would otherwise walk out of the package it claims to belong to.
177
+ const target = resolve(root, subpath);
178
+ if (target !== root && !target.startsWith(root + sep)) {
179
+ throw new Error(`gjsify-napi-addon: the addon table names '${subpath}', which leaves the package ` +
180
+ `root '${root}'. A package cannot provide a file outside itself.`);
181
+ }
182
+ return target;
183
+ }
184
+ // The package is not installed where this bundle can see it. `<bundle
185
+ // dir>/addons/<package>/` is the declared layout a packaging step would
186
+ // fill — `gjsify ship` does not yet — so name it and the two remedies.
187
+ const staged = join(dirname(fileURLToPath(bundleAnchorUrl())), 'addons', pkg, subpath);
188
+ throw new Error(`gjsify-napi-addon: cannot find the addon package '${pkg}' from this bundle. ` +
189
+ `A GJS bundle finds its addon by package identity, so '${pkg}' must be installed in a ` +
190
+ `node_modules reachable from the bundle's own location, or staged at ` +
191
+ `'${staged}' (a layout no gjsify step fills yet). Run the bundle next to its ` +
192
+ "project's node_modules, or rebuild where the package is installed.");
193
+ }
@@ -0,0 +1,23 @@
1
+ /** The one spelling: `linux-x64`, `linux-x64-musl`, `darwin-arm64`, `win32-x64`. */
2
+ export declare function addonPlatformKey(platform: string, arch: string, libc?: string): string;
3
+ /**
4
+ * A napi-rs triple as an {@link addonPlatformKey}. The ABI token napi-rs appends is
5
+ * either implied by the platform (`gnu` on linux, `msvc` on win32, the arm eabi
6
+ * flavours) or a real axis (`musl`) — only the latter survives into the key.
7
+ */
8
+ export declare function normalizeNapiRsTriple(triple: string): string;
9
+ /**
10
+ * The keys to try for a host, most specific first.
11
+ *
12
+ * A musl host also accepts the UNTAGGED key, because that is what node-gyp-build's own
13
+ * untagged prebuilds mean — "no libc claim" — and a musl-only tree would otherwise find
14
+ * nothing. A glibc host does NOT accept the musl key: loading a musl binary against
15
+ * glibc is the failure this selection exists to avoid, and a missing entry with a clear
16
+ * message beats a `dlopen` crash.
17
+ *
18
+ * `*` is last and is not host-specific: it is the entry a DIRECTLY imported `.node`
19
+ * writes, where the source named one file and there is no selection to make.
20
+ */
21
+ export declare function hostAddonKeys(platform: string, arch: string, libc?: string): string[];
22
+ /** The first key of `keys` present in `targets`, or null. */
23
+ export declare function selectAddonTarget(targets: Record<string, string>, keys: string[]): string | null;
@@ -0,0 +1,50 @@
1
+ // The platform VOCABULARY shared by the two halves of ADR 0084: the build enumerates
2
+ // every `.node` an addon package ships, keyed by platform; the bundle picks one at run
3
+ // time from the host it finds itself on. Both halves must spell a platform the same way
4
+ // or the lookup silently misses, so the spelling lives here — a pure module with no
5
+ // `node:` imports, because the runtime half (`shims/addon-resolve.ts`) is bundled into
6
+ // user output and must not drag the filesystem in.
7
+ //
8
+ // The key is node-gyp-build's tuple (`<platform>-<arch>`) plus a `-musl` suffix, NOT
9
+ // napi-rs' triple: napi-rs spells the same platform `linux-x64-gnu` / `win32-x64-msvc`
10
+ // while prebuildify spells it `linux-x64`, and one bundle can carry addons of both
11
+ // conventions. {@link normalizeNapiRsTriple} folds the second spelling into the first.
12
+ /** The one spelling: `linux-x64`, `linux-x64-musl`, `darwin-arm64`, `win32-x64`. */
13
+ export function addonPlatformKey(platform, arch, libc) {
14
+ return libc === 'musl' ? `${platform}-${arch}-musl` : `${platform}-${arch}`;
15
+ }
16
+ /**
17
+ * A napi-rs triple as an {@link addonPlatformKey}. The ABI token napi-rs appends is
18
+ * either implied by the platform (`gnu` on linux, `msvc` on win32, the arm eabi
19
+ * flavours) or a real axis (`musl`) — only the latter survives into the key.
20
+ */
21
+ export function normalizeNapiRsTriple(triple) {
22
+ if (triple.endsWith('-musl'))
23
+ return triple;
24
+ return triple.replace(/-(?:gnu|msvc|eabi|eabihf|gnueabihf|androideabi)$/, '');
25
+ }
26
+ /**
27
+ * The keys to try for a host, most specific first.
28
+ *
29
+ * A musl host also accepts the UNTAGGED key, because that is what node-gyp-build's own
30
+ * untagged prebuilds mean — "no libc claim" — and a musl-only tree would otherwise find
31
+ * nothing. A glibc host does NOT accept the musl key: loading a musl binary against
32
+ * glibc is the failure this selection exists to avoid, and a missing entry with a clear
33
+ * message beats a `dlopen` crash.
34
+ *
35
+ * `*` is last and is not host-specific: it is the entry a DIRECTLY imported `.node`
36
+ * writes, where the source named one file and there is no selection to make.
37
+ */
38
+ export function hostAddonKeys(platform, arch, libc) {
39
+ const base = addonPlatformKey(platform, arch);
40
+ return libc === 'musl' ? [`${base}-musl`, base, '*'] : [base, '*'];
41
+ }
42
+ /** The first key of `keys` present in `targets`, or null. */
43
+ export function selectAddonTarget(targets, keys) {
44
+ for (const key of keys) {
45
+ const hit = targets[key];
46
+ if (typeof hit === 'string' && hit.length > 0)
47
+ return hit;
48
+ }
49
+ return null;
50
+ }
@@ -55,6 +55,12 @@ export interface DetectAutoGlobalsOptions {
55
55
  * `process.cwd()`.
56
56
  */
57
57
  cwd?: string;
58
+ /**
59
+ * A second root for that gate: the running CLI's directory when the build was given
60
+ * a `toolchainAnchor`, whose resolver takes a `@gjsify/*` the project lacks from
61
+ * beside the CLI. Only consulted together with `cwd`.
62
+ */
63
+ toolchainDir?: string;
58
64
  }
59
65
  /**
60
66
  * Build options for the analyser: the same input options the caller would use for the
@@ -81,7 +81,7 @@ function isSubset(a, b) {
81
81
  return false;
82
82
  return true;
83
83
  }
84
- async function applyExcludeGlobals(detected, currentInject, extraRegisterPaths, excludeGlobals, cwd) {
84
+ async function applyExcludeGlobals(detected, currentInject, extraRegisterPaths, excludeGlobals, cwd, searchDirs = cwd) {
85
85
  if (excludeGlobals?.length) {
86
86
  for (const id of excludeGlobals)
87
87
  detected.delete(id);
@@ -91,12 +91,12 @@ async function applyExcludeGlobals(detected, currentInject, extraRegisterPaths,
91
91
  const finalPaths = detectedToRegisterPaths(detected);
92
92
  for (const p of extraRegisterPaths)
93
93
  finalPaths.add(p);
94
- emitGiBackedDiagnostic(finalPaths, detected, cwd);
94
+ emitGiBackedDiagnostic(finalPaths, detected, searchDirs);
95
95
  // No exclude: `currentInject` already reflects `finalPaths`, so reuse it —
96
96
  // re-running filterResolvableRegisterPaths would repeat its skip warnings.
97
97
  if (!excludeGlobals?.length)
98
98
  return { detected, injectPath: currentInject };
99
- const filtered = cwd ? filterResolvableRegisterPaths(finalPaths, cwd) : finalPaths;
99
+ const filtered = searchDirs ? filterResolvableRegisterPaths(finalPaths, searchDirs) : finalPaths;
100
100
  const injectPath = filtered.size > 0 ? ((await writeRegisterInjectFile(filtered, cwd)) ?? undefined) : undefined;
101
101
  return { detected, injectPath };
102
102
  }
@@ -169,14 +169,14 @@ let giNoteEmitted = false;
169
169
  * register, via `isRegisterPathResolvable` rather than
170
170
  * `filterResolvableRegisterPaths` to avoid duplicating that one's skip warnings.
171
171
  */
172
- function emitGiBackedDiagnostic(registerPaths, detected, cwd) {
172
+ function emitGiBackedDiagnostic(registerPaths, detected, searchDirs) {
173
173
  if (giNoteEmitted)
174
174
  return;
175
175
  const candidates = new Set();
176
176
  for (const path of registerPaths) {
177
177
  if (!giNamespacesForRegister(path))
178
178
  continue;
179
- if (cwd && !isRegisterPathResolvable(path, cwd))
179
+ if (searchDirs && !isRegisterPathResolvable(path, searchDirs))
180
180
  continue;
181
181
  candidates.add(path);
182
182
  }
@@ -265,10 +265,13 @@ export async function detectAutoGlobals(analysisOptions, pluginOptions, gjsifyPl
265
265
  : new Set();
266
266
  const excludeSet = new Set(options.excludeGlobals ?? []);
267
267
  const cwd = options.cwd;
268
+ const searchDirs = cwd && options.toolchainDir ? [cwd, options.toolchainDir] : cwd;
268
269
  let detected = new Set();
269
270
  let currentInject = undefined;
270
271
  if (extraRegisterPaths.size > 0) {
271
- const resolvableExtra = cwd ? filterResolvableRegisterPaths(extraRegisterPaths, cwd) : extraRegisterPaths;
272
+ const resolvableExtra = searchDirs
273
+ ? filterResolvableRegisterPaths(extraRegisterPaths, searchDirs)
274
+ : extraRegisterPaths;
272
275
  currentInject = (await writeRegisterInjectFile(resolvableExtra, cwd)) ?? undefined;
273
276
  }
274
277
  // Caller plugins (e.g. the PnP relay) survive every pass; the gjsify plugin is
@@ -381,7 +384,7 @@ export async function detectAutoGlobals(analysisOptions, pluginOptions, gjsifyPl
381
384
  const extras = extraRegisterPaths.size > 0 ? ` (+ ${extraRegisterPaths.size} extra register module(s))` : '';
382
385
  console.debug(`[gjsify] --globals auto: converged after ${iteration - 1} iteration(s), ${detected.size} global(s)${sorted.length ? ': ' + sorted.join(', ') : ''}${extras}`);
383
386
  }
384
- return applyExcludeGlobals(detected, currentInject, extraRegisterPaths, options.excludeGlobals, cwd);
387
+ return applyExcludeGlobals(detected, currentInject, extraRegisterPaths, options.excludeGlobals, cwd, searchDirs);
385
388
  }
386
389
  // Seed the next pass with the precomputed closure. Unioning with the
387
390
  // previously-injected set keeps growth monotonic — an over-approximated superset
@@ -405,8 +408,8 @@ export async function detectAutoGlobals(analysisOptions, pluginOptions, gjsifyPl
405
408
  // Drop register paths whose polyfill package is not installed: an unresolvable
406
409
  // import is a HARD Rolldown error, strictly worse than skipping with a warning.
407
410
  // The caller adds the dep or acknowledges the gap via --exclude-globals.
408
- if (cwd)
409
- registerPaths = filterResolvableRegisterPaths(registerPaths, cwd);
411
+ if (searchDirs)
412
+ registerPaths = filterResolvableRegisterPaths(registerPaths, searchDirs);
410
413
  if (registerPaths.size === 0) {
411
414
  return { detected, injectPath: undefined };
412
415
  }
@@ -419,7 +422,7 @@ export async function detectAutoGlobals(analysisOptions, pluginOptions, gjsifyPl
419
422
  if (verbose) {
420
423
  console.debug(`[gjsify] --globals auto: hit max iterations (${MAX_ITERATIONS}), using last detected set`);
421
424
  }
422
- return applyExcludeGlobals(detected, currentInject, extraRegisterPaths, options.excludeGlobals, cwd);
425
+ return applyExcludeGlobals(detected, currentInject, extraRegisterPaths, options.excludeGlobals, cwd, searchDirs);
423
426
  }
424
427
  /**
425
428
  * `--app node` genuine-GJS-source detection (the reverse of `--globals auto`).
@@ -0,0 +1,13 @@
1
+ /** One file the output depends on. */
2
+ export type DeclareBuildInput = (abs: string) => void;
3
+ /** A plugin context that MAY carry `addWatchFile` — an older engine does not. */
4
+ export interface WatchFileCapableContext {
5
+ addWatchFile?: (id: string) => void;
6
+ }
7
+ /**
8
+ * `this.addWatchFile(abs)`, or one line saying it was missing.
9
+ *
10
+ * The context is passed in rather than bound, so each caller keeps its own `this`
11
+ * and this stays usable from any hook shape.
12
+ */
13
+ export declare function declareBuildInput(ctx: WatchFileCapableContext, abs: string): void;
@@ -0,0 +1,37 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // "This file is an input of the output" — `this.addWatchFile`, one layer down.
3
+ //
4
+ // Two of this package's plugins read a file the BUNDLER never sees: a
5
+ // stylesheet's `@import` chain (`css-as-string`) and a package's own
6
+ // `readFileSync(new URL(..., import.meta.url))` (the static-read inliner, which
7
+ // folds the bytes into the bundle). Neither file is a module, so no graph names
8
+ // it and a `gjsify test` bundle that depends on one reads as fresh after it
9
+ // changes. `this.addWatchFile` is the standard contract for exactly that, and
10
+ // this is the one place it is issued from.
11
+ //
12
+ // FEATURE-DETECTED, and that is measured, not defensive: the engine the GJS CLI
13
+ // loads is resolved through several anchors, and one of them answered with a
14
+ // build whose plugin context had no such method — a direct call then fails the
15
+ // build with `this.addWatchFile is not a function`. A build must not fail over
16
+ // bookkeeping, so a miss degrades to a single line naming the consequence and the
17
+ // fix. The other half is in the CLI: a bundle whose build reported NO watch list
18
+ // can never be called fresh (`utils/bundle-inputs.ts`).
19
+ let _warned = false;
20
+ /**
21
+ * `this.addWatchFile(abs)`, or one line saying it was missing.
22
+ *
23
+ * The context is passed in rather than bound, so each caller keeps its own `this`
24
+ * and this stays usable from any hook shape.
25
+ */
26
+ export function declareBuildInput(ctx, abs) {
27
+ if (typeof ctx.addWatchFile === 'function') {
28
+ ctx.addWatchFile.call(ctx, abs);
29
+ return;
30
+ }
31
+ if (_warned)
32
+ return;
33
+ _warned = true;
34
+ console.warn('[gjsify] this Rolldown engine has no `addWatchFile`, so a file a plugin read itself is ' +
35
+ 'not declared as a build input — editing one may leave a `gjsify test` bundle looking ' +
36
+ 'fresh. Upgrade `@gjsify/rolldown-native` (the GJS engine) to declare it.');
37
+ }
@@ -1,3 +1,6 @@
1
+ import * as acorn from 'acorn';
2
+ /** Every name bound by a binding pattern. */
3
+ export declare function extractBindingNames(node: acorn.AnyNode): string[];
1
4
  /**
2
5
  * Free identifiers in bundled output that match the known web/Node globals in
3
6
  * `GJS_GLOBALS_MAP` — drives `--app gjs --globals auto`. Rules: `scanFreeGlobals`.
@@ -69,7 +69,7 @@ function wbgJsNameFor(fnName) {
69
69
  return match?.[1] ?? null;
70
70
  }
71
71
  /** Every name bound by a binding pattern. */
72
- function extractBindingNames(node) {
72
+ export function extractBindingNames(node) {
73
73
  if (!node)
74
74
  return [];
75
75
  switch (node.type) {
@@ -25,7 +25,12 @@ export function wrapInputWithSideEffects(input, sideEffects, opts = {}) {
25
25
  const userEntries = new Map(); // virtualId → realPath
26
26
  const PREFIX = `${GJSIFY_VIRTUAL_PREFIX}entry:`;
27
27
  function wrap(realPath) {
28
- const id = PREFIX + realPath;
28
+ // The wrapper is ESM whatever the entry is, and Rolldown reads a module's
29
+ // format off its id's EXTENSION: `\0gjsify-entry:…/prettier.cjs` was parsed as
30
+ // CommonJS and every `import` in the wrapper failed with `PARSE_ERROR: Cannot
31
+ // use import statement outside a module`. A CJS entry therefore gets `.mjs`
32
+ // appended; the others keep the id they always had.
33
+ const id = PREFIX + realPath + (/\.c[jt]sx?$/i.test(realPath) ? '.mjs' : '');
29
34
  userEntries.set(id, realPath);
30
35
  return id;
31
36
  }
@@ -9,3 +9,4 @@ export { inlineStaticReads, isAbsoluteFsPath, isWithin, resourceRootFor } from '
9
9
  export { GJSIFY_VIRTUAL_PREFIX, isGjsifyVirtualModuleId } from './virtual-module-id.js';
10
10
  export { locateSurvivingJsx, classifyJsxParseFailure, formatSurvivingJsx } from './jsx-survival.js';
11
11
  export type { SurvivingJsx } from './jsx-survival.js';
12
+ export { wrapInputWithSideEffects } from './entry-wrapper.js';
@@ -8,3 +8,4 @@ export { resolveGlobalsList, writeRegisterInjectFile } from './scan-globals.js';
8
8
  export { inlineStaticReads, isAbsoluteFsPath, isWithin, resourceRootFor } from './inline-static-reads.js';
9
9
  export { GJSIFY_VIRTUAL_PREFIX, isGjsifyVirtualModuleId } from './virtual-module-id.js';
10
10
  export { locateSurvivingJsx, classifyJsxParseFailure, formatSurvivingJsx } from './jsx-survival.js';
11
+ export { wrapInputWithSideEffects } from './entry-wrapper.js';
@@ -1,4 +1,18 @@
1
- export declare function inlineStaticReads(src: string, sourceFilePath: string): {
1
+ import * as acorn from 'acorn';
2
+ /**
3
+ * Parse a source with the parser its extension calls for.
4
+ *
5
+ * The TypeScript half is why first-party sources were invisible to this inliner
6
+ * for as long as it was scoped to `node_modules`: an installed package ships JS,
7
+ * so plain acorn could always parse it, and nothing ever asked what happens to a
8
+ * `.ts`. The answer was that `acorn.parse` threw and the `catch` returned
9
+ * "nothing to inline" — a result indistinguishable from a file that genuinely has
10
+ * no static reads. Measured on `packages/infra/cli/src/utils/app-metadata.ts`:
11
+ * `inlined: 0`, while the identical expression in a `.js` file returned
12
+ * `inlined: 1`.
13
+ */
14
+ export declare function parseSource(src: string, sourceFilePath: string): acorn.Program;
15
+ export declare function inlineStaticReads(src: string, sourceFilePath: string, declare?: (abs: string) => void): {
2
16
  contents: string;
3
17
  inlined: number;
4
18
  };
@@ -66,6 +66,7 @@ import { tsPlugin } from 'acorn-typescript';
66
66
  import { dirname, join, resolve, basename, relative, extname, posix, win32 } from 'node:path';
67
67
  import { fileURLToPath, pathToFileURL } from 'node:url';
68
68
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
69
+ import { hasZipSegment } from './zip-path.js';
69
70
  /**
70
71
  * Run the inliner on a source string. Returns the rewritten source (or the
71
72
  * original string when no inlining applied) and the count of edits applied.
@@ -101,7 +102,7 @@ const TS_PARSER = acorn.Parser.extend(tsPlugin());
101
102
  * `inlined: 0`, while the identical expression in a `.js` file returned
102
103
  * `inlined: 1`.
103
104
  */
104
- function parseSource(src, sourceFilePath) {
105
+ export function parseSource(src, sourceFilePath) {
105
106
  const shared = {
106
107
  ecmaVersion: 'latest',
107
108
  sourceType: 'module',
@@ -142,7 +143,7 @@ function forEachCallExpression(root, visit) {
142
143
  }
143
144
  }
144
145
  }
145
- export function inlineStaticReads(src, sourceFilePath) {
146
+ export function inlineStaticReads(src, sourceFilePath, declare) {
146
147
  if (!src.includes('readFileSync') && !src.includes('readdirSync') && !src.includes('existsSync')) {
147
148
  return { contents: src, inlined: 0 };
148
149
  }
@@ -160,6 +161,7 @@ export function inlineStaticReads(src, sourceFilePath) {
160
161
  const ctx = {
161
162
  sourceUrl: pathToFileURL(sourceFilePath).href,
162
163
  resourceRoot: resourceRootFor(sourceFilePath),
164
+ declare,
163
165
  };
164
166
  const edits = [];
165
167
  forEachCallExpression(ast, (node) => {
@@ -246,6 +248,7 @@ function tryInlineCall(node, ctx, _src) {
246
248
  if (path && existsSync(path) && isDirectorySafe(path)) {
247
249
  try {
248
250
  const names = readdirSync(path);
251
+ ctx.declare?.(path);
249
252
  return {
250
253
  start: node.start,
251
254
  end: node.end,
@@ -260,11 +263,11 @@ function tryInlineCall(node, ctx, _src) {
260
263
  if (calleeName === 'existsSync') {
261
264
  const path = evalPathExpr(node.arguments[0], ctx);
262
265
  if (path !== undefined) {
263
- return {
264
- start: node.start,
265
- end: node.end,
266
- replacement: existsSync(path) ? 'true' : 'false',
267
- };
266
+ // The ANSWER is in the bundle, so the file's existence is an input
267
+ // of the output — a file that appears changes the emitted literal.
268
+ const present = existsSync(path);
269
+ ctx.declare?.(path);
270
+ return { start: node.start, end: node.end, replacement: present ? 'true' : 'false' };
268
271
  }
269
272
  }
270
273
  // `createRequire(<URL>)` from `node:module` returns a CJS-style require.
@@ -288,8 +291,11 @@ function tryInlineCall(node, ctx, _src) {
288
291
  // - the path contains a `.zip/` segment (Yarn PnP virtual zip,
289
292
  // where Node's PnP hooks make `existsSync` return true at build
290
293
  // time but the path doesn't exist under GJS at runtime).
291
- const isZip = path !== undefined && path.includes('.zip/');
294
+ const isZip = path !== undefined && hasZipSegment(path);
292
295
  if (path !== undefined && (isZip || !existsSync(path))) {
296
+ // The stub-vs-real choice reads the file's existence too, so the
297
+ // same declaration applies to the branch that replaces the call.
298
+ ctx.declare?.(path);
293
299
  return {
294
300
  start: node.start,
295
301
  end: node.end,
@@ -330,6 +336,7 @@ function tryInlineReadFile(node, ctx, forceTextEncoding) {
330
336
  try {
331
337
  if (encoding) {
332
338
  const text = readFileSync(path, encoding);
339
+ ctx.declare?.(path);
333
340
  return jsStringLiteral(text);
334
341
  }
335
342
  else {
@@ -337,6 +344,7 @@ function tryInlineReadFile(node, ctx, forceTextEncoding) {
337
344
  // Buffer-vs-Uint8Array semantic difference is mostly irrelevant in
338
345
  // bundled GJS code (Buffer is polyfilled on top of Uint8Array).
339
346
  const bytes = readFileSync(path);
347
+ ctx.declare?.(path);
340
348
  return `new Uint8Array([${Array.from(bytes).join(',')}])`;
341
349
  }
342
350
  }
@@ -34,7 +34,7 @@ export declare function resolveGlobalsList(globalsArg: string): Set<string>;
34
34
  * Returns false — never throws — so a missing package causes a graceful
35
35
  * skip rather than a build crash.
36
36
  */
37
- export declare function isRegisterPathResolvable(registerPath: string, fromDir: string): boolean;
37
+ export declare function isRegisterPathResolvable(registerPath: string, fromDir: string | readonly string[]): boolean;
38
38
  /**
39
39
  * Filter `registerPaths` to only those whose base package is resolvable
40
40
  * from `fromDir`. Emits a `console.warn` for each skipped path —
@@ -45,7 +45,7 @@ export declare function isRegisterPathResolvable(registerPath: string, fromDir:
45
45
  * dep, and that polyfill is not a direct/transitive dep, the warn makes
46
46
  * the gap visible so the developer can add the dep explicitly.
47
47
  */
48
- export declare function filterResolvableRegisterPaths(registerPaths: Set<string>, fromDir: string): Set<string>;
48
+ export declare function filterResolvableRegisterPaths(registerPaths: Set<string>, fromDir: string | readonly string[]): Set<string>;
49
49
  /** Options for {@link writeRegisterInjectFile}. */
50
50
  export interface WriteRegisterInjectOptions {
51
51
  /**