@gjsify/resolve-npm 0.4.31 → 0.4.32

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/index.d.ts CHANGED
@@ -31,3 +31,35 @@ export declare const ALIASES_GJS_FOR_NODE: {[alias:string]: string};
31
31
  /** Record of Web modules and his replacement for Node */
32
32
  export declare const ALIASES_WEB_FOR_NODE: {[alias:string]: string};
33
33
 
34
+ /** Runtime-slot type carried by `package.json#gjsify.runtimes.<target>`. */
35
+ export type RuntimeSlot = 'polyfill' | 'native' | 'partial' | 'none';
36
+
37
+ /** Per-package runtime triplet — `{gjs, node, browser}` × {RuntimeSlot}. */
38
+ export interface RuntimeTriplet {
39
+ gjs?: RuntimeSlot;
40
+ node?: RuntimeSlot;
41
+ browser?: RuntimeSlot;
42
+ }
43
+
44
+ /**
45
+ * Build a derived `@gjsify/<X>` alias map for the given target runtime, driven
46
+ * by each workspace package's declared `gjsify.runtimes` triplet. See
47
+ * `runtime-aliases.mjs` for the routing semantics.
48
+ */
49
+ export declare function getDerivedAliasesSync(target: 'gjs' | 'node' | 'browser'): {[alias: string]: string};
50
+
51
+ /** Async variant of {@link getDerivedAliasesSync}. */
52
+ export declare function getDerivedAliases(
53
+ target: 'gjs' | 'node' | 'browser',
54
+ ): Promise<{[alias: string]: string}>;
55
+
56
+ /** Reset the in-memory cache. Test-only. */
57
+ export declare function resetRuntimeAliasesCache(): void;
58
+
59
+ /** Diagnostic — list every declared runtime triplet keyed by package name. */
60
+ export declare function listDeclaredRuntimes(): Promise<Map<string, {
61
+ name: string;
62
+ dir: string;
63
+ runtimes: RuntimeTriplet;
64
+ hasGlobals: boolean;
65
+ }>>;
package/lib/index.mjs CHANGED
@@ -1,3 +1,16 @@
1
+ // Runtime-aware alias derivation — driven by per-package `gjsify.runtimes`
2
+ // triplet declarations. See `./runtime-aliases.mjs` for the routing rules.
3
+ // These helpers AUGMENT the hardcoded `ALIASES_*` maps below: a package
4
+ // without a declared triplet falls through to the hardcoded map for that
5
+ // specifier, preserving backwards-compatible behavior for the 21 infra/gjs
6
+ // packages that opted out of the triplet model.
7
+ export {
8
+ getDerivedAliases,
9
+ getDerivedAliasesSync,
10
+ listDeclaredRuntimes,
11
+ resetRuntimeAliasesCache,
12
+ } from './runtime-aliases.mjs';
13
+
1
14
  /** Array of Node.js build in module names */
2
15
  export const EXTERNALS_NODE = [
3
16
  'assert',
@@ -0,0 +1,419 @@
1
+ // Runtime-aware alias derivation.
2
+ //
3
+ // Each `@gjsify/*` package may declare a `gjsify.runtimes` triplet in its
4
+ // `package.json`:
5
+ //
6
+ // {
7
+ // "gjsify": {
8
+ // "runtimes": {
9
+ // "gjs": "polyfill" | "native" | "partial" | "none",
10
+ // "node": "polyfill" | "native" | "partial" | "none",
11
+ // "browser": "polyfill" | "native" | "partial" | "none"
12
+ // }
13
+ // }
14
+ // }
15
+ //
16
+ // The bundler's alias resolver consults this triplet when routing bare
17
+ // `@gjsify/<X>` specifiers per `--app <target>`:
18
+ //
19
+ // slot=polyfill → keep as-is (`@gjsify/<X>` — our impl ships)
20
+ // slot=native → redirect to `@gjsify/<X>/globals` (re-exports native value)
21
+ // slot=partial → keep as-is (our impl, gracefully degrades at runtime)
22
+ // slot=none → no rewrite (the package's polyfill resolves normally;
23
+ // its GJS-only value-deps are stripped by the bundler's
24
+ // `gjsImportsEmptyPlugin`; runtime calls into GJS-only
25
+ // code paths fail with a structured no-op — same shape
26
+ // as `slot=partial`)
27
+ //
28
+ // Packages without a declared `runtimes` triplet fall through to the hardcoded
29
+ // `ALIASES_*` maps in `./index.mjs` — backwards-compatible behavior for the
30
+ // infra/gjs/framework packages that opted out of the triplet model.
31
+ //
32
+ // When a slot=native package is missing the corresponding `globals.mjs`
33
+ // re-export file, the resolver emits a warn-once log and falls back to
34
+ // `@gjsify/empty` (the current behavior) — surfacing the gap is desirable.
35
+
36
+ import { readdir, readFile } from 'node:fs/promises';
37
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
38
+ import { join, dirname, resolve } from 'node:path';
39
+ import { fileURLToPath } from 'node:url';
40
+
41
+ const VALID_SLOTS = new Set(['polyfill', 'native', 'partial', 'none']);
42
+ const VALID_TARGETS = new Set(['gjs', 'node', 'browser']);
43
+
44
+ /** @typedef {'polyfill'|'native'|'partial'|'none'} Slot */
45
+ /** @typedef {{gjs?:Slot, node?:Slot, browser?:Slot}} RuntimeTriplet */
46
+ /** @typedef {{name:string, dir:string, runtimes:RuntimeTriplet, hasGlobals:boolean}} PackageRecord */
47
+
48
+ let _cache = null;
49
+ const _warned = new Set();
50
+
51
+ /**
52
+ * Locate every directory that may contain `@gjsify/*` packages — workspace
53
+ * monorepo source dirs AND installed `node_modules/@gjsify/` farms.
54
+ *
55
+ * Two discovery modes (both checked, results merged):
56
+ *
57
+ * 1. **Workspace mode** — when the resolver is being consulted from inside
58
+ * the gjsify monorepo itself, walk up from this module to find a
59
+ * `packages/{node,web,dom,framework,infra}` layout. Returns the
60
+ * `packages/` root.
61
+ *
62
+ * 2. **Installed mode** — when the resolver is being consulted from a
63
+ * consumer project's `node_modules/@gjsify/resolve-npm`, the workspace
64
+ * layout is NOT present. Instead, walk up to find `node_modules/@gjsify/`
65
+ * and scan every `<pkg>/package.json` there for `gjsify.runtimes`.
66
+ *
67
+ * Returns an array of `{kind, dir}` entries; the caller scans each per its
68
+ * own layout convention.
69
+ */
70
+ function findScanRoots() {
71
+ /** @type {Array<{kind:'workspace'|'installed', dir:string}>} */
72
+ const roots = [];
73
+
74
+ const here = dirname(fileURLToPath(import.meta.url));
75
+ let cur = here;
76
+ for (let i = 0; i < 8; i++) {
77
+ // Workspace check: <cur>/packages/{node,web,dom,framework,infra}.
78
+ const packagesCandidate = resolve(cur, 'packages');
79
+ if (existsSync(packagesCandidate)) {
80
+ try {
81
+ const stamp = ['node', 'web', 'dom', 'framework', 'infra'].some((s) =>
82
+ existsSync(join(packagesCandidate, s)),
83
+ );
84
+ if (stamp) roots.push({ kind: 'workspace', dir: packagesCandidate });
85
+ } catch {
86
+ // ignore
87
+ }
88
+ }
89
+
90
+ // Installed check: <cur>/node_modules/@gjsify (single-flat npm layout).
91
+ const installedCandidate = resolve(cur, 'node_modules', '@gjsify');
92
+ if (existsSync(installedCandidate)) {
93
+ roots.push({ kind: 'installed', dir: installedCandidate });
94
+ }
95
+
96
+ const parent = dirname(cur);
97
+ if (parent === cur) break;
98
+ cur = parent;
99
+ }
100
+ // Also scan the consumer project's cwd-side node_modules — the resolver
101
+ // is invoked from the bundler's process (running in the consumer project),
102
+ // so cwd is a meaningful root even when this module's own dirname is in a
103
+ // hoisted location.
104
+ try {
105
+ const cwdRoot = resolve(process.cwd(), 'node_modules', '@gjsify');
106
+ if (existsSync(cwdRoot)) roots.push({ kind: 'installed', dir: cwdRoot });
107
+ } catch {
108
+ // process.cwd() can throw in unusual environments; ignore.
109
+ }
110
+ return roots;
111
+ }
112
+
113
+ /**
114
+ * @param {string} dir
115
+ * @param {Map<string,PackageRecord>} out
116
+ */
117
+ async function ingestPackageDir(dir, out) {
118
+ const pkgJsonPath = join(dir, 'package.json');
119
+ if (!existsSync(pkgJsonPath)) return;
120
+ try {
121
+ const raw = await readFile(pkgJsonPath, 'utf8');
122
+ const json = JSON.parse(raw);
123
+ const name = typeof json.name === 'string' ? json.name : null;
124
+ const runtimes = json?.gjsify?.runtimes;
125
+ if (
126
+ name &&
127
+ name.startsWith('@gjsify/') &&
128
+ !out.has(name) &&
129
+ runtimes &&
130
+ typeof runtimes === 'object'
131
+ ) {
132
+ /** @type {RuntimeTriplet} */
133
+ const triplet = {};
134
+ for (const t of VALID_TARGETS) {
135
+ const v = runtimes[t];
136
+ if (typeof v === 'string' && VALID_SLOTS.has(v)) {
137
+ triplet[t] = v;
138
+ }
139
+ }
140
+ const hasGlobals = existsSync(join(dir, 'globals.mjs'));
141
+ out.set(name, { name, dir, runtimes: triplet, hasGlobals });
142
+ }
143
+ } catch {
144
+ // Ignore unreadable / invalid package.json
145
+ }
146
+ }
147
+
148
+ /**
149
+ * @param {string} dir
150
+ * @param {Map<string,PackageRecord>} out
151
+ */
152
+ function ingestPackageDirSync(dir, out) {
153
+ const pkgJsonPath = join(dir, 'package.json');
154
+ if (!existsSync(pkgJsonPath)) return;
155
+ try {
156
+ const raw = readFileSync(pkgJsonPath, 'utf8');
157
+ const json = JSON.parse(raw);
158
+ const name = typeof json.name === 'string' ? json.name : null;
159
+ const runtimes = json?.gjsify?.runtimes;
160
+ if (
161
+ name &&
162
+ name.startsWith('@gjsify/') &&
163
+ !out.has(name) &&
164
+ runtimes &&
165
+ typeof runtimes === 'object'
166
+ ) {
167
+ /** @type {RuntimeTriplet} */
168
+ const triplet = {};
169
+ for (const t of VALID_TARGETS) {
170
+ const v = runtimes[t];
171
+ if (typeof v === 'string' && VALID_SLOTS.has(v)) {
172
+ triplet[t] = v;
173
+ }
174
+ }
175
+ const hasGlobals = existsSync(join(dir, 'globals.mjs'));
176
+ out.set(name, { name, dir, runtimes: triplet, hasGlobals });
177
+ }
178
+ } catch {
179
+ // Ignore unreadable / invalid package.json
180
+ }
181
+ }
182
+
183
+ /** Walk `packages/**` and collect every `@gjsify/*` package.json + signals. */
184
+ async function collectPackages() {
185
+ const scanRoots = findScanRoots();
186
+ /** @type {Map<string, PackageRecord>} */
187
+ const out = new Map();
188
+ if (scanRoots.length === 0) return out;
189
+
190
+ /**
191
+ * Workspace walker — descends past `packages/{node,web,…}/<name>/`.
192
+ * @param {string} dir
193
+ */
194
+ async function walkWorkspace(dir) {
195
+ let entries;
196
+ try {
197
+ entries = await readdir(dir, { withFileTypes: true });
198
+ } catch {
199
+ return;
200
+ }
201
+ if (existsSync(join(dir, 'package.json'))) {
202
+ await ingestPackageDir(dir, out);
203
+ return; // do not descend into package internals
204
+ }
205
+ for (const ent of entries) {
206
+ if (!ent.isDirectory()) continue;
207
+ if (ent.name === 'node_modules' || ent.name === 'lib' || ent.name === 'dist') continue;
208
+ if (ent.name.startsWith('.')) continue;
209
+ await walkWorkspace(join(dir, ent.name));
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Installed walker — `node_modules/@gjsify/<name>/` is one level deep.
215
+ * @param {string} gjsifyDir
216
+ */
217
+ async function walkInstalled(gjsifyDir) {
218
+ let entries;
219
+ try {
220
+ entries = await readdir(gjsifyDir, { withFileTypes: true });
221
+ } catch {
222
+ return;
223
+ }
224
+ for (const ent of entries) {
225
+ if (!ent.isDirectory() && !ent.isSymbolicLink()) continue;
226
+ if (ent.name.startsWith('.')) continue;
227
+ await ingestPackageDir(join(gjsifyDir, ent.name), out);
228
+ }
229
+ }
230
+
231
+ for (const root of scanRoots) {
232
+ if (root.kind === 'workspace') {
233
+ await walkWorkspace(root.dir);
234
+ } else {
235
+ await walkInstalled(root.dir);
236
+ }
237
+ }
238
+ return out;
239
+ }
240
+
241
+ /** Lazy-loaded cache of all declared runtime triplets. */
242
+ async function getCache() {
243
+ if (_cache) return _cache;
244
+ _cache = await collectPackages();
245
+ return _cache;
246
+ }
247
+
248
+ /** Synchronous variant — used by the bundler at config-time (one-shot init). */
249
+ function getCacheSync() {
250
+ if (_cache) return _cache;
251
+ const scanRoots = findScanRoots();
252
+ /** @type {Map<string, PackageRecord>} */
253
+ const out = new Map();
254
+ if (scanRoots.length === 0) {
255
+ _cache = out;
256
+ return _cache;
257
+ }
258
+ /** @param {string} dir */
259
+ function walkWorkspace(dir) {
260
+ let entries;
261
+ try {
262
+ entries = readdirSync(dir, { withFileTypes: true });
263
+ } catch {
264
+ return;
265
+ }
266
+ if (existsSync(join(dir, 'package.json'))) {
267
+ ingestPackageDirSync(dir, out);
268
+ return;
269
+ }
270
+ for (const ent of entries) {
271
+ if (!ent.isDirectory()) continue;
272
+ if (ent.name === 'node_modules' || ent.name === 'lib' || ent.name === 'dist') continue;
273
+ if (ent.name.startsWith('.')) continue;
274
+ walkWorkspace(join(dir, ent.name));
275
+ }
276
+ }
277
+ /** @param {string} gjsifyDir */
278
+ function walkInstalled(gjsifyDir) {
279
+ let entries;
280
+ try {
281
+ entries = readdirSync(gjsifyDir, { withFileTypes: true });
282
+ } catch {
283
+ return;
284
+ }
285
+ for (const ent of entries) {
286
+ if (!ent.isDirectory() && !ent.isSymbolicLink()) continue;
287
+ if (ent.name.startsWith('.')) continue;
288
+ ingestPackageDirSync(join(gjsifyDir, ent.name), out);
289
+ }
290
+ }
291
+ for (const root of scanRoots) {
292
+ if (root.kind === 'workspace') walkWorkspace(root.dir);
293
+ else walkInstalled(root.dir);
294
+ }
295
+ _cache = out;
296
+ return _cache;
297
+ }
298
+
299
+ /**
300
+ * Emit a one-shot warning. Subsequent calls with the same key are silent.
301
+ *
302
+ * @param {string} key
303
+ * @param {string} message
304
+ */
305
+ function warnOnce(key, message) {
306
+ if (_warned.has(key)) return;
307
+ _warned.add(key);
308
+ // eslint-disable-next-line no-console
309
+ console.warn(`[@gjsify/resolve-npm] ${message}`);
310
+ }
311
+
312
+ /**
313
+ * Given a package record + target runtime, resolve the alias target.
314
+ *
315
+ * @param {PackageRecord} rec
316
+ * @param {'gjs'|'node'|'browser'} target
317
+ * @returns {string|null} The alias target specifier, or null if no rewrite applies.
318
+ */
319
+ function resolveSlot(rec, target) {
320
+ const slot = rec.runtimes[target];
321
+ if (!slot) return null; // Slot undeclared → no opinion, leave to hardcoded maps.
322
+ switch (slot) {
323
+ case 'polyfill':
324
+ case 'partial':
325
+ // Keep as-is — bundler resolves `@gjsify/<X>` to its `lib/esm/index.js`.
326
+ return null;
327
+ case 'native':
328
+ if (rec.hasGlobals) {
329
+ return `${rec.name}/globals`;
330
+ }
331
+ warnOnce(
332
+ `${rec.name}-${target}-native-missing-globals`,
333
+ `${rec.name} declares runtimes.${target}="native" but ships no globals.mjs — falling back to @gjsify/empty for --app ${target} builds.`,
334
+ );
335
+ return '@gjsify/empty';
336
+ case 'none':
337
+ // Intentionally no rewrite. The original draft routed `none` to
338
+ // `@gjsify/empty` (idea: shrink GJS-only polyfills out of Node
339
+ // bundles), but that breaks bundles whose own `*.gjs.spec.ts`
340
+ // files import their host package by name — e.g. `@gjsify/tls`'s
341
+ // `session-access.gjs.spec.ts` imports `TLSSocket, connect, …`
342
+ // from `@gjsify/tls`. Empty has none of those exports, so the
343
+ // bundler errors with MISSING_EXPORT before the gjs-spec gets a
344
+ // chance to runtime-guard via `on('Gjs', …)`.
345
+ // Letting `none`-slotted packages resolve normally to the
346
+ // polyfill keeps the bundle valid; the `gjsImportsEmptyPlugin`
347
+ // (added to `--app node` in a prior PR) still strips the
348
+ // `gi://`/`@girs/*` value-deps inside the polyfill so the bundle
349
+ // doesn't crash on load. Consumers calling GJS-only code paths
350
+ // on Node will hit a runtime no-op / structured failure inside
351
+ // the polyfill, which is the documented `partial`-shaped
352
+ // behavior — and exactly what the strategy doc allows.
353
+ return null;
354
+ default:
355
+ return null;
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Build the derived `@gjsify/<X>` alias map for the given target runtime.
361
+ *
362
+ * The returned map only contains entries whose source specifier starts with
363
+ * `@gjsify/` — it is meant to be merged on top of the hardcoded `ALIASES_*`
364
+ * maps in `./index.mjs`. Entries already present in the hardcoded maps win
365
+ * (callers should `{ ...derived, ...hardcoded }` to preserve current behavior
366
+ * for packages opted out of the triplet model).
367
+ *
368
+ * @param {'gjs'|'node'|'browser'} target
369
+ * @returns {Record<string,string>}
370
+ */
371
+ export function getDerivedAliasesSync(target) {
372
+ if (!VALID_TARGETS.has(target)) return {};
373
+ const cache = getCacheSync();
374
+ /** @type {Record<string,string>} */
375
+ const out = {};
376
+ for (const rec of cache.values()) {
377
+ const alias = resolveSlot(rec, target);
378
+ if (alias !== null && alias !== rec.name) {
379
+ out[rec.name] = alias;
380
+ }
381
+ }
382
+ return out;
383
+ }
384
+
385
+ /**
386
+ * Async variant of {@link getDerivedAliasesSync} — preferred when callable from
387
+ * an async config hook.
388
+ *
389
+ * @param {'gjs'|'node'|'browser'} target
390
+ * @returns {Promise<Record<string,string>>}
391
+ */
392
+ export async function getDerivedAliases(target) {
393
+ if (!VALID_TARGETS.has(target)) return {};
394
+ const cache = await getCache();
395
+ /** @type {Record<string,string>} */
396
+ const out = {};
397
+ for (const rec of cache.values()) {
398
+ const alias = resolveSlot(rec, target);
399
+ if (alias !== null && alias !== rec.name) {
400
+ out[rec.name] = alias;
401
+ }
402
+ }
403
+ return out;
404
+ }
405
+
406
+ /**
407
+ * Reset the in-memory cache. Test-only — production code should never call
408
+ * this. Exposed so the e2e suite can force a re-scan after writing fixtures.
409
+ */
410
+ export function resetRuntimeAliasesCache() {
411
+ _cache = null;
412
+ _warned.clear();
413
+ }
414
+
415
+ /** Expose the raw declaration map (read-only) for diagnostics. */
416
+ export async function listDeclaredRuntimes() {
417
+ const cache = await getCache();
418
+ return new Map(cache);
419
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/resolve-npm",
3
- "version": "0.4.31",
3
+ "version": "0.4.32",
4
4
  "description": "Resolve NPM package aliases",
5
5
  "type": "module",
6
6
  "main": "lib/index.mjs",
@@ -12,6 +12,7 @@
12
12
  "default": "./lib/index.mjs"
13
13
  },
14
14
  "./globals-map": "./lib/globals-map.mjs",
15
+ "./runtime-aliases": "./lib/runtime-aliases.mjs",
15
16
  "./package.json": "./package.json"
16
17
  },
17
18
  "files": [