@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,145 @@
1
+ // For `--app node`: keep every Node-API addon PACKAGE external, so Node loads it from
2
+ // `node_modules` exactly as it would without a bundler.
3
+ //
4
+ // An addon package finds its `.node` binary relative to ITS OWN files —
5
+ // `require('node-gyp-build')(__dirname)`, `bindings('x')`, `require('../build/Release/x.node')`,
6
+ // napi-rs' `require(`./x.${triple}.node`)`. Bundled, `__dirname` / `import.meta.dirname`
7
+ // is the bundle's directory, and the lookup misses. Measured on
8
+ // `@signalapp/libsignal-client`: `node-gyp-build(import.meta.dirname + '/..')` threw
9
+ // "No native build was found" from an `--app node` bundle that ran fine as source.
10
+ // `bufferutil` fails SILENTLY instead — its `try { node-gyp-build } catch { fallback }`
11
+ // quietly swaps the native build for the JS one.
12
+ //
13
+ // External, not copied: `--app gjs` has to rewrite an addon (`napiNodeAddonPlugin`
14
+ // routes it through `@gjsify/napi`) because GJS cannot load one itself, but Node can,
15
+ // and every loader convention resolves against a package-shaped directory, not a flat
16
+ // prebuild next to the bundle. So an `--app node` bundle of a native dependency needs
17
+ // `node_modules` at runtime, like any `npm install`-ed program.
18
+ //
19
+ // Detection is by PACKAGE, from its manifest and layout, never by source sniffing, and
20
+ // shares the napi-rs + gjsify-bridge rules with `napiNodeAddonPlugin`.
21
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
22
+ import { dirname, isAbsolute, join } from 'node:path';
23
+ import { isGjsifyNativeBridge, isNapiRsPackageJson } from './napi-node-addon.js';
24
+ // oxlint-disable-next-line no-control-regex -- NUL marks a bundler virtual id, never a package.
25
+ const BARE_SPECIFIER_RE = /^[^.\\/:\s\x00][^:\s\x00]*$/;
26
+ /** Runtime helpers whose only job is locating an addon binary next to the package. */
27
+ const ADDON_LOADER_DEPENDENCIES = [
28
+ 'node-gyp-build',
29
+ 'node-gyp-build-optional-packages',
30
+ 'bindings',
31
+ 'prebuild-install',
32
+ 'node-pre-gyp',
33
+ '@mapbox/node-pre-gyp',
34
+ 'cmake-js',
35
+ ];
36
+ function readdirSafe(dir) {
37
+ try {
38
+ return readdirSync(dir);
39
+ }
40
+ catch {
41
+ return [];
42
+ }
43
+ }
44
+ /** A `.node` file in `dir` or one directory below it (`prebuilds/<target>/`, `build/Release/`). */
45
+ function hasNodeBinary(dir) {
46
+ for (const name of readdirSafe(dir)) {
47
+ if (name.endsWith('.node'))
48
+ return true;
49
+ if (readdirSafe(join(dir, name)).some((n) => n.endsWith('.node')))
50
+ return true;
51
+ }
52
+ return false;
53
+ }
54
+ /**
55
+ * Is the package at `pkgRoot` a Node-API addon — one whose code loads a `.node`
56
+ * relative to its own directory? True on any of: `gypfile`, a `binding.gyp`, a
57
+ * node-pre-gyp `binary` block, a dependency on an addon loader, the napi-rs manifest
58
+ * signals, or a `.node` under `prebuilds/` or `build/`. A gjsify native bridge is
59
+ * never one: it ships a GI typelib loaded through `gi://`.
60
+ */
61
+ export function isNodeAddonPackage(pkgRoot, pkg) {
62
+ if (isGjsifyNativeBridge(pkg))
63
+ return false;
64
+ if (pkg.gypfile === true)
65
+ return true;
66
+ if (pkg.binary !== null && typeof pkg.binary === 'object')
67
+ return true;
68
+ if (existsSync(join(pkgRoot, 'binding.gyp')))
69
+ return true;
70
+ const deps = { ...pkg.dependencies, ...pkg.optionalDependencies };
71
+ if (ADDON_LOADER_DEPENDENCIES.some((name) => name in deps))
72
+ return true;
73
+ if (isNapiRsPackageJson(pkg))
74
+ return true;
75
+ return hasNodeBinary(join(pkgRoot, 'prebuilds')) || hasNodeBinary(join(pkgRoot, 'build'));
76
+ }
77
+ /** `@scope/name/sub` → `@scope/name`, `name/sub` → `name`; null for anything not bare. */
78
+ export function packageNameOf(specifier) {
79
+ if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('\0'))
80
+ return null;
81
+ if (specifier.includes(':'))
82
+ return null; // node:fs, gi://Gtk, data:, C:\…
83
+ const parts = specifier.split('/');
84
+ if (specifier.startsWith('@'))
85
+ return parts.length >= 2 && parts[1] ? `${parts[0]}/${parts[1]}` : null;
86
+ return parts[0] || null;
87
+ }
88
+ /**
89
+ * The root of package `name` that `file` belongs to: the nearest ancestor whose
90
+ * `package.json` is NAMED `name`. Matching on the name skips the nested
91
+ * `{ "type": "module" }` manifests dual packages put in `dist/esm/`.
92
+ */
93
+ function packageRootFor(file, name) {
94
+ let dir = dirname(file);
95
+ for (let i = 0; i < 64; i++) {
96
+ const manifest = join(dir, 'package.json');
97
+ if (existsSync(manifest)) {
98
+ try {
99
+ const pkg = JSON.parse(readFileSync(manifest, 'utf8'));
100
+ if (pkg.name === name)
101
+ return { root: dir, pkg };
102
+ }
103
+ catch {
104
+ // An unreadable manifest is not this package's — keep walking up.
105
+ }
106
+ }
107
+ const parent = dirname(dir);
108
+ if (parent === dir)
109
+ break;
110
+ dir = parent;
111
+ }
112
+ return null;
113
+ }
114
+ /** Keeps every import of a Node-API addon package external on `--app node`. */
115
+ export function nodeNativeExternalPlugin() {
116
+ // Keyed by package name + importer directory: node resolution depends on both.
117
+ const verdicts = new Map();
118
+ return {
119
+ name: 'gjsify-node-native-external',
120
+ resolveId: {
121
+ order: 'pre',
122
+ // Rust-regex compatible (no lookaround, NUL as \x00): `@gjsify/rolldown-native`
123
+ // hands it to the Rust core as a string. Bare specifiers only.
124
+ filter: { id: BARE_SPECIFIER_RE },
125
+ async handler(source, rawImporter) {
126
+ const name = packageNameOf(source);
127
+ if (name === null)
128
+ return null;
129
+ const importer = typeof rawImporter === 'string' ? rawImporter : undefined;
130
+ const key = `${name}\0${importer === undefined ? '' : dirname(importer)}`;
131
+ let native = verdicts.get(key);
132
+ if (native === undefined) {
133
+ native = false;
134
+ const resolved = await this.resolve(source, importer, { skipSelf: true });
135
+ if (resolved && !resolved.external && isAbsolute(resolved.id) && existsSync(resolved.id)) {
136
+ const owner = packageRootFor(resolved.id, name);
137
+ native = owner !== null && isNodeAddonPackage(owner.root, owner.pkg);
138
+ }
139
+ verdicts.set(key, native);
140
+ }
141
+ return native ? { id: source, external: true } : null;
142
+ },
143
+ },
144
+ };
145
+ }
@@ -39,6 +39,48 @@ export declare const DESKTOP_OS_SUFFIXES: Readonly<Record<string, string>>;
39
39
  * Without that, an author's fork is dead code with nothing anywhere saying so.
40
40
  */
41
41
  export declare const DESKTOP_REFUSED_SUFFIXES: readonly string[];
42
+ /**
43
+ * Suffixes that must NEVER be a rung of the BROWSER chain — the mirror of
44
+ * {@link DESKTOP_REFUSED_SUFFIXES}, built by the same rule read from the other
45
+ * side: **refuse the other families' UMBRELLA rungs, not their per-target
46
+ * spellings.** The desktop list names `.web` (the whole browser chain) and
47
+ * `.native` (the phone family's umbrella) and deliberately leaves
48
+ * `.android`/`.ios`/`.visionos` out; from here the same rule names the GTK
49
+ * desktop family's two non-OS rungs plus that same phone umbrella.
50
+ *
51
+ * `.gtk` is the one a reader reaches for while chasing a missing widget, and it
52
+ * is the worst possible answer: `--app browser` redirects `gi://*` and `@girs/*`
53
+ * to an EMPTY module, so a `.gtk.tsx` reaching `Adw.ActionRow` does not fail to
54
+ * import — it gets `{}`, and `class X extends Adw.ActionRow` throws
55
+ * `Class extends value undefined` at load. That is measured, and the ADR 0034
56
+ * stage 9 e2e keeps exactly that row as its control.
57
+ *
58
+ * `.desktop` is named as well as `.gtk`, though only one of the two appears in
59
+ * this repository today: § 9 writes `.desktop` as the umbrella over `.gtk` and
60
+ * the three OS spellings, so refusing one and not the other is a gap, and a gap
61
+ * in a refusal list reads as a judgement that the omitted one is fine here.
62
+ *
63
+ * `.native` is the phone bridge — `NativeModules` exists in neither a browser
64
+ * nor a GTK host, which is why both lists carry it.
65
+ *
66
+ * NOT here, and each is a DECISION rather than an omission:
67
+ * - **the three OS spellings** (`linux`/`macos`/`windows`). They only ever
68
+ * appear as rungs of the desktop chain, whose umbrella is already named — the
69
+ * same reason `.android` is absent from the desktop list. Naming them would
70
+ * put a warning line on every OS-forked module of a normal dual-target tree,
71
+ * which is how a warning gets switched off.
72
+ * - **a refusal list for NativeScript at all.** That chain runs with
73
+ * `siblingIndex` OFF under a byte-identical mandate, and with no listing to
74
+ * filter them a refusal list costs two real `this.resolve` calls on every
75
+ * relative import that has no variant — the majority, inside
76
+ * `@nativescript/core` included. The desktop chain's own measurement (+14% on
77
+ * a ~1400-module bundle from six failed resolves) is what says failed resolves
78
+ * are not free. It is a cost with a measured-zero benefit on this tree: the
79
+ * 30 `.gtk.*`/`.native.*` pairs in `@gjsify/adwaita-react-native` all HIT the
80
+ * NS chain at `.native`, and the refusal probe only runs when the chain found
81
+ * nothing. Reopen it from a measurement of that tree with the index on.
82
+ */
83
+ export declare const BROWSER_REFUSED_SUFFIXES: readonly string[];
42
84
  /**
43
85
  * The NativeScript chain: platform-specific first, then the platform-agnostic
44
86
  * `native` suffix. Without a known platform only `native` applies.
@@ -57,6 +99,37 @@ export declare function nativescriptSuffixChain(platform?: NativescriptPlatform)
57
99
  * guessing one.
58
100
  */
59
101
  export declare function desktopSuffixChain(os?: string): readonly string[];
102
+ /**
103
+ * The BROWSER chain: `.web` → base. Exactly ONE rung, and the count is the
104
+ * decision.
105
+ *
106
+ * A desktop build knows three different things about itself and so gets three
107
+ * rungs: the toolkit (`.gtk`), the kernel (`.<os>`) and the target family
108
+ * (`.desktop`). A `--app browser` build knows ONE — it targets the DOM — and
109
+ * every candidate second rung was rejected for a stated reason rather than left
110
+ * out for tidiness:
111
+ *
112
+ * - **No engine rung** (`.firefox` / `.chromium`). The target compiles one
113
+ * `esnext` bundle with no per-engine branch anywhere in it; a rung nobody can
114
+ * fill is a resolution rule that can only ever surprise someone.
115
+ * - **No OS rung.** One browser bundle is served to every operating system, so
116
+ * an OS rung would resolve against the BUILD HOST and bake it into a
117
+ * platform-neutral artifact. That is the mistake `plugins/gi-runtime-paths.ts`
118
+ * already documents for the GI prologue, committed here at resolution time
119
+ * where the evidence is a file name and not a code path.
120
+ * - **No `.dom` synonym.** Two spellings for one concept is a priority order
121
+ * someone has to memorise, and `.web` is what § 9 writes and what every
122
+ * `.web.*` file in this repository already uses.
123
+ * - **No `.browser` umbrella.** `.desktop` earns its rung by sitting above four
124
+ * spellings; above `.web` there is nothing for an umbrella to cover.
125
+ *
126
+ * Takes no parameter, unlike {@link desktopSuffixChain}: nothing about this
127
+ * chain depends on the host, which is the previous point restated from the API
128
+ * side. It is a function rather than a constant so that
129
+ * {@link PlatformResolvePluginOptions.suffixes} keeps its rule — a hand-written
130
+ * array is a second resolution order the tree does not state.
131
+ */
132
+ export declare function browserSuffixChain(): readonly string[];
60
133
  /**
61
134
  * The desktop suffix for the BUILD HOST, or `undefined` for a host outside
62
135
  * ADR 0018's target set.
@@ -81,16 +154,19 @@ export declare function desktopOsSuffix(platform?: string): string | undefined;
81
154
  export interface PlatformResolvePluginOptions {
82
155
  /**
83
156
  * The suffix chain, most specific first. Build it with
84
- * {@link nativescriptSuffixChain} or {@link desktopSuffixChain} — a
85
- * hand-written array is a second resolution order the tree does not state.
157
+ * {@link nativescriptSuffixChain}, {@link desktopSuffixChain} or
158
+ * {@link browserSuffixChain} — a hand-written array is a second resolution
159
+ * order the tree does not state.
86
160
  */
87
161
  suffixes: readonly string[];
88
162
  /**
89
163
  * Suffixes deliberately NOT resolved, but whose presence on disk is worth
90
164
  * saying out loud: {@link DESKTOP_REFUSED_SUFFIXES} for the desktop chain,
91
- * empty for NativeScript (where `.native` is a real rung). Probed only after
92
- * the chain found nothing, so an empty list costs zero extra resolves and
93
- * the NS path is byte-unchanged.
165
+ * {@link BROWSER_REFUSED_SUFFIXES} for the browser chain, empty for
166
+ * NativeScript — where `.native` is a real rung and the rest is a measured
167
+ * decision written out on `BROWSER_REFUSED_SUFFIXES`. Probed only after the
168
+ * chain found nothing, so an empty list costs zero extra resolves and the NS
169
+ * path is byte-unchanged.
94
170
  */
95
171
  refusedSuffixes?: readonly string[];
96
172
  /**
@@ -121,6 +197,20 @@ export interface PlatformResolvePluginOptions {
121
197
  * that chain is byte-identical behaviour — a filter that another plugin's
122
198
  * virtual `resolveId` could see past is not something to switch on there
123
199
  * without a measurement of that tree.
200
+ *
201
+ * ON WHEREVER THE CHAIN CARRIES A REFUSAL LIST, and that is not a preference.
202
+ * A refused suffix is probed on every miss, so a chain with refusals pays its
203
+ * probes on the MAJORITY of imports rather than the few that have a variant.
204
+ * Measured on `tests/e2e/vite-plugin-gjsify`'s fixture with the browser chain
205
+ * (one rung, three refusals) under `--max-old-space-size=4096`: index off is
206
+ * 1172 probes and `FATAL ERROR: Reached heap limit`; index on is 0 probes,
207
+ * 0.25 s and 191 MB. Under Vite each probe re-enters the whole plugin
208
+ * container, which is where the memory goes. Leaving the index off is
209
+ * therefore a choice only a chain with NO refusal list can afford.
210
+ *
211
+ * Kept fresh by {@link platformResolvePlugin}'s `watchChange` hook, so a
212
+ * long-lived host (a Vite dev server, `gjsify build --watch`) does not answer
213
+ * from a directory listing taken before the author added the variant.
124
214
  */
125
215
  siblingIndex?: boolean;
126
216
  }
@@ -1,16 +1,20 @@
1
- // Platform file resolution — ONE plugin, ONE resolution order, two chains.
1
+ // Platform file resolution — ONE plugin, ONE resolution order, three chains.
2
2
  //
3
3
  // Lets a shared codebase fork a single module per target by file name. An
4
4
  // `import './foo'` (or `import './foo.js'`) resolves to the most specific
5
5
  // variant on disk, in the priority order the caller's chain declares:
6
6
  //
7
- // NativeScript (`--app nativescript`) GTK / desktop (`--app gjs|node`)
8
- // ./foo.<android|ios|visionos>.<ext> ./foo.gtk.<ext>
9
- // ./foo.native.<ext> ./foo.<linux|macos|windows>.<ext>
10
- // ./foo.<ext> ./foo.desktop.<ext>
11
- // ./foo.<ext>
7
+ // NativeScript (`--app nativescript`) GTK / desktop (`--app gjs|node`)
8
+ // ./foo.<android|ios|visionos>.<ext> ./foo.gtk.<ext>
9
+ // ./foo.native.<ext> ./foo.<linux|macos|windows>.<ext>
10
+ // ./foo.<ext> ./foo.desktop.<ext>
11
+ // ./foo.<ext>
12
12
  //
13
- // TWO CHAINS, NOT TWO PLUGINS (ADR 0032 § 9). A second plugin would give the
13
+ // Browser (`--app browser`)
14
+ // ./foo.web.<ext>
15
+ // ./foo.<ext>
16
+ //
17
+ // THREE CHAINS, NOT THREE PLUGINS (ADR 0032 § 9). A second plugin would give the
14
18
  // tree two resolution orders that no single file states, and the order IS the
15
19
  // contract — `.gtk` before `.<os>` before `.desktop` is a decision, not an
16
20
  // implementation detail. So the chain is a PARAMETER and the builders below are
@@ -82,6 +86,48 @@ export const DESKTOP_OS_SUFFIXES = {
82
86
  * Without that, an author's fork is dead code with nothing anywhere saying so.
83
87
  */
84
88
  export const DESKTOP_REFUSED_SUFFIXES = ['native', 'web'];
89
+ /**
90
+ * Suffixes that must NEVER be a rung of the BROWSER chain — the mirror of
91
+ * {@link DESKTOP_REFUSED_SUFFIXES}, built by the same rule read from the other
92
+ * side: **refuse the other families' UMBRELLA rungs, not their per-target
93
+ * spellings.** The desktop list names `.web` (the whole browser chain) and
94
+ * `.native` (the phone family's umbrella) and deliberately leaves
95
+ * `.android`/`.ios`/`.visionos` out; from here the same rule names the GTK
96
+ * desktop family's two non-OS rungs plus that same phone umbrella.
97
+ *
98
+ * `.gtk` is the one a reader reaches for while chasing a missing widget, and it
99
+ * is the worst possible answer: `--app browser` redirects `gi://*` and `@girs/*`
100
+ * to an EMPTY module, so a `.gtk.tsx` reaching `Adw.ActionRow` does not fail to
101
+ * import — it gets `{}`, and `class X extends Adw.ActionRow` throws
102
+ * `Class extends value undefined` at load. That is measured, and the ADR 0034
103
+ * stage 9 e2e keeps exactly that row as its control.
104
+ *
105
+ * `.desktop` is named as well as `.gtk`, though only one of the two appears in
106
+ * this repository today: § 9 writes `.desktop` as the umbrella over `.gtk` and
107
+ * the three OS spellings, so refusing one and not the other is a gap, and a gap
108
+ * in a refusal list reads as a judgement that the omitted one is fine here.
109
+ *
110
+ * `.native` is the phone bridge — `NativeModules` exists in neither a browser
111
+ * nor a GTK host, which is why both lists carry it.
112
+ *
113
+ * NOT here, and each is a DECISION rather than an omission:
114
+ * - **the three OS spellings** (`linux`/`macos`/`windows`). They only ever
115
+ * appear as rungs of the desktop chain, whose umbrella is already named — the
116
+ * same reason `.android` is absent from the desktop list. Naming them would
117
+ * put a warning line on every OS-forked module of a normal dual-target tree,
118
+ * which is how a warning gets switched off.
119
+ * - **a refusal list for NativeScript at all.** That chain runs with
120
+ * `siblingIndex` OFF under a byte-identical mandate, and with no listing to
121
+ * filter them a refusal list costs two real `this.resolve` calls on every
122
+ * relative import that has no variant — the majority, inside
123
+ * `@nativescript/core` included. The desktop chain's own measurement (+14% on
124
+ * a ~1400-module bundle from six failed resolves) is what says failed resolves
125
+ * are not free. It is a cost with a measured-zero benefit on this tree: the
126
+ * 30 `.gtk.*`/`.native.*` pairs in `@gjsify/adwaita-react-native` all HIT the
127
+ * NS chain at `.native`, and the refusal probe only runs when the chain found
128
+ * nothing. Reopen it from a measurement of that tree with the index on.
129
+ */
130
+ export const BROWSER_REFUSED_SUFFIXES = ['gtk', 'desktop', 'native'];
85
131
  /**
86
132
  * The NativeScript chain: platform-specific first, then the platform-agnostic
87
133
  * `native` suffix. Without a known platform only `native` applies.
@@ -104,6 +150,39 @@ export function nativescriptSuffixChain(platform) {
104
150
  export function desktopSuffixChain(os) {
105
151
  return os ? ['gtk', os, 'desktop'] : ['gtk', 'desktop'];
106
152
  }
153
+ /**
154
+ * The BROWSER chain: `.web` → base. Exactly ONE rung, and the count is the
155
+ * decision.
156
+ *
157
+ * A desktop build knows three different things about itself and so gets three
158
+ * rungs: the toolkit (`.gtk`), the kernel (`.<os>`) and the target family
159
+ * (`.desktop`). A `--app browser` build knows ONE — it targets the DOM — and
160
+ * every candidate second rung was rejected for a stated reason rather than left
161
+ * out for tidiness:
162
+ *
163
+ * - **No engine rung** (`.firefox` / `.chromium`). The target compiles one
164
+ * `esnext` bundle with no per-engine branch anywhere in it; a rung nobody can
165
+ * fill is a resolution rule that can only ever surprise someone.
166
+ * - **No OS rung.** One browser bundle is served to every operating system, so
167
+ * an OS rung would resolve against the BUILD HOST and bake it into a
168
+ * platform-neutral artifact. That is the mistake `plugins/gi-runtime-paths.ts`
169
+ * already documents for the GI prologue, committed here at resolution time
170
+ * where the evidence is a file name and not a code path.
171
+ * - **No `.dom` synonym.** Two spellings for one concept is a priority order
172
+ * someone has to memorise, and `.web` is what § 9 writes and what every
173
+ * `.web.*` file in this repository already uses.
174
+ * - **No `.browser` umbrella.** `.desktop` earns its rung by sitting above four
175
+ * spellings; above `.web` there is nothing for an umbrella to cover.
176
+ *
177
+ * Takes no parameter, unlike {@link desktopSuffixChain}: nothing about this
178
+ * chain depends on the host, which is the previous point restated from the API
179
+ * side. It is a function rather than a constant so that
180
+ * {@link PlatformResolvePluginOptions.suffixes} keeps its rule — a hand-written
181
+ * array is a second resolution order the tree does not state.
182
+ */
183
+ export function browserSuffixChain() {
184
+ return ['web'];
185
+ }
107
186
  /**
108
187
  * The desktop suffix for the BUILD HOST, or `undefined` for a host outside
109
188
  * ADR 0018's target set.
@@ -155,13 +234,21 @@ export function platformResolvePlugin(options) {
155
234
  // EMPTY chain is one that was computed and came out empty — the shape a
156
235
  // mis-wired orchestrator has — and it would send every import to base
157
236
  // while looking installed.
158
- throw new Error('gjsify platform resolve: the suffix chain is empty. Pass nativescriptSuffixChain(…) or ' +
159
- 'desktopSuffixChain(…), or omit the plugin.');
237
+ throw new Error('gjsify platform resolve: the suffix chain is empty. Pass nativescriptSuffixChain(…), ' +
238
+ 'desktopSuffixChain(…) or browserSuffixChain(), or omit the plugin.');
160
239
  }
161
240
  const refused = options.refusedSuffixes ?? [];
162
241
  const useIndex = options.siblingIndex ?? false;
163
242
  // Per-directory listing cache, lowercased. `null` marks a directory that
164
243
  // could not be read, which means "no opinion" — the resolver decides.
244
+ //
245
+ // CACHED FOR THE PLUGIN INSTANCE'S LIFE, so it needs the `watchChange` hook
246
+ // below: in a one-shot build the process ends before a listing can go stale,
247
+ // but `gjsify build --watch` and a Vite dev server both keep one plugin
248
+ // instance across rebuilds. Without invalidation an author who ADDS
249
+ // `foo.web.ts` beside `foo.ts` while the watcher runs keeps being served
250
+ // `foo.ts` until restart — the filter would be answering from a directory
251
+ // that no longer exists in that shape.
165
252
  const listings = new Map();
166
253
  const listingFor = (dir) => {
167
254
  const cached = listings.get(dir);
@@ -196,6 +283,19 @@ export function platformResolvePlugin(options) {
196
283
  const warned = new Set();
197
284
  return {
198
285
  name: 'gjsify-platform-resolve',
286
+ // The sibling index's invalidation. Both hosts call this for every path
287
+ // the watcher reports (`create` / `update` / `delete`), which is exactly
288
+ // the event that can make a cached listing wrong — a NEW `foo.web.ts` is
289
+ // a `create` in the directory the filter already answered for.
290
+ //
291
+ // Only the CHANGED path's directory is dropped, not the whole map: the
292
+ // map is refilled lazily, so a blanket clear would re-`readdirSync` every
293
+ // directory the next rebuild touches for one unrelated save. `dirname`
294
+ // of a virtual or `\0`-prefixed id is a directory that was never cached,
295
+ // and deleting an absent key is a no-op — no guard needed for that.
296
+ watchChange(id) {
297
+ listings.delete(dirname(id));
298
+ },
199
299
  resolveId: {
200
300
  order: 'pre',
201
301
  async handler(source, importer, extraOptions) {
@@ -204,6 +304,23 @@ export function platformResolvePlugin(options) {
204
304
  return null;
205
305
  if (!source.startsWith('./') && !source.startsWith('../'))
206
306
  return null;
307
+ // A QUERY NAMES A TRANSFORM OF ONE FILE, NOT A FILE TO FORK.
308
+ //
309
+ // `./x.blp?shared-tree`, `?raw`, `?url`: the plugin serving the query owns the
310
+ // resolution, and there is no second FILE for a platform chain to prefer —
311
+ // whatever the suffix is appended to, `./x.blp?shared-tree.native` is a
312
+ // specifier no convention gives a meaning to.
313
+ //
314
+ // Standing down is not merely tidier here, it is required. A probe that misses
315
+ // is normally free: `this.resolve('./x.native')` on a path that is not there
316
+ // returns null and the loop walks on. With a query in the specifier it is NOT
317
+ // — measured on `--app nativescript`, the missed probe surfaced as
318
+ // `UNLOADABLE_DEPENDENCY … No such file or directory (os error 2)` against the
319
+ // ORIGINAL import, and the chain never reached the plugin that would have
320
+ // resolved it. So the blind probe does not waste a lookup, it fails the build,
321
+ // and it does so pointing at a line that is correct.
322
+ if (source.includes('?'))
323
+ return null;
207
324
  const extMatch = KNOWN_EXT_RE.exec(source);
208
325
  const origExt = extMatch ? extMatch[0] : '';
209
326
  const base = origExt ? source.slice(0, -origExt.length) : source;
@@ -253,12 +370,16 @@ export function platformResolvePlugin(options) {
253
370
  if (!probe || warned.has(probe.id))
254
371
  continue;
255
372
  warned.add(probe.id);
373
+ // The remedy names THIS chain's own rungs. It used to end in
374
+ // a hardcoded `.desktop`, which was true of the only chain
375
+ // that had a refusal list and became advice to write a file
376
+ // the browser chain refuses the moment a second one did.
377
+ const ownVariants = suffixes.map((own) => `.${own}`).join(' / ');
256
378
  this.warn(`gjsify platform resolve: "${base}.${suffix}" exists but is NOT a rung of this ` +
257
379
  `target's chain (${suffixes.join(' → ')} → base), so ${importer} gets the base ` +
258
380
  `file. ADR 0032 § 9: a .${suffix} variant is written for a runtime this build ` +
259
381
  `is not, and reaching for it would hand this target code whose failure only ` +
260
- `shows up on screen. Move what applies here into a .${suffixes[0]} or ` +
261
- `.desktop variant.`);
382
+ `shows up on screen. Move what applies here into ${ownVariants} or the base file.`);
262
383
  }
263
384
  return null;
264
385
  },
@@ -58,13 +58,29 @@ export interface RewriteResult {
58
58
  moduleType?: 'ts' | 'js';
59
59
  map?: null;
60
60
  }
61
+ /** The `import.meta` members the rewriter answers for: Node's three location properties. */
62
+ type ImportMetaProp = 'url' | 'dirname' | 'filename';
63
+ /**
64
+ * Replace every `import.meta.url` / `.dirname` / `.filename` expression in `src`.
65
+ *
66
+ * On the AST, not the text: the token also occurs inside STRINGS, and a text rewrite
67
+ * put a quoted replacement inside a quoted key — vite's `define: { "import.meta.url":
68
+ * … }` became `"__gjsifyModuleUrl("vite/…")"`, a PARSE_ERROR ("Expected `:` but found
69
+ * `Identifier`") that failed every `--app gjs` build reaching vite or wxt. A source
70
+ * acorn cannot parse keeps the token rewrite it always had.
71
+ *
72
+ * `dirname` and `filename` (Node ≥ 20.11) are answered too: GJS defines neither, so
73
+ * unplugin's `path.resolve(import.meta.dirname, …)` threw `The "path" argument must be
74
+ * of type string. Received type undefined` at load — where wxt's rebuild stopped next.
75
+ */
76
+ export declare function replaceImportMeta(src: string, path: string, replacements: Record<ImportMetaProp, string>): string;
61
77
  /**
62
78
  * Pure rewriter: the rewritten code plus the module type to re-parse it with, or `null`
63
79
  * when the file references none of the tokens.
64
80
  */
65
81
  export declare function rewriteContents(args: {
66
82
  path: string;
67
- }, srcInput: string, bundleDir: string, runtimeResolve: boolean): RewriteResult | null;
83
+ }, srcInput: string, bundleDir: string, runtimeResolve: boolean, declare?: (abs: string) => void): RewriteResult | null;
68
84
  export interface NodeModulesPathRewriteOptions {
69
85
  /** Bundle output directory, derived from `output.file` / `output.dir`. */
70
86
  bundleDir: string;
@@ -85,3 +101,4 @@ export interface NodeModulesPathRewriteOptions {
85
101
  * wider scope nearly free. See {@link shouldInline} for why the scopes differ.
86
102
  */
87
103
  export declare function nodeModulesPathRewritePlugin(options: NodeModulesPathRewriteOptions): Plugin;
104
+ export {};