@gjsify/rolldown-plugin-gjsify 0.4.24 → 0.4.26

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/gjs.js CHANGED
@@ -190,8 +190,8 @@ export const setupForGjs = async (input) => {
190
190
  // `firefox: 60 << 16` makes lightningcss flatten the source
191
191
  // into the subset GTK4 understands.
192
192
  cssAsStringPlugin({ targets: { firefox: 60 << 16 } }),
193
- nodeModulesPathRewritePlugin({ bundleDir }),
194
- processStubPlugin({ userBanner: input.userBanner }),
193
+ nodeModulesPathRewritePlugin({ bundleDir, runtimeResolve: format === 'esm' }),
194
+ processStubPlugin({ userBanner: input.userBanner, captureBundleUrl: format === 'esm' }),
195
195
  // resolveShebangLine returns null when disabled (false/undefined) and
196
196
  // the resolved line otherwise — also handles `${env:…}` expansion.
197
197
  (() => {
@@ -0,0 +1 @@
1
+ export declare const BUNDLE_URL_BANNER = "globalThis.__gjsifyBundleUrl??=import.meta.url;";
@@ -0,0 +1,13 @@
1
+ // One-line banner that captures the bundle's own module URL into a global,
2
+ // read by the module-resolve shim (shims/module-resolve.ts) as its
3
+ // `createRequire` anchor.
4
+ //
5
+ // It must run at the top of the entry chunk — the single point where
6
+ // `import.meta.url` is unambiguously the bundle's own URL, BEFORE the
7
+ // node-modules path rewriter (rewrite-node-modules-paths.ts) could have
8
+ // rewritten any per-module `import.meta.url`. `??=` keeps it idempotent and
9
+ // cheap (a second entry in the same realm is a no-op).
10
+ //
11
+ // ESM-only — uses `import.meta.url`. Orchestrators gate its inclusion on
12
+ // `format === 'esm'`, matching the `runtimeResolve` gate on the rewriter.
13
+ export const BUNDLE_URL_BANNER = 'globalThis.__gjsifyBundleUrl??=import.meta.url;';
@@ -24,5 +24,10 @@ export declare function composeBanner(stub: string, userBanner: string): string;
24
24
  export interface ProcessStubPluginOptions {
25
25
  /** User-supplied banner string. May contain a leading `#!shebang`. */
26
26
  userBanner?: string;
27
+ /**
28
+ * Prepend the bundle-URL anchor banner (read by the module-resolve shim).
29
+ * ESM-only — set by the orchestrator when `format === 'esm'`.
30
+ */
31
+ captureBundleUrl?: boolean;
27
32
  }
28
33
  export declare function processStubPlugin(options?: ProcessStubPluginOptions): Plugin;
@@ -1,3 +1,4 @@
1
+ import { BUNDLE_URL_BANNER } from './bundle-url-banner.js';
1
2
  export const GJS_PROCESS_STUB = 'if(typeof globalThis.process==="undefined"){' +
2
3
  'const _s=imports.system,_G=imports.gi.GLib;' +
3
4
  // process.hrtime needs a `.bigint` property attached to the function
@@ -50,7 +51,10 @@ export function composeBanner(stub, userBanner) {
50
51
  return shebang + stub + (rest ? '\n' + rest : '');
51
52
  }
52
53
  export function processStubPlugin(options = {}) {
53
- const banner = composeBanner(GJS_PROCESS_STUB, options.userBanner ?? '');
54
+ // The anchor capture must precede the process stub so it runs at the very
55
+ // top of the chunk (where `import.meta.url` is the bundle's own URL).
56
+ const stub = (options.captureBundleUrl ? BUNDLE_URL_BANNER : '') + GJS_PROCESS_STUB;
57
+ const banner = composeBanner(stub, options.userBanner ?? '');
54
58
  return {
55
59
  name: 'gjsify-process-stub',
56
60
  renderChunk: {
@@ -3,36 +3,52 @@ export declare const REWRITE_FILTER: RegExp;
3
3
  /** True when the rewriter wants to look at this path — node_modules + supported ext. */
4
4
  export declare function shouldRewrite(path: string): boolean;
5
5
  /**
6
- * Compute the directory the bundle's outfile lives in.
7
- *
8
- * For `import.meta.url` rewriting we emit a relative URL whose base is the
9
- * bundle's `import.meta.url` — so we need to know where the bundle will be
10
- * written. Both `output.file` and `output.dir` are accepted.
6
+ * Compute the directory the bundle's outfile lives in (used for the zip-resident
7
+ * check and the legacy relative paths). Both `output.file` and `output.dir` are
8
+ * accepted.
11
9
  */
12
10
  export declare function getBundleDirFromOutput(opts: {
13
11
  file?: string;
14
12
  dir?: string;
15
13
  }): string;
14
+ /**
15
+ * The package-qualified spec for a node_modules file: everything after the LAST
16
+ * `node_modules/` segment. This is what the runtime resolver feeds to
17
+ * `createRequire(...).resolve` (via the package root).
18
+ *
19
+ * ".../node_modules/typedoc/dist/lib/app.js" → "typedoc/dist/lib/app.js"
20
+ * ".../node_modules/@scope/name/sub.js" → "@scope/name/sub.js"
21
+ * ".../node_modules/a/node_modules/b/file.js" → "b/file.js"
22
+ */
23
+ export declare function extractPackageSpec(path: string): string;
16
24
  export interface RewriteResult {
17
25
  code: string;
18
26
  moduleType?: 'ts' | 'js';
19
27
  map?: null;
20
28
  }
21
29
  /**
22
- * Pure rewriter same body as the esbuild predecessor. Returns the rewritten
23
- * code (and module type for re-parsing) or `null` if the file doesn't reference
24
- * any of the patterns we care about.
30
+ * Pure rewriter. Returns the rewritten code (and module type for re-parsing) or
31
+ * `null` if the file references none of the patterns we care about.
32
+ *
33
+ * @param runtimeResolve When true (ESM output, banner present), on-disk ESM
34
+ * files resolve their location at runtime (case 1). When false, they fall back
35
+ * to the legacy build-relative rewrite (case 2).
25
36
  */
26
37
  export declare function rewriteContents(args: {
27
38
  path: string;
28
- }, srcInput: string, bundleDir: string): RewriteResult | null;
39
+ }, srcInput: string, bundleDir: string, runtimeResolve: boolean): RewriteResult | null;
29
40
  export interface NodeModulesPathRewriteOptions {
30
41
  /** Bundle output directory, derived from `output.file` / `output.dir`. */
31
42
  bundleDir: string;
43
+ /**
44
+ * Use runtime resolution for on-disk ESM files (case 1). Requires the
45
+ * bundle-URL banner — set by the orchestrator only for `format === 'esm'`.
46
+ * Defaults to false (legacy build-relative behavior).
47
+ */
48
+ runtimeResolve?: boolean;
32
49
  }
33
50
  /**
34
51
  * Build a Rolldown plugin that runs the path rewriter as a `transform(code, id)`
35
- * hook with `order: 'post'` — runs after the deepkit/blueprint/css pre-transforms
36
- * but still during module loading, before chunking.
52
+ * hook with `order: 'post'`.
37
53
  */
38
54
  export declare function nodeModulesPathRewritePlugin(options: NodeModulesPathRewriteOptions): Plugin;
@@ -1,119 +1,215 @@
1
- // Per-source rewriter for node_modules files that reference
2
- // `import.meta.url`, `__dirname`, or `__filename`. Mirrors the esbuild
3
- // predecessor's logic body is identical because the rewrite is purely
4
- // a string transform on already-loaded source. The only delta is the
5
- // host: a Rolldown `transform(code, id)` plugin instead of an esbuild
6
- // `onLoad` registered inside the PnP plugin.
1
+ // Per-source rewriter for `node_modules` files that reference `import.meta.url`,
2
+ // `__dirname`, or `__filename`. These tokens normally point at the file's own
3
+ // on-disk location; once the file is bundled, the bundler must rewrite them so
4
+ // runtime data-file reads (a dep loading its own i18n JSON, `package.json`,
5
+ // templates, ) still resolve.
7
6
  //
8
- // Why a separate plugin and not nested in the PnP loader, like esbuild?
9
- // Rolldown / Rollup's `transform` hooks all run in sequence on every
10
- // loaded module — there is no first-onLoad-wins race. So the PnP loader
11
- // (`@gjsify/rolldown-plugin-pnp`) is solely responsible for reading
12
- // zip-resident bytes; this plugin runs as a separate `transform` step
13
- // after the bytes have been loaded, regardless of which loader produced
14
- // them. No more F5-bug folklore.
7
+ // Cases, each with its own strategy:
8
+ //
9
+ // 1. on-disk ESM, runtime-resolve (`import.meta.url` present, not zip-resident,
10
+ // `format === 'esm'`)
11
+ // rewrite to RUNTIME resolution via the `module-resolve` shim. The shim
12
+ // resolves the file's `<pkg>/<subpath>` from the bundle's actual location
13
+ // at run time, so the bundle works wherever it ends up — crucially, after
14
+ // being PUBLISHED in an npm package (where it sits at a different
15
+ // `node_modules` depth than at build time). See module-resolve.ts. Relies
16
+ // on the bundle-URL banner (bundle-url-banner.ts) having captured the
17
+ // anchor, hence the `runtimeResolve` gate.
18
+ //
19
+ // 2. on-disk ESM, legacy (same, but a non-ESM output format where the banner
20
+ // can't run)
21
+ // → the original behavior: rewrite to a path relative to the bundle's BUILD
22
+ // location. Correct only while bundle ↔ node_modules keep their build-time
23
+ // arrangement; preserved unchanged for the rare `--format iife|cjs` app
24
+ // build that can't host the `import.meta.url` anchor banner.
25
+ //
26
+ // 3. PnP zip-resident (`import.meta.url`, path inside a `.zip/`)
27
+ // → the file lives inside a Yarn-PnP zip; `import.meta.url` stays the
28
+ // bundle's own URL and `__dirname`/`__filename` derive from it.
29
+ //
30
+ // 4. CJS (`__dirname`/`__filename` only, no `import.meta.url`)
31
+ // → declare them from the file's absolute build path. NOTE: still
32
+ // build-location-coupled and shares the "breaks when published" class of
33
+ // bug case (1) fixes; injecting an ESM import into a CJS module is unsafe,
34
+ // so a CJS-safe runtime-resolve is tracked as a follow-up. ESM deps (the
35
+ // common data-reader shape, e.g. typedoc) take path (1).
36
+ //
37
+ // Hosting: a Rolldown `transform(code, id)` hook with `order: 'post'` — runs
38
+ // after deepkit/blueprint/css pre-transforms but still during module loading,
39
+ // before chunking.
15
40
  import { dirname, join, relative, resolve } from 'node:path';
16
41
  import { inlineStaticReads } from '../utils/inline-static-reads.js';
17
42
  export const REWRITE_FILTER = /\.(m?js|cjs|[cm]?tsx?)$/;
18
43
  const DIRNAME_DECL_RE = /(?:var|let|const)\s+__dirname\b|export\s+(?:var|let|const)\s+__dirname\b/;
19
44
  const FILENAME_DECL_RE = /(?:var|let|const)\s+__filename\b|export\s+(?:var|let|const)\s+__filename\b/;
45
+ // Bare specifier of the runtime module-path resolver shim (see module-resolve.ts).
46
+ // No `.js` — must match the package's `exports` subpath key (exports maps are
47
+ // exact-match), mirroring the `./shims/console-gjs` convention.
48
+ const MODULE_RESOLVE_SHIM = '@gjsify/rolldown-plugin-gjsify/shims/module-resolve';
49
+ // Our own shims (module-resolve, console-gjs) get bundled into user output, so
50
+ // they live in `node_modules/@gjsify/rolldown-plugin-gjsify/{lib,src}/shims/`
51
+ // when consumed — but they must NEVER be rewritten. In particular the
52
+ // module-resolve shim DECLARES `__gjsifyModule*` and its comments mention
53
+ // `import.meta.url`; the naive token check below would otherwise treat that as
54
+ // a rewrite target and prepend a self-import that collides with its exports.
55
+ const GJSIFY_SHIM_RE = /[\\/]rolldown-plugin-gjsify[\\/](?:lib|src)[\\/]shims[\\/]/;
20
56
  /** True when the rewriter wants to look at this path — node_modules + supported ext. */
21
57
  export function shouldRewrite(path) {
22
- return path.includes('node_modules') && REWRITE_FILTER.test(path);
58
+ if (!path.includes('node_modules') || !REWRITE_FILTER.test(path))
59
+ return false;
60
+ if (GJSIFY_SHIM_RE.test(path))
61
+ return false;
62
+ return true;
23
63
  }
24
64
  /**
25
- * Compute the directory the bundle's outfile lives in.
26
- *
27
- * For `import.meta.url` rewriting we emit a relative URL whose base is the
28
- * bundle's `import.meta.url` — so we need to know where the bundle will be
29
- * written. Both `output.file` and `output.dir` are accepted.
65
+ * Compute the directory the bundle's outfile lives in (used for the zip-resident
66
+ * check and the legacy relative paths). Both `output.file` and `output.dir` are
67
+ * accepted.
30
68
  */
31
69
  export function getBundleDirFromOutput(opts) {
32
70
  const outFile = opts.file ?? join(opts.dir ?? '.', 'bundle.mjs');
33
71
  return dirname(resolve(outFile));
34
72
  }
35
- /** Pick the per-file loader Rolldown should re-parse with. */
73
+ /** Pick the per-file loader Rolldown should re-parse the rewritten code with. */
36
74
  function moduleTypeForPath(path) {
37
75
  const ext = path.split('.').pop() ?? 'js';
38
76
  return ['ts', 'mts', 'cts', 'tsx'].includes(ext) ? 'ts' : 'js';
39
77
  }
40
- function buildDirFilenamePreamble(args) {
41
- const lines = [];
42
- if (args.needDirname && !args.dirnameDeclared) {
43
- if (args.kind === 'esm-zip') {
44
- lines.push(`var __dirname = new URL(".", import.meta.url).pathname.replace(/\\/$/, "");`);
45
- }
46
- else if (args.kind === 'esm-relative') {
47
- lines.push(`var __dirname = new URL(${JSON.stringify(args.relDirWithSlash)}, import.meta.url).pathname.replace(/\\/$/, "");`);
48
- }
49
- else {
50
- lines.push(`var __dirname = ${JSON.stringify(args.sourceDir)};`);
51
- }
78
+ /**
79
+ * The package-qualified spec for a node_modules file: everything after the LAST
80
+ * `node_modules/` segment. This is what the runtime resolver feeds to
81
+ * `createRequire(...).resolve` (via the package root).
82
+ *
83
+ * ".../node_modules/typedoc/dist/lib/app.js" → "typedoc/dist/lib/app.js"
84
+ * ".../node_modules/@scope/name/sub.js" "@scope/name/sub.js"
85
+ * ".../node_modules/a/node_modules/b/file.js" → "b/file.js"
86
+ */
87
+ export function extractPackageSpec(path) {
88
+ const marker = 'node_modules/';
89
+ const idx = path.lastIndexOf(marker);
90
+ return idx < 0 ? path : path.slice(idx + marker.length);
91
+ }
92
+ /** Whether the file needs a `var __dirname`/`__filename` declaration injected. */
93
+ function needsDirnameDecl(src, flags) {
94
+ return flags.hasDirname && !DIRNAME_DECL_RE.test(src);
95
+ }
96
+ function needsFilenameDecl(src, flags) {
97
+ return flags.hasFilename && !FILENAME_DECL_RE.test(src);
98
+ }
99
+ /** Prepend preamble + (optional) shim import to the source. */
100
+ function withPreamble(src, lines, importHeader) {
101
+ const parts = importHeader ? [importHeader, ...lines, src] : [...lines, src];
102
+ return parts.join('\n');
103
+ }
104
+ /**
105
+ * Case 1 — on-disk ESM, runtime-resolve. Rewrite the file to resolve its own
106
+ * location at runtime via the module-resolve shim — location-independent, so it
107
+ * survives being published/relocated. The fix for the "works in the workspace,
108
+ * crashes once installed" class of bug.
109
+ */
110
+ function rewriteOnDiskEsm(src, path, flags) {
111
+ const spec = JSON.stringify(extractPackageSpec(path));
112
+ // Collect only the named imports we actually use — tree-shaking has nothing
113
+ // to prune and the import line stays honest.
114
+ const used = ['__gjsifyModuleUrl'];
115
+ const preamble = [];
116
+ if (needsDirnameDecl(src, flags)) {
117
+ preamble.push(`var __dirname = __gjsifyModuleDir(${spec});`);
118
+ used.push('__gjsifyModuleDir');
52
119
  }
53
- if (args.needFilename && !args.filenameDeclared) {
54
- if (args.kind === 'esm-zip') {
55
- lines.push(`var __filename = new URL(import.meta.url).pathname;`);
56
- }
57
- else if (args.kind === 'esm-relative') {
58
- lines.push(`var __filename = new URL(${JSON.stringify(args.relPath)}, import.meta.url).pathname;`);
59
- }
60
- else {
61
- lines.push(`var __filename = ${JSON.stringify(args.sourcePath)};`);
62
- }
120
+ if (needsFilenameDecl(src, flags)) {
121
+ preamble.push(`var __filename = __gjsifyModuleFile(${spec});`);
122
+ used.push('__gjsifyModuleFile');
123
+ }
124
+ const code = src.replace(/\bimport\.meta\.url\b/g, `__gjsifyModuleUrl(${spec})`);
125
+ const header = `import { ${used.join(', ')} } from ${JSON.stringify(MODULE_RESOLVE_SHIM)};`;
126
+ return { code: withPreamble(code, preamble, header), moduleType: moduleTypeForPath(path) };
127
+ }
128
+ /**
129
+ * Case 2 — on-disk ESM, legacy. Original build-relative behavior, kept for the
130
+ * rare non-ESM output format that can't host the bundle-URL anchor banner.
131
+ */
132
+ function rewriteOnDiskEsmLegacy(src, path, bundleDir, flags) {
133
+ const relPath = relative(bundleDir, path);
134
+ const relDirWithSlash = (relative(bundleDir, dirname(path)) || '.') + '/';
135
+ const preamble = [];
136
+ if (needsDirnameDecl(src, flags)) {
137
+ preamble.push(`var __dirname = new URL(${JSON.stringify(relDirWithSlash)}, import.meta.url).pathname.replace(/\\/$/, "");`);
63
138
  }
64
- return lines;
139
+ if (needsFilenameDecl(src, flags)) {
140
+ preamble.push(`var __filename = new URL(${JSON.stringify(relPath)}, import.meta.url).pathname;`);
141
+ }
142
+ const code = src.replace(/\bimport\.meta\.url\b/g, `new URL(${JSON.stringify(relPath)}, import.meta.url).href`);
143
+ return { code: withPreamble(code, preamble), moduleType: moduleTypeForPath(path) };
65
144
  }
66
145
  /**
67
- * Pure rewritersame body as the esbuild predecessor. Returns the rewritten
68
- * code (and module type for re-parsing) or `null` if the file doesn't reference
69
- * any of the patterns we care about.
146
+ * Case 3PnP zip-resident. Keep `import.meta.url` as the bundle's own URL and
147
+ * derive `__dirname`/`__filename` from it.
70
148
  */
71
- export function rewriteContents(args, srcInput, bundleDir) {
149
+ function rewriteZipResident(src, path, flags) {
150
+ const preamble = [];
151
+ if (needsDirnameDecl(src, flags)) {
152
+ preamble.push(`var __dirname = new URL(".", import.meta.url).pathname.replace(/\\/$/, "");`);
153
+ }
154
+ if (needsFilenameDecl(src, flags)) {
155
+ preamble.push(`var __filename = new URL(import.meta.url).pathname;`);
156
+ }
157
+ return { code: withPreamble(src, preamble), moduleType: moduleTypeForPath(path) };
158
+ }
159
+ /**
160
+ * Case 4 — CJS (no `import.meta.url`). Declare `__dirname`/`__filename` from the
161
+ * absolute build path. Build-location-coupled — see file header note.
162
+ */
163
+ function rewriteCjsAbsolute(src, path, flags) {
164
+ const preamble = [];
165
+ if (needsDirnameDecl(src, flags)) {
166
+ preamble.push(`var __dirname = ${JSON.stringify(dirname(path))};`);
167
+ }
168
+ if (needsFilenameDecl(src, flags)) {
169
+ preamble.push(`var __filename = ${JSON.stringify(path)};`);
170
+ }
171
+ return { code: withPreamble(src, preamble), moduleType: moduleTypeForPath(path) };
172
+ }
173
+ /**
174
+ * Pure rewriter. Returns the rewritten code (and module type for re-parsing) or
175
+ * `null` if the file references none of the patterns we care about.
176
+ *
177
+ * @param runtimeResolve When true (ESM output, banner present), on-disk ESM
178
+ * files resolve their location at runtime (case 1). When false, they fall back
179
+ * to the legacy build-relative rewrite (case 2).
180
+ */
181
+ export function rewriteContents(args, srcInput, bundleDir, runtimeResolve) {
72
182
  if (!shouldRewrite(args.path))
73
183
  return null;
74
184
  // Step 1: inline statically-resolvable filesystem reads.
75
185
  const inlined = inlineStaticReads(srcInput, args.path);
76
186
  const src = inlined.contents;
77
- const hasMetaUrl = src.includes('import.meta.url');
78
- const hasDirname = src.includes('__dirname');
79
- const hasFilename = src.includes('__filename');
80
- if (!hasMetaUrl && !hasDirname && !hasFilename) {
81
- if (inlined.inlined === 0)
82
- return null;
83
- return { code: src, moduleType: moduleTypeForPath(args.path) };
187
+ const flags = {
188
+ hasMetaUrl: src.includes('import.meta.url'),
189
+ hasDirname: src.includes('__dirname'),
190
+ hasFilename: src.includes('__filename'),
191
+ };
192
+ if (!flags.hasMetaUrl && !flags.hasDirname && !flags.hasFilename) {
193
+ // No tokens to rewrite — emit only if inlining changed something.
194
+ return inlined.inlined > 0 ? { code: src, moduleType: moduleTypeForPath(args.path) } : null;
84
195
  }
85
- // Step 2: classify rewrite kind.
86
- const dir = dirname(args.path);
87
- const relPath = hasMetaUrl ? relative(bundleDir, args.path) : '';
88
- const isZipResident = hasMetaUrl && relPath.includes('.zip/');
89
- const kind = !hasMetaUrl ? 'cjs-absolute' : isZipResident ? 'esm-zip' : 'esm-relative';
90
- const preamble = buildDirFilenamePreamble({
91
- needDirname: hasDirname,
92
- needFilename: hasFilename,
93
- dirnameDeclared: DIRNAME_DECL_RE.test(src),
94
- filenameDeclared: FILENAME_DECL_RE.test(src),
95
- kind,
96
- sourcePath: args.path,
97
- sourceDir: dir,
98
- relPath,
99
- relDirWithSlash: (relative(bundleDir, dir) || '.') + '/',
100
- });
101
- // Step 3: rewrite import.meta.url for the regular esm-relative case.
102
- let code = src;
103
- if (kind === 'esm-relative') {
104
- const runtimeFileUrl = `new URL(${JSON.stringify(relPath)}, import.meta.url)`;
105
- code = code.replace(/\bimport\.meta\.url\b/g, `${runtimeFileUrl}.href`);
196
+ // Step 2: dispatch by case (see file header).
197
+ if (flags.hasMetaUrl) {
198
+ if (relative(bundleDir, args.path).includes('.zip/')) {
199
+ return rewriteZipResident(src, args.path, flags);
200
+ }
201
+ return runtimeResolve
202
+ ? rewriteOnDiskEsm(src, args.path, flags)
203
+ : rewriteOnDiskEsmLegacy(src, args.path, bundleDir, flags);
106
204
  }
107
- if (preamble.length > 0)
108
- code = preamble.join('\n') + '\n' + code;
109
- return { code, moduleType: moduleTypeForPath(args.path) };
205
+ return rewriteCjsAbsolute(src, args.path, flags);
110
206
  }
111
207
  /**
112
208
  * Build a Rolldown plugin that runs the path rewriter as a `transform(code, id)`
113
- * hook with `order: 'post'` — runs after the deepkit/blueprint/css pre-transforms
114
- * but still during module loading, before chunking.
209
+ * hook with `order: 'post'`.
115
210
  */
116
211
  export function nodeModulesPathRewritePlugin(options) {
212
+ const runtimeResolve = options.runtimeResolve ?? false;
117
213
  return {
118
214
  name: 'gjsify-node-modules-path-rewrite',
119
215
  transform: {
@@ -122,7 +218,7 @@ export function nodeModulesPathRewritePlugin(options) {
122
218
  handler(code, id) {
123
219
  if (!id.includes('node_modules'))
124
220
  return null;
125
- const result = rewriteContents({ path: id }, code, options.bundleDir);
221
+ const result = rewriteContents({ path: id }, code, options.bundleDir, runtimeResolve);
126
222
  if (!result)
127
223
  return null;
128
224
  return { code: result.code, map: null };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Split a bundled-file spec into its package name and the path within the
3
+ * package. Handles scoped names:
4
+ * "typedoc/dist/lib/app.js" → { pkg: "typedoc", subpath: "dist/lib/app.js" }
5
+ * "@scope/name/sub/file.js" → { pkg: "@scope/name", subpath: "sub/file.js" }
6
+ * "typedoc" → { pkg: "typedoc", subpath: "" }
7
+ */
8
+ export declare function splitPackageSpec(spec: string): {
9
+ pkg: string;
10
+ subpath: string;
11
+ };
12
+ /** Absolute path of the bundled dep file `<pkg>/<subpath>` at runtime. */
13
+ export declare function __gjsifyModuleFile(spec: string): string;
14
+ /** `file://` URL of `<pkg>/<subpath>` — replaces a rewritten `import.meta.url`. */
15
+ export declare function __gjsifyModuleUrl(spec: string): string;
16
+ /** Directory of `<pkg>/<subpath>` — replaces a rewritten `__dirname`. */
17
+ export declare function __gjsifyModuleDir(spec: string): string;
@@ -0,0 +1,140 @@
1
+ // Runtime resolver for the on-disk location of a bundled `node_modules` file.
2
+ //
3
+ // Why this exists
4
+ // ---------------
5
+ // `rewrite-node-modules-paths.ts` rewrites a bundled module's `import.meta.url`
6
+ // / `__dirname` / `__filename` so that dynamic data-file reads (a dep loading
7
+ // its own i18n JSON, `package.json`, templates, …) still find those files on
8
+ // disk after bundling. The naive rewrite baked a path relative to the bundle's
9
+ // BUILD location, which is only correct while the bundle and `node_modules/`
10
+ // keep the arrangement they had at build time. Once the bundle is PUBLISHED in
11
+ // an npm package and installed at `node_modules/<pkg>/bin/<bundle>`, that baked
12
+ // path lands one `node_modules` level too deep → ENOENT at runtime.
13
+ //
14
+ // Instead we resolve the dep at RUNTIME from the bundle's actual location, so
15
+ // it works wherever the bundle ends up.
16
+ //
17
+ // Three correctness constraints shape the implementation:
18
+ //
19
+ // 1. *Location anchor.* The resolver must anchor at the BUNDLE's URL, not its
20
+ // own. When a consumer bundles this shim it lives under
21
+ // `node_modules/@gjsify/rolldown-plugin-gjsify/...`, so the path rewriter
22
+ // would rewrite this file's own `import.meta.url` too. We therefore take no
23
+ // `import.meta.url` here and read the anchor from `globalThis.
24
+ // __gjsifyBundleUrl`, captured by a one-line banner at byte 0 of the bundle —
25
+ // the single point where `import.meta.url` is unambiguously the bundle's URL.
26
+ //
27
+ // 2. *exports-map safety.* `createRequire(...).resolve("<pkg>/<deep/path>")` is
28
+ // rejected by strict `"exports"` maps under Node's native `createRequire`
29
+ // (the `--app node` target), and `@gjsify/module` is exports-aware too. So
30
+ // we never resolve the deep path directly: we resolve the PACKAGE ROOT and
31
+ // join the subpath literally, which no `exports` map can block.
32
+ //
33
+ // 3. *Lazy / self-contained safety.* `import.meta.url` is normally a passive
34
+ // string — the extremely common `createRequire(import.meta.url)` pattern
35
+ // does NOT require the URL to point at an existing file (createRequire only
36
+ // resolves later, lazily). A self-contained bootstrap bundle (e.g. gjsify's
37
+ // own committed `cli.gjs.mjs`, which runs to CREATE `node_modules` before it
38
+ // exists) bundles its deps' code but has no `node_modules` at runtime. So
39
+ // resolution must never THROW: when a dep can't be resolved we fall back to
40
+ // the bundle's own location, keeping `createRequire(import.meta.url)` /
41
+ // `new URL(rel, import.meta.url)` valid (lazy) instead of crashing — exactly
42
+ // the pre-fix semantics for that case.
43
+ //
44
+ // @ts-ignore — `node:{module,url,path}` are resolved by the consumer's
45
+ // `gjsify build` run (aliased to `@gjsify/{module,url,path}`), not by tsc here.
46
+ import { createRequire } from 'node:module';
47
+ // @ts-ignore — see above.
48
+ import { pathToFileURL, fileURLToPath } from 'node:url';
49
+ // @ts-ignore — see above.
50
+ import { dirname, join } from 'node:path';
51
+ /**
52
+ * Split a bundled-file spec into its package name and the path within the
53
+ * package. Handles scoped names:
54
+ * "typedoc/dist/lib/app.js" → { pkg: "typedoc", subpath: "dist/lib/app.js" }
55
+ * "@scope/name/sub/file.js" → { pkg: "@scope/name", subpath: "sub/file.js" }
56
+ * "typedoc" → { pkg: "typedoc", subpath: "" }
57
+ */
58
+ export function splitPackageSpec(spec) {
59
+ const parts = spec.split('/');
60
+ const segments = spec.startsWith('@') ? 2 : 1;
61
+ return {
62
+ pkg: parts.slice(0, segments).join('/'),
63
+ subpath: parts.slice(segments).join('/'),
64
+ };
65
+ }
66
+ /** The bundle's own URL (set by the byte-0 banner). Throws only if the banner didn't run. */
67
+ function bundleAnchorUrl() {
68
+ const anchor = globalThis.__gjsifyBundleUrl;
69
+ if (!anchor) {
70
+ throw new Error('gjsify: __gjsifyBundleUrl is not set — the bundle-URL banner did not run. ' +
71
+ 'The module-resolve shim is only valid in single-file app builds (gjsify build --app gjs|node).');
72
+ }
73
+ return anchor;
74
+ }
75
+ // `createRequire` anchored at the bundle URL — built lazily so the banner is
76
+ // guaranteed to have run, and so a build that never hits a rewritten read pays
77
+ // nothing.
78
+ let _resolve = null;
79
+ function bundleResolve() {
80
+ if (!_resolve)
81
+ _resolve = createRequire(bundleAnchorUrl()).resolve;
82
+ return _resolve;
83
+ }
84
+ /**
85
+ * Find the on-disk root directory of `pkg`, exports-map-agnostic.
86
+ *
87
+ * `package.json` is resolvable for nearly every package and points straight at
88
+ * the root. When a strict `exports` map blocks it, fall back to the package's
89
+ * main entry (always an export) and derive the root from the
90
+ * `node_modules/<pkg>` boundary in its path. Throws when `pkg` is not installed.
91
+ */
92
+ function resolvePackageRoot(pkg) {
93
+ const resolve = bundleResolve();
94
+ try {
95
+ return dirname(resolve(`${pkg}/package.json`));
96
+ }
97
+ catch {
98
+ const main = resolve(pkg);
99
+ const marker = `/node_modules/${pkg}/`;
100
+ const idx = main.lastIndexOf(marker);
101
+ return idx >= 0 ? main.slice(0, idx + marker.length - 1) : dirname(main);
102
+ }
103
+ }
104
+ // Per-spec memo — resolution walks the filesystem and bundled deps read their
105
+ // data files repeatedly (per-locale i18n lookups, etc.).
106
+ const _cache = new Map();
107
+ /**
108
+ * Absolute on-disk path for a bundled dep file `<pkg>/<subpath>`, or — when the
109
+ * dep can't be resolved at runtime (no `node_modules`, e.g. a self-contained
110
+ * bootstrap bundle) — the bundle's own path as a non-throwing fallback. See
111
+ * constraint 3 in the file header.
112
+ */
113
+ function resolveFile(spec) {
114
+ const cached = _cache.get(spec);
115
+ if (cached !== undefined)
116
+ return cached;
117
+ let abs;
118
+ try {
119
+ const { pkg, subpath } = splitPackageSpec(spec);
120
+ const root = resolvePackageRoot(pkg);
121
+ abs = subpath ? join(root, subpath) : root;
122
+ }
123
+ catch {
124
+ abs = fileURLToPath(bundleAnchorUrl());
125
+ }
126
+ _cache.set(spec, abs);
127
+ return abs;
128
+ }
129
+ /** Absolute path of the bundled dep file `<pkg>/<subpath>` at runtime. */
130
+ export function __gjsifyModuleFile(spec) {
131
+ return resolveFile(spec);
132
+ }
133
+ /** `file://` URL of `<pkg>/<subpath>` — replaces a rewritten `import.meta.url`. */
134
+ export function __gjsifyModuleUrl(spec) {
135
+ return pathToFileURL(resolveFile(spec)).href;
136
+ }
137
+ /** Directory of `<pkg>/<subpath>` — replaces a rewritten `__dirname`. */
138
+ export function __gjsifyModuleDir(spec) {
139
+ return dirname(resolveFile(spec));
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/rolldown-plugin-gjsify",
3
- "version": "0.4.24",
3
+ "version": "0.4.26",
4
4
  "description": "Rolldown / Rollup / Vite plugin orchestrator for GJS, Node, and Browser targets",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -17,6 +17,9 @@
17
17
  },
18
18
  "./shims/console-gjs": {
19
19
  "default": "./lib/shims/console-gjs.js"
20
+ },
21
+ "./shims/module-resolve": {
22
+ "default": "./lib/shims/module-resolve.js"
20
23
  }
21
24
  },
22
25
  "files": [
@@ -45,11 +48,11 @@
45
48
  ],
46
49
  "license": "MIT",
47
50
  "dependencies": {
48
- "@gjsify/console": "^0.4.24",
49
- "@gjsify/resolve-npm": "^0.4.24",
50
- "@gjsify/rolldown-plugin-deepkit": "^0.4.24",
51
- "@gjsify/rolldown-plugin-pnp": "^0.4.24",
52
- "@gjsify/vite-plugin-blueprint": "^0.4.24",
51
+ "@gjsify/console": "^0.4.26",
52
+ "@gjsify/resolve-npm": "^0.4.26",
53
+ "@gjsify/rolldown-plugin-deepkit": "^0.4.26",
54
+ "@gjsify/rolldown-plugin-pnp": "^0.4.26",
55
+ "@gjsify/vite-plugin-blueprint": "^0.4.26",
53
56
  "@rollup/pluginutils": "^5.3.0",
54
57
  "acorn": "^8.16.0",
55
58
  "acorn-walk": "^8.3.5",
@@ -57,7 +60,7 @@
57
60
  "lightningcss": "^1.32.0"
58
61
  },
59
62
  "peerDependencies": {
60
- "@gjsify/lightningcss-native": "^0.4.24",
63
+ "@gjsify/lightningcss-native": "^0.4.26",
61
64
  "rolldown": "^1.0.0-rc.18"
62
65
  },
63
66
  "peerDependenciesMeta": {