@gjsify/module 0.3.12 → 0.3.14

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.
@@ -0,0 +1,48 @@
1
+ interface PnpPackageInformation {
2
+ packageLocation: string;
3
+ packageDependencies?: ReadonlyArray<readonly [string, string | null]>;
4
+ linkType: 'SOFT' | 'HARD';
5
+ discardFromLookup?: boolean;
6
+ }
7
+ /**
8
+ * Parsed manifest, indexed for O(1) lookup. Cached per file path so repeat
9
+ * `createRequire(...)` calls on the same workspace don't re-parse.
10
+ */
11
+ export interface PnpManifest {
12
+ /** Absolute path of the `.pnp.cjs` file's parent directory. */
13
+ readonly rootDir: string;
14
+ /** Map<packageName, Map<packageReference, info>> — null name = workspace root. */
15
+ readonly packages: Map<string | null, Map<string | null, PnpPackageInformation>>;
16
+ /** Locator-by-location reverse index for "which package owns this path". */
17
+ readonly locatorsByLocation: Map<string, {
18
+ name: string | null;
19
+ reference: string | null;
20
+ }>;
21
+ }
22
+ /** Walk up from `startDir` looking for the nearest `.pnp.cjs`. */
23
+ export declare function findPnpManifest(startDir: string): string | null;
24
+ /** Read + parse `.pnp.cjs`'s `RAW_RUNTIME_STATE`. Cached. */
25
+ export declare function loadPnpManifest(pnpCjsPath: string): PnpManifest | null;
26
+ /**
27
+ * Find which package owns `absolutePath`. Returns the locator + its info, or
28
+ * null when the path isn't covered by any package in the manifest.
29
+ *
30
+ * Uses longest-prefix-match against `packageLocation` entries (Yarn does the
31
+ * same in `findPackageLocator`).
32
+ */
33
+ export declare function findPackageOwning(manifest: PnpManifest, absolutePath: string): {
34
+ locator: {
35
+ name: string | null;
36
+ reference: string | null;
37
+ };
38
+ info: PnpPackageInformation;
39
+ } | null;
40
+ /**
41
+ * Resolve a bare specifier through PnP. Returns the absolute on-disk path,
42
+ * or null when the request can't be resolved this way.
43
+ *
44
+ * `id` is a bare specifier like `@scope/foo` or `@scope/foo/bar/baz.js`.
45
+ * `callerPath` is the absolute path of the file doing the require.
46
+ */
47
+ export declare function resolveBareViaPnp(manifest: PnpManifest, id: string, callerPath: string): string | null;
48
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/module",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "Node.js module module for Gjs",
5
5
  "type": "module",
6
6
  "module": "lib/esm/index.js",
@@ -30,14 +30,14 @@
30
30
  "module"
31
31
  ],
32
32
  "dependencies": {
33
- "@girs/gio-2.0": "^2.88.0-4.0.0-rc.9",
34
- "@girs/gjs": "^4.0.0-rc.9",
35
- "@girs/glib-2.0": "^2.88.0-4.0.0-rc.9",
36
- "@gjsify/utils": "^0.3.12"
33
+ "@girs/gio-2.0": "2.88.0-4.0.0-rc.9",
34
+ "@girs/gjs": "4.0.0-rc.9",
35
+ "@girs/glib-2.0": "2.88.0-4.0.0-rc.9",
36
+ "@gjsify/utils": "^0.3.14"
37
37
  },
38
38
  "devDependencies": {
39
- "@gjsify/cli": "^0.3.12",
40
- "@gjsify/unit": "^0.3.12",
39
+ "@gjsify/cli": "^0.3.14",
40
+ "@gjsify/unit": "^0.3.14",
41
41
  "@types/node": "^25.6.0",
42
42
  "typescript": "^6.0.3"
43
43
  }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import '@girs/gjs';
6
6
  import Gio from '@girs/gio-2.0';
7
7
  import GLib from '@girs/glib-2.0';
8
8
  import { resolve as resolvePath, readJSON } from '@gjsify/utils';
9
+ import { findPnpManifest, loadPnpManifest, resolveBareViaPnp } from './pnp.js';
9
10
 
10
11
  export const builtinModules = [
11
12
  'assert',
@@ -135,6 +136,23 @@ function fileUrlToPath(filenameOrURL: string | URL): string {
135
136
  return String(filenameOrURL);
136
137
  }
137
138
 
139
+ /**
140
+ * Try resolving a bare specifier through a Yarn PnP manifest (`.pnp.cjs`)
141
+ * sitting above `callerDir`. Returns null when no manifest is found, or when
142
+ * PnP can't resolve the request (e.g. the dep isn't listed in the caller
143
+ * package's `packageDependencies`). Callers fall back to the node_modules walk.
144
+ */
145
+ function resolveBareViaPnpFromCaller(id: string, callerDir: string): Gio.File | null {
146
+ const pnpPath = findPnpManifest(callerDir);
147
+ if (!pnpPath) return null;
148
+ const manifest = loadPnpManifest(pnpPath);
149
+ if (!manifest) return null;
150
+ const resolved = resolveBareViaPnp(manifest, id, callerDir);
151
+ if (!resolved) return null;
152
+ const file = Gio.File.new_for_path(resolved);
153
+ return file.query_exists(null) ? file : null;
154
+ }
155
+
138
156
  /**
139
157
  * Resolve a bare package specifier by walking ALL ancestor node_modules dirs.
140
158
  * Mirrors Node.js module resolution: try nearest node_modules first, then each
@@ -171,8 +189,11 @@ function resolveModulePath(id: string, callerDir: string): string {
171
189
  } else if (id.startsWith('.')) {
172
190
  file = resolvePath(callerDir, id);
173
191
  } else {
174
- // Bare specifier: walk all ancestor node_modules (Node.js resolution algorithm)
175
- file = resolveInNodeModules(id, callerDir);
192
+ // Bare specifier: try Yarn PnP first (for PnP-built workspaces where no
193
+ // node_modules/ tree exists on disk), then fall back to the standard
194
+ // node_modules walk.
195
+ const pnpFile = resolveBareViaPnpFromCaller(id, callerDir);
196
+ file = pnpFile ?? resolveInNodeModules(id, callerDir);
176
197
  }
177
198
 
178
199
  // Extension fallback for absolute/relative paths (bare specifiers handled in resolveInNodeModules)
package/src/pnp.ts ADDED
@@ -0,0 +1,286 @@
1
+ // Yarn PnP-aware resolution for `@gjsify/module`'s `createRequire`.
2
+ //
3
+ // Yarn-PnP keeps every package in `.yarn/cache/<pkg>.zip` and `.yarn/unplugged/`,
4
+ // addressed via a `.pnp.cjs` manifest at the workspace root. No real
5
+ // `node_modules/` tree exists on disk, so the standard "walk up
6
+ // `node_modules/`" resolver fails for any consumer running inside a
7
+ // PnP-built workspace — including a freshly-bundled GJS executable that
8
+ // uses `createRequire(import.meta.url)` to find a sibling package's
9
+ // `package.json`.
10
+ //
11
+ // We don't execute Yarn's manifest (it pulls in Node-specific APIs and is
12
+ // 5-10 MB of code). Instead we parse the `RAW_RUNTIME_STATE` JSON literal
13
+ // at the top of `.pnp.cjs` — that's the entire resolution table. Every
14
+ // package's `packageDependencies` lists the *resolved reference* for each
15
+ // requested dependency, so we don't need to run version selection: we just
16
+ // look up the locator and return its `packageLocation`.
17
+ //
18
+ // What this implementation supports
19
+ // ---------------------------------
20
+ //
21
+ // * `linkType: "SOFT"` packages — workspaces and unplugged deps; their
22
+ // `packageLocation` is a real on-disk directory, so resolved paths are
23
+ // usable directly with `readFileSync` / `Gio.File`.
24
+ // * `linkType: "HARD"` packages — zip-cached deps. The resolved path
25
+ // points into a `.yarn/cache/<pkg>.zip/...` virtual directory; reads
26
+ // against it require the `@yarnpkg/fslib` ZipFS layer that the gjsify
27
+ // plugin uses at build time. We surface the path so callers can
28
+ // decide what to do with it; runtime read support for zips is tracked
29
+ // separately in gjsify STATUS.md.
30
+ //
31
+ // What is NOT supported
32
+ // ---------------------
33
+ //
34
+ // * Resolution of bare specifiers FROM a PnP-virtual location (e.g. a
35
+ // module loaded from inside a zip resolving its own dependencies).
36
+ // We could reach this via `dependencyTreeRoots` indirection but no
37
+ // current consumer needs it.
38
+ // * Conditional exports / package.json `exports` map. We respect
39
+ // `main`/`module`/`/<sub-path>` splits like the rest of
40
+ // `@gjsify/module`'s resolver, but we don't honour `exports`-only
41
+ // entries.
42
+
43
+ import Gio from '@girs/gio-2.0';
44
+ import GLib from '@girs/glib-2.0';
45
+
46
+ interface PnpPackageInformation {
47
+ packageLocation: string;
48
+ packageDependencies?: ReadonlyArray<readonly [string, string | null]>;
49
+ linkType: 'SOFT' | 'HARD';
50
+ discardFromLookup?: boolean;
51
+ }
52
+
53
+ interface PnpRuntimeState {
54
+ packageRegistryData: ReadonlyArray<
55
+ readonly [
56
+ string | null,
57
+ ReadonlyArray<readonly [string | null, PnpPackageInformation]>,
58
+ ]
59
+ >;
60
+ }
61
+
62
+ /**
63
+ * Parsed manifest, indexed for O(1) lookup. Cached per file path so repeat
64
+ * `createRequire(...)` calls on the same workspace don't re-parse.
65
+ */
66
+ export interface PnpManifest {
67
+ /** Absolute path of the `.pnp.cjs` file's parent directory. */
68
+ readonly rootDir: string;
69
+ /** Map<packageName, Map<packageReference, info>> — null name = workspace root. */
70
+ readonly packages: Map<
71
+ string | null,
72
+ Map<string | null, PnpPackageInformation>
73
+ >;
74
+ /** Locator-by-location reverse index for "which package owns this path". */
75
+ readonly locatorsByLocation: Map<
76
+ string,
77
+ { name: string | null; reference: string | null }
78
+ >;
79
+ }
80
+
81
+ const manifestCache = new Map<string, PnpManifest | null>();
82
+
83
+ /** Walk up from `startDir` looking for the nearest `.pnp.cjs`. */
84
+ export function findPnpManifest(startDir: string): string | null {
85
+ let dir = Gio.File.new_for_path(startDir);
86
+ while (dir.has_parent(null)) {
87
+ const candidate = dir.resolve_relative_path('.pnp.cjs');
88
+ if (candidate.query_exists(null)) return candidate.get_path();
89
+ dir = dir.get_parent()!;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /** Read + parse `.pnp.cjs`'s `RAW_RUNTIME_STATE`. Cached. */
95
+ export function loadPnpManifest(pnpCjsPath: string): PnpManifest | null {
96
+ const cached = manifestCache.get(pnpCjsPath);
97
+ if (cached !== undefined) return cached;
98
+
99
+ const file = Gio.File.new_for_path(pnpCjsPath);
100
+ let text: string;
101
+ try {
102
+ const [ok, bytes] = file.load_contents(null);
103
+ if (!ok) {
104
+ manifestCache.set(pnpCjsPath, null);
105
+ return null;
106
+ }
107
+ text = new TextDecoder().decode(bytes);
108
+ } catch {
109
+ manifestCache.set(pnpCjsPath, null);
110
+ return null;
111
+ }
112
+
113
+ const state = extractRawRuntimeState(text);
114
+ if (!state) {
115
+ manifestCache.set(pnpCjsPath, null);
116
+ return null;
117
+ }
118
+
119
+ const rootDir = GLib.path_get_dirname(pnpCjsPath);
120
+ const packages = new Map<
121
+ string | null,
122
+ Map<string | null, PnpPackageInformation>
123
+ >();
124
+ const locatorsByLocation = new Map<
125
+ string,
126
+ { name: string | null; reference: string | null }
127
+ >();
128
+
129
+ for (const [name, store] of state.packageRegistryData) {
130
+ const inner = new Map<string | null, PnpPackageInformation>();
131
+ for (const [reference, info] of store) {
132
+ inner.set(reference, info);
133
+ if (!info.discardFromLookup) {
134
+ // Yarn keys locations on the trailing-slash form; preserve it.
135
+ locatorsByLocation.set(info.packageLocation, { name, reference });
136
+ }
137
+ }
138
+ packages.set(name, inner);
139
+ }
140
+
141
+ const manifest: PnpManifest = { rootDir, packages, locatorsByLocation };
142
+ manifestCache.set(pnpCjsPath, manifest);
143
+ return manifest;
144
+ }
145
+
146
+ /**
147
+ * Strip the line-continuations (`\\\n`) Yarn writes around the JSON literal
148
+ * and JSON.parse the result. Returns null if we can't find the literal.
149
+ */
150
+ function extractRawRuntimeState(text: string): PnpRuntimeState | null {
151
+ // The literal is `const RAW_RUNTIME_STATE =\n'<json-with-line-conts>';`
152
+ // followed by another JS statement on a new line. Match it directly.
153
+ const start = text.indexOf("const RAW_RUNTIME_STATE =");
154
+ if (start < 0) return null;
155
+ const openQuote = text.indexOf("'", start);
156
+ if (openQuote < 0) return null;
157
+
158
+ // Find the closing `';` — line-continuations escape newlines, NOT
159
+ // single-quotes, so the next un-escaped `';` ends the literal.
160
+ let i = openQuote + 1;
161
+ while (i < text.length) {
162
+ const ch = text.charCodeAt(i);
163
+ if (ch === 0x5c /* \ */) {
164
+ // Skip the escaped char (line-continuation \\\n, escaped quote \', etc.)
165
+ i += 2;
166
+ continue;
167
+ }
168
+ if (ch === 0x27 /* ' */) {
169
+ // End of literal.
170
+ break;
171
+ }
172
+ i++;
173
+ }
174
+ if (i >= text.length) return null;
175
+
176
+ let raw = text.slice(openQuote + 1, i);
177
+ // Yarn's only escape in this literal is `\<newline>` (line continuation).
178
+ // Strip them; the result is valid JSON.
179
+ raw = raw.replace(/\\\n/g, '');
180
+ try {
181
+ return JSON.parse(raw) as PnpRuntimeState;
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Find which package owns `absolutePath`. Returns the locator + its info, or
189
+ * null when the path isn't covered by any package in the manifest.
190
+ *
191
+ * Uses longest-prefix-match against `packageLocation` entries (Yarn does the
192
+ * same in `findPackageLocator`).
193
+ */
194
+ export function findPackageOwning(
195
+ manifest: PnpManifest,
196
+ absolutePath: string,
197
+ ): {
198
+ locator: { name: string | null; reference: string | null };
199
+ info: PnpPackageInformation;
200
+ } | null {
201
+ const relPath = relativeFromRoot(manifest.rootDir, absolutePath);
202
+ if (relPath === null) return null;
203
+
204
+ let bestMatch: string | null = null;
205
+ for (const candidateLocation of manifest.locatorsByLocation.keys()) {
206
+ if (
207
+ relPath.startsWith(candidateLocation) &&
208
+ (bestMatch === null || candidateLocation.length > bestMatch.length)
209
+ ) {
210
+ bestMatch = candidateLocation;
211
+ }
212
+ }
213
+ if (bestMatch === null) return null;
214
+
215
+ const locator = manifest.locatorsByLocation.get(bestMatch)!;
216
+ const info = manifest.packages.get(locator.name)?.get(locator.reference);
217
+ if (!info) return null;
218
+ return { locator, info };
219
+ }
220
+
221
+ /**
222
+ * Resolve a bare specifier through PnP. Returns the absolute on-disk path,
223
+ * or null when the request can't be resolved this way.
224
+ *
225
+ * `id` is a bare specifier like `@scope/foo` or `@scope/foo/bar/baz.js`.
226
+ * `callerPath` is the absolute path of the file doing the require.
227
+ */
228
+ export function resolveBareViaPnp(
229
+ manifest: PnpManifest,
230
+ id: string,
231
+ callerPath: string,
232
+ ): string | null {
233
+ const owner = findPackageOwning(manifest, callerPath);
234
+ if (!owner) return null;
235
+
236
+ // Split `@scope/name/sub/path` → name=`@scope/name`, subPath=`sub/path`.
237
+ const { pkgName, subPath } = splitSpecifier(id);
238
+
239
+ const dep = owner.info.packageDependencies?.find(([name]) => name === pkgName);
240
+ if (!dep) return null;
241
+ const [, reference] = dep;
242
+ if (reference === null) return null;
243
+
244
+ const target = manifest.packages.get(pkgName)?.get(reference);
245
+ if (!target) return null;
246
+
247
+ // `packageLocation` is `./<rel>/` from manifest.rootDir, OR an absolute
248
+ // path under `.yarn/unplugged/...` for unplugged packages — Gio.File
249
+ // handles the join either way.
250
+ const baseFile = target.packageLocation.startsWith('/')
251
+ ? Gio.File.new_for_path(target.packageLocation)
252
+ : Gio.File.new_for_path(manifest.rootDir).resolve_relative_path(
253
+ stripLeadingDotSlash(target.packageLocation),
254
+ );
255
+ const finalFile = subPath
256
+ ? baseFile.resolve_relative_path(subPath)
257
+ : baseFile;
258
+ return finalFile.get_path();
259
+ }
260
+
261
+ /** Split `@scope/foo/sub/path` into `pkgName: '@scope/foo'`, `subPath: 'sub/path'`. */
262
+ function splitSpecifier(id: string): { pkgName: string; subPath: string } {
263
+ if (id.startsWith('@')) {
264
+ const slash1 = id.indexOf('/');
265
+ if (slash1 < 0) return { pkgName: id, subPath: '' };
266
+ const slash2 = id.indexOf('/', slash1 + 1);
267
+ if (slash2 < 0) return { pkgName: id, subPath: '' };
268
+ return { pkgName: id.slice(0, slash2), subPath: id.slice(slash2 + 1) };
269
+ }
270
+ const slash = id.indexOf('/');
271
+ if (slash < 0) return { pkgName: id, subPath: '' };
272
+ return { pkgName: id.slice(0, slash), subPath: id.slice(slash + 1) };
273
+ }
274
+
275
+ function stripLeadingDotSlash(p: string): string {
276
+ return p.startsWith('./') ? p.slice(2) : p;
277
+ }
278
+
279
+ /** Returns `<absolutePath>` minus `<rootDir>/`, with a trailing slash on
280
+ * directory components, matching Yarn's `packageLocation` keys. */
281
+ function relativeFromRoot(rootDir: string, absolutePath: string): string | null {
282
+ if (absolutePath === rootDir) return './';
283
+ const prefix = rootDir.endsWith('/') ? rootDir : rootDir + '/';
284
+ if (!absolutePath.startsWith(prefix)) return null;
285
+ return './' + absolutePath.slice(prefix.length);
286
+ }