@gjsify/rolldown-plugin-gjsify 0.23.0 → 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.
- package/lib/app/browser.js +24 -5
- package/lib/app/gjs.d.ts +24 -0
- package/lib/app/gjs.js +79 -28
- package/lib/app/index.d.ts +1 -1
- package/lib/app/index.js +1 -1
- package/lib/app/nativescript.js +31 -5
- package/lib/app/node.js +13 -1
- package/lib/index.d.ts +5 -1
- package/lib/index.js +3 -1
- package/lib/plugins/alias.js +36 -0
- package/lib/plugins/css-as-string.js +26 -1
- package/lib/plugins/gjs-gi-node.js +5 -1
- package/lib/plugins/gjs-imports-empty.js +2 -1
- package/lib/plugins/napi-node-addon.d.ts +25 -5
- package/lib/plugins/napi-node-addon.js +309 -52
- package/lib/plugins/unresolved-workspace-import.d.ts +105 -0
- package/lib/plugins/unresolved-workspace-import.js +318 -0
- package/lib/utils/auto-globals.js +6 -1
- package/lib/utils/entry-wrapper.js +2 -1
- package/lib/utils/index.d.ts +1 -0
- package/lib/utils/index.js +1 -0
- package/lib/utils/virtual-module-id.d.ts +14 -0
- package/lib/utils/virtual-module-id.js +36 -0
- package/package.json +7 -7
|
@@ -38,9 +38,12 @@
|
|
|
38
38
|
// LOCATE the `.node` but cannot rescue that body. So when the entry of a napi-rs
|
|
39
39
|
// package resolves, we replace the WHOLE module with
|
|
40
40
|
// `module.exports = loadAddon('<abs platform .node>')` (the `napi-rs-entry`
|
|
41
|
-
// kind) — the platform `.node` is the current-triple sibling package
|
|
42
|
-
//
|
|
43
|
-
//
|
|
41
|
+
// kind) — the platform `.node` is the current-triple sibling package (only the
|
|
42
|
+
// host's optionalDependency is installed) or a local `pkg.<triple>.node`. The
|
|
43
|
+
// sibling is named `<self>-<triple>` OR `<napi.packageName>-<triple>`; both
|
|
44
|
+
// schemes ship in the wild and `isNapiRsSibling` knows both, which is what
|
|
45
|
+
// makes `rolldown`/`oxfmt`/`oxlint` (all `@<scope>/binding-<triple>`) reachable
|
|
46
|
+
// and not just `lightningcss`/`@node-rs/*`. Detection is CONSERVATIVE (package.json signal +
|
|
44
47
|
// the file must be the package's own native `main` entry + a real host `.node`
|
|
45
48
|
// must resolve) so it never rewrites an unrelated package's entry; when no
|
|
46
49
|
// current-platform `.node` resolves we fall through to normal resolution rather
|
|
@@ -66,7 +69,8 @@
|
|
|
66
69
|
// plugin is correct even when the filter does not pre-filter.
|
|
67
70
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
68
71
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
69
|
-
|
|
72
|
+
import { GJSIFY_VIRTUAL_PREFIX } from '../utils/virtual-module-id.js';
|
|
73
|
+
const NAPI_ADDON_VIRTUAL_PREFIX = `${GJSIFY_VIRTUAL_PREFIX}napi-addon:`;
|
|
70
74
|
/** Bare specifier the shims import — resolved + bundled from the consumer graph. */
|
|
71
75
|
const NAPI_BARE_SPECIFIER = '@gjsify/napi';
|
|
72
76
|
/**
|
|
@@ -78,8 +82,41 @@ const NAPI_BARE_SPECIFIER = '@gjsify/napi';
|
|
|
78
82
|
* `this.resolve` probes on ordinary deps.
|
|
79
83
|
*/
|
|
80
84
|
const NAPI_RS_TRIPLE_RE = /-(?:linux|darwin|win32|freebsd|openbsd|sunos|android|aix)-(?:x64|arm64|arm|ia32|ppc64|s390x|riscv64|loong64)(?:-(?:gnu|musl|msvc|eabi|eabihf|androideabi|gnueabihf))?$/;
|
|
81
|
-
/**
|
|
82
|
-
|
|
85
|
+
/**
|
|
86
|
+
* A BARE package specifier (`lightningcss`, `@rolldown/binding-…`) — no leading
|
|
87
|
+
* `.`/`/`/`\`, no protocol (`node:fs`, `gi://Gtk`, `data:…`, `C:\…`), not a
|
|
88
|
+
* `\0` virtual id.
|
|
89
|
+
*
|
|
90
|
+
* Needed because a napi-rs generated loader acquires its binary through a
|
|
91
|
+
* specifier the bundler CANNOT see: `require(`lightningcss-${parts.join('-')}`)`
|
|
92
|
+
* / `require(`../lightningcss.${parts.join('-')}.node`)` are template literals
|
|
93
|
+
* computed at runtime. So neither the sibling nor the direct-`.node`
|
|
94
|
+
* interception can fire, and the only lever is replacing the loader ENTRY —
|
|
95
|
+
* which the caller reaches by its BARE name. Matching only path-shaped ids (as
|
|
96
|
+
* before) meant `import { transform } from 'lightningcss'` was never even
|
|
97
|
+
* offered to the handler, and the addon gate had to hand-alias every bare
|
|
98
|
+
* specifier to its native entry to get coverage.
|
|
99
|
+
*
|
|
100
|
+
* PORTABILITY, and it is load-bearing: this source feeds `ADDON_FILTER_RE`,
|
|
101
|
+
* which `@gjsify/rolldown-native` hands to the Rust core as an `idFilter`
|
|
102
|
+
* STRING. Rust's `regex` crate has no lookaround and no `\0` escape, and it
|
|
103
|
+
* rejects the WHOLE combined filter rather than the offending branch — which
|
|
104
|
+
* silently disables every interception under the GJS engine while npm
|
|
105
|
+
* `rolldown` on Node keeps working. So: no `(?!…)`, and NUL is spelled `\x00`.
|
|
106
|
+
* The asymmetry is invisible to the addon gate, which drives the Node CLI
|
|
107
|
+
* entry, so only a real `--app gjs` build under `gjs` catches a regression here.
|
|
108
|
+
*/
|
|
109
|
+
// oxlint-disable-next-line no-control-regex -- the NUL is the point: it marks a bundler VIRTUAL id (`\0gjsify-…`), which is never a package specifier and must not be probed.
|
|
110
|
+
const BARE_SPECIFIER_RE = /^[^.\\/:\s\x00][^:\s\x00]*$/;
|
|
111
|
+
/**
|
|
112
|
+
* Fast-path filter (superset of what `claimSpecifier` claims). Handler re-checks.
|
|
113
|
+
*
|
|
114
|
+
* Exported so `napi-node-addon.spec.ts` can assert its Rust-compatibility (see
|
|
115
|
+
* {@link BARE_SPECIFIER_RE}) — a JS-only construct here disables the plugin
|
|
116
|
+
* entirely under `@gjsify/rolldown-native`, and no build-level test would see
|
|
117
|
+
* it, because every addon gate drives the Node CLI entry.
|
|
118
|
+
*/
|
|
119
|
+
export const ADDON_FILTER_RE = new RegExp([
|
|
83
120
|
/\.node$/.source, // direct .node
|
|
84
121
|
/^node-gyp-build(?:\/index\.js)?$/.source, // node-gyp-build helper
|
|
85
122
|
/^bindings(?:\/bindings\.js)?$/.source, // bindings helper
|
|
@@ -89,6 +126,9 @@ const ADDON_FILTER_RE = new RegExp([
|
|
|
89
126
|
// `index.*`, not every `.js`) — the handler's `detectNapiRsEntry` gate
|
|
90
127
|
// (package.json signal + native-main match) is the load-bearing check.
|
|
91
128
|
/[/\\]index\.[cm]?js$/.source,
|
|
129
|
+
// A bare specifier, so the handler can resolve it and test the RESOLVED
|
|
130
|
+
// file. Broad by design; the handler's fast rejects keep it cheap.
|
|
131
|
+
BARE_SPECIFIER_RE.source,
|
|
92
132
|
].join('|'));
|
|
93
133
|
function isMusl(platform) {
|
|
94
134
|
// Match node-gyp-build's isAlpine() probe.
|
|
@@ -351,11 +391,13 @@ export function classifySpecifier(source) {
|
|
|
351
391
|
* (a) a top-level `napi` config OBJECT (`{ binaryName, targets, … }`) — the
|
|
352
392
|
* napi-rs CLI's own build block. An ordinary npm package never declares a
|
|
353
393
|
* `napi` object at the manifest root.
|
|
354
|
-
* (b) at least one `optionalDependencies` entry
|
|
355
|
-
* napi-rs prebuilt-binary convention
|
|
356
|
-
* `@node-rs/argon2
|
|
357
|
-
*
|
|
358
|
-
*
|
|
394
|
+
* (b) at least one `optionalDependencies` entry that is a platform SIBLING
|
|
395
|
+
* (see {@link isNapiRsSibling}) — the napi-rs prebuilt-binary convention,
|
|
396
|
+
* under either naming scheme: `<self>-<triple>` (`@node-rs/argon2` ships
|
|
397
|
+
* `@node-rs/argon2-linux-x64-gnu`) or `<napi.packageName>-<triple>`
|
|
398
|
+
* (`rolldown` ships `@rolldown/binding-linux-x64-gnu`). The key MUST
|
|
399
|
+
* carry a known prefix AND end in a platform triple, so a normal optional
|
|
400
|
+
* dep (`fsevents`) can't match.
|
|
359
401
|
*
|
|
360
402
|
* Neither fires on a normal package, so pairing this with the "resolved file IS
|
|
361
403
|
* the package's own native main entry" check (see {@link detectNapiRsEntry})
|
|
@@ -364,11 +406,10 @@ export function classifySpecifier(source) {
|
|
|
364
406
|
export function isNapiRsPackageJson(pkg) {
|
|
365
407
|
if (pkg && typeof pkg.napi === 'object' && pkg.napi !== null)
|
|
366
408
|
return true;
|
|
367
|
-
const self = pkg?.name;
|
|
368
409
|
const opt = pkg?.optionalDependencies;
|
|
369
|
-
if (
|
|
410
|
+
if (opt) {
|
|
370
411
|
for (const dep of Object.keys(opt)) {
|
|
371
|
-
if (
|
|
412
|
+
if (isNapiRsSibling(pkg, dep))
|
|
372
413
|
return true;
|
|
373
414
|
}
|
|
374
415
|
}
|
|
@@ -399,13 +440,79 @@ export function detectNapiRsEntry(entryFile) {
|
|
|
399
440
|
const pkg = readPackageJsonSafe(pkgRoot);
|
|
400
441
|
if (pkg === null || !isNapiRsPackageJson(pkg))
|
|
401
442
|
return null;
|
|
402
|
-
// The resolved file must be the package's OWN native
|
|
403
|
-
// `browser`/wasm fallback or any deep module inside the package.
|
|
404
|
-
const
|
|
405
|
-
|
|
443
|
+
// The resolved file must be one of the package's OWN native entry points —
|
|
444
|
+
// reject a `browser`/wasm fallback or any deep module inside the package.
|
|
445
|
+
const target = stripJsExt(resolve(entryFile));
|
|
446
|
+
const isNativeEntry = nativeEntrySpecs(pkg).some((spec) => stripJsExt(resolve(pkgRoot, spec)) === target);
|
|
447
|
+
if (!isNativeEntry)
|
|
406
448
|
return null;
|
|
407
449
|
return { pkgRoot, pkg };
|
|
408
450
|
}
|
|
451
|
+
/** Drop a trailing `.js` / `.mjs` / `.cjs` — see {@link nativeEntrySpecs}. */
|
|
452
|
+
function stripJsExt(p) {
|
|
453
|
+
return p.replace(/\.[cm]?js$/, '');
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Every path a napi-rs manifest names as its NATIVE entry — `main`, `module`,
|
|
457
|
+
* and the `exports["."]` target(s) — with the `browser` condition deliberately
|
|
458
|
+
* excluded (that is the wasm / pure-JS fallback, which must keep resolving
|
|
459
|
+
* normally: it needs no addon and works on GJS as-is).
|
|
460
|
+
*
|
|
461
|
+
* Both halves of this matter, and each was a real miss:
|
|
462
|
+
*
|
|
463
|
+
* - `main` ALONE is not enough. A dual package declares the CJS twin in
|
|
464
|
+
* `main` while a bundler resolves the ESM one — `lightningcss` says
|
|
465
|
+
* `main: "node/index.js"` and ships `node/index.mjs` beside it, which is
|
|
466
|
+
* what `--app gjs` actually loads. Comparing only against `main` rejected
|
|
467
|
+
* the very file being bundled, so the rewrite silently never happened and
|
|
468
|
+
* the generated loader's runtime `require(\`lightningcss-${…}\`)` shipped
|
|
469
|
+
* into the bundle.
|
|
470
|
+
* - Matching is therefore extension-INSENSITIVE (`stripJsExt`): the `.js` /
|
|
471
|
+
* `.mjs` / `.cjs` twins of a declared entry are the same generated loader.
|
|
472
|
+
* It stays narrow because the path itself must still be one the manifest
|
|
473
|
+
* names — a deep module inside the package never matches.
|
|
474
|
+
*
|
|
475
|
+
* Defaults to `index.js` (node's own default `main`) when the manifest names
|
|
476
|
+
* nothing at all.
|
|
477
|
+
*/
|
|
478
|
+
function nativeEntrySpecs(pkg) {
|
|
479
|
+
const specs = [];
|
|
480
|
+
const add = (v) => {
|
|
481
|
+
if (typeof v === 'string' && v && !specs.includes(v))
|
|
482
|
+
specs.push(v);
|
|
483
|
+
};
|
|
484
|
+
add(pkg.main);
|
|
485
|
+
add(pkg.module);
|
|
486
|
+
// `exports["."]` is a string, or a condition object whose values are
|
|
487
|
+
// strings / nested condition objects. Walk it, skipping `browser`.
|
|
488
|
+
const walk = (node, depth) => {
|
|
489
|
+
if (typeof node === 'string')
|
|
490
|
+
return add(node);
|
|
491
|
+
if (depth > 4 || node === null || typeof node !== 'object' || Array.isArray(node))
|
|
492
|
+
return;
|
|
493
|
+
for (const [condition, value] of Object.entries(node)) {
|
|
494
|
+
if (condition === 'browser' || condition === 'types')
|
|
495
|
+
continue;
|
|
496
|
+
walk(value, depth + 1);
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
const exportsField = pkg.exports;
|
|
500
|
+
if (typeof exportsField === 'string') {
|
|
501
|
+
add(exportsField);
|
|
502
|
+
}
|
|
503
|
+
else if (exportsField !== null && typeof exportsField === 'object' && !Array.isArray(exportsField)) {
|
|
504
|
+
const map = exportsField;
|
|
505
|
+
// Two legal shapes: a SUBPATH map (keys start with `.`) — then only `.`
|
|
506
|
+
// is the package entry — or a bare CONDITION object, which IS the entry.
|
|
507
|
+
// Distinguishing them matters: walking a subpath map wholesale would
|
|
508
|
+
// make `./some-subpath` a candidate entry.
|
|
509
|
+
const isSubpathMap = Object.keys(map).some((k) => k.startsWith('.'));
|
|
510
|
+
walk(isSubpathMap ? map['.'] : map, 0);
|
|
511
|
+
}
|
|
512
|
+
if (specs.length === 0)
|
|
513
|
+
specs.push('index.js');
|
|
514
|
+
return specs;
|
|
515
|
+
}
|
|
409
516
|
/** napi-rs binaryName (from `napi.binaryName`/`napi.name`, else the unscoped pkg name). */
|
|
410
517
|
function napiBinaryName(pkg) {
|
|
411
518
|
const napi = pkg.napi;
|
|
@@ -419,13 +526,66 @@ function napiBinaryName(pkg) {
|
|
|
419
526
|
return pkg.name.replace(/^@[^/]+\//, '');
|
|
420
527
|
return null;
|
|
421
528
|
}
|
|
529
|
+
/**
|
|
530
|
+
* The name PREFIXES a napi-rs package's platform siblings can carry, most
|
|
531
|
+
* specific first. A sibling is always `<prefix>-<triple>`.
|
|
532
|
+
*
|
|
533
|
+
* Two conventions are in the wild, and only knowing both covers the toolchain
|
|
534
|
+
* this project builds on:
|
|
535
|
+
*
|
|
536
|
+
* - `<self>-<triple>` — the sibling is named after the package itself
|
|
537
|
+
* (`lightningcss` → `lightningcss-linux-x64-gnu`; `@node-rs/argon2` →
|
|
538
|
+
* `@node-rs/argon2-linux-x64-gnu`). This was the only shape handled.
|
|
539
|
+
* - `<napi.packageName>-<triple>` — napi-rs lets a package publish its
|
|
540
|
+
* binaries under a SEPARATE scope, declared in the manifest's own napi
|
|
541
|
+
* block. `rolldown` ships `@rolldown/binding-linux-x64-gnu`, `oxfmt`
|
|
542
|
+
* ships `@oxfmt/binding-linux-x64-gnu`, `oxlint` ships
|
|
543
|
+
* `@oxlint/binding-linux-x64-gnu` — none of which starts with the
|
|
544
|
+
* package's own name, so the `<self>-` test missed every one of them.
|
|
545
|
+
*
|
|
546
|
+
* That gap is why the three packages at the centre of this build chain
|
|
547
|
+
* (bundler, formatter, linter) could not load transparently under GJS while
|
|
548
|
+
* `lightningcss` could: `isNapiRsPackageJson` recognises all four (they all
|
|
549
|
+
* carry a `napi` config object), so the ENTRY is detected — but with no
|
|
550
|
+
* sibling resolvable, `resolveNapiRsEntryAddon` returns null and the rewrite
|
|
551
|
+
* is skipped, leaving the generated loader's CJS body to ship into the bundle
|
|
552
|
+
* and throw. Conservative by construction either way: a prefix only matters
|
|
553
|
+
* when it also resolves to a real `.node`.
|
|
554
|
+
*
|
|
555
|
+
* `napi.packageName` is read defensively — an arbitrary manifest may put
|
|
556
|
+
* anything there, and a non-string must not poison the prefix list.
|
|
557
|
+
*/
|
|
558
|
+
function napiSiblingPrefixes(pkg) {
|
|
559
|
+
const prefixes = [];
|
|
560
|
+
const napi = pkg.napi;
|
|
561
|
+
if (napi && typeof napi === 'object') {
|
|
562
|
+
const { packageName } = napi;
|
|
563
|
+
if (typeof packageName === 'string' && packageName)
|
|
564
|
+
prefixes.push(packageName);
|
|
565
|
+
}
|
|
566
|
+
if (typeof pkg.name === 'string' && pkg.name && !prefixes.includes(pkg.name))
|
|
567
|
+
prefixes.push(pkg.name);
|
|
568
|
+
return prefixes;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Is `dep` a platform sibling of `pkg` — `<prefix>-<triple>` for one of the
|
|
572
|
+
* prefixes above? Both halves must hold: a bare prefix match would claim an
|
|
573
|
+
* ordinary dependency, and a bare triple match would claim an unrelated
|
|
574
|
+
* package's binaries. Exported for the unit tests.
|
|
575
|
+
*/
|
|
576
|
+
export function isNapiRsSibling(pkg, dep) {
|
|
577
|
+
if (!NAPI_RS_TRIPLE_RE.test(dep))
|
|
578
|
+
return false;
|
|
579
|
+
return napiSiblingPrefixes(pkg).some((prefix) => dep.startsWith(`${prefix}-`));
|
|
580
|
+
}
|
|
422
581
|
/**
|
|
423
582
|
* The napi-rs short platform triple for the CURRENT host (`linux-x64-gnu`,
|
|
424
583
|
* `darwin-arm64`, `win32-x64-msvc`, …) — the tail napi-rs stamps into a sibling
|
|
425
584
|
* package name (`@node-rs/argon2-linux-x64-gnu`) and a local binary
|
|
426
|
-
* (`argon2.linux-x64-gnu.node`).
|
|
427
|
-
* fallback
|
|
428
|
-
*
|
|
585
|
+
* (`argon2.linux-x64-gnu.node`). Selects BOTH the sibling package and the
|
|
586
|
+
* local-file fallback — see `resolveNapiRsEntryAddon` for why the sibling path
|
|
587
|
+
* cannot just take whatever resolves. Returns null for a host napi-rs doesn't
|
|
588
|
+
* name, and the caller then widens rather than guessing.
|
|
429
589
|
*/
|
|
430
590
|
function hostNapiRsTriple() {
|
|
431
591
|
const platform = process.platform;
|
|
@@ -477,24 +637,29 @@ function rawAddonPath(id) {
|
|
|
477
637
|
* DOES NOT rewrite, so we never shim over a missing file.
|
|
478
638
|
*/
|
|
479
639
|
async function resolveNapiRsEntryAddon(ctx, pkgRoot, pkg, importer) {
|
|
480
|
-
const
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
640
|
+
const siblings = Object.keys(pkg.optionalDependencies ?? {}).filter((dep) => isNapiRsSibling(pkg, dep));
|
|
641
|
+
const triple = hostNapiRsTriple();
|
|
642
|
+
// HOST TRIPLE FIRST, and alone whenever we can name it. The older code took
|
|
643
|
+
// the first sibling that resolved, on the premise that "npm installs only
|
|
644
|
+
// the host's optionalDependency, so the one that resolves is the host's".
|
|
645
|
+
// That premise does not hold here: `gjsify install` materialises EVERY
|
|
646
|
+
// platform package, so `lightningcss`'s `darwin-x64` sibling resolves on a
|
|
647
|
+
// Linux box and won — baking a Mach-O `.dylib`-bearing `.node` into a linux
|
|
648
|
+
// GJS bundle, which `loadAddon` can only fail on at runtime. Selecting by
|
|
649
|
+
// the host triple is also what node-gyp-build and napi-rs' own generated
|
|
650
|
+
// loaders do, so this matches the binary Node would have loaded.
|
|
651
|
+
const ordered = triple === null ? siblings : siblings.filter((dep) => dep.endsWith(`-${triple}`));
|
|
652
|
+
for (const dep of ordered) {
|
|
653
|
+
const resolved = await ctx.resolve(dep, importer, { skipSelf: true });
|
|
654
|
+
if (!resolved)
|
|
655
|
+
continue;
|
|
656
|
+
const abs = rawAddonPath(resolved.id);
|
|
657
|
+
if (abs.endsWith('.node') && existsSync(abs))
|
|
658
|
+
return abs;
|
|
493
659
|
}
|
|
494
660
|
// Local in-package binary (`<binaryName>.<host-triple>.node`) — deterministic
|
|
495
661
|
// host-triple match so a wrong-platform local file is never picked.
|
|
496
662
|
const binaryName = napiBinaryName(pkg);
|
|
497
|
-
const triple = hostNapiRsTriple();
|
|
498
663
|
if (binaryName && triple) {
|
|
499
664
|
const local = join(pkgRoot, `${binaryName}.${triple}.node`);
|
|
500
665
|
if (existsSync(local))
|
|
@@ -555,28 +720,107 @@ export function napiNodeAddonPlugin(options = {}) {
|
|
|
555
720
|
napiEntryCache.set(entryFile, info);
|
|
556
721
|
return info;
|
|
557
722
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
723
|
+
// Bare-specifier → entry-file resolution, memoized per (specifier, importer
|
|
724
|
+
// DIRECTORY) since that pair is what node resolution actually depends on.
|
|
725
|
+
// The widened filter offers every bare import here and almost none of them
|
|
726
|
+
// is a native addon, so this cache is what keeps the extra `ctx.resolve` to
|
|
727
|
+
// one per unique pair rather than one per import site.
|
|
728
|
+
const bareEntryCache = new Map();
|
|
729
|
+
/**
|
|
730
|
+
* The FILE a specifier denotes, for napi-rs entry detection:
|
|
731
|
+
* - absolute path → itself (an already-resolved or hand-aliased entry).
|
|
732
|
+
* - bare specifier → resolved through the full chain (so aliases and
|
|
733
|
+
* export conditions apply — under `--app gjs` that is what picks a
|
|
734
|
+
* package's `browser`/wasm fallback over its native entry, and
|
|
735
|
+
* `detectNapiRsEntry` then correctly declines to rewrite it).
|
|
736
|
+
* - anything else (relative paths) → null, unchanged from before.
|
|
737
|
+
*/
|
|
738
|
+
async function entryFileFor(ctx, source, importer) {
|
|
739
|
+
if (isAbsolute(source))
|
|
740
|
+
return source;
|
|
741
|
+
if (!BARE_SPECIFIER_RE.test(source))
|
|
742
|
+
return null;
|
|
743
|
+
// Never recurse through our own shims' import.
|
|
744
|
+
if (source === NAPI_BARE_SPECIFIER || source.startsWith(`${NAPI_BARE_SPECIFIER}/`))
|
|
745
|
+
return null;
|
|
746
|
+
const key = `${source}\0${importer === undefined ? '' : dirname(importer)}`;
|
|
747
|
+
const cached = bareEntryCache.get(key);
|
|
748
|
+
if (cached !== undefined)
|
|
749
|
+
return cached;
|
|
750
|
+
let file = null;
|
|
562
751
|
try {
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
}
|
|
752
|
+
const resolved = await ctx.resolve(source, importer, { skipSelf: true });
|
|
753
|
+
// An external / virtual / unresolvable id is not a file on disk.
|
|
754
|
+
if (resolved && isAbsolute(resolved.id) && existsSync(resolved.id))
|
|
755
|
+
file = resolved.id;
|
|
568
756
|
}
|
|
569
757
|
catch {
|
|
570
|
-
/*
|
|
758
|
+
/* resolution failure is not our error to raise — fall through */
|
|
571
759
|
}
|
|
760
|
+
bareEntryCache.set(key, file);
|
|
761
|
+
return file;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Is `@gjsify/napi` resolvable from the consumer graph? This is the GATE on
|
|
765
|
+
* every rewrite, not a diagnostic — installing it (`gjsify install
|
|
766
|
+
* @gjsify/napi`) is what a project uses to opt into napi routing.
|
|
767
|
+
*
|
|
768
|
+
* It used to only WARN, and the warning said the quiet part out loud: "the
|
|
769
|
+
* bundle will fail at load". Emitting a knowingly-unloadable artifact is
|
|
770
|
+
* never better than declining to rewrite — the untouched module at least
|
|
771
|
+
* gets the default resolution, and a package with a JS/wasm fallback then
|
|
772
|
+
* works. Two concrete failures made this a gate rather than a nicety:
|
|
773
|
+
*
|
|
774
|
+
* - The CLI's own `--app gjs` bundle reaches npm `lightningcss` through
|
|
775
|
+
* `css-as-string`'s Node fallback branch (under GJS it deliberately
|
|
776
|
+
* prefers `@gjsify/lightningcss-native`). Rewriting that branch put a
|
|
777
|
+
* `require('@gjsify/napi')` into a bundle whose package does not depend
|
|
778
|
+
* on `@gjsify/napi` at all.
|
|
779
|
+
* - Worse, it made the COMMITTED bundle environment-dependent: with the
|
|
780
|
+
* package present the shim inlines, without it the import goes external,
|
|
781
|
+
* and the two module graphs minify to different variable names. That is
|
|
782
|
+
* a `verify-committed-bundles` failure — the artifact stops reproducing
|
|
783
|
+
* from its own source.
|
|
784
|
+
*
|
|
785
|
+
* Probed once per build (interception is rare) and cached, so the extra
|
|
786
|
+
* resolve costs nothing on the common path.
|
|
787
|
+
*/
|
|
788
|
+
let napiAvailable = null;
|
|
789
|
+
async function ensureNapiAvailable(ctx, importer) {
|
|
790
|
+
if (napiAvailable !== null)
|
|
791
|
+
return napiAvailable;
|
|
792
|
+
let found = false;
|
|
793
|
+
try {
|
|
794
|
+
found = Boolean(await ctx.resolve(NAPI_BARE_SPECIFIER, importer, { skipSelf: true }));
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
/* best-effort probe — treat a resolver throw as "not available" */
|
|
798
|
+
}
|
|
799
|
+
napiAvailable = found;
|
|
800
|
+
if (!found && warnOnMissingNapi && !missingNapiChecked) {
|
|
801
|
+
missingNapiChecked = true;
|
|
802
|
+
warnSafe(ctx, `[gjsify-napi-addon] a native .node addon was found but '${NAPI_BARE_SPECIFIER}' is not ` +
|
|
803
|
+
`resolvable — leaving it to normal resolution. Install it to route the addon through ` +
|
|
804
|
+
`N-API on GJS: gjsify install ${NAPI_BARE_SPECIFIER}`);
|
|
805
|
+
}
|
|
806
|
+
return found;
|
|
572
807
|
}
|
|
573
808
|
return {
|
|
574
809
|
name: 'gjsify-napi-node-addon',
|
|
575
810
|
resolveId: {
|
|
576
811
|
order: 'pre',
|
|
577
812
|
filter: { id: ADDON_FILTER_RE },
|
|
578
|
-
async handler(source,
|
|
813
|
+
async handler(source, rawImporter) {
|
|
579
814
|
const ctx = this;
|
|
815
|
+
// The two engines disagree on "no importer": npm `rolldown`
|
|
816
|
+
// passes `undefined`, `@gjsify/rolldown-native` passes `null`
|
|
817
|
+
// (its hook payload round-trips through JSON, which has no
|
|
818
|
+
// `undefined`). Every guard below is written `=== undefined`,
|
|
819
|
+
// and `null` passes all of them — `dirname(null)` then threw
|
|
820
|
+
// `The "path" argument must be of type string. Received type
|
|
821
|
+
// object` and took the whole GJS build down as an
|
|
822
|
+
// UNHANDLEABLE_ERROR. Normalise once, at the boundary.
|
|
823
|
+
const importer = typeof rawImporter === 'string' ? rawImporter : undefined;
|
|
580
824
|
const cls = classifySpecifier(source);
|
|
581
825
|
if (cls !== null) {
|
|
582
826
|
// Direct `.node` — resolve the file path itself.
|
|
@@ -584,7 +828,8 @@ export function napiNodeAddonPlugin(options = {}) {
|
|
|
584
828
|
const abs = await resolveNodeFile(ctx, source, importer);
|
|
585
829
|
if (abs === null)
|
|
586
830
|
return null; // unresolvable — let the default chain error
|
|
587
|
-
await
|
|
831
|
+
if (!(await ensureNapiAvailable(ctx, importer)))
|
|
832
|
+
return null;
|
|
588
833
|
return { id: encodeVirtual('direct', abs) };
|
|
589
834
|
}
|
|
590
835
|
// napi-rs platform sibling — confirm it resolves to a `.node`.
|
|
@@ -592,7 +837,8 @@ export function napiNodeAddonPlugin(options = {}) {
|
|
|
592
837
|
const resolved = await ctx.resolve(source, importer, { skipSelf: true });
|
|
593
838
|
if (!resolved || !resolved.id.endsWith('.node'))
|
|
594
839
|
return null; // not a native sibling
|
|
595
|
-
await
|
|
840
|
+
if (!(await ensureNapiAvailable(ctx, importer)))
|
|
841
|
+
return null;
|
|
596
842
|
return { id: encodeVirtual('napi-rs', resolved.id) };
|
|
597
843
|
}
|
|
598
844
|
// node-gyp-build / bindings — probe the importer's package root.
|
|
@@ -601,8 +847,9 @@ export function napiNodeAddonPlugin(options = {}) {
|
|
|
601
847
|
const pkgRoot = nearestPackageRoot(importer);
|
|
602
848
|
if (pkgRoot === null)
|
|
603
849
|
return null;
|
|
850
|
+
if (!(await ensureNapiAvailable(ctx, importer)))
|
|
851
|
+
return null;
|
|
604
852
|
const addonPath = resolveAddonPath(pkgRoot, { warn: (m) => warnSafe(ctx, m) }); // throws → build error
|
|
605
|
-
await warnIfNapiMissing(ctx, importer);
|
|
606
853
|
return { id: encodeVirtual(cls.kind, addonPath) };
|
|
607
854
|
}
|
|
608
855
|
// napi-rs GENERATED-LOADER ENTRY (`index.*` filter). Replace the
|
|
@@ -610,11 +857,21 @@ export function napiNodeAddonPlugin(options = {}) {
|
|
|
610
857
|
// of a napi-rs package (package.json signal) AND a current-platform
|
|
611
858
|
// `.node` resolves. Otherwise fall through (never a shim over a
|
|
612
859
|
// missing file, never an unrelated package's entry rewritten).
|
|
613
|
-
|
|
860
|
+
// The specifier may be the entry PATH (an internal import, or a
|
|
861
|
+
// caller that already aliased it) or the package's BARE name —
|
|
862
|
+
// the ordinary `import { transform } from 'lightningcss'`. For
|
|
863
|
+
// the bare form the entry file is only known after resolution,
|
|
864
|
+
// so resolve first and test the resolved id. `entryFileFor`
|
|
865
|
+
// memoizes both the resolve and the rejection.
|
|
866
|
+
const entryFile = await entryFileFor(ctx, source, importer);
|
|
867
|
+
if (entryFile === null)
|
|
868
|
+
return null;
|
|
869
|
+
const entry = detectNapiRsEntryCached(entryFile);
|
|
614
870
|
if (entry !== null) {
|
|
615
|
-
const addonPath = await resolveNapiRsEntryAddon(ctx, entry.pkgRoot, entry.pkg,
|
|
871
|
+
const addonPath = await resolveNapiRsEntryAddon(ctx, entry.pkgRoot, entry.pkg, entryFile);
|
|
616
872
|
if (addonPath !== null) {
|
|
617
|
-
await
|
|
873
|
+
if (!(await ensureNapiAvailable(ctx, importer)))
|
|
874
|
+
return null;
|
|
618
875
|
return { id: encodeVirtual('napi-rs-entry', addonPath) };
|
|
619
876
|
}
|
|
620
877
|
}
|
|
@@ -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;
|