@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
@@ -17,7 +17,7 @@
17
17
  // `@import`s are resolved + inlined in JS first (`flattenCssImports`, so bare
18
18
  // node_modules specifiers work — the native `bundle()` FileProvider can't walk
19
19
  // node_modules), then the flattened CSS goes through the native `transform()`
20
- // for lowering. Both use the same `cssBundleResolver`. The `--app gjs`
20
+ // for lowering. Both use the same resolver. The `--app gjs`
21
21
  // orchestrator passes `targets: { firefox: 60 << 16 }` so nesting + modern
22
22
  // selectors get flattened to GTK4-CSS-engine-compatible output. Targeting is
23
23
  // opt-in — a missing `targets` keeps the source pristine.
@@ -43,7 +43,10 @@ import { createRequire } from 'node:module';
43
43
  import { dirname, isAbsolute, resolve as resolvePath } from 'node:path';
44
44
  import { pathToFileURL } from 'node:url';
45
45
  import { isGjs } from '../utils/runtime.js';
46
+ import { declareBuildInput } from '../utils/declare-build-input.js';
46
47
  let _bundlerPromise = null;
48
+ /** Why the native bridge's library would not open, when `tryLoadNativeBundler()` measured it. */
49
+ let _nativeLoadError = null;
47
50
  async function pickBundler() {
48
51
  const forced = globalThis.process?.env
49
52
  ?.GJSIFY_CSS_BACKEND;
@@ -52,11 +55,22 @@ async function pickBundler() {
52
55
  if (forced === 'native') {
53
56
  const native = await tryLoadNativeBundler();
54
57
  if (!native)
55
- throw new Error('GJSIFY_CSS_BACKEND=native but @gjsify/lightningcss-native is not loadable');
58
+ throw new Error('GJSIFY_CSS_BACKEND=native but @gjsify/lightningcss-native is not loadable' +
59
+ (_nativeLoadError ? `\n${_nativeLoadError.message}` : ''));
56
60
  return native;
57
61
  }
58
62
  const native = await tryLoadNativeBundler();
59
- return native ?? loadNpmBundler();
63
+ if (native)
64
+ return native;
65
+ // The npm fallback is the right answer, and it is silent on purpose — a
66
+ // missing optional backend is not an error. But if we MEASURED why the
67
+ // native one would not load, that measurement is the only place the user
68
+ // ever hears it, so it goes to the same `console.debug` channel
69
+ // `loadOptionalNativeModule` uses rather than into nothing. A backend that
70
+ // cannot load is the reason someone reaches for this flag.
71
+ if (_nativeLoadError)
72
+ console.debug(_nativeLoadError.message);
73
+ return loadNpmBundler();
60
74
  }
61
75
  async function tryLoadNativeBundler() {
62
76
  // The native bridge only exists under GJS — `imports.gi` marker. Skip
@@ -79,14 +93,44 @@ async function tryLoadNativeBundler() {
79
93
  const mod = (await import(/* @vite-ignore */ pathToFileURL(resolved).href));
80
94
  if (!mod.hasNativeLightningcss())
81
95
  return null;
82
- return async (filename, targets) => {
96
+ // The typelib resolved; its library opens at the first class access. Open
97
+ // it now, beside that typelib: this module cannot leave it to the wrapper,
98
+ // whose `lib/` is imported by file URL where GJS resolves no bare
99
+ // specifier. A library that will not load then names its missing
100
+ // dependency and the npm fallback runs, instead of the nameless
101
+ // "Unsupported type void" inside `transform()`.
102
+ // The same resolve-then-import dance as above, for the probe itself:
103
+ // off disk, so the lazy edge costs nothing and the static one would have
104
+ // cost a bootable CLI. `./native-library` rather than `./core`: `core`
105
+ // re-exports `main-loop`, whose module-level singleton would then exist
106
+ // twice in a process that already has it inlined in the GJS bundle. The
107
+ // walk follows the WORKSPACE, so `@gjsify/utils build:esm` must precede
108
+ // it — rule 5 of `scripts/check-build-infra-order.mjs` orders that edge,
109
+ // the one `bundler-pick.ts` is on the success path of.
110
+ //
111
+ // Its own `try` because the outer one cannot tell this apart from "there
112
+ // is no native backend" — and reporting nothing is the one outcome this
113
+ // file must not produce: a missing measurement reads as a passing one.
114
+ let openNativeLibrary;
115
+ try {
116
+ const utilsHref = pathToFileURL(createRequire(import.meta.url).resolve('@gjsify/utils/native-library')).href;
117
+ ({ openNativeLibrary } = (await import(/* @vite-ignore */ utilsHref)));
118
+ }
119
+ catch (err) {
120
+ _nativeLoadError = err instanceof Error ? err : new Error(String(err));
121
+ return null;
122
+ }
123
+ _nativeLoadError = openNativeLibrary('GjsifyLightningcss');
124
+ if (_nativeLoadError)
125
+ return null;
126
+ return async (filename, targets, declare) => {
83
127
  // The native `@gjsify/lightningcss-native` `bundle()` resolves
84
128
  // `@import` chains through lightningcss's filesystem-backed
85
129
  // FileProvider, which walks relative + absolute paths but NOT
86
130
  // bare node_modules specifiers (`@import "@scope/pkg/x.css"`) —
87
131
  // a JS resolver callback can't cross the GI/Rust boundary. So we
88
132
  // do `@import` resolution in JS first, using the SAME
89
- // `cssBundleResolver` the npm `bundleAsync` path uses (npm-package
133
+ // resolver the npm `bundleAsync` path uses (npm-package
90
134
  // + `exports`-map aware), and hand the fully-flattened CSS to the
91
135
  // native `transform()` for the GTK4 nesting/modern-syntax lowering
92
136
  // (`targets`). The native shim accepts a browserslist string; the
@@ -94,7 +138,7 @@ async function tryLoadNativeBundler() {
94
138
  // (`firefox: 60 << 16` etc), so convert per browser key.
95
139
  let flattened;
96
140
  try {
97
- flattened = await flattenCssImports(filename);
141
+ flattened = await flattenCssImports(filename, declare);
98
142
  }
99
143
  catch (err) {
100
144
  // The native rolldown engine flattens a thrown plugin error to
@@ -147,13 +191,16 @@ async function loadNpmBundler() {
147
191
  // `dependency` of this package, so the runtime resolve finds it.
148
192
  const specifier = 'lightningcss';
149
193
  const { bundleAsync } = (await import(/* @vite-ignore */ specifier));
150
- return async (filename, targets) => {
194
+ return async (filename, targets, declare) => {
151
195
  const result = await bundleAsync({
152
196
  filename,
153
197
  targets,
154
198
  minify: false,
155
199
  errorRecovery: true,
156
- resolver: cssBundleResolver,
200
+ // Per call, not a module singleton: the resolver is where every
201
+ // `@import` target is resolved, so it is where the declaration has
202
+ // to happen — and it must carry THIS load's sink.
203
+ resolver: createCssBundleResolver(declare),
157
204
  });
158
205
  return { code: result.code };
159
206
  };
@@ -185,35 +232,50 @@ const ASSET_REF_RE = /\.(woff2?|ttf|otf|eot|svg|png|jpe?g|gif|webp|avif|ico)(\?|
185
232
  function isAssetReference(specifier) {
186
233
  return /^(data|https?|file):/i.test(specifier) || ASSET_REF_RE.test(specifier);
187
234
  }
188
- const cssBundleResolver = {
189
- resolve(specifier, from) {
190
- if (isAbsolute(specifier))
191
- return specifier;
192
- if (specifier.startsWith('./') || specifier.startsWith('../')) {
193
- return resolvePath(dirname(from), specifier);
194
- }
195
- // Bare specifier — walk node_modules and honor package.json exports.
196
- // `createRequire` takes a file URL or path; passing the importer
197
- // lets it scope its node_modules walk to the right starting point.
198
- const req = createRequire(pathToFileURL(from).href);
199
- try {
200
- return req.resolve(specifier);
201
- }
202
- catch (err) {
203
- // Not an installed module. If it's a `url()`/asset reference,
204
- // leave it verbatim so lightningcss keeps the `@font-face` /
205
- // `url()` rule intact (the consumer serves the asset) instead of
206
- // crashing the build. A genuine unresolvable CSS `@import`
207
- // re-throws with actionable context so the missing dependency
208
- // surfaces clearly on both backends (npm and native).
209
- if (isAssetReference(specifier))
235
+ function createCssBundleResolver(declare) {
236
+ return {
237
+ resolve(specifier, from) {
238
+ // A relative `@import` is resolved here, but lightningcss reads the
239
+ // target itself — so the resolver is the only place its name is known.
240
+ if (isAbsolute(specifier)) {
241
+ declare(stripQuery(specifier));
210
242
  return specifier;
211
- throw new Error(`cannot resolve @import "${specifier}" from ${from} (${err.message}). ` +
212
- 'If it is a workspace/npm package, ensure it is installed and exposes the CSS ' +
213
- 'file via its package.json "exports".');
214
- }
215
- },
216
- };
243
+ }
244
+ if (specifier.startsWith('./') || specifier.startsWith('../')) {
245
+ const resolved = resolvePath(dirname(from), specifier);
246
+ declare(resolved);
247
+ return resolved;
248
+ }
249
+ // Bare specifier — walk node_modules and honor package.json exports.
250
+ // `createRequire` takes a file URL or path; passing the importer
251
+ // lets it scope its node_modules walk to the right starting point.
252
+ const req = createRequire(pathToFileURL(from).href);
253
+ try {
254
+ const resolved = req.resolve(specifier);
255
+ declare(resolved);
256
+ return resolved;
257
+ }
258
+ catch (err) {
259
+ // Not an installed module. If it's a `url()`/asset reference,
260
+ // leave it verbatim so lightningcss keeps the `@font-face` /
261
+ // `url()` rule intact (the consumer serves the asset) instead of
262
+ // crashing the build. A genuine unresolvable CSS `@import`
263
+ // re-throws with actionable context so the missing dependency
264
+ // surfaces clearly on both backends (npm and native).
265
+ if (isAssetReference(specifier))
266
+ return specifier;
267
+ throw new Error(`cannot resolve @import "${specifier}" from ${from} (${err.message}). ` +
268
+ 'If it is a workspace/npm package, ensure it is installed and exposes the CSS ' +
269
+ 'file via its package.json "exports".');
270
+ }
271
+ },
272
+ };
273
+ }
274
+ /** `@scope/pkg/x.css?v=2` is a cache-busting convention; the file is the part before it. */
275
+ function stripQuery(specifier) {
276
+ const cut = specifier.search(/[?#]/);
277
+ return cut === -1 ? specifier : specifier.slice(0, cut);
278
+ }
217
279
  // Matches a CSS `@import` at-rule and captures the specifier + any trailing
218
280
  // condition tokens (media query / `layer()` / `supports()`) before the `;`.
219
281
  // Covers `@import "x";`, `@import 'x';`, and `@import url("x")` / `url(x)`.
@@ -236,7 +298,7 @@ async function replaceAllAsync(source, re, fn) {
236
298
  }
237
299
  /**
238
300
  * Recursively resolve + inline every bundleable `@import` in `entry`, using the
239
- * same {@link cssBundleResolver} the npm `bundleAsync` path uses. Returns a
301
+ * same resolver the npm `bundleAsync` path uses. Returns a
240
302
  * single flattened CSS string with no bundleable `@import` statements left — the
241
303
  * native `transform()` then applies GTK4 nesting/target lowering.
242
304
  *
@@ -249,20 +311,26 @@ async function replaceAllAsync(source, re, fn) {
249
311
  * URL) are kept verbatim (the resolver leaves them alone), matching the npm
250
312
  * path. A cycle inlines each file at most once.
251
313
  */
252
- async function flattenCssImports(entry) {
314
+ async function flattenCssImports(entry, declare) {
253
315
  const seen = new Set();
316
+ const resolver = createCssBundleResolver(declare);
254
317
  const inline = async (file, isRoot) => {
255
- const abs = isAbsolute(file) ? file : resolvePath(file);
318
+ const abs = isAbsolute(file) ? stripQuery(file) : resolvePath(file);
256
319
  if (seen.has(abs))
257
320
  return ''; // @import cycle — inline once
258
321
  seen.add(abs);
322
+ // Declared at the READ, not at the resolution: the root stylesheet, and
323
+ // any import a form the regex does not match, are still inputs of the
324
+ // output — and a resolution declares nothing if lightningcss then
325
+ // decides not to read it.
326
+ declare(abs);
259
327
  let source = await readFile(abs, 'utf8');
260
328
  // `@charset` is only valid as the very first token of a stylesheet;
261
329
  // an inlined sub-file's `@charset` mid-stream would be invalid CSS.
262
330
  if (!isRoot)
263
331
  source = source.replace(/@charset[^;]*;/gi, '');
264
332
  return replaceAllAsync(source, IMPORT_RE, async (match, spec, condition) => {
265
- const resolved = cssBundleResolver.resolve(spec, abs);
333
+ const resolved = resolver.resolve(spec, abs);
266
334
  // Asset-reference `@import` (rare): resolver returns it verbatim —
267
335
  // keep the at-rule intact.
268
336
  if (resolved === spec)
@@ -310,13 +378,29 @@ export function cssAsStringPlugin(options = {}) {
310
378
  // flattens nesting, so no further lightningcss lowering is needed).
311
379
  filter: { id: /\.(css|s[ac]ss)$/ },
312
380
  async handler(id) {
381
+ // Every file the backends read on our behalf, declared through
382
+ // the standard contract — `gjsify test`'s freshness check reads
383
+ // this list, and it is the only account of a stylesheet's
384
+ // `@import`/`@use` chain, which no module graph names. Why the
385
+ // call is feature-detected: `utils/declare-build-input.ts`.
386
+ const declare = (abs) => declareBuildInput(this, abs);
387
+ // The entry itself is read here in every branch, and a Sass
388
+ // file's PARTIALS are not: dart-sass resolves a relative
389
+ // `@use "./x"` through its own filesystem importer before any
390
+ // custom importer is consulted (measured 2026-09-29 with
391
+ // dart-sass 1.101: `importers[].findFileUrl` and
392
+ // `canonicalize` are called zero times for a relative load, and
393
+ // for a bare one only on the way to being declined, so the path
394
+ // is never ours to declare). Tracking that:
395
+ // status/open-todos/bundler.md.
396
+ declare(stripQuery(id));
313
397
  let code;
314
398
  if (/\.s[ac]ss$/.test(id)) {
315
399
  code = await compileSass(id);
316
400
  }
317
401
  else {
318
402
  code = bundle
319
- ? new TextDecoder('utf-8').decode(await loadAndBundleCss(id, targets))
403
+ ? new TextDecoder('utf-8').decode(await loadAndBundleCss(id, targets, declare))
320
404
  : await readFile(id, 'utf8');
321
405
  }
322
406
  return {
@@ -339,7 +423,7 @@ async function compileSass(filename) {
339
423
  // `import './x.scss'` fails with UNLOADABLE_DEPENDENCY. Resolving it to a file
340
424
  // first (`tryLoadNativeBundler`'s trick) does not help: `sass.default.js` then
341
425
  // imports a bare `immutable`. Only INLINING dart-sass works, and 3.6 MB minified
342
- // onto a 6.6 MB CLI bundle is a carrier-package decision — status/open-todos.md
426
+ // onto a 6.6 MB CLI bundle is a carrier-package decision — status/open-todos/README.md
343
427
  // (#1053). dart-sass DOES run under GJS: `adwaita-web`'s `build:scss` does.
344
428
  _sassPromise = import(/* @vite-ignore */ 'sass');
345
429
  }
@@ -352,10 +436,10 @@ async function compileSass(filename) {
352
436
  `Is the \`sass\` package installed? (${err.message})`);
353
437
  }
354
438
  }
355
- async function loadAndBundleCss(filename, targets) {
439
+ async function loadAndBundleCss(filename, targets, declare) {
356
440
  if (!_bundlerPromise)
357
441
  _bundlerPromise = pickBundler();
358
442
  const bundler = await _bundlerPromise;
359
- const { code } = await bundler(filename, targets);
443
+ const { code } = await bundler(filename, targets, declare);
360
444
  return code;
361
445
  }
@@ -0,0 +1,60 @@
1
+ import type { Plugin } from 'rolldown';
2
+ declare const GI_OPTIONAL_VIRTUAL_PREFIX: {
3
+ readonly gjs: "\0gjsify-gi-optional:";
4
+ readonly node: "\0gjsify-gi-optional-node:";
5
+ };
6
+ /** Which build target's shim a flagged specifier gets. */
7
+ export type GiOptionalTarget = keyof typeof GI_OPTIONAL_VIRTUAL_PREFIX;
8
+ /** The query flag that declares a `gi://` import optional. */
9
+ export declare const GI_OPTIONAL_FLAG = "optional";
10
+ /**
11
+ * `Symbol.for` key of the marker statement every optional shim emits, naming its own
12
+ * namespace so a reader of the ARTIFACT can tell an optional namespace from a hard one.
13
+ *
14
+ * WHY IT IS IN THE BUNDLE AND NOT ALONGSIDE IT, which is the design decision this
15
+ * constant exists to pin: `gjsify ship` reads the emitted file, not the build tree,
16
+ * and reads the STAGE MANIFEST when it packs on another host — so a sidecar next to
17
+ * the bundle is a second source that can be stale, absent, or a different file. The
18
+ * marker is therefore one statement in the module body the plugin already emits, and
19
+ * it is a `globalThis[Symbol.for(…)]?.(…)` call for two reasons, both measured on
20
+ * rolldown's own minifier: an unknown global call is not statically pure, so it
21
+ * survives minification where a bare string constant is dropped by tree-shaking, and
22
+ * the optional call means nothing on a host that never registers a handler.
23
+ */
24
+ export declare const GI_OPTIONAL_MARKER = "gjsify.optionalGi";
25
+ /**
26
+ * The statement that records `namespace` as optional in the artifact.
27
+ *
28
+ * Two string arguments in the spelling the node arm's `requireGi("Ns", "X")` already
29
+ * uses, so one reader answers both targets. The version argument is OMITTED when
30
+ * there is none, rather than sent empty: a `requireGi` call cannot tell `''` from a
31
+ * version, and neither can this.
32
+ */
33
+ export declare function giOptionalMarkerSource(namespace: string, version?: string): string;
34
+ /**
35
+ * Split `gi://Ns?version=X&optional` into the clean specifier the loader sees and the
36
+ * flag; `null` when the specifier is not a `gi://` one or does not carry the flag.
37
+ */
38
+ export declare function parseOptionalGiSpecifier(source: string): {
39
+ specifier: string;
40
+ namespace: string;
41
+ version?: string;
42
+ } | null;
43
+ /**
44
+ * The module body for one optional namespace. Self-contained: it runs at module
45
+ * evaluation, where no bundled helper may be assumed, and reaches ambient globals
46
+ * through `globalThis.` only.
47
+ */
48
+ export declare function giOptionalShimSource(specifier: string, namespace: string, version?: string): string;
49
+ /**
50
+ * The `--app node` body: the same contract, reached through `@gjsify/node-gi`.
51
+ *
52
+ * The load is EAGER, which is the one place this arm differs from the hard node shim
53
+ * and the reason is the flag: `requireGi` only fails when it is called, so a lazy Proxy
54
+ * cannot answer "is it there" without loading it — and the answer is the whole point.
55
+ * A missing `@gjsify/node-gi` itself lands in the same catch and yields `undefined`,
56
+ * which is honest: without node-gi no GI namespace loads, optional or not.
57
+ */
58
+ export declare function giOptionalNodeShimSource(namespace: string, version?: string): string;
59
+ export declare function giOptionalPlugin(target: GiOptionalTarget): Plugin;
60
+ export {};
@@ -0,0 +1,152 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // `import Goa from 'gi://Goa?version=1.0&optional'` — a GI namespace the app can run
3
+ // without (ADR 0087). Composed by `--app gjs` and `--app node`.
4
+ //
5
+ // A plain `gi://` import is a hard edge: GJS loads the typelib when the specifier is
6
+ // EVALUATED and a missing one aborts the module graph before the app decides anything.
7
+ // The `optional` query flag is the declaration that the app has that decision to make.
8
+ // The import resolves to a virtual module that loads the SAME specifier without the
9
+ // flag inside a try/catch, so the emitted bundle still carries the verbatim
10
+ // `gi://Goa?version=1.0` that `ship/gi-namespaces.ts` and `depends.ts` read, and the
11
+ // namespace is `undefined` when it is absent.
12
+ //
13
+ // BOTH app targets, and the second one was the trap. `--app node` already loads a
14
+ // `gi://` namespace LAZILY (`gjsGiNodePlugin` default-exports a Proxy that calls
15
+ // `requireGi` on first member access), so with the flag ignored the same source kept
16
+ // returning a truthy Proxy where the app checks `Goa === undefined`, and the first
17
+ // real member access threw instead of degrading — a degrade path that works on one
18
+ // build target and not the other is the defect, not the laziness. Hence a NODE
19
+ // shim with the SAME contract: the namespace or `undefined`, plus one warn. It is
20
+ // synchronous rather than a top-level `await import()` (the shape the node arm's
21
+ // synchronous `require()` already has), because a flag whose whole point is that
22
+ // the app can branch on the result must not force every importer to be async.
23
+ import { GJSIFY_VIRTUAL_PREFIX } from '../utils/virtual-module-id.js';
24
+ import { parseGiSpecifier } from './gjs-gi-node.js';
25
+ const GI_OPTIONAL_VIRTUAL_PREFIX = {
26
+ gjs: `${GJSIFY_VIRTUAL_PREFIX}gi-optional:`,
27
+ node: `${GJSIFY_VIRTUAL_PREFIX}gi-optional-node:`,
28
+ };
29
+ /** The query flag that declares a `gi://` import optional. */
30
+ export const GI_OPTIONAL_FLAG = 'optional';
31
+ /**
32
+ * `Symbol.for` key of the marker statement every optional shim emits, naming its own
33
+ * namespace so a reader of the ARTIFACT can tell an optional namespace from a hard one.
34
+ *
35
+ * WHY IT IS IN THE BUNDLE AND NOT ALONGSIDE IT, which is the design decision this
36
+ * constant exists to pin: `gjsify ship` reads the emitted file, not the build tree,
37
+ * and reads the STAGE MANIFEST when it packs on another host — so a sidecar next to
38
+ * the bundle is a second source that can be stale, absent, or a different file. The
39
+ * marker is therefore one statement in the module body the plugin already emits, and
40
+ * it is a `globalThis[Symbol.for(…)]?.(…)` call for two reasons, both measured on
41
+ * rolldown's own minifier: an unknown global call is not statically pure, so it
42
+ * survives minification where a bare string constant is dropped by tree-shaking, and
43
+ * the optional call means nothing on a host that never registers a handler.
44
+ */
45
+ export const GI_OPTIONAL_MARKER = 'gjsify.optionalGi';
46
+ /**
47
+ * The statement that records `namespace` as optional in the artifact.
48
+ *
49
+ * Two string arguments in the spelling the node arm's `requireGi("Ns", "X")` already
50
+ * uses, so one reader answers both targets. The version argument is OMITTED when
51
+ * there is none, rather than sent empty: a `requireGi` call cannot tell `''` from a
52
+ * version, and neither can this.
53
+ */
54
+ export function giOptionalMarkerSource(namespace, version) {
55
+ const args = version === undefined ? JSON.stringify(namespace) : `${JSON.stringify(namespace)}, ${JSON.stringify(version)}`;
56
+ return `globalThis[Symbol.for(${JSON.stringify(GI_OPTIONAL_MARKER)})]?.(${args});`;
57
+ }
58
+ /**
59
+ * Split `gi://Ns?version=X&optional` into the clean specifier the loader sees and the
60
+ * flag; `null` when the specifier is not a `gi://` one or does not carry the flag.
61
+ */
62
+ export function parseOptionalGiSpecifier(source) {
63
+ if (!source.startsWith('gi://'))
64
+ return null;
65
+ const queryIndex = source.indexOf('?');
66
+ if (queryIndex === -1)
67
+ return null;
68
+ const params = new URLSearchParams(source.slice(queryIndex + 1));
69
+ if (!params.has(GI_OPTIONAL_FLAG))
70
+ return null;
71
+ params.delete(GI_OPTIONAL_FLAG);
72
+ const rest = params.toString();
73
+ const specifier = source.slice(0, queryIndex) + (rest ? `?${rest}` : '');
74
+ const parsed = parseGiSpecifier(specifier);
75
+ if (parsed === null)
76
+ return null;
77
+ return { specifier, ...parsed };
78
+ }
79
+ /**
80
+ * The module body for one optional namespace. Self-contained: it runs at module
81
+ * evaluation, where no bundled helper may be assumed, and reaches ambient globals
82
+ * through `globalThis.` only.
83
+ */
84
+ export function giOptionalShimSource(specifier, namespace, version) {
85
+ const label = version ? `${namespace} ${version}` : namespace;
86
+ return (`let ns;\n` +
87
+ `try {\n` +
88
+ ` const m = await import(${JSON.stringify(specifier)});\n` +
89
+ ` ns = m.default ?? m;\n` +
90
+ `} catch (error) {\n` +
91
+ ` let searched = '';\n` +
92
+ ` try {\n` +
93
+ ` const r = globalThis.imports.gi.GIRepository.Repository.dup_default();\n` +
94
+ ` searched = ' (typelib search path: ' + r.get_search_path().join(':') + ')';\n` +
95
+ ` } catch {}\n` +
96
+ ` console.warn(${JSON.stringify(`optional GI namespace ${label} is not available`)} + searched + ': ' + (error && error.message ? error.message : String(error)));\n` +
97
+ `}\n` +
98
+ `${giOptionalMarkerSource(namespace, version)}\n` +
99
+ `export default ns;\n`);
100
+ }
101
+ /**
102
+ * The `--app node` body: the same contract, reached through `@gjsify/node-gi`.
103
+ *
104
+ * The load is EAGER, which is the one place this arm differs from the hard node shim
105
+ * and the reason is the flag: `requireGi` only fails when it is called, so a lazy Proxy
106
+ * cannot answer "is it there" without loading it — and the answer is the whole point.
107
+ * A missing `@gjsify/node-gi` itself lands in the same catch and yields `undefined`,
108
+ * which is honest: without node-gi no GI namespace loads, optional or not.
109
+ */
110
+ export function giOptionalNodeShimSource(namespace, version) {
111
+ const label = version ? `${namespace} ${version}` : namespace;
112
+ const versionArg = version === undefined ? '' : `, ${JSON.stringify(version)}`;
113
+ return (`import { createRequire } from 'node:module';\n` +
114
+ `const require = createRequire(import.meta.url);\n` +
115
+ `let ns;\n` +
116
+ `try {\n` +
117
+ ` ns = require('@gjsify/node-gi/gi').requireGi(${JSON.stringify(namespace)}${versionArg});\n` +
118
+ `} catch (error) {\n` +
119
+ ` console.warn(${JSON.stringify(`optional GI namespace ${label} is not available`)} + ': ' + (error && error.message ? error.message : String(error)));\n` +
120
+ `}\n` +
121
+ `${giOptionalMarkerSource(namespace, version)}\n` +
122
+ `export default ns;\n`);
123
+ }
124
+ export function giOptionalPlugin(target) {
125
+ const prefix = GI_OPTIONAL_VIRTUAL_PREFIX[target];
126
+ return {
127
+ name: `gjsify-gi-optional-${target}`,
128
+ resolveId: {
129
+ order: 'pre',
130
+ filter: { id: /^gi:\/\/.*[?&]optional(?:&|=|$)/ },
131
+ handler(source) {
132
+ const parsed = parseOptionalGiSpecifier(source);
133
+ if (parsed === null)
134
+ return null;
135
+ const version = parsed.version ? `@${parsed.version}` : '';
136
+ return { id: `${prefix}${parsed.namespace}${version}` };
137
+ },
138
+ },
139
+ load(id) {
140
+ if (!id.startsWith(prefix))
141
+ return null;
142
+ const spec = id.slice(prefix.length);
143
+ const at = spec.lastIndexOf('@');
144
+ const namespace = at === -1 ? spec : spec.slice(0, at);
145
+ const version = at === -1 ? undefined : spec.slice(at + 1);
146
+ const code = target === 'gjs'
147
+ ? giOptionalShimSource(`gi://${namespace}${version ? `?version=${version}` : ''}`, namespace, version)
148
+ : giOptionalNodeShimSource(namespace, version);
149
+ return { code, moduleSideEffects: false };
150
+ },
151
+ };
152
+ }
@@ -29,7 +29,7 @@ export interface GiRendererOptions {
29
29
  * passes `--gi-renderer`, so the arm never composes there and this text cannot reach them
30
30
  * — measured, nothing under `tests/` names the flag but `gi-renderer-arms` itself. Making
31
31
  * that suite's two assertions import-shaped would retire this rule outright; the argument,
32
- * the other twelve sites and the retirement condition are in `status/open-todos.md`.
32
+ * the other twelve sites and the retirement condition are in `status/open-todos/README.md`.
33
33
  */
34
34
  export declare function giRendererShimSource(options: GiRendererOptions, namespace: string, version: string): string;
35
35
  export declare function giRendererPlugin(options: GiRendererOptions): Plugin;
@@ -90,7 +90,7 @@ const GI_RENDERER_VIRTUAL_PREFIX = `${GJSIFY_VIRTUAL_PREFIX}gi-renderer:`;
90
90
  * passes `--gi-renderer`, so the arm never composes there and this text cannot reach them
91
91
  * — measured, nothing under `tests/` names the flag but `gi-renderer-arms` itself. Making
92
92
  * that suite's two assertions import-shaped would retire this rule outright; the argument,
93
- * the other twelve sites and the retirement condition are in `status/open-todos.md`.
93
+ * the other twelve sites and the retirement condition are in `status/open-todos/README.md`.
94
94
  */
95
95
  export function giRendererShimSource(options, namespace, version) {
96
96
  return (`import { ${namespace} as namespace } from ${JSON.stringify(options.renderer)};\n` +
@@ -9,8 +9,11 @@
9
9
  // e2e): a banner is the entry chunk's BODY and ESM evaluates imports first, so a
10
10
  // STATIC `import … from 'gi://Ns'` has already loaded its typelib before this runs.
11
11
  // What is left is what loads LATER — `await import('gi://Soup')` and the other
12
- // optional namespaces. Reaching the static ones changes how a bundle acquires GI
13
- // namespaces at all; that is an ADR, filed in the same entry.
12
+ // optional namespaces. Confirmed on darwin-arm64 in the same shape (macOS 27 / M4 /
13
+ // Homebrew, the bundle loading Gtk through `await import` and failing through a
14
+ // static one). Reaching the static ones changes how a bundle acquires GI namespaces
15
+ // at all: ADR 0085 decides to lower them here, in `renderChunk`, to awaited dynamic
16
+ // imports placed after this prologue — proposed, not implemented.
14
17
  //
15
18
  // TWO KINDS OF DIRECTORY, split by WHOSE FACT each is. `dirs` describe the SHIPPED
16
19
  // TREE, so they are baked, relative to the program. `systemProbes` describe the host
@@ -12,11 +12,49 @@ export interface NapiNodeAddonPluginOptions {
12
12
  * `@gjsify/napi` is not resolvable in the consumer graph. Default `true`.
13
13
  */
14
14
  warnOnMissingNapi?: boolean;
15
+ /**
16
+ * Is the output an ESM single-file build, i.e. does it carry the
17
+ * bundle-URL banner the run-time resolver anchors on? Default `true`;
18
+ * `app/gjs.ts` passes the same `format === 'esm'` the node-modules path
19
+ * rewriter gates its own runtime resolution on.
20
+ *
21
+ * `false` DECLINES every rewrite, with one warning naming the reason. It is
22
+ * a decline and not the pre-ADR baked path because there is no correct baked
23
+ * path left: the whole point of ADR 0084 is that a path chosen at build time
24
+ * is what made the artifact unusable off the build machine, so emitting one
25
+ * again would reintroduce the defect for a mode that cannot anchor at run
26
+ * time anyway. A `--library cjs` build is a library for a consumer's own
27
+ * toolchain, which resolves its addons itself.
28
+ */
29
+ runtimeResolve?: boolean;
15
30
  }
16
31
  /** A native addon package root has no resolvable compiled `.node`. */
17
32
  export declare class AddonNotBuiltError extends Error {
18
33
  constructor(pkgRoot: string);
19
34
  }
35
+ /**
36
+ * Enumerate every `.node` an addon package ships, keyed by platform, as a
37
+ * `<package>/<subpath>` spec per entry. ADR 0084: the build ENUMERATES, it
38
+ * does not select — the bundle picks the right entry at RUN time from the
39
+ * host it finds itself on.
40
+ *
41
+ * The table has:
42
+ * - One entry per `prebuilds/<tuple>/` directory, keyed by the tuple's
43
+ * platform and EVERY architecture it declares, and per libc variant. Each
44
+ * entry is chosen by {@link selectPrebuildFile} — node-gyp-build's own
45
+ * algorithm, run for a synthetic host of that tuple — so the entry for the
46
+ * build host is exactly the binary Node would load, and a foreign platform's
47
+ * entry is the best one that host could load rather than the best-looking
48
+ * file in the directory.
49
+ * - One entry per `build/Release` and `build/Debug`, keyed by the BUILD host
50
+ * ({@link buildHostTarget}, deliberately not the `npm_config_*` override
51
+ * those files are not built for), overriding the prebuilds entry for the
52
+ * same key: node-gyp-build's order has build/Release winning.
53
+ *
54
+ * Returns an empty record when no `.node` exists anywhere — the caller then
55
+ * throws {@link AddonNotBuiltError} as a build-time gate.
56
+ */
57
+ export declare function enumerateAddonTargets(pkgRoot: string, pkg: AddonPackageJson): Record<string, string>;
20
58
  /**
21
59
  * Locate the compiled `.node` for an addon package root, matching node-gyp-build's
22
60
  * probe order: `build/Release` → `build/Debug` →
@@ -28,14 +66,19 @@ export declare function resolveAddonPath(pkgRoot: string, opts?: {
28
66
  }): string;
29
67
  /** Nearest ancestor directory of `importerFile` holding a `package.json`. */
30
68
  export declare function nearestPackageRoot(importerFile: string): string | null;
69
+ /**
70
+ * The addon table as a JSON string — the platform-key → `<pkg>/<subpath>`
71
+ * map the runtime resolver (`__gjsifyAddonResolve`) picks from. ADR 0084.
72
+ */
73
+ type AddonTable = string;
31
74
  /** Direct `.node` import → the addon's exports (ESM default). */
32
- export declare function directNodeShim(addonPath: string): string;
75
+ export declare function directNodeShim(addonTable: AddonTable): string;
33
76
  /** `node-gyp-build` replacement — a callable `load(dir)` carrying `.path()`. */
34
- export declare function nodeGypBuildShim(addonPath: string): string;
77
+ export declare function nodeGypBuildShim(addonTable: AddonTable): string;
35
78
  /** `bindings` replacement — a callable `bindings(name)` returning the addon. */
36
- export declare function bindingsShim(addonPath: string): string;
79
+ export declare function bindingsShim(addonTable: AddonTable): string;
37
80
  /** napi-rs sibling → the raw native exports as the module value. */
38
- export declare function napiRsShim(addonPath: string): string;
81
+ export declare function napiRsShim(addonTable: AddonTable): string;
39
82
  /** Classify a specifier for interception — pure decision logic, no filesystem. */
40
83
  export declare function classifySpecifier(source: string): {
41
84
  kind: 'node-gyp-build' | 'bindings';
@@ -122,3 +165,4 @@ export declare function hostNapiRsTriple(): string | null;
122
165
  * else. Register ONLY for `--app gjs` — the C-ABI runs under `@gjsify/napi`.
123
166
  */
124
167
  export declare function napiNodeAddonPlugin(options?: NapiNodeAddonPluginOptions): Plugin;
168
+ export {};