@gjsify/rolldown-plugin-gjsify 0.48.0 → 0.50.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.
@@ -2,7 +2,11 @@
2
2
  //
3
3
  // Browser builds redirect `@girs/*` and `gi://*` to an empty virtual module
4
4
  // (they appear transitively via `@gjsify/unit` and similar packages with
5
- // GJS-specific code paths). Bare Node specifiers + their `node:*` prefix
5
+ // GJS-specific code paths) — unless `--gi-renderer` composes the ADR 0034
6
+ // stage 9 arm, which answers `gi://Adw` / `gi://Gtk` out of `@gjsify/adwaita-web`
7
+ // and carves `@girs/*` out of the empty redirect so they reach it.
8
+ //
9
+ // Bare Node specifiers + their `node:*` prefix
6
10
  // variants are routed to `@gjsify/<X>` via the curated
7
11
  // `ALIASES_NODE_FOR_BROWSER` table, whose VALUES already carry the
8
12
  // per-runtimes-triplet slot routing (`@gjsify/<X>` → `@gjsify/<X>/browser` /
@@ -15,9 +19,10 @@
15
19
  import { aliasPlugin } from '../plugins/alias.js';
16
20
  import { deepkitPlugin } from '@gjsify/rolldown-plugin-deepkit';
17
21
  import blueprintPlugin from '@gjsify/vite-plugin-blueprint';
18
- import { ALIASES_NODE_FOR_BROWSER, getDerivedAliasesSync } from '@gjsify/resolve-npm';
22
+ import { ALIASES_NODE_FOR_BROWSER, GI_RENDERERS, getDerivedAliasesSync } from '@gjsify/resolve-npm';
19
23
  import { globToEntryPoints } from '../utils/entry-points.js';
20
24
  import { gjsImportsEmptyPlugin } from '../plugins/gjs-imports-empty.js';
25
+ import { giRendererPlugin } from '../plugins/gi-renderer.js';
21
26
  import { cssAsStringPlugin } from '../plugins/css-as-string.js';
22
27
  import { unresolvedWorkspaceImportPlugin } from '../plugins/unresolved-workspace-import.js';
23
28
  export const setupForBrowser = async (input) => {
@@ -25,6 +30,9 @@ export const setupForBrowser = async (input) => {
25
30
  const external = [...userExternal];
26
31
  const exclude = input.pluginOptions.exclude ?? [];
27
32
  const entryPoints = await globToEntryPoints(input.input, exclude);
33
+ // The arm is composed only when asked for. `config.ts` already refuses the flag
34
+ // on a target with no row, so an undefined row here means the flag was not passed.
35
+ const giRenderer = input.pluginOptions.giRenderer ? GI_RENDERERS['browser'] : undefined;
28
36
  // Bare Node-builtin + `node:*` prefix aliases — both forms route to the
29
37
  // same `@gjsify/<X>` target. Generated deterministically from
30
38
  // `ALIASES_NODE_FOR_BROWSER` so a single source-of-truth in
@@ -90,7 +98,13 @@ export const setupForBrowser = async (input) => {
90
98
  // transform can strip the annotations out from under it (see `GjsifyConfig`).
91
99
  const prePlugins = [deepkitPlugin({ reflection: input.pluginOptions.reflection })];
92
100
  const plugins = [
93
- gjsImportsEmptyPlugin(),
101
+ // ADR 0034 stage 9 — the `gi://` arm, ahead of the empty redirect so it
102
+ // claims the specifier first, exactly as `gjsGiNodePlugin` does on the node
103
+ // target. `emptyGirs` follows it: with the arm on, `@girs/<ns>-<ver>` must
104
+ // reach its real body so its inner `gi://` lands here instead of being
105
+ // stranded as `{}`.
106
+ ...(giRenderer ? [giRendererPlugin({ app: 'browser', ...giRenderer })] : []),
107
+ gjsImportsEmptyPlugin({ emptyGirs: !giRenderer }),
94
108
  aliasPlugin({ entries: aliasEntries }),
95
109
  blueprintPlugin(),
96
110
  cssAsStringPlugin(),
@@ -13,7 +13,9 @@
13
13
  //
14
14
  // `@girs/*` and `gi://*` imports are silenced via `gjsImportsEmptyPlugin` —
15
15
  // they appear transitively through `@gjsify/unit` and similar packages with
16
- // GJS-specific code paths that never execute on NS.
16
+ // GJS-specific code paths that never execute on NS. `--gi-renderer` (ADR 0034
17
+ // stage 9) opts out of that for `gi://Adw` / `gi://Gtk`, answering them from
18
+ // `@gjsify/adwaita-nativescript` and carving `@girs/*` out of the empty redirect.
17
19
  //
18
20
  // Bare Node specifiers + their `node:*` prefix variants are routed to
19
21
  // `@gjsify/<X>` via the curated `ALIASES_NODE_FOR_NATIVESCRIPT` table. Unlike
@@ -34,9 +36,10 @@
34
36
  // of `@nativescript/core`) and no `blueprintPlugin` (Blueprint is GTK-only).
35
37
  import { aliasPlugin } from '../plugins/alias.js';
36
38
  import { deepkitPlugin } from '@gjsify/rolldown-plugin-deepkit';
37
- import { ALIASES_NODE_FOR_NATIVESCRIPT, getDerivedAliasesSync } from '@gjsify/resolve-npm';
39
+ import { ALIASES_NODE_FOR_NATIVESCRIPT, GI_RENDERERS, getDerivedAliasesSync } from '@gjsify/resolve-npm';
38
40
  import { globToEntryPoints } from '../utils/entry-points.js';
39
41
  import { gjsImportsEmptyPlugin } from '../plugins/gjs-imports-empty.js';
42
+ import { giRendererPlugin } from '../plugins/gi-renderer.js';
40
43
  import { platformResolvePlugin, nativescriptSuffixChain, detectNativescriptPlatform, nativescriptPlatformDefines, } from '../plugins/platform-resolve.js';
41
44
  import { unresolvedWorkspaceImportPlugin } from '../plugins/unresolved-workspace-import.js';
42
45
  // Never bundled: an app works only with the ONE core instance the runtime boots, and what
@@ -49,6 +52,9 @@ export const setupForNativescript = async (input) => {
49
52
  const isExternal = (id) => external.some((entry) => (typeof entry === 'string' ? entry === id : entry.test(id)));
50
53
  const exclude = input.pluginOptions.exclude ?? [];
51
54
  const entryPoints = await globToEntryPoints(input.input, exclude);
55
+ // The arm is composed only when asked for. `config.ts` already refuses the flag
56
+ // on a target with no row, so an undefined row here means the flag was not passed.
57
+ const giRenderer = input.pluginOptions.giRenderer ? GI_RENDERERS['nativescript'] : undefined;
52
58
  // Target platform — discovered from the env NS' CLI sets when it spawns a
53
59
  // bundler (else `undefined` → `.native`-only resolution + neutral defines).
54
60
  // `gjsify build` is a production bundler, so `__DEV__` is `false` here.
@@ -128,7 +134,13 @@ export const setupForNativescript = async (input) => {
128
134
  // transform can strip the annotations out from under it (see `GjsifyConfig`).
129
135
  const prePlugins = [deepkitPlugin({ reflection: input.pluginOptions.reflection })];
130
136
  const plugins = [
131
- gjsImportsEmptyPlugin(),
137
+ // ADR 0034 stage 9 — the `gi://` arm, ahead of the empty redirect so it
138
+ // claims the specifier first, exactly as `gjsGiNodePlugin` does on the node
139
+ // target. `emptyGirs` follows it: with the arm on, `@girs/<ns>-<ver>` must
140
+ // reach its real body so its inner `gi://` lands here instead of being
141
+ // stranded as `{}`.
142
+ ...(giRenderer ? [giRendererPlugin({ app: 'nativescript', ...giRenderer })] : []),
143
+ gjsImportsEmptyPlugin({ emptyGirs: !giRenderer }),
132
144
  // Platform-specific source variants (`*.android` / `*.ios` /
133
145
  // `*.native`) win over the base file — resolved BEFORE the Node-builtin
134
146
  // alias routing so a platform fork of a portable module is honored.
@@ -0,0 +1,35 @@
1
+ import type { Plugin } from 'rolldown';
2
+ export interface GiRendererOptions {
3
+ /** The `--app` target, named in every refusal so the message says WHERE it applies. */
4
+ app: string;
5
+ /** The package whose `Adw` / `Gtk` namespace exports answer `gi://` on this target. */
6
+ renderer: string;
7
+ /** GI namespace → the version this arm answers for, from `@gjsify/resolve-npm`. */
8
+ namespaces: Readonly<Record<string, string>>;
9
+ }
10
+ /**
11
+ * The runtime shim for one answered namespace.
12
+ *
13
+ * Exports `default` and nothing else, which is what GJS's own `gi://` module exports:
14
+ * measured on gjs 1.88.1, `import * as Adw from 'gi://Adw?version=1'` yields a namespace
15
+ * whose only key is `default`, and `import { ActionRow } from 'gi://Adw?version=1'` is a
16
+ * SyntaxError (`doesn't provide an export named: 'ActionRow'`). Adding named member
17
+ * exports here would compile on these two targets and fail on `--app gjs` — a second
18
+ * spelling, which is the thing ADR 0034 exists to remove.
19
+ *
20
+ * ITS REFUSAL NAMES THE NAMESPACE AND VERSION SEPARATELY RATHER THAN QUOTING THE
21
+ * SPECIFIER. `tests/e2e/gi-renderer-arms` asserts `!bundle.includes('gi://')` on the
22
+ * bundles it builds — a SUBSTRING, because on these two targets an unresolved GI import
23
+ * is a missing alias (`tests/AGENTS.md`) — and this string is reachable, so rolldown keeps
24
+ * it. Measured: with the specifier quoted, both green probe bundles carried one `gi://`
25
+ * occurrence and neither of them was an import.
26
+ *
27
+ * THE CONSTRAINT IS SELF-IMPOSED, and only that one suite imposes it. `app-browser` and
28
+ * `ns-bridge-bundles` hold the same substring rule over their own bundles, but neither
29
+ * passes `--gi-renderer`, so the arm never composes there and this text cannot reach them
30
+ * — measured, nothing under `tests/` names the flag but `gi-renderer-arms` itself. Making
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`.
33
+ */
34
+ export declare function giRendererShimSource(options: GiRendererOptions, namespace: string, version: string): string;
35
+ export declare function giRendererPlugin(options: GiRendererOptions): Plugin;
@@ -0,0 +1,162 @@
1
+ // For `--app browser` / `--app nativescript`: resolve `gi://Ns?version=X` to the
2
+ // TARGET'S WIDGET RENDERER instead of an empty module — ADR 0034 stage 9, the
3
+ // `gi://` arms.
4
+ //
5
+ // WHAT IT BUYS. `import Adw from 'gi://Adw?version=1'` is the spelling every GJS
6
+ // source already uses, and `@girs/adw-1/adw-1.js` is literally that line plus
7
+ // `export default Adw`. Without an arm the specifier lands in
8
+ // `gjsImportsEmptyPlugin` and becomes `{}`, so `Adw.ActionRow` is `undefined` and
9
+ // `class Row extends Adw.ActionRow {}` throws `Class extends value undefined is not
10
+ // a constructor or null` — measured on both targets before this plugin existed, and
11
+ // the exact failure the sibling header records for `@girs/*`. With the arm the same
12
+ // import evaluates to `@gjsify/adwaita-web`'s (or `@gjsify/adwaita-nativescript`'s)
13
+ // `Adw` namespace, which ADR 0034 clause 2 makes a namespace OBJECT precisely so it
14
+ // can stand in for the GI one.
15
+ //
16
+ // IT IS OPT-IN (`--gi-renderer`), and NOT because turning it on breaks anything here.
17
+ // That was the hypothesis and it is measured wrong: forcing the arm on across the 53
18
+ // `--app browser` test entries and the 5 `packages/nativescript-bridge/*` packages in
19
+ // this repository changes ZERO outcomes, because no `gi://` reaches those builds at
20
+ // all — instrumented, the only specifier that arrives at the empty-import plugin is
21
+ // `@girs/gjs` (7 entries), whose body is `globalThis.imports || {}`. That measurement
22
+ // bounds the blast radius HERE and says nothing about a consumer tree, where a
23
+ // transitively-imported `gi://GLib` would go from a silent stub to a build failure.
24
+ //
25
+ // What opt-in buys is that the arm makes a tier-2 widget toolkit a BUILD-TIME
26
+ // DEPENDENCY of any bundle naming `gi://Adw` (measured on the probe fixture:
27
+ // 133 bytes to 500 202 on browser and 214 041 on NativeScript, the latter with a
28
+ // `@nativescript/core` import clause the flag-less bundle did not have), and nothing
29
+ // in a tree can infer that its `gi://Adw` was meant to be `@gjsify/adwaita-web`
30
+ // rather than nothing. `--dialect` is opt-in for the same shape of reason; see
31
+ // `PluginOptions.dialect`.
32
+ //
33
+ // THREE REFUSALS, EACH AT THE EARLIEST POINT ITS FACT IS KNOWN.
34
+ //
35
+ // 1. A namespace with no renderer — `gi://Gio` — is refused at BUILD time, from
36
+ // `resolveId`. The specifier carries the namespace, so nothing has to run for
37
+ // the answer to be knowable, and the alternative is the empty module this arm
38
+ // exists to replace.
39
+ // 2. A `?version=` the renderer's vocabulary was not generated against is refused
40
+ // at BUILD time too. GJS itself throws here (`Requiring Adw, version 9: Typelib
41
+ // file for namespace 'Adw', version '9' not found`, measured on gjs 1.88.1), so
42
+ // accepting it would make these two targets the only place a wrong version
43
+ // passes.
44
+ // 3. An ABSENT MEMBER — `Adw.Window` on a renderer that ships no window — is
45
+ // refused at RUNTIME, by a Proxy, because a property access is not knowable
46
+ // from a specifier. These namespaces are SPARSE by construction (a renderer
47
+ // implements a fraction of the GIR's widgets), so this is ADR 0034 § 1 clause 3
48
+ // at member granularity: reading an absent member would hand back `undefined`
49
+ // and re-create the very `Class extends value undefined` this arm removes,
50
+ // thrown far from the import that caused it.
51
+ //
52
+ // WHY A PROXY AND NOT A COPIED OBJECT. The member set has to be the renderer's own,
53
+ // read at runtime from the module it re-exports — a second list here would be the
54
+ // copy that drifts, and `Object.keys` of the real namespace is also what makes the
55
+ // refusal able to PRINT what is available.
56
+ //
57
+ // THE EMITTED MODULE IS NOT ALIASED, by construction: `aliasPlugin` skips importers
58
+ // whose id carries `GJSIFY_VIRTUAL_PREFIX`, so the renderer import below names the
59
+ // package the arm decided on and no slot-routing or user `--alias` re-points it. On
60
+ // `--app nativescript` that layer would today be a no-op anyway — the renderer
61
+ // declares `nativescript: "native"` and ships no `globals.mjs`, so the derived map
62
+ // leaves it alone with a warn-once (visible in every NS build's output) — but the
63
+ // exemption is what keeps that true after the slot vocabulary is settled.
64
+ //
65
+ // Portability note (as in `gjs-imports-empty.ts`): the `filter` is a Rolldown
66
+ // fast-path; the guard inside the handler is the load-bearing check, so the plugin
67
+ // stays correct under an engine that does not pre-filter.
68
+ import { GJSIFY_VIRTUAL_PREFIX } from '../utils/virtual-module-id.js';
69
+ import { parseGiSpecifier } from './gjs-gi-node.js';
70
+ const GI_RENDERER_VIRTUAL_PREFIX = `${GJSIFY_VIRTUAL_PREFIX}gi-renderer:`;
71
+ /**
72
+ * The runtime shim for one answered namespace.
73
+ *
74
+ * Exports `default` and nothing else, which is what GJS's own `gi://` module exports:
75
+ * measured on gjs 1.88.1, `import * as Adw from 'gi://Adw?version=1'` yields a namespace
76
+ * whose only key is `default`, and `import { ActionRow } from 'gi://Adw?version=1'` is a
77
+ * SyntaxError (`doesn't provide an export named: 'ActionRow'`). Adding named member
78
+ * exports here would compile on these two targets and fail on `--app gjs` — a second
79
+ * spelling, which is the thing ADR 0034 exists to remove.
80
+ *
81
+ * ITS REFUSAL NAMES THE NAMESPACE AND VERSION SEPARATELY RATHER THAN QUOTING THE
82
+ * SPECIFIER. `tests/e2e/gi-renderer-arms` asserts `!bundle.includes('gi://')` on the
83
+ * bundles it builds — a SUBSTRING, because on these two targets an unresolved GI import
84
+ * is a missing alias (`tests/AGENTS.md`) — and this string is reachable, so rolldown keeps
85
+ * it. Measured: with the specifier quoted, both green probe bundles carried one `gi://`
86
+ * occurrence and neither of them was an import.
87
+ *
88
+ * THE CONSTRAINT IS SELF-IMPOSED, and only that one suite imposes it. `app-browser` and
89
+ * `ns-bridge-bundles` hold the same substring rule over their own bundles, but neither
90
+ * passes `--gi-renderer`, so the arm never composes there and this text cannot reach them
91
+ * — measured, nothing under `tests/` names the flag but `gi-renderer-arms` itself. Making
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`.
94
+ */
95
+ export function giRendererShimSource(options, namespace, version) {
96
+ return (`import { ${namespace} as namespace } from ${JSON.stringify(options.renderer)};\n` +
97
+ `const RENDERER = ${JSON.stringify(options.renderer)};\n` +
98
+ `const APP = ${JSON.stringify(options.app)};\n` +
99
+ `const NS = ${JSON.stringify(namespace)};\n` +
100
+ `const VERSION = ${JSON.stringify(version)};\n` +
101
+ `export default new Proxy(namespace, {\n` +
102
+ ` get(target, property, receiver) {\n` +
103
+ // Symbols are protocol probes (`Symbol.toStringTag`, `Symbol.iterator`,
104
+ // `Symbol.toPrimitive`) and `then` is the thenable probe every `await` and
105
+ // dynamic `import()` performs; refusing those would break the module rather
106
+ // than report a missing widget.
107
+ ` if (typeof property === 'symbol' || property === 'then' || property in target) {\n` +
108
+ ` return Reflect.get(target, property, receiver);\n` +
109
+ ` }\n` +
110
+ ` throw new Error(\n` +
111
+ ` 'the ' + NS + ' arm (version ' + VERSION + ') on --app ' + APP + ': ' + RENDERER +\n` +
112
+ ` ' has no ' + String(property) + '. A renderer implements a fraction of the GIR ' +\n` +
113
+ ` 'vocabulary, and every absent member is a declared remainder (ADR 0034 clause 3) ' +\n` +
114
+ ` 'rather than an oversight — refused by name here because reading it would be ' +\n` +
115
+ ` 'undefined and \`class X extends undefined\` throws far from the import that asked ' +\n` +
116
+ ` 'for it. It has: ' + Object.keys(target).sort().join(', ') + '.'\n` +
117
+ ` );\n` +
118
+ ` },\n` +
119
+ `});\n`);
120
+ }
121
+ export function giRendererPlugin(options) {
122
+ const known = Object.keys(options.namespaces).sort();
123
+ const answered = known.map((name) => `${name} (version ${options.namespaces[name]})`).join(', ');
124
+ return {
125
+ name: 'gjsify-gi-renderer',
126
+ resolveId: {
127
+ order: 'pre',
128
+ filter: { id: /^gi:\/\// },
129
+ handler(source) {
130
+ const parsed = parseGiSpecifier(source);
131
+ if (parsed === null)
132
+ return null;
133
+ const expected = options.namespaces[parsed.namespace];
134
+ if (expected === undefined) {
135
+ throw new Error(`gjsify build --app ${options.app}: \`${source}\` has no widget renderer. ` +
136
+ `--gi-renderer answers ${answered} out of ${options.renderer} and nothing else — ` +
137
+ 'there is no GObject introspection on this target, so a namespace with no renderer ' +
138
+ 'counterpart cannot be substituted for. Drop --gi-renderer to keep the previous ' +
139
+ 'behaviour (every `gi://` import becomes an empty module), or gate the import to ' +
140
+ 'a target that has GI (`--app gjs`, `--app node`).');
141
+ }
142
+ if (parsed.version !== undefined && parsed.version !== expected) {
143
+ throw new Error(`gjsify build --app ${options.app}: \`${source}\` asks for ${parsed.namespace} version ` +
144
+ `${parsed.version}; this arm answers version ${expected} — the version ` +
145
+ `${options.renderer}'s vocabulary is held against. GJS throws on a version it has no ` +
146
+ 'typelib for, so accepting it here would make this target the only place a wrong ' +
147
+ 'version passes.');
148
+ }
149
+ return { id: `${GI_RENDERER_VIRTUAL_PREFIX}${parsed.namespace}` };
150
+ },
151
+ },
152
+ load(id) {
153
+ if (!id.startsWith(GI_RENDERER_VIRTUAL_PREFIX))
154
+ return null;
155
+ const namespace = id.slice(GI_RENDERER_VIRTUAL_PREFIX.length);
156
+ const version = options.namespaces[namespace];
157
+ if (version === undefined)
158
+ return null;
159
+ return { code: giRendererShimSource(options, namespace, version), moduleSideEffects: false };
160
+ },
161
+ };
162
+ }
@@ -87,6 +87,24 @@ export interface PluginOptions {
87
87
  * opposite questions and once shared a name. See `SourceDialect`.
88
88
  */
89
89
  dialect?: SourceDialect;
90
+ /**
91
+ * Resolve `gi://Ns?version=X` to the target's widget renderer rather than to an
92
+ * empty module — ADR 0034 stage 9. `--app browser` answers out of
93
+ * `@gjsify/adwaita-web`, `--app nativescript` out of `@gjsify/adwaita-nativescript`;
94
+ * the table is `GI_RENDERERS` in `@gjsify/resolve-npm`, and a target with no row
95
+ * there has no arm.
96
+ *
97
+ * Composing it also sets `emptyGirs: false` on the same build, so `@girs/adw-1`
98
+ * reaches its real body (`import Adw from 'gi://Adw?version=1'; export default Adw`)
99
+ * and its inner `gi://` is claimed here — identical to the node-gi path.
100
+ *
101
+ * OPT-IN and never inferred, for the reason `plugins/gi-renderer.ts` states at
102
+ * length: a `gi://` import reaching a browser or NativeScript build today is
103
+ * usually a GJS-only code path pulled in transitively, and answering it by default
104
+ * would both drag a widget toolkit into bundles that never asked for one and turn
105
+ * every unanswerable namespace from a silent stub into a hard build failure.
106
+ */
107
+ giRenderer?: boolean;
90
108
  /**
91
109
  * Where to look for a `@gjsify/*` the PROJECT cannot resolve, when the input is
92
110
  * TOOLCHAIN rather than user code: a file path inside the running CLI's own
@@ -5,7 +5,7 @@ export * from './runtime.js';
5
5
  export * from './merge.js';
6
6
  export { detectFreeGlobals } from './detect-free-globals.js';
7
7
  export { resolveGlobalsList, writeRegisterInjectFile } from './scan-globals.js';
8
- export { inlineStaticReads, isAbsoluteFsPath } from './inline-static-reads.js';
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
11
  export type { SurvivingJsx } from './jsx-survival.js';
@@ -5,6 +5,6 @@ export * from './runtime.js';
5
5
  export * from './merge.js';
6
6
  export { detectFreeGlobals } from './detect-free-globals.js';
7
7
  export { resolveGlobalsList, writeRegisterInjectFile } from './scan-globals.js';
8
- export { inlineStaticReads, isAbsoluteFsPath } from './inline-static-reads.js';
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';
@@ -2,6 +2,55 @@ export declare function inlineStaticReads(src: string, sourceFilePath: string):
2
2
  contents: string;
3
3
  inlined: number;
4
4
  };
5
+ /**
6
+ * The tree whose contents are a property of the BUILD rather than of the host —
7
+ * the reading module's own package.
8
+ *
9
+ * A package's own resources are what this module exists to bundle. Anything else
10
+ * — `/lib`, `/etc`, `/proc`, a sibling package's data — is either the machine's
11
+ * layout or another package's business, and its answer at build time is not its
12
+ * answer at run time.
13
+ *
14
+ * TWO RULES, because "the package" is answered two different ways and picking one
15
+ * of them gets the other wrong.
16
+ *
17
+ * For an INSTALLED file the root is STRUCTURAL: the directory whose parent is
18
+ * `node_modules` (or `node_modules/@scope`). Probing for the nearest
19
+ * `package.json` instead lands on the wrong directory for any package that
20
+ * dual-publishes, because the `{"type":"commonjs"}` marker beside the emitted
21
+ * output IS a `package.json`, and some of those markers carry the package's own
22
+ * `name` too — so neither presence nor a `name` tells a marker from a root.
23
+ * Measured in this repo's installed tree: `engine.io-client/build/{cjs,esm}` and
24
+ * `@socket.io/component-emitter/lib/cjs` are that shape. A module there reading
25
+ * its package's real manifest — the `new URL('../package.json',
26
+ * import.meta.url)` this file's header opens with, one level deeper — would be
27
+ * reading OUTSIDE its root, get declined, and ship the live read this module
28
+ * exists to remove.
29
+ *
30
+ * For a FIRST-PARTY file the root is the nearest ancestor carrying a
31
+ * `package.json`, and it has to be the NEAREST: in a monorepo the outermost one
32
+ * is the workspace root, which would let one package inline its siblings' files.
33
+ * The fallback when nothing above declares one is the module's own directory,
34
+ * which is the narrowest answer available rather than a licence to read upward.
35
+ */
36
+ export declare function resourceRootFor(sourceFilePath: string): string;
37
+ /**
38
+ * Is `target` inside `root` (or `root` itself)?
39
+ *
40
+ * `relative()` rather than `startsWith`: the string test calls `/lib64` a child
41
+ * of `/lib`. `resolve()` first, so a `..` composition is normalised before the
42
+ * comparison rather than after it. `platform` is injected and the branches use
43
+ * `path.win32`/`path.posix` explicitly, the same shape as
44
+ * {@link isAbsoluteFsPath}, so both run from either host.
45
+ *
46
+ * TEXTUAL, deliberately: no `realpathSync`. Both directions of that choice are
47
+ * the safe one here. A read that reaches into the package through a symlink
48
+ * resolves to a path outside the root and is DECLINED, which leaves a working
49
+ * runtime read; and a path textually inside the root cannot be widened by a
50
+ * symlink npm never creates. Resolving would also cost a syscall per candidate
51
+ * and break the zip-resident PnP case, whose paths are not files.
52
+ */
53
+ export declare function isWithin(target: string, root: string, platform?: string): boolean;
5
54
  /**
6
55
  * Is `v` an absolute filesystem path on `platform`?
7
56
  *
@@ -40,6 +40,27 @@
40
40
  //
41
41
  // Anything not statically resolvable is left untouched — the legacy
42
42
  // `import.meta.url` rewriter still applies as a fallback.
43
+ //
44
+ // WHAT IS DELIBERATELY NOT INLINED, and it is not about resolvability. A read
45
+ // only belongs in the bundle when its answer is a property of the BUILD; a read
46
+ // of the machine's own layout is a property of the HOST, and freezing it ships
47
+ // the build machine's answer to every user. `evalPathExpr` therefore refuses a
48
+ // path that resolves outside the reading module's own package (see
49
+ // `resourceRootFor`), however statically resolvable it is.
50
+ //
51
+ // Measured on a OnePlus 6T (postmarketOS, musl 1.2.6, aarch64) against the
52
+ // bundled CLI of 2026-09-08, before that gate existed:
53
+ //
54
+ // readdirSync('/lib') 77 entries, no ld-musl-*
55
+ // existsSync('/lib/ld-musl-aarch64.so.1') false
56
+ //
57
+ // Both answers were the Fedora build host's, baked in at build time — the real
58
+ // directory holds 2076 entries including `ld-musl-aarch64.so.1`. That is the
59
+ // probe `detectHostLibc` reads, so a bundled `gjsify` answered `glibc` on a musl
60
+ // machine and every musl-aware decision downstream — `prebuildDirCandidates`'
61
+ // `-musl` preference, the launcher shims, the fallback report — was inert for
62
+ // the life of the bundle. Ten runs, ten times `glibc`. Nothing failed loudly:
63
+ // the glibc prebuild loads under musl's loader until it wants a glibc symbol.
43
64
  import * as acorn from 'acorn';
44
65
  import { tsPlugin } from 'acorn-typescript';
45
66
  import { dirname, join, resolve, basename, relative, extname, posix, win32 } from 'node:path';
@@ -138,6 +159,7 @@ export function inlineStaticReads(src, sourceFilePath) {
138
159
  }
139
160
  const ctx = {
140
161
  sourceUrl: pathToFileURL(sourceFilePath).href,
162
+ resourceRoot: resourceRootFor(sourceFilePath),
141
163
  };
142
164
  const edits = [];
143
165
  forEachCallExpression(ast, (node) => {
@@ -341,18 +363,125 @@ function tryInlineReadFile(node, ctx, forceTextEncoding) {
341
363
  */
342
364
  function evalPathExpr(node, ctx) {
343
365
  const v = evalExpr(node, ctx);
366
+ // ONE exit for both shapes, and that is the whole point of the arrangement. The
367
+ // URL branch used to `return` before any gate ran, so `new URL('/proc',
368
+ // import.meta.url)` and `new URL('file:///lib')` were read on the builder while
369
+ // the same read spelled `fileURLToPath(new URL(…))` was declined. Two spellings
370
+ // of one expression cannot answer differently, and `new URL(<lit>,
371
+ // import.meta.url)` is the spelling this module was written for.
372
+ let asPath;
344
373
  if (v instanceof URL) {
345
- if (v.protocol === 'file:')
346
- return fileURLToPath(v);
347
- return undefined;
374
+ asPath = v.protocol === 'file:' ? fileURLToPath(v) : undefined;
375
+ }
376
+ else if (typeof v === 'string') {
377
+ asPath = v.startsWith('file://') ? fileURLToPath(v) : isAbsoluteFsPath(v) ? v : undefined;
348
378
  }
349
- if (typeof v !== 'string')
379
+ if (asPath === undefined)
350
380
  return undefined;
351
- if (v.startsWith('file://'))
352
- return fileURLToPath(v);
353
- if (isAbsoluteFsPath(v))
354
- return v;
355
- return undefined;
381
+ // Resolvable is not the same as bundlable. Declining is the safe direction:
382
+ // the call is simply left in place and reads at runtime, which is what a host
383
+ // probe wants. Silent on purpose — a probe of `/proc`, `/sys` or `/lib` is
384
+ // ordinary in a runtime shim, and warning about each one would bury the
385
+ // decline that matters under the ones that do not. What stops this silence
386
+ // costing what `isAbsoluteFsPath`'s did is that the declines have ROWS in
387
+ // `inline-static-reads.spec.ts` — a warning nobody reads would not have caught
388
+ // that one either.
389
+ if (!isWithin(asPath, ctx.resourceRoot))
390
+ return undefined;
391
+ return asPath;
392
+ }
393
+ /**
394
+ * The tree whose contents are a property of the BUILD rather than of the host —
395
+ * the reading module's own package.
396
+ *
397
+ * A package's own resources are what this module exists to bundle. Anything else
398
+ * — `/lib`, `/etc`, `/proc`, a sibling package's data — is either the machine's
399
+ * layout or another package's business, and its answer at build time is not its
400
+ * answer at run time.
401
+ *
402
+ * TWO RULES, because "the package" is answered two different ways and picking one
403
+ * of them gets the other wrong.
404
+ *
405
+ * For an INSTALLED file the root is STRUCTURAL: the directory whose parent is
406
+ * `node_modules` (or `node_modules/@scope`). Probing for the nearest
407
+ * `package.json` instead lands on the wrong directory for any package that
408
+ * dual-publishes, because the `{"type":"commonjs"}` marker beside the emitted
409
+ * output IS a `package.json`, and some of those markers carry the package's own
410
+ * `name` too — so neither presence nor a `name` tells a marker from a root.
411
+ * Measured in this repo's installed tree: `engine.io-client/build/{cjs,esm}` and
412
+ * `@socket.io/component-emitter/lib/cjs` are that shape. A module there reading
413
+ * its package's real manifest — the `new URL('../package.json',
414
+ * import.meta.url)` this file's header opens with, one level deeper — would be
415
+ * reading OUTSIDE its root, get declined, and ship the live read this module
416
+ * exists to remove.
417
+ *
418
+ * For a FIRST-PARTY file the root is the nearest ancestor carrying a
419
+ * `package.json`, and it has to be the NEAREST: in a monorepo the outermost one
420
+ * is the workspace root, which would let one package inline its siblings' files.
421
+ * The fallback when nothing above declares one is the module's own directory,
422
+ * which is the narrowest answer available rather than a licence to read upward.
423
+ */
424
+ export function resourceRootFor(sourceFilePath) {
425
+ const own = dirname(resolve(sourceFilePath));
426
+ const installed = installedPackageRootFor(own);
427
+ if (installed !== undefined)
428
+ return installed;
429
+ let dir = own;
430
+ for (;;) {
431
+ if (existsSync(join(dir, 'package.json')))
432
+ return dir;
433
+ const parent = dirname(dir);
434
+ if (parent === dir)
435
+ return own;
436
+ dir = parent;
437
+ }
438
+ }
439
+ /**
440
+ * The `node_modules/<name>` — or `node_modules/@scope/<name>` — directory `dir`
441
+ * lives in, or `undefined` when it is not inside an installed package.
442
+ *
443
+ * The LAST `node_modules` segment on the way up, so a nested dependency
444
+ * (`node_modules/a/node_modules/b`) and a Yarn-PnP zip
445
+ * (`…/cache/x.zip/node_modules/@scope/b`) both resolve to themselves rather than
446
+ * to whatever contains them. No filesystem probe: the layout is the answer, and a
447
+ * zip-resident path has no `package.json` a plain `existsSync` can see.
448
+ */
449
+ function installedPackageRootFor(dir) {
450
+ let cur = dir;
451
+ for (;;) {
452
+ const parent = dirname(cur);
453
+ if (parent === cur)
454
+ return undefined;
455
+ const parentName = basename(parent);
456
+ if (parentName === 'node_modules')
457
+ return cur;
458
+ if (parentName.startsWith('@') && basename(dirname(parent)) === 'node_modules')
459
+ return cur;
460
+ cur = parent;
461
+ }
462
+ }
463
+ /**
464
+ * Is `target` inside `root` (or `root` itself)?
465
+ *
466
+ * `relative()` rather than `startsWith`: the string test calls `/lib64` a child
467
+ * of `/lib`. `resolve()` first, so a `..` composition is normalised before the
468
+ * comparison rather than after it. `platform` is injected and the branches use
469
+ * `path.win32`/`path.posix` explicitly, the same shape as
470
+ * {@link isAbsoluteFsPath}, so both run from either host.
471
+ *
472
+ * TEXTUAL, deliberately: no `realpathSync`. Both directions of that choice are
473
+ * the safe one here. A read that reaches into the package through a symlink
474
+ * resolves to a path outside the root and is DECLINED, which leaves a working
475
+ * runtime read; and a path textually inside the root cannot be widened by a
476
+ * symlink npm never creates. Resolving would also cost a syscall per candidate
477
+ * and break the zip-resident PnP case, whose paths are not files.
478
+ */
479
+ export function isWithin(target, root, platform = process.platform) {
480
+ const p = platform === 'win32' ? win32 : posix;
481
+ const rel = p.relative(p.resolve(root), p.resolve(target));
482
+ if (rel === '')
483
+ return true;
484
+ return !rel.startsWith('..') && !p.isAbsolute(rel);
356
485
  }
357
486
  /**
358
487
  * Is `v` an absolute filesystem path on `platform`?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/rolldown-plugin-gjsify",
3
- "version": "0.48.0",
3
+ "version": "0.50.0",
4
4
  "description": "Rolldown / Rollup / Vite plugin orchestrator for GJS, Node, and Browser targets",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -60,11 +60,11 @@
60
60
  ],
61
61
  "license": "MIT",
62
62
  "dependencies": {
63
- "@gjsify/console": "^0.48.0",
64
- "@gjsify/resolve-npm": "^0.48.0",
65
- "@gjsify/rolldown-plugin-deepkit": "^0.48.0",
66
- "@gjsify/rolldown-plugin-pnp": "^0.48.0",
67
- "@gjsify/vite-plugin-blueprint": "^0.48.0",
63
+ "@gjsify/console": "^0.50.0",
64
+ "@gjsify/resolve-npm": "^0.50.0",
65
+ "@gjsify/rolldown-plugin-deepkit": "^0.50.0",
66
+ "@gjsify/rolldown-plugin-pnp": "^0.50.0",
67
+ "@gjsify/vite-plugin-blueprint": "^0.50.0",
68
68
  "@rollup/pluginutils": "^5.4.0",
69
69
  "acorn": "^8.17.0",
70
70
  "acorn-typescript": "^1.4.13",
@@ -74,7 +74,7 @@
74
74
  "sass": "^1.101.0"
75
75
  },
76
76
  "peerDependencies": {
77
- "@gjsify/lightningcss-native": "^0.48.0",
77
+ "@gjsify/lightningcss-native": "^0.50.0",
78
78
  "rolldown": "^1.1.4"
79
79
  },
80
80
  "peerDependenciesMeta": {
@@ -86,8 +86,8 @@
86
86
  }
87
87
  },
88
88
  "devDependencies": {
89
- "@gjsify/cli": "^0.48.0",
90
- "@gjsify/unit": "^0.48.0",
89
+ "@gjsify/cli": "^0.50.0",
90
+ "@gjsify/unit": "^0.50.0",
91
91
  "@types/node": "^25.9.2",
92
92
  "rolldown": "^1.1.4",
93
93
  "typescript": "^6.0.3"