@gjsify/rolldown-plugin-gjsify 0.22.0 → 0.24.1
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 +8 -4
- package/lib/app/gjs.d.ts +24 -0
- package/lib/app/gjs.js +74 -22
- package/lib/app/index.d.ts +2 -2
- package/lib/app/index.js +2 -2
- package/lib/app/nativescript.js +15 -4
- package/lib/app/node.d.ts +41 -7
- package/lib/app/node.js +73 -12
- package/lib/index.d.ts +4 -0
- package/lib/index.js +2 -0
- 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 +120 -0
- package/lib/plugins/napi-node-addon.js +888 -0
- package/lib/plugins/process-stub.js +13 -2
- 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
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
// For `--app gjs`: transparently route a compiled N-API `.node` addon through
|
|
2
|
+
// `@gjsify/napi`'s `loadAddon()` so `import Database from 'better-sqlite3'` /
|
|
3
|
+
// `require('bufferutil')` "just works" in a GJS build — no per-project shim, no
|
|
4
|
+
// hand-pinned addon path.
|
|
5
|
+
//
|
|
6
|
+
// This is the FORWARD MIRROR of `gjsGiNodePlugin` (`gjs-gi-node.ts`): that
|
|
7
|
+
// plugin rewrites `gi://Ns` → `requireGi` for `--app node` (run GObject code on
|
|
8
|
+
// Node); this one rewrites a native-addon acquisition → `loadAddon` for
|
|
9
|
+
// `--app gjs` (run Node's `.node` addons on GJS). Both intercept the ONE
|
|
10
|
+
// specifier a bundler can see and replace it with a virtual module whose value
|
|
11
|
+
// is the runtime bridge.
|
|
12
|
+
//
|
|
13
|
+
// The problem is always the same: the compiled `.node` is acquired with a
|
|
14
|
+
// DYNAMIC `require(<computed path>)` no bundler can rewrite. So we intercept the
|
|
15
|
+
// well-known helper the addon uses and replace it with a virtual module that
|
|
16
|
+
// returns `loadAddon('<abs .node>')`. Handled conventions (keyed by the import
|
|
17
|
+
// a bundler DOES see):
|
|
18
|
+
//
|
|
19
|
+
// - direct `.node` — a `source` (or a `@scope/pkg-<triple>` sibling that
|
|
20
|
+
// resolves to a `.node`) ending in `.node`.
|
|
21
|
+
// - `node-gyp-build` — prebuildify layout (bufferutil, utf-8-validate, …).
|
|
22
|
+
// Default export is a `load(dir)` function.
|
|
23
|
+
// - `bindings` — node-bindings layout (better-sqlite3, node-sqlite3).
|
|
24
|
+
// Default export is a `bindings(name)` function.
|
|
25
|
+
// - napi-rs sibling — `@scope/pkg-<triple>` platform package (or local
|
|
26
|
+
// `pkg.<triple>.node` fallback), whose exports ARE the
|
|
27
|
+
// addon API.
|
|
28
|
+
// - napi-rs ENTRY — the napi-rs GENERATED loader index
|
|
29
|
+
// (`@node-rs/argon2/index.js`), detected by a
|
|
30
|
+
// package.json signal and replaced WHOLESALE.
|
|
31
|
+
//
|
|
32
|
+
// napi-rs GENERATED-LOADER ENTRY replacement: a napi-rs generated index
|
|
33
|
+
// (`@node-rs/argon2/index.js`) wraps its acquisition in a
|
|
34
|
+
// `require('node:module')` + `createRequire(__filename)` + runtime
|
|
35
|
+
// `existsSync`/`process` branch chain whose CJS body does NOT survive `--app gjs`
|
|
36
|
+
// bundling (the top-level `require = createRequire(...)` reassignment throws
|
|
37
|
+
// `ReferenceError: require`). The sibling/direct interceptions above correctly
|
|
38
|
+
// LOCATE the `.node` but cannot rescue that body. So when the entry of a napi-rs
|
|
39
|
+
// package resolves, we replace the WHOLE module with
|
|
40
|
+
// `module.exports = loadAddon('<abs platform .node>')` (the `napi-rs-entry`
|
|
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 +
|
|
47
|
+
// the file must be the package's own native `main` entry + a real host `.node`
|
|
48
|
+
// must resolve) so it never rewrites an unrelated package's entry; when no
|
|
49
|
+
// current-platform `.node` resolves we fall through to normal resolution rather
|
|
50
|
+
// than emit a shim over a missing file. The C/C++ path (node-gyp-build +
|
|
51
|
+
// bindings) and napi-rs are all proven byte-identical in the transparent gate.
|
|
52
|
+
//
|
|
53
|
+
// The addon's compiled `.node` is located by `resolveAddonPath()`, which
|
|
54
|
+
// replicates node-gyp-build's OWN selection algorithm (build/Release →
|
|
55
|
+
// build/Debug → prebuilds/<platform>-<arch>/<best tag>) so the GJS build routes
|
|
56
|
+
// the SAME binary Node would load.
|
|
57
|
+
//
|
|
58
|
+
// KEY: the shims import `@gjsify/napi` by BARE SPECIFIER (`require('@gjsify/napi')`).
|
|
59
|
+
// `@gjsify/napi`'s L1 is a `gjs:polyfill` package reading `imports.gi`; it
|
|
60
|
+
// bundles normally, and its native typelib is auto-added to `GI_TYPELIB_PATH` by
|
|
61
|
+
// the CLI's `detectNativePackages` (because it declares `gjsify.prebuilds`). The
|
|
62
|
+
// callable-helper shims (`node-gyp-build`/`bindings`) stay CJS
|
|
63
|
+
// (`module.exports = fn`) so the consumer's `require('node-gyp-build')(dir)`
|
|
64
|
+
// call site keeps working through gjsify's cjs-compat interop — a callable, not
|
|
65
|
+
// an ESM namespace.
|
|
66
|
+
//
|
|
67
|
+
// Portability note (same as `gjs-gi-node.ts`): the `filter` is a Rolldown
|
|
68
|
+
// fast-path; the internal guard in the handler is the load-bearing check so the
|
|
69
|
+
// plugin is correct even when the filter does not pre-filter.
|
|
70
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
71
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
72
|
+
import { GJSIFY_VIRTUAL_PREFIX } from '../utils/virtual-module-id.js';
|
|
73
|
+
const NAPI_ADDON_VIRTUAL_PREFIX = `${GJSIFY_VIRTUAL_PREFIX}napi-addon:`;
|
|
74
|
+
/** Bare specifier the shims import — resolved + bundled from the consumer graph. */
|
|
75
|
+
const NAPI_BARE_SPECIFIER = '@gjsify/napi';
|
|
76
|
+
/**
|
|
77
|
+
* Platform-triple tail of a napi-rs sibling platform package
|
|
78
|
+
* (`@node-rs/argon2-linux-x64-gnu`) or its local fallback
|
|
79
|
+
* (`argon2.linux-x64-gnu.node`). Deliberately narrow — an interception on a
|
|
80
|
+
* false match is self-correcting (it only fires when the specifier ALSO
|
|
81
|
+
* resolves to a real `.node`), but keeping the shape tight avoids needless
|
|
82
|
+
* `this.resolve` probes on ordinary deps.
|
|
83
|
+
*/
|
|
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))?$/;
|
|
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([
|
|
120
|
+
/\.node$/.source, // direct .node
|
|
121
|
+
/^node-gyp-build(?:\/index\.js)?$/.source, // node-gyp-build helper
|
|
122
|
+
/^bindings(?:\/bindings\.js)?$/.source, // bindings helper
|
|
123
|
+
NAPI_RS_TRIPLE_RE.source, // napi-rs platform sibling
|
|
124
|
+
// napi-rs generated-loader ENTRY: the conventional package-root
|
|
125
|
+
// `index.{js,cjs,mjs}` a napi-rs build emits. Narrow on purpose (only
|
|
126
|
+
// `index.*`, not every `.js`) — the handler's `detectNapiRsEntry` gate
|
|
127
|
+
// (package.json signal + native-main match) is the load-bearing check.
|
|
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,
|
|
132
|
+
].join('|'));
|
|
133
|
+
function isMusl(platform) {
|
|
134
|
+
// Match node-gyp-build's isAlpine() probe.
|
|
135
|
+
return platform === 'linux' && existsSync('/etc/alpine-release');
|
|
136
|
+
}
|
|
137
|
+
function hostTarget() {
|
|
138
|
+
const platform = process.env.npm_config_platform || process.platform;
|
|
139
|
+
const arch = process.env.npm_config_arch || process.arch;
|
|
140
|
+
const abi = process.versions ? process.versions.modules : undefined;
|
|
141
|
+
const uv = ((process.versions && process.versions.uv) || '').split('.')[0] || '';
|
|
142
|
+
const armv = process.env.ARM_VERSION || (arch === 'arm64' ? '8' : '') || '';
|
|
143
|
+
const libc = process.env.LIBC === 'musl' || isMusl(platform) ? 'musl' : 'glibc';
|
|
144
|
+
return { platform, arch, libc, abi, uv, armv, runtime: 'node' };
|
|
145
|
+
}
|
|
146
|
+
function readdirSafe(dir) {
|
|
147
|
+
try {
|
|
148
|
+
return readdirSync(dir);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** First `*.node` in `dir` (sorted for determinism), or null. */
|
|
155
|
+
function firstNodeFile(dir) {
|
|
156
|
+
const all = readdirSafe(dir)
|
|
157
|
+
.filter((n) => n.endsWith('.node'))
|
|
158
|
+
.sort();
|
|
159
|
+
if (all.length === 0)
|
|
160
|
+
return null;
|
|
161
|
+
return { file: all[0], all };
|
|
162
|
+
}
|
|
163
|
+
function parseTuple(name) {
|
|
164
|
+
const arr = name.split('-');
|
|
165
|
+
if (arr.length !== 2)
|
|
166
|
+
return null;
|
|
167
|
+
const platform = arr[0];
|
|
168
|
+
const architectures = arr[1].split('+');
|
|
169
|
+
if (!platform || architectures.length === 0 || !architectures.every(Boolean))
|
|
170
|
+
return null;
|
|
171
|
+
return { name, platform, architectures };
|
|
172
|
+
}
|
|
173
|
+
function parseTags(file) {
|
|
174
|
+
const arr = file.split('.');
|
|
175
|
+
const extension = arr.pop();
|
|
176
|
+
const tags = { file, specificity: 0 };
|
|
177
|
+
if (extension !== 'node')
|
|
178
|
+
return null;
|
|
179
|
+
for (const tag of arr) {
|
|
180
|
+
if (tag === 'node' || tag === 'electron' || tag === 'node-webkit') {
|
|
181
|
+
tags.runtime = tag;
|
|
182
|
+
}
|
|
183
|
+
else if (tag === 'napi') {
|
|
184
|
+
tags.napi = true;
|
|
185
|
+
}
|
|
186
|
+
else if (tag.slice(0, 3) === 'abi') {
|
|
187
|
+
tags.abi = tag.slice(3);
|
|
188
|
+
}
|
|
189
|
+
else if (tag.slice(0, 2) === 'uv') {
|
|
190
|
+
tags.uv = tag.slice(2);
|
|
191
|
+
}
|
|
192
|
+
else if (tag.slice(0, 4) === 'armv') {
|
|
193
|
+
tags.armv = tag.slice(4);
|
|
194
|
+
}
|
|
195
|
+
else if (tag === 'glibc' || tag === 'musl') {
|
|
196
|
+
tags.libc = tag;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
tags.specificity++;
|
|
202
|
+
}
|
|
203
|
+
return tags;
|
|
204
|
+
}
|
|
205
|
+
function runtimeAgnostic(tags) {
|
|
206
|
+
return tags.runtime === 'node' && tags.napi === true;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Resolve the best `prebuilds/<platform>-<arch>/<file>.node` for `pkgRoot`,
|
|
210
|
+
* ported from node-gyp-build's `resolve(dir)` — tolerant of a missing `abi`.
|
|
211
|
+
*/
|
|
212
|
+
function resolvePrebuild(pkgRoot, host) {
|
|
213
|
+
const prebuildsDir = join(pkgRoot, 'prebuilds');
|
|
214
|
+
const tuple = readdirSafe(prebuildsDir)
|
|
215
|
+
.map(parseTuple)
|
|
216
|
+
.filter((t) => t !== null && t.platform === host.platform && t.architectures.includes(host.arch))
|
|
217
|
+
// Prefer single-arch prebuilds over multi-arch (compareTuples).
|
|
218
|
+
.sort((a, b) => a.architectures.length - b.architectures.length)[0];
|
|
219
|
+
if (!tuple)
|
|
220
|
+
return null;
|
|
221
|
+
const tupleDir = join(prebuildsDir, tuple.name);
|
|
222
|
+
const winner = readdirSafe(tupleDir)
|
|
223
|
+
.map(parseTags)
|
|
224
|
+
.filter((t) => {
|
|
225
|
+
if (t === null)
|
|
226
|
+
return false;
|
|
227
|
+
if (t.runtime && t.runtime !== host.runtime && !runtimeAgnostic(t))
|
|
228
|
+
return false;
|
|
229
|
+
// abi undefined (GJS host): reject an abi-pinned, non-napi prebuild —
|
|
230
|
+
// only runtime-agnostic napi prebuilds legitimately match.
|
|
231
|
+
if (t.abi && t.abi !== host.abi && !t.napi)
|
|
232
|
+
return false;
|
|
233
|
+
if (t.uv && t.uv !== host.uv)
|
|
234
|
+
return false;
|
|
235
|
+
if (t.armv && t.armv !== host.armv)
|
|
236
|
+
return false;
|
|
237
|
+
if (t.libc && t.libc !== host.libc)
|
|
238
|
+
return false;
|
|
239
|
+
return true;
|
|
240
|
+
})
|
|
241
|
+
// compareTags: matching runtime first, abi over napi, then specificity.
|
|
242
|
+
.sort((a, b) => {
|
|
243
|
+
if (a.runtime !== b.runtime)
|
|
244
|
+
return a.runtime === host.runtime ? -1 : 1;
|
|
245
|
+
if (a.abi !== b.abi)
|
|
246
|
+
return a.abi ? -1 : 1;
|
|
247
|
+
if (a.specificity !== b.specificity)
|
|
248
|
+
return a.specificity > b.specificity ? -1 : 1;
|
|
249
|
+
return 0;
|
|
250
|
+
})[0];
|
|
251
|
+
return winner ? join(tupleDir, winner.file) : null;
|
|
252
|
+
}
|
|
253
|
+
/** A native addon package root has no resolvable compiled `.node`. */
|
|
254
|
+
export class AddonNotBuiltError extends Error {
|
|
255
|
+
constructor(pkgRoot) {
|
|
256
|
+
super(`[gjsify-napi-addon] no compiled .node found for '${pkgRoot}'. Build it ` +
|
|
257
|
+
`(node-gyp / prebuildify) or install a prebuild before bundling for --app gjs.`);
|
|
258
|
+
this.name = 'AddonNotBuiltError';
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Locate the compiled `.node` for an addon package root, matching
|
|
263
|
+
* node-gyp-build's probe order: `build/Release` → `build/Debug` →
|
|
264
|
+
* `prebuilds/<platform>-<arch>/<best tag>`. Throws {@link AddonNotBuiltError}
|
|
265
|
+
* when nothing is found.
|
|
266
|
+
*
|
|
267
|
+
* @param pkgRoot Absolute path to the addon package root (dir with package.json).
|
|
268
|
+
* @param opts.warn Optional sink for a non-fatal warning (ambiguous build dir).
|
|
269
|
+
*/
|
|
270
|
+
export function resolveAddonPath(pkgRoot, opts) {
|
|
271
|
+
const host = hostTarget();
|
|
272
|
+
for (const flavor of ['Release', 'Debug']) {
|
|
273
|
+
const dir = join(pkgRoot, 'build', flavor);
|
|
274
|
+
const hit = firstNodeFile(dir);
|
|
275
|
+
if (hit) {
|
|
276
|
+
if (hit.all.length > 1 && opts?.warn) {
|
|
277
|
+
opts.warn(`[gjsify-napi-addon] ${hit.all.length} .node files in ${dir} ` +
|
|
278
|
+
`(${hit.all.join(', ')}); picking '${hit.file}'. If wrong, import the ` +
|
|
279
|
+
`.node directly.`);
|
|
280
|
+
}
|
|
281
|
+
return join(dir, hit.file);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const prebuild = resolvePrebuild(pkgRoot, host);
|
|
285
|
+
if (prebuild)
|
|
286
|
+
return prebuild;
|
|
287
|
+
throw new AddonNotBuiltError(pkgRoot);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Walk up from an importer file to the nearest `package.json` directory (the
|
|
291
|
+
* addon package root). Returns null when none is found (should not happen for a
|
|
292
|
+
* real node_modules dep).
|
|
293
|
+
*/
|
|
294
|
+
export function nearestPackageRoot(importerFile) {
|
|
295
|
+
let dir = isAbsolute(importerFile) ? dirname(importerFile) : dirname(resolve(importerFile));
|
|
296
|
+
// Guard against an infinite loop at the filesystem root.
|
|
297
|
+
for (let i = 0; i < 64; i++) {
|
|
298
|
+
if (existsSync(join(dir, 'package.json')))
|
|
299
|
+
return dir;
|
|
300
|
+
const parent = dirname(dir);
|
|
301
|
+
if (parent === dir)
|
|
302
|
+
break;
|
|
303
|
+
dir = parent;
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Shim generators — the virtual-module bodies. All import `@gjsify/napi` by BARE
|
|
309
|
+
// specifier. The callable-helper shims are CJS (`module.exports = fn`) so the
|
|
310
|
+
// consumer's `require('node-gyp-build')(dir)` / `require('bindings')(name)` stays
|
|
311
|
+
// callable through cjs-compat interop.
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
/** Direct `.node` import → the addon's exports (ESM default). */
|
|
314
|
+
export function directNodeShim(addonPath) {
|
|
315
|
+
return (`import { loadAddon } from ${JSON.stringify(NAPI_BARE_SPECIFIER)};\n` +
|
|
316
|
+
`export default loadAddon(${JSON.stringify(addonPath)});\n`);
|
|
317
|
+
}
|
|
318
|
+
/** `node-gyp-build` replacement — a callable `load(dir)` carrying `.path()`. */
|
|
319
|
+
export function nodeGypBuildShim(addonPath) {
|
|
320
|
+
return (`const { loadAddon } = require(${JSON.stringify(NAPI_BARE_SPECIFIER)});\n` +
|
|
321
|
+
`function load() { return loadAddon(${JSON.stringify(addonPath)}); }\n` +
|
|
322
|
+
`load.path = function () { return ${JSON.stringify(addonPath)}; };\n` +
|
|
323
|
+
`load.resolve = load.path;\n` +
|
|
324
|
+
`module.exports = load;\n`);
|
|
325
|
+
}
|
|
326
|
+
/** `bindings` replacement — a callable `bindings(name)` returning the addon. */
|
|
327
|
+
export function bindingsShim(addonPath) {
|
|
328
|
+
return (`const { loadAddon } = require(${JSON.stringify(NAPI_BARE_SPECIFIER)});\n` +
|
|
329
|
+
`function bindings() { return loadAddon(${JSON.stringify(addonPath)}); }\n` +
|
|
330
|
+
`module.exports = bindings;\n`);
|
|
331
|
+
}
|
|
332
|
+
/** napi-rs sibling → the raw native exports as the module value. */
|
|
333
|
+
export function napiRsShim(addonPath) {
|
|
334
|
+
return (`const { loadAddon } = require(${JSON.stringify(NAPI_BARE_SPECIFIER)});\n` +
|
|
335
|
+
`module.exports = loadAddon(${JSON.stringify(addonPath)});\n`);
|
|
336
|
+
}
|
|
337
|
+
function shimFor(kind, addonPath) {
|
|
338
|
+
switch (kind) {
|
|
339
|
+
case 'direct':
|
|
340
|
+
return directNodeShim(addonPath);
|
|
341
|
+
case 'node-gyp-build':
|
|
342
|
+
return nodeGypBuildShim(addonPath);
|
|
343
|
+
case 'bindings':
|
|
344
|
+
return bindingsShim(addonPath);
|
|
345
|
+
case 'napi-rs':
|
|
346
|
+
case 'napi-rs-entry':
|
|
347
|
+
// Both hand back the raw native exports as the module value. The
|
|
348
|
+
// `napi-rs-entry` kind replaces the whole GENERATED loader; `napi-rs`
|
|
349
|
+
// replaces a directly-imported platform sibling. Same body.
|
|
350
|
+
return napiRsShim(addonPath);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function encodeVirtual(kind, addonPath) {
|
|
354
|
+
return `${NAPI_ADDON_VIRTUAL_PREFIX}${kind}:${addonPath}`;
|
|
355
|
+
}
|
|
356
|
+
function decodeVirtual(id) {
|
|
357
|
+
if (!id.startsWith(NAPI_ADDON_VIRTUAL_PREFIX))
|
|
358
|
+
return null;
|
|
359
|
+
const rest = id.slice(NAPI_ADDON_VIRTUAL_PREFIX.length);
|
|
360
|
+
const sep = rest.indexOf(':');
|
|
361
|
+
if (sep === -1)
|
|
362
|
+
return null;
|
|
363
|
+
const kind = rest.slice(0, sep);
|
|
364
|
+
const addonPath = rest.slice(sep + 1);
|
|
365
|
+
return { kind, addonPath };
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Classify a specifier for interception. Pure decision logic (no filesystem
|
|
369
|
+
* probing beyond what `resolveAddonPath` does via the importer) — exported for
|
|
370
|
+
* the unit tests. Returns the shim kind + the package root to probe, or the
|
|
371
|
+
* literal `.node` path for the direct case.
|
|
372
|
+
*/
|
|
373
|
+
export function classifySpecifier(source) {
|
|
374
|
+
if (source === 'node-gyp-build' || source === 'node-gyp-build/index.js')
|
|
375
|
+
return { kind: 'node-gyp-build' };
|
|
376
|
+
if (source === 'bindings' || source === 'bindings/bindings.js')
|
|
377
|
+
return { kind: 'bindings' };
|
|
378
|
+
if (source.endsWith('.node'))
|
|
379
|
+
return { kind: 'direct-node' };
|
|
380
|
+
// A napi-rs platform sibling (`@scope/pkg-<triple>`) — only a CANDIDATE; the
|
|
381
|
+
// handler confirms by resolving it to a real `.node`.
|
|
382
|
+
const last = source.split('/').pop() ?? source;
|
|
383
|
+
if (!source.startsWith('.') && NAPI_RS_TRIPLE_RE.test(last))
|
|
384
|
+
return { kind: 'napi-rs-candidate' };
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* A package.json describes a napi-rs generated-loader package when EITHER
|
|
389
|
+
* signal holds — both are package.json-level (not source sniffing):
|
|
390
|
+
*
|
|
391
|
+
* (a) a top-level `napi` config OBJECT (`{ binaryName, targets, … }`) — the
|
|
392
|
+
* napi-rs CLI's own build block. An ordinary npm package never declares a
|
|
393
|
+
* `napi` object at the manifest root.
|
|
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.
|
|
401
|
+
*
|
|
402
|
+
* Neither fires on a normal package, so pairing this with the "resolved file IS
|
|
403
|
+
* the package's own native main entry" check (see {@link detectNapiRsEntry})
|
|
404
|
+
* makes entry-replacement safe. Exported for the unit tests.
|
|
405
|
+
*/
|
|
406
|
+
export function isNapiRsPackageJson(pkg) {
|
|
407
|
+
if (pkg && typeof pkg.napi === 'object' && pkg.napi !== null)
|
|
408
|
+
return true;
|
|
409
|
+
const opt = pkg?.optionalDependencies;
|
|
410
|
+
if (opt) {
|
|
411
|
+
for (const dep of Object.keys(opt)) {
|
|
412
|
+
if (isNapiRsSibling(pkg, dep))
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
function readPackageJsonSafe(pkgRoot) {
|
|
419
|
+
try {
|
|
420
|
+
return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'));
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Decide whether an absolute file path is the NATIVE generated-loader entry of a
|
|
428
|
+
* napi-rs package. Returns the package root + parsed manifest when it is, else
|
|
429
|
+
* null. "Native entry" = the package's `main` (default `index.js`) — NOT its
|
|
430
|
+
* `browser`/wasm fallback and NOT a deep file — so we only ever replace the one
|
|
431
|
+
* generated loader, never an unrelated module. Filesystem-based (reads the
|
|
432
|
+
* nearest package.json); the plugin memoizes calls. Exported for the unit tests.
|
|
433
|
+
*/
|
|
434
|
+
export function detectNapiRsEntry(entryFile) {
|
|
435
|
+
if (!isAbsolute(entryFile) || !/\.[cm]?js$/.test(entryFile))
|
|
436
|
+
return null;
|
|
437
|
+
const pkgRoot = nearestPackageRoot(entryFile);
|
|
438
|
+
if (pkgRoot === null)
|
|
439
|
+
return null;
|
|
440
|
+
const pkg = readPackageJsonSafe(pkgRoot);
|
|
441
|
+
if (pkg === null || !isNapiRsPackageJson(pkg))
|
|
442
|
+
return null;
|
|
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)
|
|
448
|
+
return null;
|
|
449
|
+
return { pkgRoot, pkg };
|
|
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
|
+
}
|
|
516
|
+
/** napi-rs binaryName (from `napi.binaryName`/`napi.name`, else the unscoped pkg name). */
|
|
517
|
+
function napiBinaryName(pkg) {
|
|
518
|
+
const napi = pkg.napi;
|
|
519
|
+
if (napi && typeof napi === 'object') {
|
|
520
|
+
const cfg = napi;
|
|
521
|
+
const bn = typeof cfg.binaryName === 'string' ? cfg.binaryName : cfg.name;
|
|
522
|
+
if (typeof bn === 'string' && bn)
|
|
523
|
+
return bn;
|
|
524
|
+
}
|
|
525
|
+
if (typeof pkg.name === 'string' && pkg.name)
|
|
526
|
+
return pkg.name.replace(/^@[^/]+\//, '');
|
|
527
|
+
return null;
|
|
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
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* The napi-rs short platform triple for the CURRENT host (`linux-x64-gnu`,
|
|
583
|
+
* `darwin-arm64`, `win32-x64-msvc`, …) — the tail napi-rs stamps into a sibling
|
|
584
|
+
* package name (`@node-rs/argon2-linux-x64-gnu`) and a local binary
|
|
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.
|
|
589
|
+
*/
|
|
590
|
+
function hostNapiRsTriple() {
|
|
591
|
+
const platform = process.platform;
|
|
592
|
+
const arch = process.arch;
|
|
593
|
+
const archTok = {
|
|
594
|
+
x64: 'x64',
|
|
595
|
+
arm64: 'arm64',
|
|
596
|
+
arm: 'arm',
|
|
597
|
+
ia32: 'ia32',
|
|
598
|
+
ppc64: 'ppc64',
|
|
599
|
+
s390x: 's390x',
|
|
600
|
+
riscv64: 'riscv64',
|
|
601
|
+
};
|
|
602
|
+
const a = archTok[arch];
|
|
603
|
+
if (!a)
|
|
604
|
+
return null;
|
|
605
|
+
switch (platform) {
|
|
606
|
+
case 'linux': {
|
|
607
|
+
if (arch === 'arm')
|
|
608
|
+
return 'linux-arm-gnueabihf';
|
|
609
|
+
const libc = process.env.LIBC === 'musl' || isMusl('linux') ? 'musl' : 'gnu';
|
|
610
|
+
return `linux-${a}-${libc}`;
|
|
611
|
+
}
|
|
612
|
+
case 'darwin':
|
|
613
|
+
return `darwin-${a}`;
|
|
614
|
+
case 'win32':
|
|
615
|
+
return `win32-${a}-msvc`;
|
|
616
|
+
case 'freebsd':
|
|
617
|
+
return `freebsd-${a}`;
|
|
618
|
+
case 'android':
|
|
619
|
+
return arch === 'arm' ? 'android-arm-eabi' : `android-${a}`;
|
|
620
|
+
default:
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/** Decode a napi virtual id back to its raw `.node` path (safety net for a resolve hit). */
|
|
625
|
+
function rawAddonPath(id) {
|
|
626
|
+
const decoded = decodeVirtual(id);
|
|
627
|
+
return decoded ? decoded.addonPath : id;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Resolve the current-platform compiled `.node` for a napi-rs generated-loader
|
|
631
|
+
* package: the current-triple sibling package (`@scope/pkg-<triple>`, whose own
|
|
632
|
+
* `main` IS the `.node` — npm installs ONLY the host's optionalDependency, so
|
|
633
|
+
* the one that resolves is the host's), then a local `pkg.<triple>.node`. Reuses
|
|
634
|
+
* the plugin's `ctx.resolve` sibling-resolution path (`skipSelf` so this
|
|
635
|
+
* plugin's own napi-rs-candidate interception is bypassed → a raw `.node` id).
|
|
636
|
+
* Returns null when no current-platform binary is present — the caller then
|
|
637
|
+
* DOES NOT rewrite, so we never shim over a missing file.
|
|
638
|
+
*/
|
|
639
|
+
async function resolveNapiRsEntryAddon(ctx, pkgRoot, pkg, importer) {
|
|
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;
|
|
659
|
+
}
|
|
660
|
+
// Local in-package binary (`<binaryName>.<host-triple>.node`) — deterministic
|
|
661
|
+
// host-triple match so a wrong-platform local file is never picked.
|
|
662
|
+
const binaryName = napiBinaryName(pkg);
|
|
663
|
+
if (binaryName && triple) {
|
|
664
|
+
const local = join(pkgRoot, `${binaryName}.${triple}.node`);
|
|
665
|
+
if (existsSync(local))
|
|
666
|
+
return local;
|
|
667
|
+
}
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
/** Emit a non-fatal warning through the context, if it supports it. */
|
|
671
|
+
function warnSafe(ctx, msg) {
|
|
672
|
+
if (typeof ctx.warn === 'function')
|
|
673
|
+
ctx.warn(msg);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Resolve a direct `.node` specifier to an EXISTING absolute path
|
|
677
|
+
* (relative/bare/abs), or null when it does not resolve to a real file. Null is
|
|
678
|
+
* load-bearing: a napi-rs generated loader statically references a local
|
|
679
|
+
* `./pkg.<triple>.node` fallback that is absent when the binary ships in the
|
|
680
|
+
* sibling platform package — that dead branch must fall through to normal
|
|
681
|
+
* (external) resolution, NOT be rewritten to a shim over a missing file.
|
|
682
|
+
*/
|
|
683
|
+
async function resolveNodeFile(ctx, source, importer) {
|
|
684
|
+
if (isAbsolute(source))
|
|
685
|
+
return existsSync(source) ? source : null;
|
|
686
|
+
if (importer !== undefined) {
|
|
687
|
+
// Importer-relative path math first (a plain `./foo.node`), then the full
|
|
688
|
+
// resolver chain (a bare `pkg/foo.node`). Only claim an EXISTING file.
|
|
689
|
+
const rel = resolve(dirname(importer), source);
|
|
690
|
+
if (existsSync(rel))
|
|
691
|
+
return rel;
|
|
692
|
+
const resolved = await ctx.resolve(source, importer, { skipSelf: true });
|
|
693
|
+
if (resolved && resolved.id.endsWith('.node') && existsSync(resolved.id))
|
|
694
|
+
return resolved.id;
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Transparent `.node`-addon loader for `gjsify build --app gjs`.
|
|
701
|
+
*
|
|
702
|
+
* resolveId (`order: 'pre'`, importer-aware) intercepts a native-addon
|
|
703
|
+
* acquisition and rewrites it to a virtual module whose value is
|
|
704
|
+
* `loadAddon('<abs .node>')`. Inert unless such a specifier is in the graph —
|
|
705
|
+
* every other id returns null. Register ONLY for `--app gjs` (the C-ABI runs
|
|
706
|
+
* under `@gjsify/napi`); never for node/browser/nativescript.
|
|
707
|
+
*/
|
|
708
|
+
export function napiNodeAddonPlugin(options = {}) {
|
|
709
|
+
const warnOnMissingNapi = options.warnOnMissingNapi !== false;
|
|
710
|
+
let missingNapiChecked = false;
|
|
711
|
+
// Memoize napi-rs entry detection per resolved file — the `index.*` filter
|
|
712
|
+
// fires the handler for every package's index entry across every build pass;
|
|
713
|
+
// this bounds the package.json reads to one per unique entry file.
|
|
714
|
+
const napiEntryCache = new Map();
|
|
715
|
+
function detectNapiRsEntryCached(entryFile) {
|
|
716
|
+
const cached = napiEntryCache.get(entryFile);
|
|
717
|
+
if (cached !== undefined)
|
|
718
|
+
return cached;
|
|
719
|
+
const info = detectNapiRsEntry(entryFile);
|
|
720
|
+
napiEntryCache.set(entryFile, info);
|
|
721
|
+
return info;
|
|
722
|
+
}
|
|
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;
|
|
751
|
+
try {
|
|
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;
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
/* resolution failure is not our error to raise — fall through */
|
|
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;
|
|
807
|
+
}
|
|
808
|
+
return {
|
|
809
|
+
name: 'gjsify-napi-node-addon',
|
|
810
|
+
resolveId: {
|
|
811
|
+
order: 'pre',
|
|
812
|
+
filter: { id: ADDON_FILTER_RE },
|
|
813
|
+
async handler(source, rawImporter) {
|
|
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;
|
|
824
|
+
const cls = classifySpecifier(source);
|
|
825
|
+
if (cls !== null) {
|
|
826
|
+
// Direct `.node` — resolve the file path itself.
|
|
827
|
+
if (cls.kind === 'direct-node') {
|
|
828
|
+
const abs = await resolveNodeFile(ctx, source, importer);
|
|
829
|
+
if (abs === null)
|
|
830
|
+
return null; // unresolvable — let the default chain error
|
|
831
|
+
if (!(await ensureNapiAvailable(ctx, importer)))
|
|
832
|
+
return null;
|
|
833
|
+
return { id: encodeVirtual('direct', abs) };
|
|
834
|
+
}
|
|
835
|
+
// napi-rs platform sibling — confirm it resolves to a `.node`.
|
|
836
|
+
if (cls.kind === 'napi-rs-candidate') {
|
|
837
|
+
const resolved = await ctx.resolve(source, importer, { skipSelf: true });
|
|
838
|
+
if (!resolved || !resolved.id.endsWith('.node'))
|
|
839
|
+
return null; // not a native sibling
|
|
840
|
+
if (!(await ensureNapiAvailable(ctx, importer)))
|
|
841
|
+
return null;
|
|
842
|
+
return { id: encodeVirtual('napi-rs', resolved.id) };
|
|
843
|
+
}
|
|
844
|
+
// node-gyp-build / bindings — probe the importer's package root.
|
|
845
|
+
if (importer === undefined)
|
|
846
|
+
return null;
|
|
847
|
+
const pkgRoot = nearestPackageRoot(importer);
|
|
848
|
+
if (pkgRoot === null)
|
|
849
|
+
return null;
|
|
850
|
+
if (!(await ensureNapiAvailable(ctx, importer)))
|
|
851
|
+
return null;
|
|
852
|
+
const addonPath = resolveAddonPath(pkgRoot, { warn: (m) => warnSafe(ctx, m) }); // throws → build error
|
|
853
|
+
return { id: encodeVirtual(cls.kind, addonPath) };
|
|
854
|
+
}
|
|
855
|
+
// napi-rs GENERATED-LOADER ENTRY (`index.*` filter). Replace the
|
|
856
|
+
// whole module only when the resolved file IS the native `main`
|
|
857
|
+
// of a napi-rs package (package.json signal) AND a current-platform
|
|
858
|
+
// `.node` resolves. Otherwise fall through (never a shim over a
|
|
859
|
+
// missing file, never an unrelated package's entry rewritten).
|
|
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);
|
|
870
|
+
if (entry !== null) {
|
|
871
|
+
const addonPath = await resolveNapiRsEntryAddon(ctx, entry.pkgRoot, entry.pkg, entryFile);
|
|
872
|
+
if (addonPath !== null) {
|
|
873
|
+
if (!(await ensureNapiAvailable(ctx, importer)))
|
|
874
|
+
return null;
|
|
875
|
+
return { id: encodeVirtual('napi-rs-entry', addonPath) };
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return null;
|
|
879
|
+
},
|
|
880
|
+
},
|
|
881
|
+
load(id) {
|
|
882
|
+
const decoded = decodeVirtual(id);
|
|
883
|
+
if (decoded === null)
|
|
884
|
+
return null;
|
|
885
|
+
return { code: shimFor(decoded.kind, decoded.addonPath), moduleSideEffects: false };
|
|
886
|
+
},
|
|
887
|
+
};
|
|
888
|
+
}
|