@gjsify/rolldown-plugin-gjsify 0.41.0 → 0.43.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/browser.d.ts +2 -0
- package/lib/app/browser.js +4 -2
- package/lib/app/gjs.d.ts +10 -0
- package/lib/app/gjs.js +17 -4
- package/lib/app/nativescript.d.ts +2 -0
- package/lib/app/nativescript.js +4 -2
- package/lib/app/node.d.ts +2 -0
- package/lib/app/node.js +4 -2
- package/lib/index.d.ts +2 -1
- package/lib/index.js +1 -1
- package/lib/library/lib.d.ts +2 -0
- package/lib/library/lib.js +12 -1
- package/lib/plugin.d.ts +25 -0
- package/lib/plugin.js +1 -0
- package/lib/plugins/gi-runtime-paths.d.ts +27 -5
- package/lib/plugins/gi-runtime-paths.js +74 -27
- package/lib/plugins/intl-segmenter-banner.d.ts +1 -0
- package/lib/plugins/intl-segmenter-banner.js +42 -0
- package/lib/plugins/process-stub.d.ts +7 -0
- package/lib/plugins/process-stub.js +5 -1
- package/lib/plugins/rewrite-node-modules-paths.d.ts +35 -0
- package/lib/plugins/rewrite-node-modules-paths.js +57 -5
- package/lib/plugins/unresolved-workspace-import.d.ts +48 -2
- package/lib/plugins/unresolved-workspace-import.js +71 -7
- package/lib/types/plugin-options.d.ts +12 -0
- package/lib/utils/detect-free-globals.js +29 -7
- package/lib/utils/index.d.ts +2 -0
- package/lib/utils/index.js +1 -0
- package/lib/utils/inline-static-reads.d.ts +0 -7
- package/lib/utils/inline-static-reads.js +79 -16
- package/lib/utils/jsx-survival.d.ts +31 -0
- package/lib/utils/jsx-survival.js +102 -0
- package/package.json +10 -8
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
//
|
|
19
19
|
// Hosted as a `transform(code, id)` hook with `order: 'post'`: after the
|
|
20
20
|
// deepkit/blueprint/css pre-transforms, still during module loading, before chunking.
|
|
21
|
+
//
|
|
22
|
+
// TWO SCOPES, one transform. The four cases above are node_modules-only, because
|
|
23
|
+
// they are about a file whose recorded location stopped being true. The static-read
|
|
24
|
+
// INLINER that runs alongside them is not: it folds build-time-resolvable reads in
|
|
25
|
+
// ANY source, first-party included, and it was trapped inside the node_modules gate
|
|
26
|
+
// until 2026-08-22 — see `shouldInline` for the incident that measured the cost.
|
|
21
27
|
import { createRequire } from 'node:module';
|
|
22
28
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
23
29
|
import { inlineStaticReads } from '../utils/inline-static-reads.js';
|
|
@@ -39,6 +45,40 @@ export function shouldRewrite(path) {
|
|
|
39
45
|
return false;
|
|
40
46
|
return true;
|
|
41
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* True when the static-read INLINER wants to look at this path — any supported
|
|
50
|
+
* source, first-party included.
|
|
51
|
+
*
|
|
52
|
+
* Deliberately wider than {@link shouldRewrite}, because the two answer different
|
|
53
|
+
* questions. The inliner asks *is this read resolvable at build time*, which is a
|
|
54
|
+
* property of the expression and can be true in any file. The rewriter asks *does
|
|
55
|
+
* this file's `import.meta.url` still mean anything once bundled*, which is only
|
|
56
|
+
* worth asking of a file whose original location was `node_modules` — the whole
|
|
57
|
+
* subject of this plugin.
|
|
58
|
+
*
|
|
59
|
+
* They were ONE gate until 2026-08-22, and the cost was silent. Each of
|
|
60
|
+
* `@gjsify/cli`'s own six `readFileSync(new URL(<literal>, import.meta.url))`
|
|
61
|
+
* template loaders shipped into `dist/cli.gjs.mjs` as a LIVE read pointing at a
|
|
62
|
+
* `templates/` directory that exists neither in the repo (the sources are in
|
|
63
|
+
* `src/templates/`; the build copies them to `lib/templates/`) nor in the
|
|
64
|
+
* published tarball (`files` ships `lib`, not a root `templates`). Measured on a
|
|
65
|
+
* fresh build: `gjs -m dist/cli.gjs.mjs ship --stage` died with
|
|
66
|
+
* `ENOENT … packages/infra/cli/templates/app/desktop.tmpl` while the identical
|
|
67
|
+
* command through `lib/index.js` staged the desktop entry. Two of those six files
|
|
68
|
+
* carry a comment asserting the inliner handles them, so reading the code
|
|
69
|
+
* confirmed the belief rather than the behaviour.
|
|
70
|
+
*
|
|
71
|
+
* Consequence for a CONSUMER's build, stated because it is a behaviour change: a
|
|
72
|
+
* statically resolvable read in their own source is baked into their bundle too.
|
|
73
|
+
* That is the promise the inliner exists to make — a bundle that survives leaving
|
|
74
|
+
* its build site — and a read meant to stay dynamic keeps working by not being
|
|
75
|
+
* statically resolvable, since anything the evaluator cannot fold is left alone.
|
|
76
|
+
*/
|
|
77
|
+
export function shouldInline(path) {
|
|
78
|
+
if (!REWRITE_FILTER.test(path))
|
|
79
|
+
return false;
|
|
80
|
+
return !GJSIFY_SHIM_RE.test(path);
|
|
81
|
+
}
|
|
42
82
|
/** The directory the bundle's outfile lives in, from `output.file` or `output.dir`. */
|
|
43
83
|
export function getBundleDirFromOutput(opts) {
|
|
44
84
|
const outFile = opts.file ?? join(opts.dir ?? '.', 'bundle.mjs');
|
|
@@ -265,6 +305,11 @@ export function rewriteContents(args, srcInput, bundleDir, runtimeResolve) {
|
|
|
265
305
|
/**
|
|
266
306
|
* Build a Rolldown plugin that runs the path rewriter as a `transform(code, id)`
|
|
267
307
|
* hook with `order: 'post'`.
|
|
308
|
+
*
|
|
309
|
+
* Two scopes in one transform, not two plugins: a second transform would parse
|
|
310
|
+
* every module a second time, and the inliner's own fast path (skip unless the
|
|
311
|
+
* source mentions `readFileSync`/`readdirSync`/`existsSync`) already makes the
|
|
312
|
+
* wider scope nearly free. See {@link shouldInline} for why the scopes differ.
|
|
268
313
|
*/
|
|
269
314
|
export function nodeModulesPathRewritePlugin(options) {
|
|
270
315
|
const runtimeResolve = options.runtimeResolve ?? false;
|
|
@@ -274,12 +319,19 @@ export function nodeModulesPathRewritePlugin(options) {
|
|
|
274
319
|
order: 'post',
|
|
275
320
|
filter: { id: REWRITE_FILTER },
|
|
276
321
|
handler(code, id) {
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
322
|
+
if (id.includes('node_modules')) {
|
|
323
|
+
const result = rewriteContents({ path: id }, code, options.bundleDir, runtimeResolve);
|
|
324
|
+
if (!result)
|
|
325
|
+
return null;
|
|
326
|
+
return { code: result.code, map: null };
|
|
327
|
+
}
|
|
328
|
+
// First-party source: inline statically resolvable reads, and
|
|
329
|
+
// nothing else. `import.meta.url` here is not rewritten — that
|
|
330
|
+
// is the node_modules question this plugin was written for.
|
|
331
|
+
if (!shouldInline(id))
|
|
281
332
|
return null;
|
|
282
|
-
|
|
333
|
+
const inlined = inlineStaticReads(code, id);
|
|
334
|
+
return inlined.inlined > 0 ? { code: inlined.contents, map: null } : null;
|
|
283
335
|
},
|
|
284
336
|
},
|
|
285
337
|
};
|
|
@@ -16,6 +16,43 @@ export interface WorkspaceImportGuardOptions {
|
|
|
16
16
|
* (plus exact-name membership) so the two cannot drift.
|
|
17
17
|
*/
|
|
18
18
|
isExternal?: (id: string) => boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Last-resort anchor for a `@gjsify/*` the PROJECT cannot resolve — set only
|
|
21
|
+
* when the thing being bundled is TOOLCHAIN rather than user code, and unset
|
|
22
|
+
* for every ordinary `gjsify build`.
|
|
23
|
+
*
|
|
24
|
+
* A repo build script (`node scripts/x.mjs`, re-entered as `gjsify run
|
|
25
|
+
* --node-script` on a Node-less host) does not need the WORKSPACE's copy of
|
|
26
|
+
* `@gjsify/fs` — it needs a working one. Anchored at the script, resolution
|
|
27
|
+
* finds the workspace symlink, whose `lib/esm` a cold clone has not built
|
|
28
|
+
* yet; anchored additionally at the running CLI, it finds the copy installed
|
|
29
|
+
* beside it, which is built by construction.
|
|
30
|
+
*
|
|
31
|
+
* Measured on postmarketOS/aarch64 (gjs 1.88.1, no node): without this the
|
|
32
|
+
* ADR 0002 bootstrap fails at its third step, because `--globals auto` pulls
|
|
33
|
+
* the whole `@gjsify/<pkg>/register` closure into a one-line script (the
|
|
34
|
+
* count and the incident are in this package's AGENTS.md § toolchainAnchor).
|
|
35
|
+
* The alternative is building most of the polyfill tree before the first
|
|
36
|
+
* script runs, on every host including the ones that never bundle one.
|
|
37
|
+
*
|
|
38
|
+
* The value is a FILE PATH inside the running CLI's own install, used as the
|
|
39
|
+
* IMPORTER of the fallback resolve — not a second resolver. That matters:
|
|
40
|
+
* `--app gjs` deliberately omits both `import` and `require` from
|
|
41
|
+
* `conditionNames` (`app/gjs.ts`), and a `createRequire().resolve()` here
|
|
42
|
+
* would resolve under `require` and hand a `cjs-compat.cjs` shim to a GJS
|
|
43
|
+
* bundle for the ~3 `@gjsify/*` packages that declare a `require` branch.
|
|
44
|
+
* Going back through `this.resolve` uses the target's own condition set by
|
|
45
|
+
* construction, so the rescue can only pick the entry the build would have
|
|
46
|
+
* picked. The precedent is `commands/tsc.ts`, which resolves
|
|
47
|
+
* `@gjsify/tsc/bundle` from the project first and the running CLI second for
|
|
48
|
+
* exactly this reason; `tests/e2e/node-free-bootstrap` states that invariant.
|
|
49
|
+
*
|
|
50
|
+
* PROJECT FIRST, ALWAYS: consulted only after normal resolution returns
|
|
51
|
+
* null, so a workspace that HAS the package keeps using it and nothing about
|
|
52
|
+
* a warm tree changes. A project resolve that ERRORED is not a null and is
|
|
53
|
+
* never rescued — see the handler.
|
|
54
|
+
*/
|
|
55
|
+
toolchainAnchor?: string;
|
|
19
56
|
}
|
|
20
57
|
/**
|
|
21
58
|
* Is `id` a bare `@gjsify/*` package specifier (root or subpath)? Excludes disk
|
|
@@ -62,11 +99,16 @@ export interface UnresolvedWorkspaceImportDetails {
|
|
|
62
99
|
*/
|
|
63
100
|
aliasedFrom?: readonly string[];
|
|
64
101
|
importer: string;
|
|
102
|
+
/**
|
|
103
|
+
* Set when `this.resolve` THREW instead of answering — the message then names
|
|
104
|
+
* it rather than listing three causes that cannot apply.
|
|
105
|
+
*/
|
|
106
|
+
resolverFailure?: string;
|
|
65
107
|
}
|
|
66
108
|
/** A `@gjsify/*` edge the build promised to substitute could not be resolved. */
|
|
67
109
|
export declare class UnresolvedWorkspaceImportError extends Error {
|
|
68
110
|
readonly details: UnresolvedWorkspaceImportDetails;
|
|
69
|
-
constructor(details: UnresolvedWorkspaceImportDetails);
|
|
111
|
+
constructor(details: UnresolvedWorkspaceImportDetails, cause?: unknown);
|
|
70
112
|
}
|
|
71
113
|
/**
|
|
72
114
|
* The actionable message: names the specifier, the importer and the likely cause —
|
|
@@ -82,6 +124,10 @@ export declare function buildReverseAliasIndex(aliases: Record<string, string> |
|
|
|
82
124
|
* imports-empty/externals at normal) gets first refusal — only ids NOTHING claimed
|
|
83
125
|
* reach this hook. It then re-runs the resolution Rolldown would have run next
|
|
84
126
|
* (`this.resolve(…, { skipSelf })`) and returns that, so a healthy build resolves
|
|
85
|
-
* each specifier exactly once and behaves identically.
|
|
127
|
+
* each specifier exactly once and behaves identically.
|
|
128
|
+
*
|
|
129
|
+
* A genuine `null` throws — unless `toolchainAnchor` is set and the CLI's own install
|
|
130
|
+
* has it. A THROW is not a `null`: it is rethrown with this plugin's context and the
|
|
131
|
+
* original as `cause`, never rescued and never reported as a missing dependency.
|
|
86
132
|
*/
|
|
87
133
|
export declare function unresolvedWorkspaceImportPlugin(options: WorkspaceImportGuardOptions): Plugin;
|
|
@@ -72,8 +72,8 @@ export function classifyImport(input) {
|
|
|
72
72
|
/** A `@gjsify/*` edge the build promised to substitute could not be resolved. */
|
|
73
73
|
export class UnresolvedWorkspaceImportError extends Error {
|
|
74
74
|
details;
|
|
75
|
-
constructor(details) {
|
|
76
|
-
super(formatUnresolvedWorkspaceImport(details));
|
|
75
|
+
constructor(details, cause) {
|
|
76
|
+
super(formatUnresolvedWorkspaceImport(details), cause === undefined ? undefined : { cause });
|
|
77
77
|
this.name = 'UnresolvedWorkspaceImportError';
|
|
78
78
|
this.details = details;
|
|
79
79
|
}
|
|
@@ -83,7 +83,7 @@ export class UnresolvedWorkspaceImportError extends Error {
|
|
|
83
83
|
* the three things `UNRESOLVED_IMPORT` and the post-hoc `node:` guard cannot.
|
|
84
84
|
*/
|
|
85
85
|
export function formatUnresolvedWorkspaceImport(details) {
|
|
86
|
-
const { target, source, candidate, aliasTarget, aliasedFrom, importer } = details;
|
|
86
|
+
const { target, source, candidate, aliasTarget, aliasedFrom, importer, resolverFailure } = details;
|
|
87
87
|
// The specifiers `candidate` stood in for: `source` when IT carried the table
|
|
88
88
|
// entry, else the reverse lookup — but only when its fan-in is SMALL. A shared
|
|
89
89
|
// sink inverts to a wall of text (`@gjsify/empty` is the browser target for 49
|
|
@@ -114,11 +114,18 @@ export function formatUnresolvedWorkspaceImport(details) {
|
|
|
114
114
|
`\`--app ${target}\` runtime cannot resolve.`
|
|
115
115
|
: `Leaving it unresolved would silently externalise the ORIGINAL ${substitutedFor.length === 1 ? 'specifier' : 'specifiers'} ${quoted} into the ` +
|
|
116
116
|
`bundle, defeating the substitution the \`--app ${target}\` target exists to perform.`) + symptom;
|
|
117
|
+
// A resolver that FAILED is not a resolver that found nothing, and the three
|
|
118
|
+
// likely causes below are all wrong for it: an EACCES on `node_modules`, a
|
|
119
|
+
// corrupt `package.json`, or a deliberate throw from another `resolveId` hook
|
|
120
|
+
// (`napi-node-addon`'s `resolveAddonPath`) would otherwise be reported as the
|
|
121
|
+
// user's missing dependency, with the real error gone from the process.
|
|
122
|
+
const failure = resolverFailure === undefined ? [] : ['', `The project resolver failed with: ${resolverFailure}`];
|
|
117
123
|
return [
|
|
118
124
|
headline,
|
|
119
125
|
` imported by: ${importer}`,
|
|
120
126
|
'',
|
|
121
127
|
consequence,
|
|
128
|
+
...failure,
|
|
122
129
|
'',
|
|
123
130
|
'Likely causes:',
|
|
124
131
|
' - `node_modules` is missing or incomplete — run `gjsify install` (`--immutable` in CI).',
|
|
@@ -143,6 +150,10 @@ export function buildReverseAliasIndex(aliases) {
|
|
|
143
150
|
list.sort();
|
|
144
151
|
return index;
|
|
145
152
|
}
|
|
153
|
+
/** One line for the message; the untouched error rides along as `cause`. */
|
|
154
|
+
function describeResolverFailure(err) {
|
|
155
|
+
return err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
156
|
+
}
|
|
146
157
|
/**
|
|
147
158
|
* Refuse a build whose `@gjsify/*` substitution could not be resolved.
|
|
148
159
|
*
|
|
@@ -150,10 +161,14 @@ export function buildReverseAliasIndex(aliases) {
|
|
|
150
161
|
* imports-empty/externals at normal) gets first refusal — only ids NOTHING claimed
|
|
151
162
|
* reach this hook. It then re-runs the resolution Rolldown would have run next
|
|
152
163
|
* (`this.resolve(…, { skipSelf })`) and returns that, so a healthy build resolves
|
|
153
|
-
* each specifier exactly once and behaves identically.
|
|
164
|
+
* each specifier exactly once and behaves identically.
|
|
165
|
+
*
|
|
166
|
+
* A genuine `null` throws — unless `toolchainAnchor` is set and the CLI's own install
|
|
167
|
+
* has it. A THROW is not a `null`: it is rethrown with this plugin's context and the
|
|
168
|
+
* original as `cause`, never rescued and never reported as a missing dependency.
|
|
154
169
|
*/
|
|
155
170
|
export function unresolvedWorkspaceImportPlugin(options) {
|
|
156
|
-
const { target, aliases, isExternal } = options;
|
|
171
|
+
const { target, aliases, isExternal, toolchainAnchor } = options;
|
|
157
172
|
// Re-entrancy belt: `skipSelf: true` should keep our own `this.resolve` out of
|
|
158
173
|
// this hook, but a hang is a far worse failure than a missed check. Keyed by
|
|
159
174
|
// `candidate\0importer` — the same package legitimately resolves from many.
|
|
@@ -185,6 +200,12 @@ export function unresolvedWorkspaceImportPlugin(options) {
|
|
|
185
200
|
const importer = typeof rawImporter === 'string' ? rawImporter : undefined;
|
|
186
201
|
if (extraOptions?.isEntry)
|
|
187
202
|
return null;
|
|
203
|
+
// The toolchain fallback below re-enters this hook with the anchor as
|
|
204
|
+
// the importer. Declining there keeps the probe from rescuing itself:
|
|
205
|
+
// the anchor cannot answer for the anchor, and the recursion has no
|
|
206
|
+
// bound of its own.
|
|
207
|
+
if (toolchainAnchor !== undefined && importer === toolchainAnchor)
|
|
208
|
+
return null;
|
|
188
209
|
const verdict = classifyImport({ source, importer, aliases, isExternal });
|
|
189
210
|
if (verdict.verdict === 'ignore')
|
|
190
211
|
return null;
|
|
@@ -198,13 +219,29 @@ export function unresolvedWorkspaceImportPlugin(options) {
|
|
|
198
219
|
if (inFlight.has(key))
|
|
199
220
|
return null;
|
|
200
221
|
inFlight.add(key);
|
|
201
|
-
let resolved;
|
|
222
|
+
let resolved = null;
|
|
223
|
+
// KEPT DELIBERATELY, and NOT as a miss. `this.resolve` re-runs the whole
|
|
224
|
+
// `pre`-order chain, so it can fail two very different ways. "Nothing is
|
|
225
|
+
// there" is `null` on both engines — npm `rolldown` always answered that,
|
|
226
|
+
// and `@gjsify/rolldown-native` now does too (`isResolveMiss`, which is
|
|
227
|
+
// where the engine mismatch that hid this plugin's diagnostic under GJS
|
|
228
|
+
// is fixed). What reaches this catch is the OTHER kind: a hook in that
|
|
229
|
+
// chain throwing on purpose (`napi-node-addon`'s `resolveAddonPath`), an
|
|
230
|
+
// EACCES, a corrupt `package.json`, a bridge fault. Letting it escape
|
|
231
|
+
// surfaces it as a bare "plugin `gjsify-alias` threw an error" naming
|
|
232
|
+
// neither specifier nor importer; swallowing it would report a real fault
|
|
233
|
+
// as the user's missing dependency. So it is caught, kept, and thrown
|
|
234
|
+
// BELOW with this plugin's context around it and the original as `cause`.
|
|
235
|
+
let resolverFailure;
|
|
202
236
|
try {
|
|
203
237
|
resolved = await this.resolve(verdict.candidate, importer, {
|
|
204
238
|
skipSelf: true,
|
|
205
239
|
kind: extraOptions?.kind,
|
|
206
240
|
});
|
|
207
241
|
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
resolverFailure = err;
|
|
244
|
+
}
|
|
208
245
|
finally {
|
|
209
246
|
inFlight.delete(key);
|
|
210
247
|
}
|
|
@@ -212,6 +249,30 @@ export function unresolvedWorkspaceImportPlugin(options) {
|
|
|
212
249
|
resolvedCache.set(key, resolved);
|
|
213
250
|
return resolved;
|
|
214
251
|
}
|
|
252
|
+
// Toolchain fallback — see `toolchainAnchor`. Only after a real `null`:
|
|
253
|
+
// a resolver that ERRORED has not established that the project lacks the
|
|
254
|
+
// package, and rescuing there would turn "project first, always" into
|
|
255
|
+
// "project first unless the project errors".
|
|
256
|
+
if (resolverFailure === undefined && toolchainAnchor !== undefined) {
|
|
257
|
+
const fromToolchain = await this.resolve(verdict.candidate, toolchainAnchor, {
|
|
258
|
+
skipSelf: true,
|
|
259
|
+
kind: extraOptions?.kind,
|
|
260
|
+
});
|
|
261
|
+
if (fromToolchain) {
|
|
262
|
+
// The one visible trace that this artifact is MIXED — some of its
|
|
263
|
+
// `@gjsify/*` from the project, this one from the CLI's own
|
|
264
|
+
// install, possibly a different major. Without it a rescued build
|
|
265
|
+
// and a healthy one have byte-identical logs, and a wrong answer
|
|
266
|
+
// that leaves no trace is the expensive kind. Deduplicated by
|
|
267
|
+
// `resolvedCache`: one line per (candidate, importer dir, kind).
|
|
268
|
+
this.warn(`gjsify: \`${verdict.candidate}\` did not resolve from the project ` +
|
|
269
|
+
`(imported by ${importer ?? '<entry>'}) — using the copy installed beside the ` +
|
|
270
|
+
`running CLI (${toolchainAnchor}). Build the workspace package to use the ` +
|
|
271
|
+
`project's own copy.`);
|
|
272
|
+
resolvedCache.set(key, fromToolchain);
|
|
273
|
+
return fromToolchain;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
215
276
|
throw new UnresolvedWorkspaceImportError({
|
|
216
277
|
target,
|
|
217
278
|
source,
|
|
@@ -219,7 +280,10 @@ export function unresolvedWorkspaceImportPlugin(options) {
|
|
|
219
280
|
aliasTarget: verdict.aliasTarget,
|
|
220
281
|
aliasedFrom: reverseAliases.get(verdict.candidate)?.filter((s) => s !== source),
|
|
221
282
|
importer: importer ?? '<entry>',
|
|
222
|
-
|
|
283
|
+
...(resolverFailure === undefined
|
|
284
|
+
? {}
|
|
285
|
+
: { resolverFailure: describeResolverFailure(resolverFailure) }),
|
|
286
|
+
}, resolverFailure);
|
|
223
287
|
},
|
|
224
288
|
},
|
|
225
289
|
};
|
|
@@ -67,4 +67,16 @@ export interface PluginOptions {
|
|
|
67
67
|
* the wrapper. Defaults to `false`.
|
|
68
68
|
*/
|
|
69
69
|
preserveDefaultExport?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Where to look for a `@gjsify/*` the PROJECT cannot resolve, when the input is
|
|
72
|
+
* TOOLCHAIN rather than user code: a file path inside the running CLI's own
|
|
73
|
+
* install, used as the importer of a second resolve.
|
|
74
|
+
*
|
|
75
|
+
* OPT-IN PER CALL SITE, never inferred from the code path. `gjsify build` never
|
|
76
|
+
* sets it, and neither does a `node <file>` in an ordinary project's package
|
|
77
|
+
* script — a user's application must still fail loudly on a missing dependency
|
|
78
|
+
* rather than silently pick up whatever version the CLI ships. Rationale and the
|
|
79
|
+
* measured incident: `WorkspaceImportGuardOptions.toolchainAnchor`.
|
|
80
|
+
*/
|
|
81
|
+
toolchainAnchor?: string;
|
|
70
82
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import * as acorn from 'acorn';
|
|
10
10
|
import * as walk from 'acorn-walk';
|
|
11
11
|
import { GJS_GLOBALS_MAP } from '@gjsify/resolve-npm/globals-map';
|
|
12
|
+
import { classifyJsxParseFailure, formatSurvivingJsx } from './jsx-survival.js';
|
|
12
13
|
const KNOWN_GLOBALS = new Set(Object.keys(GJS_GLOBALS_MAP));
|
|
13
14
|
/**
|
|
14
15
|
* `<host>.<method>` patterns that must inject a global even though the identifier
|
|
@@ -88,6 +89,33 @@ function extractBindingNames(node) {
|
|
|
88
89
|
return [];
|
|
89
90
|
}
|
|
90
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Parse one analysis chunk, and say what a JSX failure IS.
|
|
94
|
+
*
|
|
95
|
+
* This parse is the first thing a `--globals auto` build does with bundled output, so a
|
|
96
|
+
* `jsx: "preserve"` project with no framework compiler dies HERE — and acorn's own
|
|
97
|
+
* message is `Unexpected token (3:11)`, which names neither JSX, nor the file, nor a
|
|
98
|
+
* setting. Every other parse failure is re-thrown untouched: acorn trailing
|
|
99
|
+
* SpiderMonkey on new syntax is acorn's problem, not the project's, and claiming JSX
|
|
100
|
+
* for it would be a lie with a fix attached.
|
|
101
|
+
*/
|
|
102
|
+
function parseAnalysisChunk(code) {
|
|
103
|
+
try {
|
|
104
|
+
return acorn.parse(code, {
|
|
105
|
+
ecmaVersion: 'latest',
|
|
106
|
+
sourceType: 'module',
|
|
107
|
+
// Acorn rejects shebangs by default, and any project bundling its own CLI gets
|
|
108
|
+
// one hoisted to byte 0.
|
|
109
|
+
allowHashBang: true,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
const jsx = classifyJsxParseFailure(code, err);
|
|
114
|
+
if (jsx === null)
|
|
115
|
+
throw err;
|
|
116
|
+
throw new Error(formatSurvivingJsx(jsx, 'the analysis bundle'), { cause: err });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
91
119
|
/**
|
|
92
120
|
* Core scanner behind `detectFreeGlobals` (web/Node globals map) and
|
|
93
121
|
* `detectGjsAmbientGlobals` (the `--app node` GJS-ambient set), parametrised so the node
|
|
@@ -108,13 +136,7 @@ function scanFreeGlobals(code, opts) {
|
|
|
108
136
|
const knownGlobals = opts.knownGlobals;
|
|
109
137
|
const methodMarkers = opts.methodMarkers ?? {};
|
|
110
138
|
const wasmMarkers = opts.wasmMarkers ?? {};
|
|
111
|
-
const ast =
|
|
112
|
-
ecmaVersion: 'latest',
|
|
113
|
-
sourceType: 'module',
|
|
114
|
-
// Acorn rejects shebangs by default, and any project bundling its own CLI gets
|
|
115
|
-
// one hoisted to byte 0.
|
|
116
|
-
allowHashBang: true,
|
|
117
|
-
});
|
|
139
|
+
const ast = parseAnalysisChunk(code);
|
|
118
140
|
// Pass 1: every declared name in the module.
|
|
119
141
|
const declaredNames = new Set();
|
|
120
142
|
walk.simple(ast, {
|
package/lib/utils/index.d.ts
CHANGED
|
@@ -7,3 +7,5 @@ export { detectFreeGlobals } from './detect-free-globals.js';
|
|
|
7
7
|
export { resolveGlobalsList, writeRegisterInjectFile } from './scan-globals.js';
|
|
8
8
|
export { inlineStaticReads, isAbsoluteFsPath } from './inline-static-reads.js';
|
|
9
9
|
export { GJSIFY_VIRTUAL_PREFIX, isGjsifyVirtualModuleId } from './virtual-module-id.js';
|
|
10
|
+
export { locateSurvivingJsx, classifyJsxParseFailure, formatSurvivingJsx } from './jsx-survival.js';
|
|
11
|
+
export type { SurvivingJsx } from './jsx-survival.js';
|
package/lib/utils/index.js
CHANGED
|
@@ -7,3 +7,4 @@ export { detectFreeGlobals } from './detect-free-globals.js';
|
|
|
7
7
|
export { resolveGlobalsList, writeRegisterInjectFile } from './scan-globals.js';
|
|
8
8
|
export { inlineStaticReads, isAbsoluteFsPath } from './inline-static-reads.js';
|
|
9
9
|
export { GJSIFY_VIRTUAL_PREFIX, isGjsifyVirtualModuleId } from './virtual-module-id.js';
|
|
10
|
+
export { locateSurvivingJsx, classifyJsxParseFailure, formatSurvivingJsx } from './jsx-survival.js';
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Run the inliner on a source string. Returns the rewritten source (or the
|
|
3
|
-
* original string when no inlining applied) and the count of edits applied.
|
|
4
|
-
*
|
|
5
|
-
* Safe to call on any JS source. Files that don't reference `readFileSync` /
|
|
6
|
-
* `readdirSync` / `existsSync` skip the AST parse entirely (cheap fast path).
|
|
7
|
-
*/
|
|
8
1
|
export declare function inlineStaticReads(src: string, sourceFilePath: string): {
|
|
9
2
|
contents: string;
|
|
10
3
|
inlined: number;
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
// Anything not statically resolvable is left untouched — the legacy
|
|
42
42
|
// `import.meta.url` rewriter still applies as a fallback.
|
|
43
43
|
import * as acorn from 'acorn';
|
|
44
|
-
import
|
|
44
|
+
import { tsPlugin } from 'acorn-typescript';
|
|
45
45
|
import { dirname, join, resolve, basename, relative, extname, posix, win32 } from 'node:path';
|
|
46
46
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
47
47
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
@@ -52,35 +52,98 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
52
52
|
* Safe to call on any JS source. Files that don't reference `readFileSync` /
|
|
53
53
|
* `readdirSync` / `existsSync` skip the AST parse entirely (cheap fast path).
|
|
54
54
|
*/
|
|
55
|
+
const TS_EXT_RE = /\.[cm]?tsx?$/i;
|
|
56
|
+
/**
|
|
57
|
+
* The TypeScript-capable parser, built once.
|
|
58
|
+
*
|
|
59
|
+
* `acorn-typescript` and not the bundler's own parser, and the reason is the
|
|
60
|
+
* runtime rather than a preference: under GJS the engine is
|
|
61
|
+
* `@gjsify/rolldown-native`, and npm `rolldown` is "a Rust napi crate that cannot
|
|
62
|
+
* run under GJS" (`bundler-pick.ts`), loaded only behind a dynamic import on Node.
|
|
63
|
+
* A top-level `import { parseAst } from 'rolldown/parseAst'` therefore links a
|
|
64
|
+
* Node-only N-API package into a module that has to load under GJS — measured:
|
|
65
|
+
* the CLI's own bundle then died at startup with `createRequire: Cannot require
|
|
66
|
+
* builtin module "fs" synchronously in GJS`. `acorn-typescript` is pure JS, emits
|
|
67
|
+
* the same ESTree shapes the evaluator below already speaks, and needs
|
|
68
|
+
* `locations: true` (it refuses to parse without them).
|
|
69
|
+
*/
|
|
70
|
+
const TS_PARSER = acorn.Parser.extend(tsPlugin());
|
|
71
|
+
/**
|
|
72
|
+
* Parse a source with the parser its extension calls for.
|
|
73
|
+
*
|
|
74
|
+
* The TypeScript half is why first-party sources were invisible to this inliner
|
|
75
|
+
* for as long as it was scoped to `node_modules`: an installed package ships JS,
|
|
76
|
+
* so plain acorn could always parse it, and nothing ever asked what happens to a
|
|
77
|
+
* `.ts`. The answer was that `acorn.parse` threw and the `catch` returned
|
|
78
|
+
* "nothing to inline" — a result indistinguishable from a file that genuinely has
|
|
79
|
+
* no static reads. Measured on `packages/infra/cli/src/utils/app-metadata.ts`:
|
|
80
|
+
* `inlined: 0`, while the identical expression in a `.js` file returned
|
|
81
|
+
* `inlined: 1`.
|
|
82
|
+
*/
|
|
83
|
+
function parseSource(src, sourceFilePath) {
|
|
84
|
+
const shared = {
|
|
85
|
+
ecmaVersion: 'latest',
|
|
86
|
+
sourceType: 'module',
|
|
87
|
+
allowAwaitOutsideFunction: true,
|
|
88
|
+
allowReturnOutsideFunction: true,
|
|
89
|
+
allowImportExportEverywhere: true,
|
|
90
|
+
};
|
|
91
|
+
if (!TS_EXT_RE.test(sourceFilePath))
|
|
92
|
+
return acorn.parse(src, shared);
|
|
93
|
+
return TS_PARSER.parse(src, { ...shared, locations: true });
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Visit every `CallExpression` in the tree.
|
|
97
|
+
*
|
|
98
|
+
* A generic descent rather than `acorn-walk`, and that is not a preference:
|
|
99
|
+
* `walk.simple` looks its visitor up in a table keyed by node type and throws
|
|
100
|
+
* `No walker function defined for node type TSInterfaceDeclaration` on the first
|
|
101
|
+
* interface in a first-party source. A walker with no node table cannot fall
|
|
102
|
+
* behind the parser — which matters precisely because the parser is now allowed
|
|
103
|
+
* to emit node types this file has never heard of.
|
|
104
|
+
*/
|
|
105
|
+
function forEachCallExpression(root, visit) {
|
|
106
|
+
const stack = [root];
|
|
107
|
+
while (stack.length > 0) {
|
|
108
|
+
const node = stack.pop();
|
|
109
|
+
if (node === null || typeof node !== 'object')
|
|
110
|
+
continue;
|
|
111
|
+
if (Array.isArray(node)) {
|
|
112
|
+
for (const child of node)
|
|
113
|
+
stack.push(child);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (node.type === 'CallExpression')
|
|
117
|
+
visit(node);
|
|
118
|
+
for (const value of Object.values(node)) {
|
|
119
|
+
if (value !== null && typeof value === 'object')
|
|
120
|
+
stack.push(value);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
55
124
|
export function inlineStaticReads(src, sourceFilePath) {
|
|
56
125
|
if (!src.includes('readFileSync') && !src.includes('readdirSync') && !src.includes('existsSync')) {
|
|
57
126
|
return { contents: src, inlined: 0 };
|
|
58
127
|
}
|
|
59
128
|
let ast;
|
|
60
129
|
try {
|
|
61
|
-
ast =
|
|
62
|
-
ecmaVersion: 'latest',
|
|
63
|
-
sourceType: 'module',
|
|
64
|
-
allowAwaitOutsideFunction: true,
|
|
65
|
-
allowReturnOutsideFunction: true,
|
|
66
|
-
allowImportExportEverywhere: true,
|
|
67
|
-
});
|
|
130
|
+
ast = parseSource(src, sourceFilePath);
|
|
68
131
|
}
|
|
69
132
|
catch {
|
|
70
|
-
//
|
|
71
|
-
// syntax, ...). Skip; the rest of the rewriter
|
|
133
|
+
// Not parseable as the language its extension claims (a CJS source with
|
|
134
|
+
// a shebang, mixed module syntax, ...). Skip; the rest of the rewriter
|
|
135
|
+
// still runs. This catch used to swallow every TypeScript file in the
|
|
136
|
+
// tree as well — see `parseSource`.
|
|
72
137
|
return { contents: src, inlined: 0 };
|
|
73
138
|
}
|
|
74
139
|
const ctx = {
|
|
75
140
|
sourceUrl: pathToFileURL(sourceFilePath).href,
|
|
76
141
|
};
|
|
77
142
|
const edits = [];
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
edits.push(edit);
|
|
83
|
-
},
|
|
143
|
+
forEachCallExpression(ast, (node) => {
|
|
144
|
+
const edit = tryInlineCall(node, ctx, src);
|
|
145
|
+
if (edit)
|
|
146
|
+
edits.push(edit);
|
|
84
147
|
});
|
|
85
148
|
if (edits.length === 0)
|
|
86
149
|
return { contents: src, inlined: 0 };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Where the parser stopped, and on what. */
|
|
2
|
+
export interface SurvivingJsx {
|
|
3
|
+
/** 1-based line, as acorn reports it. */
|
|
4
|
+
line: number;
|
|
5
|
+
/** 0-based column, as acorn reports it. */
|
|
6
|
+
column: number;
|
|
7
|
+
/** The text around the failure, trimmed so a minified bundle stays readable. */
|
|
8
|
+
excerpt: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Does `err` — a failure from parsing `code` — say that raw JSX survived? Returns the
|
|
12
|
+
* location when the position attributes it to JSX, `null` when it does not.
|
|
13
|
+
*
|
|
14
|
+
* Split from {@link locateSurvivingJsx} so a caller that ALREADY parsed (and threw) can
|
|
15
|
+
* attribute its own failure without parsing a second time.
|
|
16
|
+
*/
|
|
17
|
+
export declare function classifyJsxParseFailure(code: string, err: unknown): SurvivingJsx | null;
|
|
18
|
+
/**
|
|
19
|
+
* Parse `code` as an ES module and report surviving JSX. `null` means either "parses
|
|
20
|
+
* fine" or "fails for a reason that is not JSX" — the two cases this must not conflate,
|
|
21
|
+
* because only the first is a healthy bundle and only the second is acorn's problem
|
|
22
|
+
* rather than the build's.
|
|
23
|
+
*/
|
|
24
|
+
export declare function locateSurvivingJsx(code: string): SurvivingJsx | null;
|
|
25
|
+
/**
|
|
26
|
+
* The actionable message: what the artifact does, what the runtime will do with it, and
|
|
27
|
+
* the two settings that fix it. Shared by the post-bundle artifact gate and the
|
|
28
|
+
* `--globals auto` analysis pass, which hits the same source with a bare
|
|
29
|
+
* "Unexpected token (3:11)" naming neither JSX nor a setting.
|
|
30
|
+
*/
|
|
31
|
+
export declare function formatSurvivingJsx(found: SurvivingJsx, label: string): string;
|