@gjsify/rolldown-plugin-gjsify 0.51.1 → 0.53.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 (44) hide show
  1. package/README.md +11 -0
  2. package/lib/app/browser.js +28 -0
  3. package/lib/app/gjs.js +14 -2
  4. package/lib/app/nativescript.js +15 -3
  5. package/lib/app/node.d.ts +1 -1
  6. package/lib/app/node.js +26 -10
  7. package/lib/index.d.ts +4 -2
  8. package/lib/index.js +4 -2
  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 +126 -44
  12. package/lib/plugins/gi-renderer.d.ts +1 -1
  13. package/lib/plugins/gi-renderer.js +1 -1
  14. package/lib/plugins/gi-runtime-paths.js +5 -2
  15. package/lib/plugins/napi-node-addon.d.ts +48 -4
  16. package/lib/plugins/napi-node-addon.js +261 -85
  17. package/lib/plugins/node-native-external.d.ts +20 -0
  18. package/lib/plugins/node-native-external.js +145 -0
  19. package/lib/plugins/platform-resolve.d.ts +95 -5
  20. package/lib/plugins/platform-resolve.js +132 -11
  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 +92 -15
  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/runtime.js +1 -1
  40. package/lib/utils/scan-globals.d.ts +2 -2
  41. package/lib/utils/scan-globals.js +20 -14
  42. package/lib/utils/zip-path.d.ts +9 -0
  43. package/lib/utils/zip-path.js +12 -0
  44. package/package.json +13 -9
@@ -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
  }
@@ -189,6 +189,70 @@ function emitGiBackedDiagnostic(registerPaths, detected, cwd) {
189
189
  function isFullConfig(v) {
190
190
  return v !== null && typeof v === 'object' && !Array.isArray(v) && 'plugins' in v && 'options' in v;
191
191
  }
192
+ /**
193
+ * Merge the orchestrator's `transform` with the analysis side's, PER KEY — the
194
+ * analysis side wins for the keys it actually sets.
195
+ *
196
+ * `analysisOptions.transform ?? orchestratorOptions?.transform` was wrong for a
197
+ * MEASURED reason, not a stylistic one: the CLI's config merge does
198
+ * `bundler.transform ??= {}` (`@gjsify/cli` `src/config.ts`), so the analysis side is
199
+ * ALWAYS a non-nullish object and `??` therefore always took it — dropping the
200
+ * orchestrator's `target`, `define` and `inject` from every single analysis pass. The
201
+ * detector then reads a bundle transformed differently from the one that ships, which
202
+ * is exactly what the "analyse the bundled output AFTER tree-shaking" invariant exists
203
+ * to prevent.
204
+ *
205
+ * What that cost, measured on `--app gjs`, whose orchestrator defines
206
+ * `process.env.READABLE_STREAM` to `"disable"`. Entry:
207
+ *
208
+ * if (process.env.READABLE_STREAM !== 'disable') document.getElementById('app');
209
+ *
210
+ * The define makes that branch dead, so the emitted bundle carries no `document`
211
+ * reference at all — and the build injected `document` + `HTMLCanvasElement` + `Path2D`
212
+ * anyway: 24 globals and 276_726 bytes where the same entry now yields 21 and 176_487.
213
+ * 100 KB of DOM/canvas register code, one of it a GI-backed register that makes the
214
+ * artifact hard-require a GTK runtime at load. Silently, at exit 0.
215
+ *
216
+ * Note what does NOT discriminate here, so the next reproduction does not start with
217
+ * it: `process` is detected on every `--app gjs` build regardless, because the process
218
+ * stub the target prepends in `renderChunk` writes `globalThis.process` and the
219
+ * detector reads its own banner back.
220
+ *
221
+ * `define` and `inject` are string maps and merge per key; every other transform key is
222
+ * a scalar (`target`, `lang`, `sourceType`) or a coherent option bag (`typescript`,
223
+ * `decorator`, `assumptions`, `tsconfig`) that is replaced whole. This is deliberately
224
+ * the same shape as `mergeBundlerOptions` in `@gjsify/cli`, which merges the FINAL
225
+ * build's options: the two must agree, or the analysis is measuring a different bundle
226
+ * again one key further down.
227
+ */
228
+ function mergeTransformOptions(orchestrator, analysis) {
229
+ if (!orchestrator)
230
+ return analysis;
231
+ if (!analysis)
232
+ return orchestrator;
233
+ const merged = { ...orchestrator, ...analysis };
234
+ if (orchestrator.define || analysis.define) {
235
+ merged.define = { ...orchestrator.define, ...analysis.define };
236
+ }
237
+ if (orchestrator.inject || analysis.inject) {
238
+ merged.inject = { ...orchestrator.inject, ...analysis.inject };
239
+ }
240
+ return merged;
241
+ }
242
+ /**
243
+ * Same per-key rule for `resolve`. No caller sets `analysisOptions.resolve` today, so
244
+ * this is inert right now — it is here because `resolve` is the third plain-object
245
+ * field on the same code path and a whole-object `??` on it would silently drop the
246
+ * orchestrator's `conditionNames`/`mainFields`, which is the divergence the class
247
+ * comment above describes.
248
+ */
249
+ function mergeResolveOptions(orchestrator, analysis) {
250
+ if (!orchestrator)
251
+ return analysis;
252
+ if (!analysis)
253
+ return orchestrator;
254
+ return { ...orchestrator, ...analysis };
255
+ }
192
256
  /**
193
257
  * Run the iterative in-memory build with acorn-based global detection, each pass
194
258
  * seeded by the previous pass's globals, until the detected set is stable. Returns the
@@ -201,10 +265,13 @@ export async function detectAutoGlobals(analysisOptions, pluginOptions, gjsifyPl
201
265
  : new Set();
202
266
  const excludeSet = new Set(options.excludeGlobals ?? []);
203
267
  const cwd = options.cwd;
268
+ const searchDirs = cwd && options.toolchainDir ? [cwd, options.toolchainDir] : cwd;
204
269
  let detected = new Set();
205
270
  let currentInject = undefined;
206
271
  if (extraRegisterPaths.size > 0) {
207
- const resolvableExtra = cwd ? filterResolvableRegisterPaths(extraRegisterPaths, cwd) : extraRegisterPaths;
272
+ const resolvableExtra = searchDirs
273
+ ? filterResolvableRegisterPaths(extraRegisterPaths, searchDirs)
274
+ : extraRegisterPaths;
208
275
  currentInject = (await writeRegisterInjectFile(resolvableExtra, cwd)) ?? undefined;
209
276
  }
210
277
  // Caller plugins (e.g. the PnP relay) survive every pass; the gjsify plugin is
@@ -237,10 +304,16 @@ export async function detectAutoGlobals(analysisOptions, pluginOptions, gjsifyPl
237
304
  : analysisOptions.input;
238
305
  // Take resolve/external/transform from the orchestrator so the analysis bundle
239
306
  // goes through the same module resolution as the final build; explicit
240
- // analysis-side overrides win.
241
- const mergedResolve = analysisOptions.resolve ?? orchestratorOptions?.resolve;
307
+ // analysis-side overrides win PER KEY (see `mergeTransformOptions`).
308
+ //
309
+ // `external` stays a whole-value choice on purpose: it is not a plain object
310
+ // (string | RegExp | array | predicate), the orchestrator's array has already
311
+ // folded the caller's own entries in, and the shape rules an array cannot
312
+ // express live in `externalsPlugin` — which is part of `gjsifyPluginsArray`
313
+ // below, so it applies to the analysis bundle whichever array wins here.
314
+ const mergedResolve = mergeResolveOptions(orchestratorOptions?.resolve, analysisOptions.resolve);
242
315
  const mergedExternal = analysisOptions.external ?? orchestratorOptions?.external;
243
- const mergedTransform = analysisOptions.transform ?? orchestratorOptions?.transform;
316
+ const mergedTransform = mergeTransformOptions(orchestratorOptions?.transform, analysisOptions.transform);
244
317
  const orchTreeshake = orchestratorOptions?.treeshake;
245
318
  const gjsifyPluginsArray = Array.isArray(gjsifyInstance) ? gjsifyInstance : [gjsifyInstance];
246
319
  const chunkCodes = await bundler({
@@ -311,7 +384,7 @@ export async function detectAutoGlobals(analysisOptions, pluginOptions, gjsifyPl
311
384
  const extras = extraRegisterPaths.size > 0 ? ` (+ ${extraRegisterPaths.size} extra register module(s))` : '';
312
385
  console.debug(`[gjsify] --globals auto: converged after ${iteration - 1} iteration(s), ${detected.size} global(s)${sorted.length ? ': ' + sorted.join(', ') : ''}${extras}`);
313
386
  }
314
- return applyExcludeGlobals(detected, currentInject, extraRegisterPaths, options.excludeGlobals, cwd);
387
+ return applyExcludeGlobals(detected, currentInject, extraRegisterPaths, options.excludeGlobals, cwd, searchDirs);
315
388
  }
316
389
  // Seed the next pass with the precomputed closure. Unioning with the
317
390
  // previously-injected set keeps growth monotonic — an over-approximated superset
@@ -335,8 +408,8 @@ export async function detectAutoGlobals(analysisOptions, pluginOptions, gjsifyPl
335
408
  // Drop register paths whose polyfill package is not installed: an unresolvable
336
409
  // import is a HARD Rolldown error, strictly worse than skipping with a warning.
337
410
  // The caller adds the dep or acknowledges the gap via --exclude-globals.
338
- if (cwd)
339
- registerPaths = filterResolvableRegisterPaths(registerPaths, cwd);
411
+ if (searchDirs)
412
+ registerPaths = filterResolvableRegisterPaths(registerPaths, searchDirs);
340
413
  if (registerPaths.size === 0) {
341
414
  return { detected, injectPath: undefined };
342
415
  }
@@ -349,7 +422,7 @@ export async function detectAutoGlobals(analysisOptions, pluginOptions, gjsifyPl
349
422
  if (verbose) {
350
423
  console.debug(`[gjsify] --globals auto: hit max iterations (${MAX_ITERATIONS}), using last detected set`);
351
424
  }
352
- return applyExcludeGlobals(detected, currentInject, extraRegisterPaths, options.excludeGlobals, cwd);
425
+ return applyExcludeGlobals(detected, currentInject, extraRegisterPaths, options.excludeGlobals, cwd, searchDirs);
353
426
  }
354
427
  /**
355
428
  * `--app node` genuine-GJS-source detection (the reverse of `--globals auto`).
@@ -408,8 +481,12 @@ export async function detectNodeGiGlobals(analysisOptions, pluginOptions, gjsify
408
481
  ...baseOptions,
409
482
  input: analysisOptions.input,
410
483
  external: analysisOptions.external ?? baseOptions.external,
411
- resolve: analysisOptions.resolve ?? baseOptions.resolve,
412
- transform: analysisOptions.transform ?? baseOptions.transform,
484
+ // Per key, for the reason spelled out on `mergeTransformOptions`: the
485
+ // CLI always hands an object here, so a whole-object `??` dropped this
486
+ // target's `target: 'node24'` and `define: { global, window }` from
487
+ // every reverse-bridge analysis pass.
488
+ resolve: mergeResolveOptions(baseOptions.resolve, analysisOptions.resolve),
489
+ transform: mergeTransformOptions(baseOptions.transform, analysisOptions.transform),
413
490
  plugins: [...callerPlugins, ...gjsifyPluginsArray],
414
491
  logLevel: 'silent',
415
492
  },
@@ -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
  };