@gjsify/rolldown-plugin-gjsify 0.24.1 → 0.24.2

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.
@@ -19,6 +19,7 @@ import { ALIASES_NODE_FOR_BROWSER, getDerivedAliasesSync } from '@gjsify/resolve
19
19
  import { globToEntryPoints } from '../utils/entry-points.js';
20
20
  import { gjsImportsEmptyPlugin } from '../plugins/gjs-imports-empty.js';
21
21
  import { cssAsStringPlugin } from '../plugins/css-as-string.js';
22
+ import { unresolvedWorkspaceImportPlugin } from '../plugins/unresolved-workspace-import.js';
22
23
  export const setupForBrowser = async (input) => {
23
24
  const userExternal = input.userExternal ?? [];
24
25
  const external = [...userExternal];
@@ -81,12 +82,26 @@ export const setupForBrowser = async (input) => {
81
82
  },
82
83
  treeshake: true,
83
84
  };
85
+ // The substitution table exactly as `aliasPlugin` receives it — shared with
86
+ // the unresolved-workspace-import guard below so the guard reports the same
87
+ // promise the alias layer made.
88
+ const aliasEntries = flattenAliases(aliasMap);
84
89
  const plugins = [
85
90
  gjsImportsEmptyPlugin(),
86
- aliasPlugin({ entries: flattenAliases(aliasMap) }),
91
+ aliasPlugin({ entries: aliasEntries }),
87
92
  blueprintPlugin(),
88
93
  deepkitPlugin({ reflection: input.pluginOptions.reflection }),
89
94
  cssAsStringPlugin(),
95
+ // `order: 'post'` — see app/gjs.ts. The browser target's whole job is to
96
+ // replace Node builtins with their `@gjsify/*` browser entries; when one
97
+ // of those cannot be resolved, Rolldown externalises the ORIGINAL bare
98
+ // specifier and a browser cannot resolve it at all. There is no
99
+ // `node:`-shaped emit-time symptom here, so this guard is the only signal.
100
+ unresolvedWorkspaceImportPlugin({
101
+ target: 'browser',
102
+ aliases: aliasEntries,
103
+ isExternal: (id) => external.includes(id),
104
+ }),
90
105
  ];
91
106
  return { options, plugins };
92
107
  };
package/lib/app/gjs.js CHANGED
@@ -15,6 +15,7 @@ import { createRequire } from 'node:module';
15
15
  import { aliasPlugin } from '../plugins/alias.js';
16
16
  import { externalsPlugin } from '../plugins/externals.js';
17
17
  import { napiNodeAddonPlugin } from '../plugins/napi-node-addon.js';
18
+ import { unresolvedWorkspaceImportPlugin } from '../plugins/unresolved-workspace-import.js';
18
19
  import { deepkitPlugin } from '@gjsify/rolldown-plugin-deepkit';
19
20
  import blueprintPlugin from '@gjsify/vite-plugin-blueprint';
20
21
  import { getAliasesForGjs } from '../utils/alias.js';
@@ -188,6 +189,13 @@ export const setupForGjs = async (input) => {
188
189
  treeshake: true,
189
190
  };
190
191
  const bundleDir = getBundleDirFromOutput(input.output);
192
+ // The substitution table exactly as `aliasPlugin` receives it — shared with
193
+ // the unresolved-workspace-import guard below so the guard reports the same
194
+ // promise the alias layer made.
195
+ const aliasEntries = {
196
+ 'random-access-file': 'random-access-file/index.js',
197
+ ...flattenAliases(aliasMap),
198
+ };
191
199
  const plugins = [
192
200
  // Virtual-entry plugin runs FIRST so its resolveId/load match the
193
201
  // synthetic input ids that `wrapInputWithSideEffects` produces.
@@ -201,12 +209,7 @@ export const setupForGjs = async (input) => {
201
209
  // random-access-file's 'browser' field maps to a throwing stub; force
202
210
  // the fs-backed Node entry. Implemented via the gjsify alias plugin
203
211
  // as a direct entry-table override.
204
- aliasPlugin({
205
- entries: {
206
- 'random-access-file': 'random-access-file/index.js',
207
- ...flattenAliases(aliasMap),
208
- },
209
- }),
212
+ aliasPlugin({ entries: aliasEntries }),
210
213
  // Transparent N-API `.node`-addon loader — the forward mirror of
211
214
  // `gjsGiNodePlugin`. Its `order:'pre'` resolveId claims a native-addon
212
215
  // acquisition (`bindings`/`node-gyp-build`/a direct `.node`/a napi-rs
@@ -239,6 +242,14 @@ export const setupForGjs = async (input) => {
239
242
  const line = resolveShebangLine(input.shebang);
240
243
  return shebangPlugin({ enabled: line !== null, line: line ?? undefined });
241
244
  })(),
245
+ // LAST claim on an id nothing else wanted (`order: 'post'`): a
246
+ // `@gjsify/*` substitution that cannot be resolved is a build ERROR, not
247
+ // a silent external. Without it Rolldown externalises the ORIGINAL
248
+ // specifier, exits 0, and writes a bundle stock GJS aborts on at load
249
+ // (`ImportError: Unsupported URI scheme for importing: node`) — the
250
+ // failure `utils/gjs-bundle-guard.ts` catches at emit time, caught here
251
+ // at its source instead, with the importer and the cause named.
252
+ unresolvedWorkspaceImportPlugin({ target: 'gjs', aliases: aliasEntries, isExternal: external }),
242
253
  ];
243
254
  return { options, plugins };
244
255
  };
@@ -40,6 +40,7 @@ import { ALIASES_NODE_FOR_NATIVESCRIPT, getDerivedAliasesSync } from '@gjsify/re
40
40
  import { globToEntryPoints } from '../utils/entry-points.js';
41
41
  import { gjsImportsEmptyPlugin } from '../plugins/gjs-imports-empty.js';
42
42
  import { platformResolvePlugin, detectNativescriptPlatform, nativescriptPlatformDefines, } from '../plugins/platform-resolve.js';
43
+ import { unresolvedWorkspaceImportPlugin } from '../plugins/unresolved-workspace-import.js';
43
44
  export const setupForNativescript = async (input) => {
44
45
  const userExternal = input.userExternal ?? [];
45
46
  const external = [...userExternal];
@@ -116,18 +117,32 @@ export const setupForNativescript = async (input) => {
116
117
  },
117
118
  treeshake: true,
118
119
  };
120
+ // The substitution table exactly as `aliasPlugin` receives it — shared with
121
+ // the unresolved-workspace-import guard below so the guard reports the same
122
+ // promise the alias layer made.
123
+ const aliasEntries = flattenAliases(aliasMap);
119
124
  const plugins = [
120
125
  gjsImportsEmptyPlugin(),
121
126
  // Platform-specific source variants (`*.android` / `*.ios` /
122
127
  // `*.native`) win over the base file — resolved BEFORE the Node-builtin
123
128
  // alias routing so a platform fork of a portable module is honored.
124
129
  platformResolvePlugin({ platform }),
125
- aliasPlugin({ entries: flattenAliases(aliasMap) }),
130
+ aliasPlugin({ entries: aliasEntries }),
126
131
  // NO blueprintPlugin — Blueprint is a GTK-specific UI DSL
127
132
  // NO cssAsStringPlugin — NS ships its own CSS pipeline via
128
133
  // @nativescript/core; .css imports are handled by the consuming
129
134
  // @nativescript/webpack or @nativescript/vite build
130
135
  deepkitPlugin({ reflection: input.pluginOptions.reflection }),
136
+ // `order: 'post'` — see app/gjs.ts. Native-bridge identifiers
137
+ // (`java.*`, `NS*`, …) are ambient GLOBALS on this target, never
138
+ // imports, so they never reach this hook; only a `@gjsify/*` edge does,
139
+ // and on NS as everywhere else an unresolvable one is a broken bundle
140
+ // rather than something the runtime will provide.
141
+ unresolvedWorkspaceImportPlugin({
142
+ target: 'nativescript',
143
+ aliases: aliasEntries,
144
+ isExternal: (id) => external.includes(id),
145
+ }),
131
146
  ];
132
147
  return { options, plugins };
133
148
  };
package/lib/app/node.js CHANGED
@@ -14,6 +14,7 @@ import { nodeModulesPathRewritePlugin, getBundleDirFromOutput } from '../plugins
14
14
  import { cssAsStringPlugin } from '../plugins/css-as-string.js';
15
15
  import { gjsImportsEmptyPlugin } from '../plugins/gjs-imports-empty.js';
16
16
  import { gjsGiNodePlugin, gjsBuiltinModulesNodePlugin } from '../plugins/gjs-gi-node.js';
17
+ import { unresolvedWorkspaceImportPlugin } from '../plugins/unresolved-workspace-import.js';
17
18
  import { wrapInputWithSideEffects } from '../utils/entry-wrapper.js';
18
19
  /**
19
20
  * Side-effect specifier the node target injects when GJS ambient globals
@@ -219,6 +220,10 @@ export const setupForNode = async (input) => {
219
220
  ...input.pluginOptions.aliases,
220
221
  ...input.userAliases,
221
222
  };
223
+ // The substitution table exactly as `aliasPlugin` receives it — shared with
224
+ // the unresolved-workspace-import guard below so the guard reports the same
225
+ // promise the alias layer made.
226
+ const aliasEntries = flattenAliases(aliasMap);
222
227
  const bundleDir = getBundleDirFromOutput(input.output);
223
228
  // Rolldown's CJS interop wraps bundled CJS via `__commonJSMin` and
224
229
  // routes external Node-builtin `require()` through `__require` —
@@ -317,7 +322,7 @@ export const setupForNode = async (input) => {
317
322
  // `requireGi`, exactly like the ambient-globals case. Same predicate as
318
323
  // the register-alias lift above — one question, one flag.
319
324
  gjsImportsEmptyPlugin({ emptyGirs: !gjsSourceBuild }),
320
- aliasPlugin({ entries: flattenAliases(aliasMap) }),
325
+ aliasPlugin({ entries: aliasEntries }),
321
326
  // Blueprint (.blp → XML string): the reverse bridge runs REAL GTK on
322
327
  // Node, so a GJS app entry (composite-template windows) must build for
323
328
  // `--app node` too. Only claims `.blp` files — inert for plain-Node
@@ -334,6 +339,13 @@ export const setupForNode = async (input) => {
334
339
  // symptom). A plain-Node bundle (no node-gi) keeps the CSS pristine.
335
340
  cssAsStringPlugin(input.pluginOptions.nodeGiGlobalsInject ? { targets: { firefox: 60 << 16 } } : {}),
336
341
  nodeModulesPathRewritePlugin({ bundleDir }),
342
+ // `order: 'post'` — fires only for ids nothing above claimed, i.e. the
343
+ // ones headed for Rolldown's unresolved-import → external fallback. The
344
+ // node target has no `node:`-shaped symptom for the emit-time guard to
345
+ // detect (its `node:` builtins ARE external by policy), so this is the
346
+ // ONLY thing standing between an unbuilt `@gjsify/*` workspace edge and
347
+ // a bundle that re-imports the bare specifier at runtime.
348
+ unresolvedWorkspaceImportPlugin({ target: 'node', aliases: aliasEntries, isExternal: external }),
337
349
  ];
338
350
  return { options, plugins };
339
351
  };
package/lib/index.d.ts CHANGED
@@ -16,6 +16,8 @@ export type { AliasPluginOptions } from './plugins/alias.js';
16
16
  export { gjsImportsEmptyPlugin } from './plugins/gjs-imports-empty.js';
17
17
  export { externalsPlugin } from './plugins/externals.js';
18
18
  export type { ExternalsPredicate, ExternalsPluginOptions } from './plugins/externals.js';
19
+ export { unresolvedWorkspaceImportPlugin, classifyImport, isWorkspaceSpecifier, formatUnresolvedWorkspaceImport, buildReverseAliasIndex, UnresolvedWorkspaceImportError, } from './plugins/unresolved-workspace-import.js';
20
+ export type { WorkspaceImportGuardOptions, WorkspaceImportGuardTarget, ImportVerdict, ClassifyImportInput, UnresolvedWorkspaceImportDetails, } from './plugins/unresolved-workspace-import.js';
19
21
  export { napiNodeAddonPlugin, resolveAddonPath, nearestPackageRoot, classifySpecifier, directNodeShim, nodeGypBuildShim, bindingsShim, napiRsShim, ADDON_FILTER_RE, isNapiRsPackageJson, isNapiRsSibling, detectNapiRsEntry, AddonNotBuiltError, } from './plugins/napi-node-addon.js';
20
22
  export type { NapiNodeAddonPluginOptions, AddonPackageJson } from './plugins/napi-node-addon.js';
21
23
  export { platformResolvePlugin, detectNativescriptPlatform, nativescriptPlatformDefines, } from './plugins/platform-resolve.js';
package/lib/index.js CHANGED
@@ -11,6 +11,7 @@ export { shebangPlugin, GJS_SHEBANG, NODE_SHEBANG, expandEnvTemplate, resolveShe
11
11
  export { aliasPlugin } from './plugins/alias.js';
12
12
  export { gjsImportsEmptyPlugin } from './plugins/gjs-imports-empty.js';
13
13
  export { externalsPlugin } from './plugins/externals.js';
14
+ export { unresolvedWorkspaceImportPlugin, classifyImport, isWorkspaceSpecifier, formatUnresolvedWorkspaceImport, buildReverseAliasIndex, UnresolvedWorkspaceImportError, } from './plugins/unresolved-workspace-import.js';
14
15
  export { napiNodeAddonPlugin, resolveAddonPath, nearestPackageRoot, classifySpecifier, directNodeShim, nodeGypBuildShim, bindingsShim, napiRsShim, ADDON_FILTER_RE, isNapiRsPackageJson, isNapiRsSibling, detectNapiRsEntry, AddonNotBuiltError, } from './plugins/napi-node-addon.js';
15
16
  export { platformResolvePlugin, detectNativescriptPlatform, nativescriptPlatformDefines, } from './plugins/platform-resolve.js';
16
17
  export * from './plugin.js';
@@ -0,0 +1,105 @@
1
+ import type { Plugin } from 'rolldown';
2
+ /** Build targets that compose this guard. Used only for the error message. */
3
+ export type WorkspaceImportGuardTarget = 'gjs' | 'node' | 'browser' | 'nativescript';
4
+ export interface WorkspaceImportGuardOptions {
5
+ /** `--app <target>` this build is for; named verbatim in the error. */
6
+ target: WorkspaceImportGuardTarget;
7
+ /**
8
+ * The substitution table this target promised (specifier → target), already
9
+ * merged across tiers exactly as `aliasPlugin` received it. A miss here is
10
+ * fine — case (b) still covers a direct `@gjsify/*` import.
11
+ */
12
+ aliases?: Record<string, string>;
13
+ /**
14
+ * The target's own externals policy. Anything it claims is a DELIBERATE
15
+ * external and is ignored. Pass the same predicate `externalsPlugin` gets
16
+ * (plus exact-name membership) so the two cannot drift.
17
+ */
18
+ isExternal?: (id: string) => boolean;
19
+ }
20
+ /**
21
+ * Is `id` a bare `@gjsify/*` package specifier (root or subpath)?
22
+ *
23
+ * Deliberately excludes disk paths and synthetic ids: a resolved
24
+ * `/…/node_modules/@gjsify/fs/lib/esm/index.js` is already resolved, and a
25
+ * `\0gjsify-*` virtual id belongs to the plugin that emitted it.
26
+ */
27
+ export declare function isWorkspaceSpecifier(id: string): boolean;
28
+ /** What the guard decided about one specifier. */
29
+ export type ImportVerdict = {
30
+ verdict: 'ignore';
31
+ reason: 'entry' | 'not-bare' | 'declared-external' | 'out-of-scope';
32
+ } | {
33
+ verdict: 'check';
34
+ candidate: string;
35
+ aliasTarget?: string;
36
+ };
37
+ export interface ClassifyImportInput {
38
+ source: string;
39
+ importer: string | undefined;
40
+ aliases?: Record<string, string>;
41
+ isExternal?: (id: string) => boolean;
42
+ }
43
+ /**
44
+ * Pure decision: does this import edge have to resolve, and to what?
45
+ *
46
+ * Split out from the plugin so the legitimate-vs-broken line is unit-testable
47
+ * without a bundler (`packages/infra/cli/src/unresolved-workspace-import.spec.ts`).
48
+ */
49
+ export declare function classifyImport(input: ClassifyImportInput): ImportVerdict;
50
+ export interface UnresolvedWorkspaceImportDetails {
51
+ target: WorkspaceImportGuardTarget;
52
+ /** The specifier as written in the importing module. */
53
+ source: string;
54
+ /** What actually had to resolve (the alias target, or `source` itself). */
55
+ candidate: string;
56
+ /** Set when `source` was rewritten by the substitution table. */
57
+ aliasTarget?: string;
58
+ /**
59
+ * Specifiers whose substitution-table entry names `candidate`, discovered by
60
+ * REVERSE lookup — the half of the story `source` alone cannot tell.
61
+ *
62
+ * `aliasPlugin` resolves its target through `this.resolve(target, importer)`,
63
+ * so when `node:fs` → `@gjsify/fs` fails, the id that reaches THIS hook is
64
+ * `@gjsify/fs` with no alias entry of its own: the message would name the
65
+ * missing package but not the `node:fs` the user actually wrote, which is
66
+ * the specifier that survives into the bundle and the one they will grep
67
+ * for. Reversing the table recovers it (`['fs', 'node:fs']` — both forms
68
+ * route to the same target, and naming both is accurate).
69
+ */
70
+ aliasedFrom?: readonly string[];
71
+ importer: string;
72
+ }
73
+ /** A `@gjsify/*` edge the build promised to substitute could not be resolved. */
74
+ export declare class UnresolvedWorkspaceImportError extends Error {
75
+ readonly details: UnresolvedWorkspaceImportDetails;
76
+ constructor(details: UnresolvedWorkspaceImportDetails);
77
+ }
78
+ /**
79
+ * The actionable message. Names the specifier, the importer and the likely
80
+ * cause — the three things `UNRESOLVED_IMPORT` (and the post-hoc `node:` guard)
81
+ * cannot tell you.
82
+ */
83
+ export declare function formatUnresolvedWorkspaceImport(details: UnresolvedWorkspaceImportDetails): string;
84
+ /**
85
+ * Reverse the substitution table: alias TARGET → the specifiers that route to it.
86
+ *
87
+ * Built once per plugin instance (the tables run to ~150 entries per target, and
88
+ * the guard only consults this on the failure path).
89
+ */
90
+ export declare function buildReverseAliasIndex(aliases: Record<string, string> | undefined): Map<string, string[]>;
91
+ /**
92
+ * Refuse a build whose `@gjsify/*` substitution could not be resolved.
93
+ *
94
+ * `order: 'post'` so every deliberate claim — `aliasPlugin` (`pre`),
95
+ * `napiNodeAddonPlugin` (`pre`), `gjsGiNodePlugin` (`pre`),
96
+ * `gjsImportsEmptyPlugin`, `externalsPlugin` — gets first refusal. Only ids that
97
+ * NOTHING claimed reach this hook, i.e. exactly the ones headed for the
98
+ * unresolved-import fallback.
99
+ *
100
+ * The hook re-runs the full resolution chain via `this.resolve(…, { skipSelf })`
101
+ * — which is what Rolldown's builtin resolver would have done next — and returns
102
+ * its result when it succeeds, so a healthy build resolves the specifier exactly
103
+ * once and behaves identically. Only a genuine `null` throws.
104
+ */
105
+ export declare function unresolvedWorkspaceImportPlugin(options: WorkspaceImportGuardOptions): Plugin;
@@ -0,0 +1,318 @@
1
+ // Resolver-layer guard: an unresolvable `@gjsify/*` edge is a build ERROR,
2
+ // never a silent external.
3
+ //
4
+ // ## The failure this closes
5
+ //
6
+ // Rolldown (like Rollup) treats an import nothing resolves as EXTERNAL: it
7
+ // emits an `UNRESOLVED_IMPORT` **warning**, re-emits the ORIGINAL specifier
8
+ // into the output, and exits 0. For a normal bundler that is a reasonable
9
+ // default — the specifier might be provided by the host. For `gjsify build` it
10
+ // is not, because the `@gjsify/*` edge that failed is precisely the SUBSTITUTION
11
+ // that makes the bundle runnable on the target:
12
+ //
13
+ // import { readFileSync } from 'node:fs'
14
+ // → aliasPlugin rewrites to `@gjsify/fs`
15
+ // → `@gjsify/fs` cannot be resolved (no `node_modules`, or the workspace
16
+ // package has no built `lib/` so its `exports["."]` points at a file
17
+ // that does not exist)
18
+ // → aliasPlugin falls through, nothing else claims it
19
+ // → `node:fs` is externalised, the build "succeeds"
20
+ //
21
+ // Measured on `packages/infra/cli/dist/cli.gjs.mjs` (repo task #63): 1 bare
22
+ // `node:` import on `origin/main` vs 11 from a worktree with no `node_modules`
23
+ // — a bundle stock GJS aborts on before one line runs
24
+ // (`ImportError: Unsupported URI scheme for importing: node`). The pre-commit
25
+ // hook then rebuilt and AUTO-STAGED it, and nothing local objected: tsc, lint,
26
+ // format, unit and e2e all run the Node entry, never the emitted GJS bundle.
27
+ //
28
+ // ## Why this is upstream of the `--app gjs` `node:` guard (PR #828)
29
+ //
30
+ // `utils/gjs-bundle-guard.ts` reads the finished module graph and refuses to
31
+ // EMIT a `--app gjs` bundle that still statically imports a bare `node:`
32
+ // specifier. That is the right last line of defence, and it stays. But it is:
33
+ //
34
+ // - **gjs-only** — the same silent externalisation happens on `--app node`
35
+ // and `--app browser`, where a surviving bare `@gjsify/foo` is equally
36
+ // unloadable and no `node:`-shaped symptom exists to detect;
37
+ // - **symptom-shaped** — it names `node:fs`, not the `@gjsify/fs` edge that
38
+ // actually failed, and cannot name the importer;
39
+ // - **post-tree-shake** — an unresolved edge that gets shaken out leaves no
40
+ // trace at all, so the artefact passes while the build is still wrong.
41
+ //
42
+ // This guard fires at RESOLUTION time instead: it names the specifier, the
43
+ // substitution target, the importer, and the likely cause.
44
+ //
45
+ // ## The legitimate-vs-broken line
46
+ //
47
+ // A blanket "fail on any external" would break every real build — externals are
48
+ // load-bearing on every target (`gi://*`, `cairo`/`system`/`gettext` on gjs; the
49
+ // whole `EXTERNALS_NODE` set plus `@gjsify/node-gi*` and `node-datachannel` on
50
+ // node; whatever the user passed to `--external`). Those never reach the
51
+ // unresolved fallback by accident — a `resolveId` hook or the `external` option
52
+ // CLAIMS them, deliberately. So the distinction drawn here is:
53
+ //
54
+ // DECLARED external → legitimate, ignored. Asked via the target's own
55
+ // externals predicate (`isExternal`), i.e. exactly the
56
+ // policy `externalsPlugin` enforces, so the guard and the
57
+ // policy cannot drift.
58
+ // PROMISED, missing → broken. Either (a) the specifier has an entry in the
59
+ // target's substitution table and that target does not
60
+ // resolve, or (b) the specifier IS a bare `@gjsify/*`
61
+ // workspace package that does not resolve.
62
+ // anything else → not our business. A missing `lodash` is a plain
63
+ // dependency bug that `UNRESOLVED_IMPORT` already
64
+ // reports; widening to every bare specifier would turn a
65
+ // genuinely optional peer into a hard build failure, and
66
+ // this guard's whole claim is that the `@gjsify/*` edge
67
+ // is NOT optional — the target cannot run without it.
68
+ //
69
+ // On (a): the check is keyed on the ALIAS TARGET being a `@gjsify/*` package,
70
+ // not merely on the entry existing. Every value in the curated + derived tables
71
+ // is a `@gjsify/*` specifier today, so this is a distinction without a
72
+ // difference for them — but it keeps a user `--alias foo=./some/local.js` (or a
73
+ // shim path like `random-access-file/index.js`) on the old fall-through
74
+ // behaviour instead of promoting a user's own mapping mistake to a fatal error
75
+ // in a plugin they did not ask for.
76
+ //
77
+ // ## Not registered for `--library`
78
+ //
79
+ // Library mode PRESERVES imports on purpose (`preserveModules`, bare specifiers
80
+ // kept so the consumer resolves them). An unresolved `@gjsify/*` there is the
81
+ // contract, not a bug, so `library/lib.ts` deliberately does not compose this
82
+ // plugin.
83
+ import { dirname } from 'node:path';
84
+ /** Bare `@gjsify/*` package specifier — the workspace edge this guard protects. */
85
+ const WORKSPACE_SCOPE = '@gjsify/';
86
+ /**
87
+ * How many reverse-looked-up source specifiers the error may name before the
88
+ * list stops being evidence and becomes noise. A per-builtin target inverts to
89
+ * 2 (`fs` + `node:fs`); a shared sink like `@gjsify/empty` inverts to ~50.
90
+ */
91
+ const MAX_REPORTED_ALIAS_SOURCES = 4;
92
+ /**
93
+ * Is `id` a bare `@gjsify/*` package specifier (root or subpath)?
94
+ *
95
+ * Deliberately excludes disk paths and synthetic ids: a resolved
96
+ * `/…/node_modules/@gjsify/fs/lib/esm/index.js` is already resolved, and a
97
+ * `\0gjsify-*` virtual id belongs to the plugin that emitted it.
98
+ */
99
+ export function isWorkspaceSpecifier(id) {
100
+ if (!id.startsWith(WORKSPACE_SCOPE))
101
+ return false;
102
+ if (id.includes('\0'))
103
+ return false;
104
+ // `@gjsify/fs` and `@gjsify/fs/register/x` qualify; `@gjsify/` and
105
+ // `@gjsify//x` do not (no package name). A resolved disk path can never
106
+ // reach here — it is absolute or `./`-relative, so it never starts with the
107
+ // scope in the first place.
108
+ const rest = id.slice(WORKSPACE_SCOPE.length);
109
+ return rest.length > 0 && !rest.startsWith('/');
110
+ }
111
+ /**
112
+ * Pure decision: does this import edge have to resolve, and to what?
113
+ *
114
+ * Split out from the plugin so the legitimate-vs-broken line is unit-testable
115
+ * without a bundler (`packages/infra/cli/src/unresolved-workspace-import.spec.ts`).
116
+ */
117
+ export function classifyImport(input) {
118
+ const { source, importer, aliases, isExternal } = input;
119
+ // Entry modules are files the caller named; they are resolved by the
120
+ // orchestrator, never externalised.
121
+ if (importer === undefined)
122
+ return { verdict: 'ignore', reason: 'entry' };
123
+ // Relative / absolute / synthetic ids are not workspace edges.
124
+ if (source.startsWith('.') || source.startsWith('/') || source.startsWith('\0')) {
125
+ return { verdict: 'ignore', reason: 'not-bare' };
126
+ }
127
+ // A specifier the target DECLARES external is a deliberate external.
128
+ if (isExternal?.(source))
129
+ return { verdict: 'ignore', reason: 'declared-external' };
130
+ const aliasTarget = aliases?.[source];
131
+ const candidate = aliasTarget ?? source;
132
+ // The alias target may itself be declared external (`system` →
133
+ // `@gjsify/node-gi/system` on the node target).
134
+ if (candidate !== source && isExternal?.(candidate)) {
135
+ return { verdict: 'ignore', reason: 'declared-external' };
136
+ }
137
+ if (!isWorkspaceSpecifier(candidate))
138
+ return { verdict: 'ignore', reason: 'out-of-scope' };
139
+ return aliasTarget === undefined ? { verdict: 'check', candidate } : { verdict: 'check', candidate, aliasTarget };
140
+ }
141
+ /** A `@gjsify/*` edge the build promised to substitute could not be resolved. */
142
+ export class UnresolvedWorkspaceImportError extends Error {
143
+ details;
144
+ constructor(details) {
145
+ super(formatUnresolvedWorkspaceImport(details));
146
+ this.name = 'UnresolvedWorkspaceImportError';
147
+ this.details = details;
148
+ }
149
+ }
150
+ /**
151
+ * The actionable message. Names the specifier, the importer and the likely
152
+ * cause — the three things `UNRESOLVED_IMPORT` (and the post-hoc `node:` guard)
153
+ * cannot tell you.
154
+ */
155
+ export function formatUnresolvedWorkspaceImport(details) {
156
+ const { target, source, candidate, aliasTarget, aliasedFrom, importer } = details;
157
+ // The specifiers `candidate` was standing in for: `source` itself when IT
158
+ // carried the table entry, else whatever the reverse lookup recovered.
159
+ //
160
+ // The reverse lookup is only reported when its fan-in is SMALL. A shared
161
+ // sink inverts to a useless wall of text — `@gjsify/empty` is the browser
162
+ // target for 49 specifiers, and printing all of them says nothing about
163
+ // which one this importer wrote, while `@gjsify/fs` inverts to exactly
164
+ // `['fs','node:fs']` (the two spellings of one builtin), which is the
165
+ // information the reader needs. Above the threshold, fall back to naming
166
+ // the package alone rather than guessing.
167
+ const reversed = aliasedFrom ?? [];
168
+ const substitutedFor = aliasTarget === undefined ? (reversed.length <= MAX_REPORTED_ALIAS_SOURCES ? reversed : []) : [source];
169
+ const quoted = substitutedFor.map((s) => `\`${s}\``).join(', ');
170
+ const headline = substitutedFor.length === 0
171
+ ? `gjsify build --app ${target}: cannot resolve the workspace package \`${candidate}\`.`
172
+ : `gjsify build --app ${target}: cannot resolve \`${candidate}\`, the \`--app ${target}\` ` +
173
+ `substitution for ${quoted}.`;
174
+ // The symptom is a property of the SPECIFIER that would survive, not of the
175
+ // target: only a `node:` specifier reaching stock GJS produces the URI-scheme
176
+ // abort; a bare `@gjsify/*` or `<pkg>/register` one produces `Module not
177
+ // found` (GJS's ESM loader has no node_modules walker and does not follow
178
+ // `exports` maps). Naming the wrong one sends the reader hunting the wrong bug.
179
+ const surviving = substitutedFor.length === 0 ? [source] : substitutedFor;
180
+ const symptom = target !== 'gjs'
181
+ ? ''
182
+ : surviving.some((s) => s.startsWith('node:'))
183
+ ? ' Stock GJS aborts the whole module graph at load: ' +
184
+ '"ImportError: Unsupported URI scheme for importing: node".'
185
+ : ' Stock GJS would fail at load with "Module not found" — its ESM loader has no node_modules ' +
186
+ 'walker and does not follow `package.json#exports` for bare specifiers.';
187
+ const consequence = (substitutedFor.length === 0
188
+ ? `Leaving it unresolved would silently externalise \`${source}\` into the bundle, which the ` +
189
+ `\`--app ${target}\` runtime cannot resolve.`
190
+ : `Leaving it unresolved would silently externalise the ORIGINAL ${substitutedFor.length === 1 ? 'specifier' : 'specifiers'} ${quoted} into the ` +
191
+ `bundle, defeating the substitution the \`--app ${target}\` target exists to perform.`) + symptom;
192
+ return [
193
+ headline,
194
+ ` imported by: ${importer}`,
195
+ '',
196
+ consequence,
197
+ '',
198
+ 'Likely causes:',
199
+ ' - `node_modules` is missing or incomplete — run `gjsify install` (`--immutable` in CI).',
200
+ ` - the workspace package is not built: its \`exports["."]\` names a \`lib/\` that does not exist —`,
201
+ ' run `gjsify run build:infra`, or `gjsify workspace <pkg> build` for the one package.',
202
+ ` - \`${candidate}\` is genuinely not a dependency of this project — add it.`,
203
+ ].join('\n');
204
+ }
205
+ /**
206
+ * Reverse the substitution table: alias TARGET → the specifiers that route to it.
207
+ *
208
+ * Built once per plugin instance (the tables run to ~150 entries per target, and
209
+ * the guard only consults this on the failure path).
210
+ */
211
+ export function buildReverseAliasIndex(aliases) {
212
+ const index = new Map();
213
+ if (!aliases)
214
+ return index;
215
+ for (const [from, to] of Object.entries(aliases)) {
216
+ const existing = index.get(to);
217
+ if (existing)
218
+ existing.push(from);
219
+ else
220
+ index.set(to, [from]);
221
+ }
222
+ for (const list of index.values())
223
+ list.sort();
224
+ return index;
225
+ }
226
+ /**
227
+ * Refuse a build whose `@gjsify/*` substitution could not be resolved.
228
+ *
229
+ * `order: 'post'` so every deliberate claim — `aliasPlugin` (`pre`),
230
+ * `napiNodeAddonPlugin` (`pre`), `gjsGiNodePlugin` (`pre`),
231
+ * `gjsImportsEmptyPlugin`, `externalsPlugin` — gets first refusal. Only ids that
232
+ * NOTHING claimed reach this hook, i.e. exactly the ones headed for the
233
+ * unresolved-import fallback.
234
+ *
235
+ * The hook re-runs the full resolution chain via `this.resolve(…, { skipSelf })`
236
+ * — which is what Rolldown's builtin resolver would have done next — and returns
237
+ * its result when it succeeds, so a healthy build resolves the specifier exactly
238
+ * once and behaves identically. Only a genuine `null` throws.
239
+ */
240
+ export function unresolvedWorkspaceImportPlugin(options) {
241
+ const { target, aliases, isExternal } = options;
242
+ // Re-entrancy belt: `skipSelf: true` is supposed to keep our own
243
+ // `this.resolve` from re-entering this hook, but a hang is a far worse
244
+ // failure than a missed check, so the in-flight candidate is tracked
245
+ // explicitly. Keyed by `candidate\0importer` — the same package legitimately
246
+ // resolves from many importers.
247
+ const inFlight = new Set();
248
+ // Alias TARGET → the specifiers that route to it. Only read on the failure
249
+ // path, to recover the `node:fs` behind a failed `@gjsify/fs`.
250
+ const reverseAliases = buildReverseAliasIndex(aliases);
251
+ // Successful resolutions, memoized per (candidate, importer DIRECTORY) —
252
+ // the pair node resolution actually depends on, so a hit is the answer the
253
+ // chain would have recomputed. Without it this hook was the single most
254
+ // expensive plugin in a CLI-bundle build (32% of plugin time): `@gjsify/*`
255
+ // edges repeat across hundreds of modules, and each miss re-runs the whole
256
+ // `pre`-order chain (alias, napi-addon, gi-node) for the same target. Same
257
+ // shape + rationale as `napiNodeAddonPlugin`'s `bareEntryCache` (#840).
258
+ // Only POSITIVE results are cached: a failure throws, so it is never hot,
259
+ // and caching it could outlive a package appearing mid-watch.
260
+ const resolvedCache = new Map();
261
+ return {
262
+ name: 'gjsify-unresolved-workspace-import',
263
+ resolveId: {
264
+ order: 'post',
265
+ async handler(source, rawImporter, extraOptions) {
266
+ // The two engines disagree on "no importer": npm `rolldown`
267
+ // passes `undefined`, `@gjsify/rolldown-native` passes `null`
268
+ // (its hook payload round-trips through JSON, which has no
269
+ // `undefined`). `classifyImport` reads an absent importer as
270
+ // "this is an ENTRY, never externalised" via `=== undefined`,
271
+ // and `null` fails that test — so under the GJS bundler an
272
+ // entry module would have been resolved as a normal edge and
273
+ // could throw this plugin's error for a file the caller named
274
+ // explicitly. Normalise once, at the boundary — same fix as
275
+ // `napiNodeAddonPlugin` (#840), whose `dirname(null)` threw
276
+ // and took the whole GJS build down as an UNHANDLEABLE_ERROR.
277
+ const importer = typeof rawImporter === 'string' ? rawImporter : undefined;
278
+ if (extraOptions?.isEntry)
279
+ return null;
280
+ const verdict = classifyImport({ source, importer, aliases, isExternal });
281
+ if (verdict.verdict === 'ignore')
282
+ return null;
283
+ // `kind` participates in the key: it selects the `import`
284
+ // vs `require` export condition, so the same specifier can
285
+ // legitimately resolve to different files from one directory.
286
+ const key = `${verdict.candidate}\0${importer === undefined ? '' : dirname(importer)}\0${extraOptions?.kind ?? ''}`;
287
+ const cached = resolvedCache.get(key);
288
+ if (cached)
289
+ return cached;
290
+ if (inFlight.has(key))
291
+ return null;
292
+ inFlight.add(key);
293
+ let resolved;
294
+ try {
295
+ resolved = await this.resolve(verdict.candidate, importer, {
296
+ skipSelf: true,
297
+ kind: extraOptions?.kind,
298
+ });
299
+ }
300
+ finally {
301
+ inFlight.delete(key);
302
+ }
303
+ if (resolved) {
304
+ resolvedCache.set(key, resolved);
305
+ return resolved;
306
+ }
307
+ throw new UnresolvedWorkspaceImportError({
308
+ target,
309
+ source,
310
+ candidate: verdict.candidate,
311
+ aliasTarget: verdict.aliasTarget,
312
+ aliasedFrom: reverseAliases.get(verdict.candidate)?.filter((s) => s !== source),
313
+ importer: importer ?? '<entry>',
314
+ });
315
+ },
316
+ },
317
+ };
318
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/rolldown-plugin-gjsify",
3
- "version": "0.24.1",
3
+ "version": "0.24.2",
4
4
  "description": "Rolldown / Rollup / Vite plugin orchestrator for GJS, Node, and Browser targets",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -55,11 +55,11 @@
55
55
  ],
56
56
  "license": "MIT",
57
57
  "dependencies": {
58
- "@gjsify/console": "^0.24.1",
59
- "@gjsify/resolve-npm": "^0.24.1",
60
- "@gjsify/rolldown-plugin-deepkit": "^0.24.1",
61
- "@gjsify/rolldown-plugin-pnp": "^0.24.1",
62
- "@gjsify/vite-plugin-blueprint": "^0.24.1",
58
+ "@gjsify/console": "^0.24.2",
59
+ "@gjsify/resolve-npm": "^0.24.2",
60
+ "@gjsify/rolldown-plugin-deepkit": "^0.24.2",
61
+ "@gjsify/rolldown-plugin-pnp": "^0.24.2",
62
+ "@gjsify/vite-plugin-blueprint": "^0.24.2",
63
63
  "@rollup/pluginutils": "^5.4.0",
64
64
  "acorn": "^8.17.0",
65
65
  "acorn-walk": "^8.3.5",
@@ -68,7 +68,7 @@
68
68
  "sass": "^1.101.0"
69
69
  },
70
70
  "peerDependencies": {
71
- "@gjsify/lightningcss-native": "^0.24.1",
71
+ "@gjsify/lightningcss-native": "^0.24.2",
72
72
  "rolldown": "^1.1.4"
73
73
  },
74
74
  "peerDependenciesMeta": {