@gjsify/rolldown-plugin-gjsify 0.54.0 → 0.56.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
@@ -21,6 +21,7 @@ import { nodeModulesPathRewritePlugin, getBundleDirFromOutput } from '../plugins
21
21
  import { processStubPlugin } from '../plugins/process-stub.js';
22
22
  import { cssAsStringPlugin } from '../plugins/css-as-string.js';
23
23
  import { consoleAssignPlugin } from '../plugins/console-assign.js';
24
+ import { implicitGlobalAssignPlugin } from '../plugins/implicit-global-assign.js';
24
25
  import { shebangPlugin, resolveShebangLine, inputShebangStripPlugin } from '../plugins/shebang.js';
25
26
  import { wrapInputWithSideEffects } from '../utils/entry-wrapper.js';
26
27
  const _shimDir = dirname(fileURLToPath(import.meta.url));
@@ -97,6 +98,7 @@ export const setupForGjs = async (input) => {
97
98
  sideEffectImports.push(input.pluginOptions.autoGlobalsInject);
98
99
  const virtualEntries = wrapInputWithSideEffects(entryPoints, sideEffectImports, {
99
100
  preserveDefaultExport: input.pluginOptions.preserveDefaultExport === true,
101
+ exitOnReportedCode: true,
100
102
  });
101
103
  const finalInput = virtualEntries.input;
102
104
  const options = {
@@ -192,6 +194,14 @@ export const setupForGjs = async (input) => {
192
194
  // A module assigning the global `console` gets a local binding, or the inject
193
195
  // below turns its assignment into `ASSIGN_TO_IMPORT` and fails the build.
194
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(),
195
205
  // `gi://Ns?version=X&optional` → a guarded import (ADR 0087), claimed `pre`
196
206
  // so the externals policy never sees the flagged specifier.
197
207
  giOptionalPlugin('gjs'),
@@ -237,11 +247,19 @@ export const setupForGjs = async (input) => {
237
247
  // into the subset GTK4 understands.
238
248
  cssAsStringPlugin({ targets: { firefox: 60 << 16 } }),
239
249
  nodeModulesPathRewritePlugin({ bundleDir, runtimeResolve: format === 'esm' }),
240
- processStubPlugin({
241
- userBanner: input.userBanner,
242
- captureBundleUrl: format === 'esm',
243
- giSystemProbes: input.giSystemProbes,
244
- }),
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
+ ]),
245
263
  // resolveShebangLine returns null when disabled, else the resolved line
246
264
  // with `${env:…}` expanded.
247
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
@@ -11,6 +11,7 @@ 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
13
  import { giOptionalPlugin } from '../plugins/gi-optional.js';
14
+ import { implicitGlobalAssignPlugin } from '../plugins/implicit-global-assign.js';
14
15
  import { unresolvedWorkspaceImportPlugin } from '../plugins/unresolved-workspace-import.js';
15
16
  import { nodeNativeExternalPlugin } from '../plugins/node-native-external.js';
16
17
  import { platformResolvePlugin, desktopSuffixChain, desktopOsSuffix, DESKTOP_REFUSED_SUFFIXES, } from '../plugins/platform-resolve.js';
@@ -344,6 +345,13 @@ export const setupForNode = async (input) => {
344
345
  // externalisation itself rides `NODE_GI_BARE_MODULE_SPECIFIERS` in
345
346
  // `exactExternal` — see that const's doc comment.
346
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(),
347
355
  // Decides the fate of `@girs/*` before `aliasPlugin` and the default
348
356
  // resolver (same composition order as `app/browser.ts`). `emptyGirs` is
349
357
  // gated on `gjsSourceBuild`:
package/lib/index.d.ts CHANGED
@@ -11,6 +11,8 @@ export type { GiOptionalTarget } from './plugins/gi-optional.js';
11
11
  export { bindConsoleLocally, consoleAssignPlugin, freeConsoleAssignmentInsertion, CONSOLE_LOCAL_BINDING, } from './plugins/console-assign.js';
12
12
  export type { GiSystemProbe } from './plugins/gi-runtime-paths.js';
13
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';
14
16
  export { cssAsStringPlugin } from './plugins/css-as-string.js';
15
17
  export { textLoaderPlugin } from './plugins/text-loader.js';
16
18
  export type { TextLoaderPluginOptions, LoaderKind } from './plugins/text-loader.js';
package/lib/index.js CHANGED
@@ -8,6 +8,7 @@ export { processStubPlugin, GJS_PROCESS_STUB, composeBanner } from './plugins/pr
8
8
  export { giRuntimePathsStub } from './plugins/gi-runtime-paths.js';
9
9
  export { giOptionalPlugin, giOptionalShimSource, giOptionalNodeShimSource, giOptionalMarkerSource, parseOptionalGiSpecifier, GI_OPTIONAL_FLAG, GI_OPTIONAL_MARKER, } from './plugins/gi-optional.js';
10
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';
11
12
  export { cssAsStringPlugin } from './plugins/css-as-string.js';
12
13
  export { textLoaderPlugin } from './plugins/text-loader.js';
13
14
  export { shebangPlugin, GJS_SHEBANG, NODE_SHEBANG, expandEnvTemplate, resolveShebangLine } from './plugins/shebang.js';
@@ -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.54.0",
3
+ "version": "0.56.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.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",
66
+ "@gjsify/console": "^0.56.0",
67
+ "@gjsify/resolve-npm": "^0.56.0",
68
+ "@gjsify/rolldown-plugin-deepkit": "^0.56.0",
69
+ "@gjsify/rolldown-plugin-pnp": "^0.56.0",
70
+ "@gjsify/utils": "^0.56.0",
71
+ "@gjsify/vite-plugin-blueprint": "^0.56.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.54.0",
81
+ "@gjsify/lightningcss-native": "^0.56.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.54.0",
94
- "@gjsify/unit": "^0.54.0",
93
+ "@gjsify/cli": "^0.56.0",
94
+ "@gjsify/unit": "^0.56.0",
95
95
  "@types/node": "^25.9.2",
96
96
  "rolldown": "^1.1.4",
97
97
  "typescript": "^6.0.3"