@gjsify/rolldown-plugin-gjsify 0.53.0 → 0.55.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.
@@ -30,6 +30,7 @@ import { platformResolvePlugin, browserSuffixChain, BROWSER_REFUSED_SUFFIXES } f
30
30
  import { giRendererPlugin } from '../plugins/gi-renderer.js';
31
31
  import { cssAsStringPlugin } from '../plugins/css-as-string.js';
32
32
  import { unresolvedWorkspaceImportPlugin } from '../plugins/unresolved-workspace-import.js';
33
+ import { implicitGlobalAssignPlugin } from '../plugins/implicit-global-assign.js';
33
34
  export const setupForBrowser = async (input) => {
34
35
  const userExternal = input.userExternal ?? [];
35
36
  const external = [...userExternal];
@@ -135,6 +136,11 @@ export const setupForBrowser = async (input) => {
135
136
  // GJS app's sources must not start failing on an import that used to resolve — and the
136
137
  // exit a browser can actually use is now beside it.
137
138
  blueprintPlugin(),
139
+ // ADR 0079's addendum. The `window` define above replaces an assignment TARGET as
140
+ // readily as a read, so `window = {…}` would emit `globalThis = {…}` — code that
141
+ // replaces the whole global object. This transform runs first and leaves the define
142
+ // nothing to rewrite; the guarded branch stays unreachable on a page either way.
143
+ implicitGlobalAssignPlugin(),
138
144
  cssAsStringPlugin(),
139
145
  // `order: 'post'` — see app/gjs.ts. The browser target's whole job is to
140
146
  // replace Node builtins with their `@gjsify/*` browser entries; when one
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';
@@ -20,6 +21,7 @@ import { nodeModulesPathRewritePlugin, getBundleDirFromOutput } from '../plugins
20
21
  import { processStubPlugin } from '../plugins/process-stub.js';
21
22
  import { cssAsStringPlugin } from '../plugins/css-as-string.js';
22
23
  import { consoleAssignPlugin } from '../plugins/console-assign.js';
24
+ import { implicitGlobalAssignPlugin } from '../plugins/implicit-global-assign.js';
23
25
  import { shebangPlugin, resolveShebangLine, inputShebangStripPlugin } from '../plugins/shebang.js';
24
26
  import { wrapInputWithSideEffects } from '../utils/entry-wrapper.js';
25
27
  const _shimDir = dirname(fileURLToPath(import.meta.url));
@@ -96,6 +98,7 @@ export const setupForGjs = async (input) => {
96
98
  sideEffectImports.push(input.pluginOptions.autoGlobalsInject);
97
99
  const virtualEntries = wrapInputWithSideEffects(entryPoints, sideEffectImports, {
98
100
  preserveDefaultExport: input.pluginOptions.preserveDefaultExport === true,
101
+ exitOnReportedCode: true,
99
102
  });
100
103
  const finalInput = virtualEntries.input;
101
104
  const options = {
@@ -191,6 +194,17 @@ export const setupForGjs = async (input) => {
191
194
  // A module assigning the global `console` gets a local binding, or the inject
192
195
  // below turns its assignment into `ASSIGN_TO_IMPORT` and fails the build.
193
196
  ...(consoleShimPath ? [consoleAssignPlugin()] : []),
197
+ // ADR 0079's addendum: a bare `window = …` reaches a module body that is strict
198
+ // once bundled, so it is a `ReferenceError` rather than the "this runtime has no
199
+ // window, make one" its `typeof` guard asked for. Here the guarded branch is dead
200
+ // anyway — the host has a `window` — but WITHOUT this transform the `window`
201
+ // define in `transform.define` replaces the assignment TARGET, emitting
202
+ // `globalThis = {…}`, i.e. code that would replace the whole global object if the
203
+ // branch ever ran. Measured; the plugin header carries the artifact diff.
204
+ implicitGlobalAssignPlugin(),
205
+ // `gi://Ns?version=X&optional` → a guarded import (ADR 0087), claimed `pre`
206
+ // so the externals policy never sees the flagged specifier.
207
+ giOptionalPlugin('gjs'),
194
208
  // Platform-file forks for the desktop, ADR 0032 § 9: `.gtk` → `.<os>` →
195
209
  // `.desktop` → base. BEFORE the alias layer, so a platform fork of a
196
210
  // module that also has a Node-builtin substitution wins over the
@@ -233,11 +247,19 @@ export const setupForGjs = async (input) => {
233
247
  // into the subset GTK4 understands.
234
248
  cssAsStringPlugin({ targets: { firefox: 60 << 16 } }),
235
249
  nodeModulesPathRewritePlugin({ bundleDir, runtimeResolve: format === 'esm' }),
236
- processStubPlugin({
237
- userBanner: input.userBanner,
238
- captureBundleUrl: format === 'esm',
239
- giSystemProbes: input.giSystemProbes,
240
- }),
250
+ // ADR 0081: the byte-1 banner ASSIGNS `globalThis.process`, so a
251
+ // `--globals auto` ANALYSIS bundle that carries it reads its own stub
252
+ // back as a live global and injects `@gjsify/process` into every build.
253
+ // The final build keeps the stub — `glob`/`path-scurry` need it.
254
+ ...(input.pluginOptions.skipProcessStub
255
+ ? []
256
+ : [
257
+ processStubPlugin({
258
+ userBanner: input.userBanner,
259
+ captureBundleUrl: format === 'esm',
260
+ giSystemProbes: input.giSystemProbes,
261
+ }),
262
+ ]),
241
263
  // resolveShebangLine returns null when disabled, else the resolved line
242
264
  // with `${env:…}` expanded.
243
265
  (() => {
@@ -46,6 +46,7 @@ import { gjsImportsEmptyPlugin } from '../plugins/gjs-imports-empty.js';
46
46
  import { giRendererPlugin } from '../plugins/gi-renderer.js';
47
47
  import { platformResolvePlugin, nativescriptSuffixChain, detectNativescriptPlatform, nativescriptPlatformDefines, } from '../plugins/platform-resolve.js';
48
48
  import { unresolvedWorkspaceImportPlugin } from '../plugins/unresolved-workspace-import.js';
49
+ import { implicitGlobalAssignPlugin } from '../plugins/implicit-global-assign.js';
49
50
  // Never bundled: an app works only with the ONE core instance the runtime boots, and what
50
51
  // this target emits is an ENTRY the NS bundler resolves it for. An optional peer absent from
51
52
  // the workspace install, it was not resolvable either — every UI-widget bridge failed to build.
@@ -159,6 +160,11 @@ export const setupForNativescript = async (input) => {
159
160
  // consumes. A `.blp` whose projection loses anything is refused at build time
160
161
  // rather than rendered partially; see `SHARED_TREE_QUERY` in the plugin.
161
162
  blueprintPlugin(),
163
+ // ADR 0079's addendum, and the `define` comment above is why it is needed here:
164
+ // a NativeScript app gates on the absence of `window`, but a WRITE to it is a
165
+ // `ReferenceError` in a strict module body, not a gate. `globalThis.window = …`
166
+ // holds the author's intent; the guard and the absence are untouched.
167
+ implicitGlobalAssignPlugin(),
162
168
  // NO cssAsStringPlugin — NS ships its own CSS pipeline via
163
169
  // @nativescript/core; .css imports are handled by the consuming
164
170
  // @nativescript/webpack or @nativescript/vite build
package/lib/app/node.js CHANGED
@@ -10,6 +10,8 @@ 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';
14
+ import { implicitGlobalAssignPlugin } from '../plugins/implicit-global-assign.js';
13
15
  import { unresolvedWorkspaceImportPlugin } from '../plugins/unresolved-workspace-import.js';
14
16
  import { nodeNativeExternalPlugin } from '../plugins/node-native-external.js';
15
17
  import { platformResolvePlugin, desktopSuffixChain, desktopOsSuffix, DESKTOP_REFUSED_SUFFIXES, } from '../plugins/platform-resolve.js';
@@ -326,6 +328,13 @@ export const setupForNode = async (input) => {
326
328
  // `\0gjsify-entry:` ids `wrapInputWithSideEffects` produces (no-op when
327
329
  // nothing was injected).
328
330
  ...(virtualEntries.plugin ? [virtualEntries.plugin] : []),
331
+ // `gi://Ns?version=X&optional` → the guarded load, claimed `pre` and
332
+ // AHEAD of `gjsGiNodePlugin` on array order, because both match a flagged
333
+ // specifier and the optional arm has to win: the hard arm's lazy Proxy
334
+ // answers every member access with `load()` and is never `undefined`, so an
335
+ // app that degrades on `Ns === undefined` would compile and then throw at
336
+ // the first real access — on one target only (ADR 0087).
337
+ giOptionalPlugin('node'),
329
338
  // Claims `gi://Ns?version=X` (resolveId `pre` + array order) and rewrites it
330
339
  // onto the `@gjsify/node-gi` runtime so a real GJS/GI source builds and runs
331
340
  // on Node. Returns null for `@girs/*`.
@@ -336,6 +345,13 @@ export const setupForNode = async (input) => {
336
345
  // externalisation itself rides `NODE_GI_BARE_MODULE_SPECIFIERS` in
337
346
  // `exactExternal` — see that const's doc comment.
338
347
  gjsBuiltinModulesNodePlugin(ALIASES_GJS_FOR_NODE),
348
+ // ADR 0079's addendum, and the target that NEEDS it: `transform.define` above
349
+ // deliberately does not define `window`, so Excalibur's
350
+ // `if (typeof window === 'undefined') window = {…}` is a LIVE branch here — and
351
+ // in a strict module body that write is a `ReferenceError` (map-editor#300).
352
+ // `globalThis.window = {…}` is the same statement, the guard is untouched, and
353
+ // nothing defines `window`.
354
+ implicitGlobalAssignPlugin(),
339
355
  // Decides the fate of `@girs/*` before `aliasPlugin` and the default
340
356
  // resolver (same composition order as `app/browser.ts`). `emptyGirs` is
341
357
  // gated on `gjsSourceBuild`:
package/lib/index.d.ts CHANGED
@@ -6,9 +6,13 @@ 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';
14
+ export { findImplicitGlobalAssignments, rewriteImplicitGlobalAssignments, implicitGlobalAssignPlugin, IMPLICIT_GLOBAL_ASSIGN_PLUGIN, } from './plugins/implicit-global-assign.js';
15
+ export type { ImplicitGlobalAssignment } from './plugins/implicit-global-assign.js';
12
16
  export { cssAsStringPlugin } from './plugins/css-as-string.js';
13
17
  export { textLoaderPlugin } from './plugins/text-loader.js';
14
18
  export type { TextLoaderPluginOptions, LoaderKind } from './plugins/text-loader.js';
package/lib/index.js CHANGED
@@ -6,7 +6,9 @@ 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';
11
+ export { findImplicitGlobalAssignments, rewriteImplicitGlobalAssignments, implicitGlobalAssignPlugin, IMPLICIT_GLOBAL_ASSIGN_PLUGIN, } from './plugins/implicit-global-assign.js';
10
12
  export { cssAsStringPlugin } from './plugins/css-as-string.js';
11
13
  export { textLoaderPlugin } from './plugins/text-loader.js';
12
14
  export { shebangPlugin, GJS_SHEBANG, NODE_SHEBANG, expandEnvTemplate, resolveShebangLine } from './plugins/shebang.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
+ }
@@ -0,0 +1,26 @@
1
+ import type { Plugin } from 'rolldown';
2
+ /** The plugin's name — what the four app orchestrators are pinned on. */
3
+ export declare const IMPLICIT_GLOBAL_ASSIGN_PLUGIN = "gjsify-implicit-global-assign";
4
+ /** One free assignment: the identifier to prefix, and the name that was free. */
5
+ export interface ImplicitGlobalAssignment {
6
+ /** The undeclared name — the property the rewrite writes. */
7
+ readonly name: string;
8
+ /** Inclusive start offset. */
9
+ readonly start: number;
10
+ /** Exclusive end offset. */
11
+ readonly end: number;
12
+ }
13
+ /**
14
+ * The free assignments in `code` (one entry per undeclared name written to), or `null` when
15
+ * the module cannot be read — the whole module in the `with` case, since its bindings there
16
+ * are dynamic.
17
+ */
18
+ export declare function findImplicitGlobalAssignments(code: string, id: string): ImplicitGlobalAssignment[] | null;
19
+ /** `code` with every free assignment rewritten, or null when it needs none. */
20
+ export declare function rewriteImplicitGlobalAssignments(code: string, id: string): string | null;
21
+ /**
22
+ * Composed by ALL FOUR `--app` targets (ADR 0079's addendum) — for the measured reason in
23
+ * the header: it is the fix on node and nativescript, and on gjs and browser it keeps the
24
+ * `window` define from turning the assignment target into `globalThis = …`.
25
+ */
26
+ export declare function implicitGlobalAssignPlugin(): Plugin;
@@ -0,0 +1,350 @@
1
+ // A bare ASSIGNMENT to an UNDECLARED identifier is rewritten to `globalThis.X = …`.
2
+ //
3
+ // ADR 0079 dropped the `window` define on `--app node`, and code that was only green
4
+ // because the define supplied the binding came apart. Excalibur 0.32.0's
5
+ // `src/engine/polyfill.ts` opens with
6
+ //
7
+ // if (typeof window === 'undefined') { window = <any>{ audioContext() { return; } }; }
8
+ //
9
+ // An ES module body is strict, so the write is a `ReferenceError` on every runtime that
10
+ // has no `window` — the branch has never been able to run (PixelRPG/map-editor#300), and
11
+ // on GJS or in a browser the guard is false, so nothing noticed.
12
+ //
13
+ // `window` is the MOTIVATING case, not the subject. The bug is sloppy mode's IMPLICIT
14
+ // GLOBAL CREATION, and it is not window-shaped: `self = …`, `document = …` and `foo = 1` in
15
+ // a CommonJS file the bundler wrapped into a strict ES module all die the same way, and
16
+ // only the first of them has an ADR behind it. So the subject here is any identifier no
17
+ // enclosing scope binds, and `globalThis.X = …` is that same statement with the author's
18
+ // intent spelled out — it holds on Node, Bun, Deno, GJS and in a page alike.
19
+ //
20
+ // NOT A DEFINE AND NOT A REGISTER (ADR 0079's addendum). A define rewrites the very
21
+ // `typeof` guard that decides the branch — the @mtcute/web failure ADR 0079 documents —
22
+ // and a register that defines `window` flips every guard in the ecosystem. The guard stays a
23
+ // guard and nothing anywhere defines a global, so no allowlist is needed either: writing a
24
+ // global the runtime ALREADY has (`onerror = fn`, `self = globalThis`) is legal in strict
25
+ // mode, which makes `globalThis.X = …` an identity rather than a change.
26
+ //
27
+ // WHY ALL FOUR TARGETS AND NOT THE TWO THAT NEED IT, measured on the bundled artifact of
28
+ // the shape above. On `--app node` and `--app nativescript` the plugin IS the fix: without
29
+ // it the `--app node` bundle dies at load with `ReferenceError: window is not defined`,
30
+ // and with it the same bundle runs and the author's `audioContext` stub is there. On the
31
+ // two targets that DO define `window`, the `define` replaces the assignment TARGET too —
32
+ // `window = {…}` becomes `globalThis = {…}`, which REPLACES THE WHOLE GLOBAL OBJECT — and a
33
+ // plugin `transform` runs before it, so the line is already `globalThis.window = {…}` and
34
+ // the define finds nothing left to rewrite.
35
+ //
36
+ // WHY AN AST AND NOT A TEXT PASS: the question is "is X a BINDING in scope at this
37
+ // assignment", which no regex answers. `X.y = 1`, `foo.X = 1`, `{ X: 1 }`, `typeof X` and
38
+ // an `X` that is a parameter of the enclosing function all share the bytes of the shape,
39
+ // and rewriting a local binding would silently retarget a program that means something
40
+ // else. Same reason `utils/detect-free-globals.ts` parses.
41
+ //
42
+ // ONE WALK FOR EVERY NAME. The descent records bindings as ROOT → the names bound in it,
43
+ // rather than answering "is `window` bound" for one name at a time: a module has as many
44
+ // implicit globals as it has typos, and re-walking per name made the pass quadratic for an
45
+ // answer that is one set lookup once the chain is there.
46
+ //
47
+ // THE REFUSALS, each where its fact is knowable:
48
+ // - a member expression on the left (`X.y = 1`, `foo.X = 1`): a write ONTO a runtime
49
+ // object, which is exactly what the guarded branches around the Excalibur assignment do,
50
+ // and no error to fix;
51
+ // - an assignment whose name a `var`/`let`/`const`, a parameter, a `catch` binding, a
52
+ // function or class name, or an import binds: a local variable, not a global;
53
+ // - an ARITHMETIC compound assignment (`X += 1`) and `X++`/`X--`: their left side is a
54
+ // value USE, so sloppy mode threw there too and rewriting would trade that
55
+ // `ReferenceError` for a silent `NaN`. `||=`, `&&=` and `??=` read the name too, so on
56
+ // an undeclared one sloppy mode threw there as well and they stay untouched;
57
+ // - a name that can never be an implicit global (below): it is a binding, an unassignable
58
+ // global, or a `SyntaxError` before this pass ever sees it;
59
+ // - a module containing `with`: the binding is dynamic there, and no static pass can see
60
+ // which object supplies it;
61
+ // - a source the pinned parser cannot read: the module keeps the bug and the bundler
62
+ // reports the parse itself, loudly and with its own position. The measured list is
63
+ // `utils/scan-named-imports.ts`'s (`satisfies`, a `const` type parameter) plus
64
+ // acorn-typescript 1.4.13's blind spot on an angle-bracket assertion — `window =
65
+ // <any>{…}`, which is how Excalibur's SOURCE spells the line and what its shipped
66
+ // dist (the shape a build actually reads) has already erased.
67
+ //
68
+ // NOT HANDLED: a DESTRUCTURING target (`({ a, b } = obj)`) keeps its bare names.
69
+ // `extractBindingNames` yields names, not source ranges, so a pattern would need a second
70
+ // positional walk over every pattern shape (defaults, rest, computed keys) that nothing
71
+ // here already carries — an implicit global spelled `({ foo } = obj)` stays broken until
72
+ // that walk exists. `for (X in/of …)` IS handled, because its left is the same bare
73
+ // `Identifier` the assignment case already asks for, and sloppy mode created that `X` too.
74
+ import { extractBindingNames } from '../utils/detect-free-globals.js';
75
+ import { parseSource } from '../utils/inline-static-reads.js';
76
+ import { REWRITE_FILTER } from './rewrite-node-modules-paths.js';
77
+ /** The plugin's name — what the four app orchestrators are pinned on. */
78
+ export const IMPLICIT_GLOBAL_ASSIGN_PLUGIN = 'gjsify-implicit-global-assign';
79
+ /**
80
+ * Names that are never an implicit global, whatever the scope says.
81
+ *
82
+ * `undefined`/`NaN`/`Infinity` are non-writable own properties of the global object, so
83
+ * assigning to them is a `TypeError` in strict mode and a silent no-op in sloppy mode —
84
+ * never the `ReferenceError` this plugin exists for, and `globalThis.` would only spell the
85
+ * same failure out. `arguments` and `eval` are rejected by the parser in a strict body
86
+ * (`SyntaxError: Unexpected eval or arguments`), so they cannot reach a build; they are
87
+ * listed because a name this pass must not rewrite should not depend on that. `globalThis`
88
+ * IS a writable global property on every runtime, so `globalThis = x` already works — and
89
+ * rewriting it would write the property through itself.
90
+ *
91
+ * The CommonJS WRAPPER bindings are here for a measured reason, and it is the incident that
92
+ * put them in the set rather than a rule of thumb: `exports`, `module`, `require`,
93
+ * `__filename` and `__dirname` are parameters of the function the bundler wraps a
94
+ * CommonJS module in, so `exports = module.exports = require('./lib/_stream_readable.js')`
95
+ * — `readable-stream/readable.js`, the polyfill behind `node:stream` — is a LOCAL write. The
96
+ * descent cannot see the wrapper, because it parses with `sourceType: 'module'`, so the pass
97
+ * rewrote it to `globalThis.exports = …`: `module.exports` was still set, but every
98
+ * `exports.Writable = require('./lib/_stream_writable.js')` beside it landed on the wrapper's
99
+ * original object, which nothing returns. Measured on a bundle of `readable-stream` with and
100
+ * without the plugin: `Writable`, `Duplex`, `Transform`, `PassThrough` and `Readable` went
101
+ * from `function` to `undefined` with no error anywhere — and a consumer that inherits from
102
+ * one of them is what throws next, as `util.inherits(Child, undefined)`. The same line
103
+ * appears in every bundled copy of `semver`, whose default import is what a Babel-based
104
+ * plugin bundle then calls. Refusing them unconditionally costs one rewrite that no build
105
+ * can use: in an ES module these names resolve to nothing at all.
106
+ */
107
+ const NEVER_IMPLICIT_GLOBAL = new Set([
108
+ 'undefined',
109
+ 'NaN',
110
+ 'Infinity',
111
+ 'arguments',
112
+ 'eval',
113
+ 'globalThis',
114
+ // The CommonJS wrapper parameters — a local write in the module that has them.
115
+ 'exports',
116
+ 'module',
117
+ 'require',
118
+ '__filename',
119
+ '__dirname',
120
+ ]);
121
+ /** Where the write lands: the global object, on every runtime there is one (ADR 0079). */
122
+ const GLOBAL_PREFIX = 'globalThis.';
123
+ /** Operators whose left side is a PLACE — see the third refusal for what is absent. */
124
+ const PLACE_ASSIGNMENTS = new Set(['=']);
125
+ /** `extractBindingNames` speaks acorn's node union, which a generic descent cannot be. */
126
+ const asAcornNode = (node) => node;
127
+ /**
128
+ * A container a `var`/lexical binding is scoped to: the function, or the module.
129
+ *
130
+ * A BLOCK is deliberately not one. Precision is needed between functions — a parameter
131
+ * named `window` in one function does not bind the name in its neighbour — and every
132
+ * coarsening above this line (a block, or a hoisted name marked in the scope enclosing the
133
+ * function it is written in) can only LOSE a rewrite, never retarget a program.
134
+ */
135
+ function isScopeRoot(node) {
136
+ switch (node.type) {
137
+ case 'Program':
138
+ case 'FunctionDeclaration':
139
+ case 'FunctionExpression':
140
+ case 'ArrowFunctionExpression':
141
+ // Bodyless TypeScript overload signatures still bind their parameters.
142
+ case 'TSDeclareFunction':
143
+ case 'TSDeclareMethod':
144
+ case 'TSEmptyBodyFunctionExpression':
145
+ return true;
146
+ default:
147
+ return false;
148
+ }
149
+ }
150
+ /** Every name this node binds in the scope it stands in, empty when it binds none. */
151
+ function bindingNames(node) {
152
+ switch (node.type) {
153
+ case 'VariableDeclarator':
154
+ return node.id ? extractBindingNames(asAcornNode(node.id)) : [];
155
+ case 'FunctionDeclaration':
156
+ case 'FunctionExpression':
157
+ case 'ArrowFunctionExpression':
158
+ case 'TSDeclareFunction':
159
+ case 'TSDeclareMethod':
160
+ case 'TSEmptyBodyFunctionExpression': {
161
+ // A function's own name and its parameters bind in ITS scope, so the chain is
162
+ // already one root deeper by the time this is asked.
163
+ const names = node.id?.name === undefined ? [] : [node.id.name];
164
+ for (const param of node.params ?? [])
165
+ names.push(...extractBindingNames(asAcornNode(param)));
166
+ return names;
167
+ }
168
+ case 'ClassDeclaration':
169
+ case 'ClassExpression':
170
+ return node.id?.name === undefined ? [] : [node.id.name];
171
+ case 'CatchClause':
172
+ return node.param ? extractBindingNames(asAcornNode(node.param)) : [];
173
+ case 'ImportSpecifier':
174
+ case 'ImportDefaultSpecifier':
175
+ case 'ImportNamespaceSpecifier':
176
+ return node.local?.name === undefined ? [] : [node.local.name];
177
+ default:
178
+ return [];
179
+ }
180
+ }
181
+ /**
182
+ * Does `name` bind OUTSIDE this node as well?
183
+ *
184
+ * A `function`/`class` DECLARATION's name is hoisted into the enclosing scope like a
185
+ * `var`, so `function window() {}` hides the name from its module; a PARAMETER binds only
186
+ * inside its function, which is why a neighbouring function's `window` parameter must not
187
+ * cost the module its rewrite.
188
+ */
189
+ function hoistsOutward(node, name) {
190
+ switch (node.type) {
191
+ case 'FunctionDeclaration':
192
+ case 'ClassDeclaration':
193
+ case 'TSDeclareFunction':
194
+ return node.id?.name === name;
195
+ default:
196
+ return false;
197
+ }
198
+ }
199
+ /**
200
+ * The bare identifier a statement writes to, or null.
201
+ *
202
+ * Both accepted shapes are an implicit global in sloppy mode, so both become a write to the
203
+ * global object; everything else here is a refusal, and the reason is at each branch.
204
+ */
205
+ function freeTarget(node) {
206
+ if (node.type === 'AssignmentExpression') {
207
+ // A member expression on the left is a write ONTO a runtime object, not a write of
208
+ // one — the first refusal, and the reason `left.type` is asked at all.
209
+ if (node.left?.type !== 'Identifier')
210
+ return null;
211
+ // Every compound operator (`+=`, `||=`, `??=`, …) and `++` READS the left side
212
+ // first, so on an undeclared name sloppy mode threw there too: only plain `=` creates.
213
+ return PLACE_ASSIGNMENTS.has(node.operator ?? '') ? node.left : null;
214
+ }
215
+ // `for (X in/of …)` declares `X` in sloppy mode exactly as `X = …` does, and its left
216
+ // is the same bare Identifier — so it costs nothing to accept here. A `for (var X …)`
217
+ // or `for (const X …)` left is a VariableDeclaration and never reaches this branch.
218
+ if (node.type === 'ForInStatement' || node.type === 'ForOfStatement') {
219
+ return node.left?.type === 'Identifier' ? node.left : null;
220
+ }
221
+ return null;
222
+ }
223
+ function collectAssignments(ast) {
224
+ const candidates = [];
225
+ // Every scope root that has bound a name, and which names.
226
+ const boundInRoot = new Map();
227
+ let hasWith = false;
228
+ const stack = [{ node: ast, roots: [] }];
229
+ while (stack.length > 0) {
230
+ const frame = stack.pop();
231
+ const { node, roots } = frame;
232
+ // A function's own name and parameters bind in ITS scope, so a node that opens a
233
+ // scope joins the chain before it is asked what it binds.
234
+ const nodeRoots = isScopeRoot(node) ? [node, ...roots] : roots;
235
+ if (node.type === 'WithStatement')
236
+ hasWith = true;
237
+ for (const name of bindingNames(node)) {
238
+ // The NEAREST enclosing root — index 0, the chain is innermost-first — and only
239
+ // that one. Marking every root up the chain would degrade this into a
240
+ // module-wide "does any declaration anywhere mention the name" test, which loses
241
+ // the rewrite for a module whose unrelated function takes such a parameter.
242
+ const nearest = nodeRoots[0];
243
+ if (!nearest)
244
+ continue;
245
+ addBinding(boundInRoot, nearest, name);
246
+ if (hoistsOutward(node, name)) {
247
+ const enclosing = nodeRoots[1];
248
+ if (enclosing)
249
+ addBinding(boundInRoot, enclosing, name);
250
+ }
251
+ }
252
+ const target = freeTarget(node);
253
+ if (target && target.name !== undefined && !NEVER_IMPLICIT_GLOBAL.has(target.name)) {
254
+ candidates.push({ name: target.name, start: target.start, end: target.end, roots: nodeRoots });
255
+ }
256
+ for (const child of childrenOf(node))
257
+ stack.push({ node: child, roots: nodeRoots });
258
+ }
259
+ // A binding in scope for an assignment is declared in one of its ENCLOSING roots, so
260
+ // walking the chain is what makes the rewrite safe — an undeclared name has none.
261
+ // `with` is refused here rather than above: its answer is "no idea", not "rewrite".
262
+ if (hasWith)
263
+ return null;
264
+ return candidates
265
+ .filter(({ name, roots }) => !roots.some((root) => boundInRoot.get(root)?.has(name)))
266
+ .map(({ name, start, end }) => ({ name, start, end }));
267
+ }
268
+ function addBinding(boundInRoot, root, name) {
269
+ const names = boundInRoot.get(root);
270
+ if (names) {
271
+ names.add(name);
272
+ return;
273
+ }
274
+ boundInRoot.set(root, new Set([name]));
275
+ }
276
+ /** Every own-property value that is a node — `Object.values` order is irrelevant here. */
277
+ function childrenOf(node) {
278
+ const out = [];
279
+ for (const value of Object.values(node)) {
280
+ if (Array.isArray(value)) {
281
+ for (const item of value)
282
+ pushIfNode(item, out);
283
+ continue;
284
+ }
285
+ pushIfNode(value, out);
286
+ }
287
+ return out;
288
+ }
289
+ function pushIfNode(value, out) {
290
+ if (value === null || typeof value !== 'object')
291
+ return;
292
+ if (typeof value.type !== 'string')
293
+ return;
294
+ out.push(value);
295
+ }
296
+ /**
297
+ * The free assignments in `code` (one entry per undeclared name written to), or `null` when
298
+ * the module cannot be read — the whole module in the `with` case, since its bindings there
299
+ * are dynamic.
300
+ */
301
+ export function findImplicitGlobalAssignments(code, id) {
302
+ let ast;
303
+ try {
304
+ // Extension-aware (a `.ts` source arrives with its type syntax intact), and the
305
+ // shared parser this package already parses source with — not a second copy.
306
+ ast = parseSource(code, id);
307
+ }
308
+ catch {
309
+ return null;
310
+ }
311
+ return collectAssignments(ast);
312
+ }
313
+ /** `code` with every free assignment rewritten, or null when it needs none. */
314
+ export function rewriteImplicitGlobalAssignments(code, id) {
315
+ const edits = findImplicitGlobalAssignments(code, id);
316
+ if (!edits || edits.length === 0)
317
+ return null;
318
+ // Descending, so each splice leaves the offsets of the ones after it valid. The name
319
+ // from the AST rather than the source slice, so an escaped `\u0061ssignments` writes
320
+ // the property it means.
321
+ let out = code;
322
+ for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
323
+ out = out.slice(0, edit.start) + GLOBAL_PREFIX + edit.name + out.slice(edit.end);
324
+ }
325
+ return out;
326
+ }
327
+ /**
328
+ * Composed by ALL FOUR `--app` targets (ADR 0079's addendum) — for the measured reason in
329
+ * the header: it is the fix on node and nativescript, and on gjs and browser it keeps the
330
+ * `window` define from turning the assignment target into `globalThis = …`.
331
+ */
332
+ export function implicitGlobalAssignPlugin() {
333
+ return {
334
+ name: IMPLICIT_GLOBAL_ASSIGN_PLUGIN,
335
+ transform: {
336
+ filter: { id: REWRITE_FILTER },
337
+ handler(code, id) {
338
+ // Re-applied inside the handler, the way `react-native-gate.ts` does: the
339
+ // object form is engine plumbing (under GJS `filter.id` is lifted into a
340
+ // plugin-level `idFilter`), and a source rewrite must not depend on
341
+ // plumbing to stay off `.css`, `.blp` and a data URL, none of which acorn
342
+ // can read.
343
+ if (!REWRITE_FILTER.test(id))
344
+ return null;
345
+ const out = rewriteImplicitGlobalAssignments(code, id);
346
+ return out === null ? null : { code: out, map: null };
347
+ },
348
+ },
349
+ };
350
+ }
@@ -117,4 +117,19 @@ export interface PluginOptions {
117
117
  * measured incident: `WorkspaceImportGuardOptions.toolchainAnchor`.
118
118
  */
119
119
  toolchainAnchor?: string;
120
+ /**
121
+ * Leave the byte-1 `globalThis.process` stub OUT of the bundle (ADR 0081).
122
+ *
123
+ * The stub is not inert to `--globals auto`: `renderChunk` ASSIGNS
124
+ * `globalThis.process` before any module body, and the detector reads the
125
+ * ANALYSIS bundle's own output — so every pass saw `process` as a free
126
+ * global it had detected and injected `@gjsify/process` unconditionally.
127
+ * Measured on an empty entry: 140 KB instead of ~3 KB.
128
+ *
129
+ * Set ONLY by `detectAutoGlobals`, which builds a throwaway bundle to
130
+ * analyse. The FINAL build must keep the stub: `glob`, `path-scurry` and
131
+ * `readable-stream` read `process.platform` at top level during `__esm`
132
+ * lazy init, before any import side effect fires. A consumer never sets it.
133
+ */
134
+ skipProcessStub?: boolean;
120
135
  }
@@ -284,6 +284,7 @@ export async function detectAutoGlobals(analysisOptions, pluginOptions, gjsifyPl
284
284
  const factoryResult = await gjsifyPluginFactory({
285
285
  ...pluginOptions,
286
286
  autoGlobalsInject: currentInject,
287
+ skipProcessStub: true,
287
288
  });
288
289
  let gjsifyInstance;
289
290
  let orchestratorOptions;
@@ -15,4 +15,5 @@ export interface VirtualEntriesResult {
15
15
  */
16
16
  export declare function wrapInputWithSideEffects(input: RolldownOptions['input'], sideEffects: string[], opts?: {
17
17
  preserveDefaultExport?: boolean;
18
+ exitOnReportedCode?: boolean;
18
19
  }): VirtualEntriesResult;
@@ -8,6 +8,22 @@
8
8
  // convention for synthetic modules — Rolldown treats them as not-from-disk and
9
9
  // skips the default loader.
10
10
  import { GJSIFY_VIRTUAL_PREFIX } from './virtual-module-id.js';
11
+ /**
12
+ * The wrapper's last statement, GJS only (`opts.exitOnReportedCode`).
13
+ *
14
+ * GJS has no atexit hook: nothing reads `process.exitCode` when the entry ends
15
+ * naturally, so `process.exitCode = 1` exited 0 where Node exits 1. The wrapper
16
+ * body is the one place that runs after the entry's body AND its top-level
17
+ * awaits (ESM evaluates the imported entry first), so it is the end of main.
18
+ *
19
+ * Reaches `process` through `globalThis` (see the process-stub banner: a bare
20
+ * identifier can bind to a bundled module's own top-level). `exit(code)` is
21
+ * passed the number explicitly because the byte-1 stub's `exit(c)` ignores
22
+ * `exitCode`; the full `@gjsify/process` then also emits `'exit'`. Zero,
23
+ * `undefined` and non-numeric values stay a natural 0, as on Node.
24
+ */
25
+ const END_OF_MAIN_EXIT = 'const __gjsify_p = globalThis.process, __gjsify_c = Number(__gjsify_p?.exitCode);\n' +
26
+ 'if (Number.isInteger(__gjsify_c) && __gjsify_c !== 0) __gjsify_p.exit(__gjsify_c);';
11
27
  /**
12
28
  * If there are side-effect imports to land alongside the user's entry, wrap each
13
29
  * entry in a virtual module that imports them first then re-exports the entry.
@@ -19,7 +35,7 @@ import { GJSIFY_VIRTUAL_PREFIX } from './virtual-module-id.js';
19
35
  * Record-input case: values get wrapped, keys preserved.
20
36
  */
21
37
  export function wrapInputWithSideEffects(input, sideEffects, opts = {}) {
22
- if (sideEffects.length === 0 || input === undefined) {
38
+ if ((sideEffects.length === 0 && !opts.exitOnReportedCode) || input === undefined) {
23
39
  return { input, plugin: null };
24
40
  }
25
41
  const userEntries = new Map(); // virtualId → realPath
@@ -49,6 +65,7 @@ export function wrapInputWithSideEffects(input, sideEffects, opts = {}) {
49
65
  wrappedInput = out;
50
66
  }
51
67
  const sideEffectImports = sideEffects.map((p) => `import ${JSON.stringify(p)};`).join('\n');
68
+ const endOfMain = opts.exitOnReportedCode ? `\n${END_OF_MAIN_EXIT}\n` : '';
52
69
  // Resolved real-path targets from `userEntries` get their moduleSideEffects
53
70
  // forced to 'no-treeshake' so the user-entry's top-level body (`run({...})`,
54
71
  // side-effect calls) survives tree-shake even when its package.json restricts
@@ -95,12 +112,12 @@ export function wrapInputWithSideEffects(input, sideEffects, opts = {}) {
95
112
  const ns = '__gjsify_entry__';
96
113
  return {
97
114
  code: `${sideEffectImports}\nimport * as ${ns} from ${JSON.stringify(target)};\n` +
98
- `export * from ${JSON.stringify(target)};\nexport default ${ns}.default;\n`,
115
+ `export * from ${JSON.stringify(target)};\nexport default ${ns}.default;\n${endOfMain}`,
99
116
  moduleSideEffects: 'no-treeshake',
100
117
  };
101
118
  }
102
119
  return {
103
- code: `${sideEffectImports}\nimport ${JSON.stringify(target)};\nexport * from ${JSON.stringify(target)};\n`,
120
+ code: `${sideEffectImports}\nimport ${JSON.stringify(target)};\nexport * from ${JSON.stringify(target)};\n${endOfMain}`,
104
121
  moduleSideEffects: 'no-treeshake',
105
122
  };
106
123
  },
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.55.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.55.0",
67
+ "@gjsify/resolve-npm": "^0.55.0",
68
+ "@gjsify/rolldown-plugin-deepkit": "^0.55.0",
69
+ "@gjsify/rolldown-plugin-pnp": "^0.55.0",
70
+ "@gjsify/utils": "^0.55.0",
71
+ "@gjsify/vite-plugin-blueprint": "^0.55.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.55.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.55.0",
94
+ "@gjsify/unit": "^0.55.0",
95
95
  "@types/node": "^25.9.2",
96
96
  "rolldown": "^1.1.4",
97
97
  "typescript": "^6.0.3"