Skip to content

feat(bundler): add generate-manifest subprocess with tsx injection#5881

Open
killagu wants to merge 1 commit intosplit/09-bundler-manifest-loaderfrom
split/10-bundler-generate-manifest
Open

feat(bundler): add generate-manifest subprocess with tsx injection#5881
killagu wants to merge 1 commit intosplit/09-bundler-manifest-loaderfrom
split/10-bundler-generate-manifest

Conversation

@killagu
Copy link
Copy Markdown
Contributor

@killagu killagu commented Apr 21, 2026

Adds scripts/generate-manifest.mjs (59 lines). ManifestLoader uses spawn() (not fork) with --import to inject tsx loader, resolves framework entry from package.json exports['.'], passes explicit file:// URL as frameworkEntry.

Part of #5863 split. Tracking: #5871.

🤖 Generated with Claude Code

…tion

Add scripts/generate-manifest.mjs which boots the egg framework to
produce a manifest via app.loader.generateManifest() and writes it
through ManifestStore.write(). The script accepts both:

- `framework` (absolute package dir) — forwarded to framework.start()
  so egg's resolveFrameworkClasses() keeps working via importResolve.
- `frameworkEntry` (file:// URL) — used for the subprocess's initial
  dynamic import(), which is the only way to load a workspace-linked
  framework whose `exports` map points at a TypeScript source.

ManifestLoader changes to drive the script:

- Switch from fork() to spawn(): tsx's ESM loader has resolver issues
  inside Node 22's IPC hooks-worker, causing workspace-linked packages
  with `exports: "./src/*.ts"` to fall back to directory resolution.
- #buildExecArgv() injects `--import=file://.../tsx/dist/esm/index.mjs`
  unless a tsx loader is already present. The detection regex accepts
  bare specifiers and absolute file:// URLs to prevent recursive
  duplication.
- #resolveFrameworkEntryUrl() reads the framework package.json's
  exports['.'] (string or conditional) and falls back to module/main,
  returning an absolute file:// URL.

tsdown.config.ts: land the script at dist/scripts/generate-manifest.mjs
via a copy rule so the subprocess resolves correctly in both dev
(workspace link) and publish (tgz install) forms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 21, 2026 15:19
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 21, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 09b4fe5a-96ca-4e89-8e0a-403b097b692c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch split/10-bundler-generate-manifest

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the manifest generation process by switching from fork to spawn to avoid IPC-related ESM resolution issues in Node 22. It introduces a new generate-manifest.mjs script and logic to resolve framework entry points from package.json. Reviewers identified that the exports resolution logic fails for shorthand condition maps and noted a potential risk of exceeding command-line argument length limits when passing JSON payloads.

Comment on lines +236 to +250
} else if (pkg.exports && typeof pkg.exports === 'object') {
const dot = (pkg.exports as Record<string, unknown>)['.'];
if (typeof dot === 'string') {
entryRel = dot;
} else if (dot && typeof dot === 'object') {
const cond = dot as Record<string, unknown>;
for (const key of ['import', 'module', 'default'] as const) {
const val = cond[key];
if (typeof val === 'string') {
entryRel = val;
break;
}
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The exports resolution logic is incomplete. According to the Node.js specification, if exports is an object and does not contain keys starting with ., the object itself is treated as the mapping for the root (.) entry. The current implementation only looks for a . key, which will fail for packages using the shorthand condition map (e.g., "exports": { "import": "..." }).

      } else if (pkg.exports && typeof pkg.exports === 'object') {
        const keys = Object.keys(pkg.exports);
        const isConditionalMap = keys.length > 0 && !keys.some(k => k.startsWith('.'));
        const dot = isConditionalMap ? pkg.exports : (pkg.exports as Record<string, unknown>)['.'];

        if (typeof dot === 'string') {
          entryRel = dot;
        } else if (dot && typeof dot === 'object') {
          const cond = dot as Record<string, unknown>;
          for (const key of ['import', 'module', 'require', 'default'] as const) {
            const val = cond[key];
            if (typeof val === 'string') {
              entryRel = val;
              break;
            }
          }
        }
      }

// packages with `exports: "./src/*.ts"` to fall back to directory resolution.
await new Promise<void>((resolve, reject) => {
const child = fork(scriptPath, [JSON.stringify(payload)], {
const child = spawn(process.execPath, [...execArgv, scriptPath, JSON.stringify(payload)], {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Passing a large JSON string as a command-line argument can potentially exceed the operating system's maximum argument length limit (ARG_MAX). While the current payload might be small, it's safer to pass this data via an environment variable or stdin to ensure scalability as the project grows.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds a dedicated manifest-generation subprocess script and updates ManifestLoader to use spawn() with tsx ESM loader injection, enabling manifest generation when workspace-linked frameworks export TypeScript sources.

Changes:

  • Add src/scripts/generate-manifest.mjs and ensure it’s copied into dist/.
  • Switch manifest generation from fork() to spawn() and inject tsx/esm via --import=... when needed.
  • Resolve and pass an explicit file:// framework entry URL derived from package.json exports['.'].

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
tools/egg-bundler/tsdown.config.ts Copies the new manifest-generation script into the build output.
tools/egg-bundler/src/scripts/generate-manifest.mjs New subprocess entrypoint that starts the framework and writes the manifest.
tools/egg-bundler/src/lib/ManifestLoader.ts Uses spawn() + --import injection and resolves frameworkEntry from exports.

env: this.#env,
scope: this.#scope,
};
debug('fork generate-manifest: %o', payload);
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The debug message still says 'fork' but the implementation now uses spawn(). Update the log string to match the actual behavior so debugging output is not misleading.

Suggested change
debug('fork generate-manifest: %o', payload);
debug('spawn generate-manifest: %o', payload);

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +32
if (options.frameworkEntry) {
framework = await import(options.frameworkEntry);
} else if (options.framework) {
framework = await import(options.framework);
} else {
framework = await import('egg');
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

options.framework (as constructed by ManifestLoader) is an absolute package directory path, which is not a reliable ESM import() specifier and can fail to resolve. Since frameworkEntry is now the supported import target, consider removing the options.framework import fallback, or convert absolute paths to a file:// URL before importing (and/or fall back by resolving an entry file from that directory).

Copilot uses AI. Check for mistakes.
Comment on lines +224 to +255
#resolveFrameworkEntryUrl(): string {
const frameworkDir = this.#resolveFrameworkPath();
const pkgJsonPath = path.join(frameworkDir, 'package.json');
try {
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) as {
exports?: Record<string, unknown> | string;
main?: string;
module?: string;
};
let entryRel: string | undefined;
if (typeof pkg.exports === 'string') {
entryRel = pkg.exports;
} else if (pkg.exports && typeof pkg.exports === 'object') {
const dot = (pkg.exports as Record<string, unknown>)['.'];
if (typeof dot === 'string') {
entryRel = dot;
} else if (dot && typeof dot === 'object') {
const cond = dot as Record<string, unknown>;
for (const key of ['import', 'module', 'default'] as const) {
const val = cond[key];
if (typeof val === 'string') {
entryRel = val;
break;
}
}
}
}
entryRel = entryRel ?? pkg.module ?? pkg.main;
if (!entryRel) {
throw new Error(`[@eggjs/egg-bundler] framework package ${pkgJsonPath} has no resolvable entry`);
}
return pathToFileURL(path.resolve(frameworkDir, entryRel)).href;
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This exports resolution only handles a shallow set of shapes. Real-world exports['.'] often contains nested conditional objects (e.g. import: { development: '...', default: '...' }) or other patterns; in those cases entryRel will fall back to module/main and may point at the wrong file (or be missing), defeating the purpose of frameworkEntry. Consider implementing a small recursive resolver that walks the exports['.'] target and returns the first string for preferred conditions (e.g. import, then default), and validate that the resolved target is a relative path (./...) before path.resolve().

Copilot uses AI. Check for mistakes.
Comment on lines +202 to +208
#buildExecArgv(): string[] {
const base = this.#execArgv ?? process.execArgv;
// Detect any prior tsx loader injection so recursive forks don't append duplicates.
// Accepts either bare specifier (`tsx`, `tsx/esm`) or absolute file:// URL pointing
// inside a tsx package (e.g. `.../tsx/dist/esm/index.mjs`).
const hasTsxLoader = base.some((arg) => /(^|[=\s])tsx($|\/|\s)|\/tsx(@[^/]*)?\/(dist\/)?esm\//.test(arg));
if (hasTsxLoader) return [...base];
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tsx-injection detection regex is not portable to Windows paths (backslashes) and can miss/duplicate injections depending on how Node flags are represented (--import, --import=..., file URLs vs paths). A more robust approach is to explicitly parse base for --import / --import=... values and check whether the resolved specifier/path is tsx, tsx/esm, or ends with tsx/dist/esm/index.mjs, normalizing separators (or comparing as file:// URLs).

Suggested change
#buildExecArgv(): string[] {
const base = this.#execArgv ?? process.execArgv;
// Detect any prior tsx loader injection so recursive forks don't append duplicates.
// Accepts either bare specifier (`tsx`, `tsx/esm`) or absolute file:// URL pointing
// inside a tsx package (e.g. `.../tsx/dist/esm/index.mjs`).
const hasTsxLoader = base.some((arg) => /(^|[=\s])tsx($|\/|\s)|\/tsx(@[^/]*)?\/(dist\/)?esm\//.test(arg));
if (hasTsxLoader) return [...base];
#isTsxImportTarget(specifier: string): boolean {
if (specifier === 'tsx' || specifier === 'tsx/esm') return true;
let normalized = specifier;
if (specifier.startsWith('file://')) {
try {
normalized = fileURLToPath(specifier);
} catch {
return false;
}
}
normalized = normalized.replaceAll('\\', '/');
return normalized.endsWith('/tsx/dist/esm/index.mjs');
}
#hasTsxLoader(base: string[]): boolean {
for (let i = 0; i < base.length; i++) {
const arg = base[i];
let importTarget: string | undefined;
if (arg === '--import') {
importTarget = base[i + 1];
i++;
} else if (arg.startsWith('--import=')) {
importTarget = arg.slice('--import='.length);
}
if (importTarget && this.#isTsxImportTarget(importTarget)) {
return true;
}
}
return false;
}
#buildExecArgv(): string[] {
const base = this.#execArgv ?? process.execArgv;
// Detect any prior tsx loader injection so recursive forks don't append duplicates.
// Accepts either bare specifier (`tsx`, `tsx/esm`) or absolute file:// URL pointing
// inside a tsx package (e.g. `.../tsx/dist/esm/index.mjs`).
if (this.#hasTsxLoader(base)) return [...base];

Copilot uses AI. Check for mistakes.
console.log('[bundler-manifest] extensions: %d', extensionCount);

await app.close();
process.exit(0);
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling process.exit(0) after awaited async work can still terminate the process abruptly (e.g., before streams flush in some environments or before any remaining microtasks complete). Prefer allowing natural exit (returning from main) or setting process.exitCode = 0 to make shutdown behavior more predictable.

Suggested change
process.exit(0);
return;

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants