@gjsify/rolldown-plugin-gjsify 0.53.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.
package/lib/app/gjs.js CHANGED
@@ -8,6 +8,7 @@ import { createRequire } from 'node:module';
8
8
  import { aliasPlugin } from '../plugins/alias.js';
9
9
  import { externalsPlugin } from '../plugins/externals.js';
10
10
  import { napiNodeAddonPlugin } from '../plugins/napi-node-addon.js';
11
+ import { giOptionalPlugin } from '../plugins/gi-optional.js';
11
12
  import { unresolvedWorkspaceImportPlugin } from '../plugins/unresolved-workspace-import.js';
12
13
  import { platformResolvePlugin, desktopSuffixChain, desktopOsSuffix, DESKTOP_REFUSED_SUFFIXES, } from '../plugins/platform-resolve.js';
13
14
  import { reactNativeAliasPlugin } from '../plugins/react-native-alias.js';
@@ -191,6 +192,9 @@ export const setupForGjs = async (input) => {
191
192
  // A module assigning the global `console` gets a local binding, or the inject
192
193
  // below turns its assignment into `ASSIGN_TO_IMPORT` and fails the build.
193
194
  ...(consoleShimPath ? [consoleAssignPlugin()] : []),
195
+ // `gi://Ns?version=X&optional` → a guarded import (ADR 0087), claimed `pre`
196
+ // so the externals policy never sees the flagged specifier.
197
+ giOptionalPlugin('gjs'),
194
198
  // Platform-file forks for the desktop, ADR 0032 § 9: `.gtk` → `.<os>` →
195
199
  // `.desktop` → base. BEFORE the alias layer, so a platform fork of a
196
200
  // module that also has a Node-builtin substitution wins over the
package/lib/app/node.js CHANGED
@@ -10,6 +10,7 @@ import { nodeModulesPathRewritePlugin, getBundleDirFromOutput } from '../plugins
10
10
  import { cssAsStringPlugin } from '../plugins/css-as-string.js';
11
11
  import { gjsImportsEmptyPlugin } from '../plugins/gjs-imports-empty.js';
12
12
  import { gjsGiNodePlugin, gjsBuiltinModulesNodePlugin } from '../plugins/gjs-gi-node.js';
13
+ import { giOptionalPlugin } from '../plugins/gi-optional.js';
13
14
  import { unresolvedWorkspaceImportPlugin } from '../plugins/unresolved-workspace-import.js';
14
15
  import { nodeNativeExternalPlugin } from '../plugins/node-native-external.js';
15
16
  import { platformResolvePlugin, desktopSuffixChain, desktopOsSuffix, DESKTOP_REFUSED_SUFFIXES, } from '../plugins/platform-resolve.js';
@@ -326,6 +327,13 @@ export const setupForNode = async (input) => {
326
327
  // `\0gjsify-entry:` ids `wrapInputWithSideEffects` produces (no-op when
327
328
  // nothing was injected).
328
329
  ...(virtualEntries.plugin ? [virtualEntries.plugin] : []),
330
+ // `gi://Ns?version=X&optional` → the guarded load, claimed `pre` and
331
+ // AHEAD of `gjsGiNodePlugin` on array order, because both match a flagged
332
+ // specifier and the optional arm has to win: the hard arm's lazy Proxy
333
+ // answers every member access with `load()` and is never `undefined`, so an
334
+ // app that degrades on `Ns === undefined` would compile and then throw at
335
+ // the first real access — on one target only (ADR 0087).
336
+ giOptionalPlugin('node'),
329
337
  // Claims `gi://Ns?version=X` (resolveId `pre` + array order) and rewrites it
330
338
  // onto the `@gjsify/node-gi` runtime so a real GJS/GI source builds and runs
331
339
  // on Node. Returns null for `@girs/*`.
package/lib/index.d.ts CHANGED
@@ -6,6 +6,8 @@ export { REWRITE_FILTER, extractPackageSpec, getBundleDirFromOutput, rewriteCont
6
6
  export type { NodeModulesPathRewriteOptions, RewriteResult } from './plugins/rewrite-node-modules-paths.js';
7
7
  export { processStubPlugin, GJS_PROCESS_STUB, composeBanner } from './plugins/process-stub.js';
8
8
  export { giRuntimePathsStub } from './plugins/gi-runtime-paths.js';
9
+ export { giOptionalPlugin, giOptionalShimSource, giOptionalNodeShimSource, giOptionalMarkerSource, parseOptionalGiSpecifier, GI_OPTIONAL_FLAG, GI_OPTIONAL_MARKER, } from './plugins/gi-optional.js';
10
+ export type { GiOptionalTarget } from './plugins/gi-optional.js';
9
11
  export { bindConsoleLocally, consoleAssignPlugin, freeConsoleAssignmentInsertion, CONSOLE_LOCAL_BINDING, } from './plugins/console-assign.js';
10
12
  export type { GiSystemProbe } from './plugins/gi-runtime-paths.js';
11
13
  export type { ProcessStubPluginOptions } from './plugins/process-stub.js';
package/lib/index.js CHANGED
@@ -6,6 +6,7 @@ export * from './library/index.js';
6
6
  export { REWRITE_FILTER, extractPackageSpec, getBundleDirFromOutput, rewriteContents, shouldRewrite, shouldInline, nodeModulesPathRewritePlugin, } from './plugins/rewrite-node-modules-paths.js';
7
7
  export { processStubPlugin, GJS_PROCESS_STUB, composeBanner } from './plugins/process-stub.js';
8
8
  export { giRuntimePathsStub } from './plugins/gi-runtime-paths.js';
9
+ export { giOptionalPlugin, giOptionalShimSource, giOptionalNodeShimSource, giOptionalMarkerSource, parseOptionalGiSpecifier, GI_OPTIONAL_FLAG, GI_OPTIONAL_MARKER, } from './plugins/gi-optional.js';
9
10
  export { bindConsoleLocally, consoleAssignPlugin, freeConsoleAssignmentInsertion, CONSOLE_LOCAL_BINDING, } from './plugins/console-assign.js';
10
11
  export { cssAsStringPlugin } from './plugins/css-as-string.js';
11
12
  export { textLoaderPlugin } from './plugins/text-loader.js';
@@ -99,12 +99,14 @@ async function tryLoadNativeBundler() {
99
99
  // specifier. A library that will not load then names its missing
100
100
  // dependency and the npm fallback runs, instead of the nameless
101
101
  // "Unsupported type void" inside `transform()`.
102
- // The same resolve-then-import dance as above, for the probe itself: by
103
- // the time a CSS transform asks for the native bundler, utils' `lib/esm`
104
- // is long built, so the lazy edge costs nothing and the static one would
105
- // have cost a bootable CLI. `./native-library` rather than `./core`:
106
- // `core` re-exports `main-loop`, whose module-level singleton would then
107
- // exist twice in a process that already has it inlined in the GJS bundle.
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.
108
110
  //
109
111
  // Its own `try` because the outer one cannot tell this apart from "there
110
112
  // is no native backend" — and reporting nothing is the one outcome this
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/rolldown-plugin-gjsify",
3
- "version": "0.53.0",
3
+ "version": "0.54.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",
@@ -63,12 +63,12 @@
63
63
  ],
64
64
  "license": "MIT",
65
65
  "dependencies": {
66
- "@gjsify/console": "^0.53.0",
67
- "@gjsify/resolve-npm": "^0.53.0",
68
- "@gjsify/rolldown-plugin-deepkit": "^0.53.0",
69
- "@gjsify/rolldown-plugin-pnp": "^0.53.0",
70
- "@gjsify/utils": "^0.53.0",
71
- "@gjsify/vite-plugin-blueprint": "^0.53.0",
66
+ "@gjsify/console": "^0.54.0",
67
+ "@gjsify/resolve-npm": "^0.54.0",
68
+ "@gjsify/rolldown-plugin-deepkit": "^0.54.0",
69
+ "@gjsify/rolldown-plugin-pnp": "^0.54.0",
70
+ "@gjsify/utils": "^0.54.0",
71
+ "@gjsify/vite-plugin-blueprint": "^0.54.0",
72
72
  "@rollup/pluginutils": "^5.4.0",
73
73
  "acorn": "^8.17.0",
74
74
  "acorn-typescript": "^1.4.13",
@@ -78,7 +78,7 @@
78
78
  "sass": "^1.101.0"
79
79
  },
80
80
  "peerDependencies": {
81
- "@gjsify/lightningcss-native": "^0.53.0",
81
+ "@gjsify/lightningcss-native": "^0.54.0",
82
82
  "rolldown": "^1.1.4"
83
83
  },
84
84
  "peerDependenciesMeta": {
@@ -90,8 +90,8 @@
90
90
  }
91
91
  },
92
92
  "devDependencies": {
93
- "@gjsify/cli": "^0.53.0",
94
- "@gjsify/unit": "^0.53.0",
93
+ "@gjsify/cli": "^0.54.0",
94
+ "@gjsify/unit": "^0.54.0",
95
95
  "@types/node": "^25.9.2",
96
96
  "rolldown": "^1.1.4",
97
97
  "typescript": "^6.0.3"