diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts new file mode 100644 index 0000000000..7c8b22972a --- /dev/null +++ b/packages/core/src/global.ts @@ -0,0 +1,7 @@ +import type { ManifestStore } from './loader/manifest.ts'; + +declare global { + var __EGG_BUNDLE_STORE__: ManifestStore | undefined; +} + +export {}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 348b136118..386867619c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ import '@eggjs/typings/global'; +import type {} from './global.ts'; import utils from './utils/index.ts'; export { utils }; diff --git a/packages/core/src/loader/manifest.ts b/packages/core/src/loader/manifest.ts index f7523228ec..dcea3e6ca1 100644 --- a/packages/core/src/loader/manifest.ts +++ b/packages/core/src/loader/manifest.ts @@ -12,6 +12,7 @@ const debug = debuglog('egg/core/loader/manifest'); const MANIFEST_VERSION = 1; const LOCKFILE_NAMES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'] as const; +const BUNDLE_STORE_KEY = '__EGG_BUNDLE_STORE__' as const; export interface ManifestInvalidation { lockfileFingerprint: string; @@ -51,22 +52,22 @@ export class ManifestStore { /** * Register a pre-built manifest store for bundled egg apps. When set, - * `ManifestStore.load()` returns this store unconditionally, bypassing - * disk reads and invalidation checks. The bundler-generated entry calls - * this at startup before creating the Application. + * `ManifestStore.load()` returns this store for matching baseDir requests, + * bypassing disk reads and invalidation checks. The bundler-generated entry + * calls this at startup before creating the Application. * * Uses globalThis so that bundled and external copies of @eggjs/core * share the same store instance. */ static setBundleStore(store: ManifestStore | undefined): void { - (globalThis as any).__EGG_BUNDLE_STORE__ = store; + globalThis[BUNDLE_STORE_KEY] = store; } /** * Return the registered bundle store, if any. */ static getBundleStore(): ManifestStore | undefined { - return (globalThis as any).__EGG_BUNDLE_STORE__; + return globalThis[BUNDLE_STORE_KEY]; } /** @@ -74,9 +75,9 @@ export class ManifestStore { * Returns null if manifest doesn't exist or is invalid. */ static load(baseDir: string, serverEnv: string, serverScope: string): ManifestStore | null { - const bundleStore: ManifestStore | undefined = (globalThis as any).__EGG_BUNDLE_STORE__; - if (bundleStore) { - debug('load: returning registered bundle store'); + const bundleStore = ManifestStore.getBundleStore(); + if (bundleStore && bundleStore.baseDir === baseDir) { + debug('load: returning registered bundle store for %s', baseDir); return bundleStore; } if (serverEnv === 'local' && process.env.EGG_MANIFEST !== 'true') { @@ -115,11 +116,15 @@ export class ManifestStore { * guaranteeing the data matches the shipped artifact. */ static fromBundle(data: StartupManifest, baseDir: string): ManifestStore { - if (data.version !== MANIFEST_VERSION) { + if (!data || data.version !== MANIFEST_VERSION) { throw new Error( - `[@eggjs/core] bundled manifest version mismatch: expected ${MANIFEST_VERSION}, got ${data.version}`, + `[@eggjs/core] bundled manifest version mismatch: expected ${MANIFEST_VERSION}, got ${data?.version}`, ); } + if (!data.invalidation) { + throw new Error('[@eggjs/core] bundled manifest missing invalidation data'); + } + debug('manifest loaded from bundle'); return new ManifestStore(data, baseDir); } diff --git a/packages/core/test/loader/manifest.test.ts b/packages/core/test/loader/manifest.test.ts index fba88a14e7..5457fe9840 100644 --- a/packages/core/test/loader/manifest.test.ts +++ b/packages/core/test/loader/manifest.test.ts @@ -7,6 +7,7 @@ import mm from 'mm'; import { describe, it, beforeEach, afterEach } from 'vitest'; import { ManifestStore } from '../../src/loader/manifest.ts'; +import type { StartupManifest } from '../../src/loader/manifest.ts'; import { createTmpDir, setupBaseDir, generateAndWrite } from './manifest_helper.ts'; let tmpDir: string; @@ -18,6 +19,7 @@ describe('ManifestStore', () => { afterEach(() => { mm.restore(); + ManifestStore.setBundleStore(undefined); fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -257,6 +259,15 @@ describe('ManifestStore', () => { }); describe('load()', () => { + function createBundleStore(baseDir: string, serverEnv = 'prod') { + const manifest = ManifestStore.createCollector(baseDir).generateManifest({ + serverEnv, + serverScope: '', + typescriptEnabled: true, + }); + return ManifestStore.fromBundle(manifest, baseDir); + } + it('should load a valid manifest', async () => { const baseDir = setupBaseDir(); try { @@ -270,6 +281,69 @@ describe('ManifestStore', () => { } }); + it('should return registered bundle store when manifest file does not exist', () => { + const baseDir = setupBaseDir(); + try { + const bundleStore = createBundleStore(baseDir); + ManifestStore.setBundleStore(bundleStore); + + const store = ManifestStore.load(baseDir, 'prod', ''); + assert.equal(store, bundleStore); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should return registered bundle store before reading invalid manifest JSON', () => { + const baseDir = setupBaseDir(); + try { + const eggDir = path.join(baseDir, '.egg'); + fs.mkdirSync(eggDir, { recursive: true }); + fs.writeFileSync(path.join(eggDir, 'manifest.json'), 'not json{{{'); + + const bundleStore = createBundleStore(baseDir); + ManifestStore.setBundleStore(bundleStore); + + const store = ManifestStore.load(baseDir, 'prod', ''); + assert.equal(store, bundleStore); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should return registered bundle store in local env when EGG_MANIFEST is unset', () => { + const baseDir = setupBaseDir(); + const savedEggManifest = process.env.EGG_MANIFEST; + try { + delete process.env.EGG_MANIFEST; + const bundleStore = createBundleStore(baseDir, 'local'); + ManifestStore.setBundleStore(bundleStore); + + const store = ManifestStore.load(baseDir, 'local', ''); + assert.equal(store, bundleStore); + } finally { + if (savedEggManifest !== undefined) { + process.env.EGG_MANIFEST = savedEggManifest; + } else { + delete process.env.EGG_MANIFEST; + } + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should ignore registered bundle store for a different baseDir', () => { + const bundleBaseDir = setupBaseDir(); + try { + const bundleStore = createBundleStore(bundleBaseDir); + ManifestStore.setBundleStore(bundleStore); + + const store = ManifestStore.load(tmpDir, 'prod', ''); + assert.equal(store, null); + } finally { + fs.rmSync(bundleBaseDir, { recursive: true, force: true }); + } + }); + it('should return null when manifest file does not exist', () => { const store = ManifestStore.load(tmpDir, 'prod', ''); assert.equal(store, null); @@ -377,6 +451,95 @@ describe('ManifestStore', () => { }); }); + describe('fromBundle()', () => { + it('should create store from bundled manifest data', () => { + const baseDir = setupBaseDir(); + try { + const manifest = ManifestStore.createCollector(baseDir).generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + manifest.resolveCache['config/plugin'] = 'config/plugin.ts'; + + const store = ManifestStore.fromBundle(manifest, baseDir); + const result = store.resolveModule(path.join(baseDir, 'config/plugin'), () => { + throw new Error('should not be called'); + }); + + assert.equal(store.baseDir, baseDir); + assert.equal(store.data, manifest); + assert.equal(result, path.join(baseDir, 'config/plugin.ts')); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should throw when bundled manifest version mismatches', () => { + const baseDir = setupBaseDir(); + try { + const manifest = ManifestStore.createCollector(baseDir).generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + manifest.version = 999; + + assert.throws( + () => ManifestStore.fromBundle(manifest, baseDir), + /bundled manifest version mismatch: expected 1, got 999/, + ); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should throw when bundled manifest is missing invalidation data', () => { + const baseDir = setupBaseDir(); + try { + const manifest = ManifestStore.createCollector(baseDir).generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + delete (manifest as Partial).invalidation; + + assert.throws(() => ManifestStore.fromBundle(manifest, baseDir), /bundled manifest missing invalidation data/); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should throw a clear error when bundled manifest data is null', () => { + assert.throws( + () => ManifestStore.fromBundle(null as unknown as StartupManifest, tmpDir), + /bundled manifest version mismatch: expected 1, got undefined/, + ); + }); + + it('should bypass normal invalidation checks for bundled manifest data', async () => { + const baseDir = setupBaseDir({ lockfile: 'pnpm' }); + try { + const manifest = ManifestStore.createCollector(baseDir).generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + manifest.invalidation.serverEnv = 'stale-env'; + manifest.invalidation.serverScope = 'stale-scope'; + manifest.invalidation.typescriptEnabled = !manifest.invalidation.typescriptEnabled; + manifest.invalidation.lockfileFingerprint = 'stale-lockfile'; + manifest.invalidation.configFingerprint = 'stale-config'; + await ManifestStore.write(baseDir, manifest); + + assert.equal(ManifestStore.load(baseDir, 'prod', ''), null); + assert.doesNotThrow(() => ManifestStore.fromBundle(manifest, baseDir)); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + }); + describe('resolveModule()', () => { it('should return cached result from loaded manifest', async () => { const baseDir = setupBaseDir(); diff --git a/tools/egg-bundler/package.json b/tools/egg-bundler/package.json index cce510449e..47aa803129 100644 --- a/tools/egg-bundler/package.json +++ b/tools/egg-bundler/package.json @@ -20,23 +20,59 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { - ".": "./src/index.ts", - "./lib/Bundler": "./src/lib/Bundler.ts", - "./lib/EntryGenerator": "./src/lib/EntryGenerator.ts", - "./lib/ExternalsResolver": "./src/lib/ExternalsResolver.ts", - "./lib/ManifestLoader": "./src/lib/ManifestLoader.ts", - "./lib/PackRunner": "./src/lib/PackRunner.ts", + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./lib/Bundler": { + "types": "./src/lib/Bundler.ts", + "import": "./src/lib/Bundler.ts" + }, + "./lib/EntryGenerator": { + "types": "./src/lib/EntryGenerator.ts", + "import": "./src/lib/EntryGenerator.ts" + }, + "./lib/ExternalsResolver": { + "types": "./src/lib/ExternalsResolver.ts", + "import": "./src/lib/ExternalsResolver.ts" + }, + "./lib/ManifestLoader": { + "types": "./src/lib/ManifestLoader.ts", + "import": "./src/lib/ManifestLoader.ts" + }, + "./lib/PackRunner": { + "types": "./src/lib/PackRunner.ts", + "import": "./src/lib/PackRunner.ts" + }, "./package.json": "./package.json" }, "publishConfig": { "access": "public", "exports": { - ".": "./dist/index.js", - "./lib/Bundler": "./dist/lib/Bundler.js", - "./lib/EntryGenerator": "./dist/lib/EntryGenerator.js", - "./lib/ExternalsResolver": "./dist/lib/ExternalsResolver.js", - "./lib/ManifestLoader": "./dist/lib/ManifestLoader.js", - "./lib/PackRunner": "./dist/lib/PackRunner.js", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./lib/Bundler": { + "types": "./dist/lib/Bundler.d.ts", + "import": "./dist/lib/Bundler.js" + }, + "./lib/EntryGenerator": { + "types": "./dist/lib/EntryGenerator.d.ts", + "import": "./dist/lib/EntryGenerator.js" + }, + "./lib/ExternalsResolver": { + "types": "./dist/lib/ExternalsResolver.d.ts", + "import": "./dist/lib/ExternalsResolver.js" + }, + "./lib/ManifestLoader": { + "types": "./dist/lib/ManifestLoader.d.ts", + "import": "./dist/lib/ManifestLoader.js" + }, + "./lib/PackRunner": { + "types": "./dist/lib/PackRunner.d.ts", + "import": "./dist/lib/PackRunner.js" + }, "./package.json": "./package.json" } }, @@ -53,6 +89,7 @@ "@eggjs/core": "workspace:*", "@eggjs/utils": "workspace:*", "@utoo/pack": "catalog:", + "execa": "catalog:", "tsx": "catalog:" }, "devDependencies": { @@ -63,6 +100,7 @@ "@eggjs/tegg-config": "workspace:*", "@eggjs/tegg-plugin": "workspace:*", "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", "rimraf": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/tools/egg-bundler/src/lib/Bundler.ts b/tools/egg-bundler/src/lib/Bundler.ts index 72ab9a999c..9df71f616f 100644 --- a/tools/egg-bundler/src/lib/Bundler.ts +++ b/tools/egg-bundler/src/lib/Bundler.ts @@ -58,6 +58,7 @@ export class Bundler { baseDir: absBaseDir, manifestPath, framework, + autoGenerate: true, }); await wrapStep('manifest load', () => manifestLoader.load()); diff --git a/tools/egg-bundler/src/lib/EntryGenerator.ts b/tools/egg-bundler/src/lib/EntryGenerator.ts index e887c1b40d..9dfdeb419e 100644 --- a/tools/egg-bundler/src/lib/EntryGenerator.ts +++ b/tools/egg-bundler/src/lib/EntryGenerator.ts @@ -20,7 +20,6 @@ export interface EntryGeneratorOptions { export interface GeneratedEntries { workerEntry: string; - agentEntry: string; entryDir: string; } @@ -67,14 +66,11 @@ export class EntryGenerator { await fs.mkdir(this.#outputDir, { recursive: true }); const workerEntry = path.join(this.#outputDir, 'worker.entry.ts'); - const agentEntry = path.join(this.#outputDir, 'agent.entry.ts'); await fs.writeFile(workerEntry, this.#renderWorkerEntry(entries, manifest)); - await fs.writeFile(agentEntry, this.#renderAgentEntry()); return { workerEntry, - agentEntry, entryDir: this.#outputDir, }; } @@ -263,20 +259,6 @@ startEgg({ baseDir: __baseDir, mode: 'single' }).then((app) => { `; } - #renderAgentEntry(): string { - // Single-mode bundled apps run the agent inside the worker process - // (via startEgg). The agent entry exists to satisfy the entry-pair - // contract for T8/T11; if the bundler ever needs a standalone agent - // bundle (cluster mode), this template is the place to expand it. - return `// ⚠️ auto-generated by @eggjs/egg-bundler — do not edit -/* eslint-disable */ -// Single-mode bundled apps run the agent in-process with the worker. -// This stub exists so every bundle exposes a symmetric pair of entries. -// eslint-disable-next-line no-console -console.log('[egg-bundler] agent entry is a no-op in single-mode bundles'); -`; - } - #toImportSpecifier(absPath: string): string { // Prefer a relative specifier from the entry output dir to keep the // bundled paths portable across machines (absolute paths would leak diff --git a/tools/egg-bundler/src/lib/ExternalsResolver.ts b/tools/egg-bundler/src/lib/ExternalsResolver.ts index 0870592f66..a21b47a87d 100644 --- a/tools/egg-bundler/src/lib/ExternalsResolver.ts +++ b/tools/egg-bundler/src/lib/ExternalsResolver.ts @@ -13,13 +13,12 @@ interface PackageJson { readonly name?: string; readonly type?: string; readonly dependencies?: Record; + readonly optionalDependencies?: Record; readonly peerDependencies?: Record; readonly scripts?: Record; readonly exports?: unknown; } -const ALWAYS_EXTERNAL_NAMES: ReadonlySet = new Set(['egg', '@swc/helpers']); - // install-time hooks using one of these tools strongly imply a native addon const NATIVE_SCRIPT_PATTERN = /node-gyp|prebuild-install|napi-rs|node-pre-gyp|electron-rebuild/i; @@ -27,6 +26,8 @@ export class ExternalsResolver { readonly #baseDir: string; readonly #force: ReadonlySet; readonly #inline: ReadonlySet; + readonly #packageDirCache = new Map>(); + readonly #packageJsonCache = new Map>(); constructor(options: ExternalsResolverOptions) { this.#baseDir = options.baseDir; @@ -36,7 +37,11 @@ export class ExternalsResolver { async resolve(): Promise { const rootPkg = await this.#readPackageJson(this.#baseDir); - const deps = Object.keys(rootPkg.dependencies ?? {}); + const deps = new Set([ + ...Object.keys(rootPkg.dependencies ?? {}), + ...Object.keys(rootPkg.optionalDependencies ?? {}), + ]); + const optionalDeps = new Set(Object.keys(rootPkg.optionalDependencies ?? {})); const peerDeps = new Set(Object.keys(rootPkg.peerDependencies ?? {})); const result: Record = {}; @@ -47,7 +52,7 @@ export class ExternalsResolver { for (const name of deps) { if (this.#inline.has(name) && !this.#force.has(name)) continue; if (result[name]) continue; - if (await this.#shouldExternalize(name, peerDeps)) { + if (await this.#shouldExternalize(name, optionalDeps, peerDeps)) { result[name] = name; } } @@ -60,19 +65,30 @@ export class ExternalsResolver { return result; } - async #shouldExternalize(name: string, peerDeps: ReadonlySet): Promise { + async #shouldExternalize( + name: string, + optionalDeps: ReadonlySet, + peerDeps: ReadonlySet, + ): Promise { + if (optionalDeps.has(name)) return true; if (peerDeps.has(name)) return true; - if (ALWAYS_EXTERNAL_NAMES.has(name)) return true; - if (name === 'egg' || name.startsWith('@eggjs/')) return true; const pkgDir = await this.#findPackageDir(name); if (!pkgDir) return false; - if (await this.#hasNativeBinary(pkgDir)) return true; - if (await this.#isEsmOnly(pkgDir)) return true; + const pkg = await this.#readPackageJson(pkgDir); + if (await this.#hasNativeBinary(pkgDir, pkg)) return true; return false; } async #findPackageDir(name: string): Promise { + const cached = this.#packageDirCache.get(name); + if (cached) return cached; + const result = this.#findPackageDirUncached(name); + this.#packageDirCache.set(name, result); + return result; + } + + async #findPackageDirUncached(name: string): Promise { let dir = this.#baseDir; while (true) { const candidate = path.join(dir, 'node_modules', name); @@ -88,8 +104,7 @@ export class ExternalsResolver { } } - async #hasNativeBinary(pkgDir: string): Promise { - const pkg = await this.#readPackageJson(pkgDir); + async #hasNativeBinary(pkgDir: string, pkg: PackageJson): Promise { const scripts = pkg.scripts ?? {}; for (const hook of ['install', 'postinstall', 'preinstall'] as const) { const script = scripts[hook]; @@ -115,35 +130,24 @@ export class ExternalsResolver { return false; } - async #isEsmOnly(pkgDir: string): Promise { - const pkg = await this.#readPackageJson(pkgDir); - if (pkg.type !== 'module') return false; - const exportsField = pkg.exports; - if (!exportsField || typeof exportsField !== 'object') { - return false; - } - return !this.#hasRequireCondition(exportsField); - } - - #hasRequireCondition(value: unknown): boolean { - if (!value || typeof value !== 'object') return false; - if (Array.isArray(value)) { - return value.some((v) => this.#hasRequireCondition(v)); - } - const obj = value as Record; - if ('require' in obj) return true; - for (const v of Object.values(obj)) { - if (this.#hasRequireCondition(v)) return true; - } - return false; + async #readPackageJson(dir: string): Promise { + const cached = this.#packageJsonCache.get(dir); + if (cached) return cached; + const result = this.#readPackageJsonUncached(dir); + this.#packageJsonCache.set(dir, result); + return result; } - async #readPackageJson(dir: string): Promise { + async #readPackageJsonUncached(dir: string): Promise { + const packageJsonPath = path.join(dir, 'package.json'); try { - const raw = await fs.readFile(path.join(dir, 'package.json'), 'utf8'); + const raw = await fs.readFile(packageJsonPath, 'utf8'); return JSON.parse(raw) as PackageJson; - } catch { - return {}; + } catch (error) { + if ((error as { code?: string }).code === 'ENOENT') { + return {}; + } + throw new Error(`[@eggjs/egg-bundler] failed to read ${packageJsonPath}`, { cause: error }); } } diff --git a/tools/egg-bundler/src/lib/ManifestLoader.ts b/tools/egg-bundler/src/lib/ManifestLoader.ts index d439a34fc1..cbb1972822 100644 --- a/tools/egg-bundler/src/lib/ManifestLoader.ts +++ b/tools/egg-bundler/src/lib/ManifestLoader.ts @@ -1,16 +1,17 @@ -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; +import fsp from 'node:fs/promises'; import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { debuglog } from 'node:util'; -import type { ManifestStore, StartupManifest } from '@eggjs/core'; +import { ManifestStore, type StartupManifest } from '@eggjs/core'; +import { execaNode } from 'execa'; const debug = debuglog('egg/bundler/manifest-loader'); const SUPPORTED_MANIFEST_VERSION = 1; const FRAMEWORK_DEFAULT = 'egg'; +const PACKAGE_ENTRY_ACTIVE_CONDITIONS = new Set(['import', 'node', 'default']); export interface ManifestLoaderOptions { baseDir: string; @@ -33,7 +34,7 @@ interface TeggManifestExtension { interface ModuleMapEntry { realDir: string; - pkgName: string; + normalizedDir: string; } export class ManifestLoader { @@ -44,38 +45,41 @@ export class ManifestLoader { readonly #scope: string | undefined; readonly #framework: string; readonly #execArgv: string[] | undefined; + readonly #baseRequire: NodeJS.Require; + readonly #realpathCache = new Map(); #manifest: StartupManifest | undefined; #store: ManifestStore | undefined; constructor(options: ManifestLoaderOptions) { this.#baseDir = options.baseDir; this.#manifestPath = options.manifestPath ?? path.join(options.baseDir, '.egg', 'manifest.json'); - this.#autoGenerate = options.autoGenerate ?? true; + this.#autoGenerate = options.autoGenerate ?? false; this.#env = options.env; this.#scope = options.scope; this.#framework = options.framework ?? FRAMEWORK_DEFAULT; this.#execArgv = options.execArgv; + this.#baseRequire = createRequire(path.join(this.#baseDir, 'package.json')); } async load(): Promise { if (this.#manifest) return this.#manifest; - let data = this.#readFromDisk(); + let data = await this.#readFromDisk(); if (!data) { if (!this.#autoGenerate) { throw new Error(`[@eggjs/egg-bundler] manifest not found at ${this.#manifestPath}`); } await this.#generate(); - data = this.#readFromDisk(); + data = await this.#readFromDisk(); if (!data) { throw new Error(`[@eggjs/egg-bundler] manifest generation did not produce ${this.#manifestPath}`); } } - const normalized = this.#normalize(data); + const normalized = await this.#normalize(data); + const store = ManifestStore.fromBundle(normalized, this.#baseDir); + this.#store = store; this.#manifest = normalized; - // TODO: wire ManifestStore.fromBundle once @eggjs/core exposes it (tracked in - // a separate runtime split). Until then, the #store getter throws when accessed. return normalized; } @@ -124,13 +128,12 @@ export class ManifestLoader { #resolveFromBase(rel: string): string { if (path.isAbsolute(rel)) return rel; if (rel.startsWith('node_modules/')) { - const req = createRequire(path.join(this.#baseDir, 'package.json')); const rest = rel.slice('node_modules/'.length); const slashIdx = rest.startsWith('@') ? rest.indexOf('/', rest.indexOf('/') + 1) : rest.indexOf('/'); const pkgName = slashIdx === -1 ? rest : rest.slice(0, slashIdx); const sub = slashIdx === -1 ? '' : rest.slice(slashIdx + 1); try { - const pkgJson = req.resolve(`${pkgName}/package.json`); + const pkgJson = this.#baseRequire.resolve(`${pkgName}/package.json`); return path.resolve(path.dirname(pkgJson), sub); } catch { return path.resolve(this.#baseDir, rel); @@ -139,14 +142,25 @@ export class ManifestLoader { return path.resolve(this.#baseDir, rel); } - #readFromDisk(): StartupManifest | undefined { + async #readFromDisk(): Promise { let raw: string; try { - raw = fs.readFileSync(this.#manifestPath, 'utf-8'); - } catch { - return undefined; + raw = await fsp.readFile(this.#manifestPath, 'utf-8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + throw error; + } + let parsed: StartupManifest; + try { + parsed = JSON.parse(raw) as StartupManifest; + } catch (error) { + throw new Error( + `[@eggjs/egg-bundler] invalid manifest JSON at ${this.#manifestPath}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); } - const parsed = JSON.parse(raw) as StartupManifest; if (parsed.version !== SUPPORTED_MANIFEST_VERSION) { throw new Error( `[@eggjs/egg-bundler] manifest version mismatch at ${this.#manifestPath}: expected ${SUPPORTED_MANIFEST_VERSION}, got ${parsed.version}`, @@ -158,138 +172,188 @@ export class ManifestLoader { async #generate(): Promise { const scriptUrl = new URL('../scripts/generate-manifest.mjs', import.meta.url); const scriptPath = fileURLToPath(scriptUrl); - // The child loader needs BOTH: - // 1. `framework` — the absolute package dir egg's internal - // `resolveFrameworkClasses()` uses (it calls `importResolve(framework, - // baseDir)` which walks node_modules from baseDir). - // 2. `frameworkEntry` — a concrete file URL for the subprocess's initial - // `import(framework)`. Importing the package dir directly bypasses - // `exports` (workspace dev links point at `./src/index.ts`, and bare - // directory resolution falls through to `index.js/json`). + try { + await fsp.access(scriptPath); + } catch { + throw new Error(`[@eggjs/egg-bundler] manifest auto-generation is not available: ${scriptPath} does not exist`); + } const payload = { baseDir: this.#baseDir, - framework: this.#resolveFrameworkPath(), - frameworkEntry: this.#resolveFrameworkEntryUrl(), + framework: await this.#resolveFrameworkPath(), + frameworkEntry: await this.#resolveFrameworkEntryUrl(), env: this.#env, scope: this.#scope, }; - debug('fork generate-manifest: %o', payload); - - const execArgv = this.#buildExecArgv(); - - // Use spawn (not fork) so the child has no IPC channel: 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. - await new Promise((resolve, reject) => { - const child = spawn(process.execPath, [...execArgv, scriptPath, JSON.stringify(payload)], { - stdio: 'inherit', - env: { - ...process.env, - EGG_MANIFEST: 'true', - }, - }); - child.on('exit', (code, signal) => { - if (code === 0) resolve(); - else - reject( - new Error(`[@eggjs/egg-bundler] manifest generate subprocess exited with code=${code} signal=${signal}`), - ); - }); - child.on('error', reject); + debug('execa generate-manifest: %o', payload); + + await execaNode(scriptPath, [], { + input: JSON.stringify(payload), + stdin: 'pipe', + stdout: 'inherit', + stderr: 'inherit', + nodeOptions: this.#buildExecArgv(), + env: { + ...process.env, + EGG_MANIFEST: 'true', + }, }); } + #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') || normalized.endsWith('/tsx/esm/index.mjs'); + } + + #hasTsxLoader(base: readonly 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`). - const hasTsxLoader = base.some((arg) => /(^|[=\s])tsx($|\/|\s)|\/tsx(@[^/]*)?\/(dist\/)?esm\//.test(arg)); - if (hasTsxLoader) return [...base]; - // The subprocess imports 'egg' through workspace dev links into raw .ts sources - // (which contain decorators Node's strip-types mode cannot transform). Inject - // tsx's ESM loader so the child can load those sources regardless of how the - // parent Node was invoked (raw node, egg-bin, vitest, etc.). + if (this.#hasTsxLoader(base)) return [...base]; try { const req = createRequire(import.meta.url); const tsxEsm = req.resolve('tsx/esm'); - const tsxUrl = pathToFileURL(tsxEsm).href; - return [...base, `--import=${tsxUrl}`]; + return [...base, `--import=${pathToFileURL(tsxEsm).href}`]; } catch { debug('tsx/esm not resolvable from @eggjs/egg-bundler; falling back to inherited execArgv'); return [...base]; } } - #resolveFrameworkEntryUrl(): string { - const frameworkDir = this.#resolveFrameworkPath(); + #resolvePackageEntry(target: unknown): string | undefined { + if (typeof target === 'string') return target; + if (Array.isArray(target)) { + for (const item of target) { + const resolved = this.#resolvePackageEntry(item); + if (resolved) return resolved; + } + return undefined; + } + if (!target || typeof target !== 'object') return undefined; + + const map = target as Record; + if (Object.keys(map).some((key) => key.startsWith('.'))) { + return undefined; + } + + for (const [key, value] of Object.entries(map)) { + if (key.startsWith('.') || !PACKAGE_ENTRY_ACTIVE_CONDITIONS.has(key)) continue; + const resolved = this.#resolvePackageEntry(value); + if (resolved) return resolved; + } + return undefined; + } + + #resolveExportsEntry(exportsField: Record | string): string | undefined { + if (typeof exportsField === 'string') return exportsField; + + const keys = Object.keys(exportsField); + const rootTarget = keys.length > 0 && !keys.some((key) => key.startsWith('.')) ? exportsField : exportsField['.']; + return this.#resolvePackageEntry(rootTarget); + } + + #resolvePackageEntryUrl(frameworkDir: string, entryRel: string, pkgJsonPath: string): string { + if (path.isAbsolute(entryRel) || /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(entryRel)) { + throw new Error(`[@eggjs/egg-bundler] framework package ${pkgJsonPath} entry must be a relative path`); + } + const entryPath = path.resolve(frameworkDir, entryRel); + const rel = path.relative(frameworkDir, entryPath); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error(`[@eggjs/egg-bundler] framework package ${pkgJsonPath} entry escapes package root`); + } + return pathToFileURL(entryPath).href; + } + + async #resolveFrameworkEntryUrl(): Promise { + const frameworkDir = await this.#resolveFrameworkPath(); const pkgJsonPath = path.join(frameworkDir, 'package.json'); + let pkg: { + exports?: Record | string; + main?: string; + module?: string; + }; try { - const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) as { - exports?: Record | 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)['.']; - if (typeof dot === 'string') { - entryRel = dot; - } else if (dot && typeof dot === 'object') { - const cond = dot as Record; - 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; + pkg = JSON.parse(await fsp.readFile(pkgJsonPath, 'utf-8')); + } catch (error) { + throw new Error(`[@eggjs/egg-bundler] failed to read framework package ${pkgJsonPath}`, { cause: error }); + } + let entryRel: string | undefined; + if (pkg.exports !== undefined) { + entryRel = this.#resolveExportsEntry(pkg.exports); if (!entryRel) { throw new Error(`[@eggjs/egg-bundler] framework package ${pkgJsonPath} has no resolvable entry`); } - return pathToFileURL(path.resolve(frameworkDir, entryRel)).href; - } catch (err) { - debug('resolve framework entry failed: %o', err); - throw err; + } else { + entryRel = pkg.module ?? pkg.main; + } + if (!entryRel) { + throw new Error(`[@eggjs/egg-bundler] framework package ${pkgJsonPath} has no resolvable entry`); } + return this.#resolvePackageEntryUrl(frameworkDir, entryRel, pkgJsonPath); } - #resolveFrameworkPath(): string { + async #resolveFrameworkPath(): Promise { if (path.isAbsolute(this.#framework)) return this.#framework; try { - const req = createRequire(path.join(this.#baseDir, 'package.json')); - const pkgJson = req.resolve(`${this.#framework}/package.json`); + const pkgJson = await this.#resolvePackageJson(this.#framework, this.#baseRequire, this.#baseDir); return path.dirname(pkgJson); - } catch { + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'MODULE_NOT_FOUND' && code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw error; return this.#framework; } } // --- Key normalization --- - #normalize(data: StartupManifest): StartupManifest { - const moduleMap = this.#buildModuleMap(); + async #normalize(data: StartupManifest): Promise { + const moduleMap = await this.#buildModuleMap(); debug('moduleMap size: %d', moduleMap.length); const normalizedDiscovery: Record = {}; for (const [key, files] of Object.entries(data.fileDiscovery)) { - const newKey = this.#normalizeRelKey(key, moduleMap); - normalizedDiscovery[newKey] = files; + const newKey = await this.#normalizeRelKey(key, moduleMap); + const existing = normalizedDiscovery[newKey]; + normalizedDiscovery[newKey] = existing ? Array.from(new Set([...existing, ...files])) : files; } const normalizedResolveCache: Record = {}; for (const [key, value] of Object.entries(data.resolveCache)) { - const newKey = this.#normalizeRelKey(key, moduleMap); - const newValue = value === null ? null : this.#normalizeRelKey(value, moduleMap); + const newKey = await this.#normalizeRelKey(key, moduleMap); + const newValue = value === null ? null : await this.#normalizeRelKey(value, moduleMap); + if (Object.hasOwn(normalizedResolveCache, newKey) && normalizedResolveCache[newKey] !== newValue) { + throw new Error(`[@eggjs/egg-bundler] conflicting normalized resolveCache entry for ${newKey}`); + } normalizedResolveCache[newKey] = newValue; } - const normalizedExtensions = this.#normalizeExtensions(data.extensions, moduleMap); + const normalizedExtensions = await this.#normalizeExtensions(data.extensions ?? {}, moduleMap); return { ...data, @@ -299,26 +363,27 @@ export class ManifestLoader { }; } - #normalizeRelKey(relKey: string, moduleMap: ModuleMapEntry[]): string { + async #normalizeRelKey(relKey: string, moduleMap: ModuleMapEntry[]): Promise { if (!relKey) return relKey; - // Already inside baseDir and not escaping — leave as-is. - if (!relKey.startsWith('..') && !relKey.includes('node_modules/') && !relKey.includes('.pnpm/')) { - return relKey; - } const abs = path.resolve(this.#baseDir, relKey); - let realAbs: string; - try { - realAbs = fs.realpathSync(abs); - } catch { - realAbs = abs; + const relativeToBase = path.relative(this.#baseDir, abs).replaceAll(path.sep, '/'); + const segments = this.#pathSegments(relativeToBase); + // Already inside baseDir, relative, and not escaping — leave as-is. + if ( + !path.isAbsolute(relKey) && + !relativeToBase.startsWith('..') && + !path.isAbsolute(relativeToBase) && + !segments.includes('node_modules') && + !segments.includes('.pnpm') + ) { + return relKey; } - // Longest-prefix match + const realAbs = await this.#realpath(abs); let best: ModuleMapEntry | undefined; for (const entry of moduleMap) { if (realAbs === entry.realDir || realAbs.startsWith(entry.realDir + path.sep)) { - if (!best || entry.realDir.length > best.realDir.length) { - best = entry; - } + best = entry; + break; } } if (!best) { @@ -326,76 +391,168 @@ export class ManifestLoader { return relKey; } const rest = realAbs === best.realDir ? '' : realAbs.slice(best.realDir.length + 1); - const normalized = ['node_modules', best.pkgName, rest].filter(Boolean).join('/').replaceAll(path.sep, '/'); + const normalized = [best.normalizedDir, rest].filter(Boolean).join('/').replaceAll(path.sep, '/'); return normalized; } - #normalizeExtensions(extensions: Record, moduleMap: ModuleMapEntry[]): Record { + async #normalizeExtensions( + extensions: Record = {}, + moduleMap: ModuleMapEntry[], + ): Promise> { const result: Record = { ...extensions }; const tegg = extensions?.tegg as TeggManifestExtension | undefined; if (tegg?.moduleDescriptors) { result.tegg = { ...tegg, - moduleDescriptors: tegg.moduleDescriptors.map((desc) => { - if (!path.isAbsolute(desc.unitPath)) return desc; - let real: string; - try { - real = fs.realpathSync(desc.unitPath); - } catch { - real = desc.unitPath; - } - let best: ModuleMapEntry | undefined; - for (const entry of moduleMap) { - if (real === entry.realDir || real.startsWith(entry.realDir + path.sep)) { - if (!best || entry.realDir.length > best.realDir.length) best = entry; + moduleDescriptors: await Promise.all( + tegg.moduleDescriptors.map(async (desc) => { + if (!path.isAbsolute(desc.unitPath)) return desc; + const real = await this.#realpath(desc.unitPath); + let best: ModuleMapEntry | undefined; + for (const entry of moduleMap) { + if (real === entry.realDir || real.startsWith(entry.realDir + path.sep)) { + best = entry; + break; + } } - } - if (!best) { - // keep as relative-to-baseDir form so runtime can resolve via #resolveFromBase - const rel = path.relative(this.#baseDir, real).replaceAll(path.sep, '/'); - return { ...desc, unitPath: rel }; - } - const rest = real === best.realDir ? '' : real.slice(best.realDir.length + 1); - const unitPath = ['node_modules', best.pkgName, rest].filter(Boolean).join('/'); - return { ...desc, unitPath }; - }), + if (!best) { + // keep as relative-to-baseDir form so runtime can resolve via #resolveFromBase + const rel = path.relative(this.#baseDir, real).replaceAll(path.sep, '/'); + return { ...desc, unitPath: rel }; + } + const rest = real === best.realDir ? '' : real.slice(best.realDir.length + 1); + const unitPath = [best.normalizedDir, rest].filter(Boolean).join('/').replaceAll(path.sep, '/'); + return { ...desc, unitPath }; + }), + ), }; } return result; } - #buildModuleMap(): ModuleMapEntry[] { + async #findPackageJsonFromNodeModules(name: string, startDir: string): Promise { + let dir = startDir; + const nameSegments = name.split('/'); + while (true) { + const candidates = [ + path.join(dir, 'node_modules', ...nameSegments, 'package.json'), + path.join(dir, 'node_modules', '.pnpm', 'node_modules', ...nameSegments, 'package.json'), + ]; + for (const candidate of candidates) { + try { + await fsp.access(candidate); + return candidate; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } + } + + async #resolvePackageJson(name: string, req: ReturnType, startDir: string): Promise { + try { + return req.resolve(`${name}/package.json`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw error; + const packageJsonFromNodeModules = await this.#findPackageJsonFromNodeModules(name, startDir); + if (packageJsonFromNodeModules) return packageJsonFromNodeModules; + const entry = req.resolve(name); + let dir = path.dirname(entry); + while (true) { + const candidate = path.join(dir, 'package.json'); + try { + await fsp.access(candidate); + return candidate; + } catch (accessError) { + if ((accessError as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error(`[@eggjs/egg-bundler] failed to access ${candidate}`, { cause: accessError }); + } + const parent = path.dirname(dir); + if (parent === dir) { + throw new Error(`[@eggjs/egg-bundler] failed to resolve package.json for ${name}`, { + cause: accessError, + }); + } + dir = parent; + } + } + } + } + + async #buildModuleMap(): Promise { const entries = new Map(); - const seenPkgs = new Set(); + const seen = new Set(); - const addFromPackageJson = (packageJsonPath: string): void => { - let pkg: { dependencies?: Record; devDependencies?: Record }; + const addPackageDeps = async (packageJsonPath: string, parentNormalizedDir: string): Promise => { + let pkg: { + dependencies?: Record; + optionalDependencies?: Record; + devDependencies?: Record; + }; try { - pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); - } catch { - return; + pkg = JSON.parse(await fsp.readFile(packageJsonPath, 'utf-8')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw new Error(`[@eggjs/egg-bundler] failed to read ${packageJsonPath}`, { cause: error }); } const req = createRequire(packageJsonPath); - const depNames = [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})]; + const depNames = [ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.optionalDependencies ?? {}), + ...Object.keys(pkg.devDependencies ?? {}), + ]; for (const name of depNames) { - if (seenPkgs.has(name)) continue; - seenPkgs.add(name); try { - const depPkgJson = req.resolve(`${name}/package.json`); - const realDir = fs.realpathSync(path.dirname(depPkgJson)); - if (!entries.has(realDir)) entries.set(realDir, name); - } catch { + const depPkgJson = await this.#resolvePackageJson(name, req, path.dirname(packageJsonPath)); + const realDir = await this.#realpath(path.dirname(depPkgJson)); + if (seen.has(realDir)) continue; + seen.add(realDir); + const normalizedDir = this.#normalizePackageDir(realDir, parentNormalizedDir, name); + if (!entries.has(realDir)) entries.set(realDir, normalizedDir); + await addPackageDeps(depPkgJson, normalizedDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'MODULE_NOT_FOUND') throw error; /* dep not resolvable (optional/peer); skip */ } } }; - addFromPackageJson(path.join(this.#baseDir, 'package.json')); - const frameworkDir = this.#resolveFrameworkPath(); - addFromPackageJson(path.join(frameworkDir, 'package.json')); + await addPackageDeps(path.join(this.#baseDir, 'package.json'), ''); + const frameworkDir = await this.#resolveFrameworkPath(); + const frameworkNormalizedDir = entries.get(await this.#realpath(frameworkDir)) ?? ''; + await addPackageDeps(path.join(frameworkDir, 'package.json'), frameworkNormalizedDir); - return Array.from(entries, ([realDir, pkgName]) => ({ realDir, pkgName })).sort( + return Array.from(entries, ([realDir, normalizedDir]) => ({ realDir, normalizedDir })).sort( (a, b) => b.realDir.length - a.realDir.length, ); } + + #normalizePackageDir(realDir: string, parentNormalizedDir: string, name: string): string { + const rel = path.relative(this.#baseDir, realDir).replaceAll(path.sep, '/'); + const segments = this.#pathSegments(rel); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel) && segments[0] === 'node_modules') { + if (!segments.includes('.pnpm')) return rel; + } + return [parentNormalizedDir, 'node_modules', name].filter(Boolean).join('/'); + } + + #pathSegments(filepath: string): string[] { + return filepath.split(/[\\/]+/).filter(Boolean); + } + + async #realpath(filepath: string): Promise { + const cached = this.#realpathCache.get(filepath); + if (cached) return cached; + let resolved: string; + try { + resolved = await fsp.realpath(filepath); + } catch { + resolved = filepath; + } + this.#realpathCache.set(filepath, resolved); + return resolved; + } } diff --git a/tools/egg-bundler/src/scripts/generate-manifest.mjs b/tools/egg-bundler/src/scripts/generate-manifest.mjs index 8bc681fc25..9e1db95131 100644 --- a/tools/egg-bundler/src/scripts/generate-manifest.mjs +++ b/tools/egg-bundler/src/scripts/generate-manifest.mjs @@ -1,10 +1,24 @@ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; import { debuglog } from 'node:util'; const debug = debuglog('egg/bundler/scripts/generate-manifest'); +async function readOptions() { + if (process.argv[2]) { + return JSON.parse(process.argv[2]); + } + + let raw = ''; + for await (const chunk of process.stdin) { + raw += chunk; + } + return JSON.parse(raw); +} + async function main() { debug('argv: %o', process.argv); - const options = JSON.parse(process.argv[2]); + const options = await readOptions(); debug('generate manifest options: %o', options); if (options.env) { @@ -16,17 +30,13 @@ async function main() { process.env.EGG_MANIFEST = 'true'; const { ManifestStore } = await import('@eggjs/core'); - ManifestStore.clean(options.baseDir); - // `frameworkEntry` (a file:// URL to the package's real entry file) is the - // only way to load a workspace-linked framework whose `exports` map points at - // a TypeScript source. Importing the package directory directly would bypass - // `exports` and fall through to legacy directory resolution. let framework; if (options.frameworkEntry) { framework = await import(options.frameworkEntry); } else if (options.framework) { - framework = await import(options.framework); + const specifier = path.isAbsolute(options.framework) ? pathToFileURL(options.framework).href : options.framework; + framework = await import(specifier); } else { framework = await import('egg'); } @@ -50,7 +60,6 @@ async function main() { console.log('[bundler-manifest] extensions: %d', extensionCount); await app.close(); - process.exit(0); } main().catch((err) => { diff --git a/tools/egg-bundler/test/Bundler.test.ts b/tools/egg-bundler/test/Bundler.test.ts new file mode 100644 index 0000000000..8869720565 --- /dev/null +++ b/tools/egg-bundler/test/Bundler.test.ts @@ -0,0 +1,100 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + manifestLoaderOptions: [] as unknown[], + manifestLoad: vi.fn(async () => undefined), + externalsResolve: vi.fn(async () => ({})), + entryGenerate: vi.fn(async () => ({ workerEntry: '/tmp/worker.entry.ts' })), +})); + +vi.mock('../src/lib/ManifestLoader.ts', () => ({ + ManifestLoader: vi.fn().mockImplementation(function (options: unknown) { + mocks.manifestLoaderOptions.push(options); + return { + load: mocks.manifestLoad, + get manifest() { + return { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + invalidation: { + lockfileFingerprint: '', + configFingerprint: '', + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }, + extensions: {}, + resolveCache: {}, + fileDiscovery: {}, + }; + }, + get store() { + return {}; + }, + getAllDiscoveredFiles: () => [], + getTeggDecoratedFiles: () => [], + }; + }), +})); + +vi.mock('../src/lib/ExternalsResolver.ts', () => ({ + ExternalsResolver: vi.fn().mockImplementation(function () { + return { + resolve: mocks.externalsResolve, + }; + }), +})); + +vi.mock('../src/lib/EntryGenerator.ts', () => ({ + EntryGenerator: vi.fn().mockImplementation(function () { + return { + generate: mocks.entryGenerate, + }; + }), +})); + +import { bundle } from '../src/index.ts'; + +describe('Bundler', () => { + let tmpApp: string; + let tmpOutput: string; + + beforeEach(async () => { + mocks.manifestLoaderOptions.length = 0; + mocks.manifestLoad.mockClear(); + mocks.externalsResolve.mockClear(); + mocks.entryGenerate.mockClear(); + + tmpApp = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-unit-app-')); + tmpOutput = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-unit-out-')); + await fs.writeFile(path.join(tmpApp, 'package.json'), JSON.stringify({ name: 'unit-app' })); + }); + + afterEach(async () => { + await fs.rm(tmpApp, { recursive: true, force: true }); + await fs.rm(tmpOutput, { recursive: true, force: true }); + }); + + it('opts ManifestLoader into auto-generation when bundling owns manifest loading', async () => { + await bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + pack: { + buildFunc: async () => { + await fs.writeFile(path.join(tmpOutput, 'worker.js'), '// worker\n'); + }, + }, + }); + + expect(mocks.manifestLoaderOptions).toHaveLength(1); + expect(mocks.manifestLoaderOptions[0]).toMatchObject({ + baseDir: tmpApp, + framework: 'egg', + autoGenerate: true, + }); + }); +}); diff --git a/tools/egg-bundler/test/EntryGenerator.test.ts b/tools/egg-bundler/test/EntryGenerator.test.ts index e694ecb27b..b4238a37cb 100644 --- a/tools/egg-bundler/test/EntryGenerator.test.ts +++ b/tools/egg-bundler/test/EntryGenerator.test.ts @@ -55,7 +55,7 @@ describe('EntryGenerator', () => { } }); - it('writes worker.entry.ts and agent.entry.ts under /.egg-bundle/entries by default', async () => { + it('writes only worker.entry.ts under /.egg-bundle/entries by default', async () => { const gen = new EntryGenerator({ baseDir: tmpDir, manifestLoader: createFakeLoader(makeManifest()), @@ -65,9 +65,8 @@ describe('EntryGenerator', () => { expect(result.entryDir).toBe(path.join(tmpDir, '.egg-bundle', 'entries')); expect(result.workerEntry).toBe(path.join(result.entryDir, 'worker.entry.ts')); - expect(result.agentEntry).toBe(path.join(result.entryDir, 'agent.entry.ts')); await expect(fs.stat(result.workerEntry)).resolves.toBeTruthy(); - await expect(fs.stat(result.agentEntry)).resolves.toBeTruthy(); + await expect(fs.stat(path.join(result.entryDir, 'agent.entry.ts'))).rejects.toMatchObject({ code: 'ENOENT' }); }); it('collects fileDiscovery + resolveCache + tegg decoratedFiles and sorts imports by relKey', async () => { @@ -224,18 +223,14 @@ describe('EntryGenerator', () => { expect(worker).toContain('ManifestStore.setBundleStore'); }); - it('generates an agent entry that is a no-op stub in single mode', async () => { + it('does not generate an agent entry in single mode', async () => { const gen = new EntryGenerator({ baseDir: tmpDir, manifestLoader: createFakeLoader(makeManifest()), }); const result = await gen.generate(); - const agent = await fs.readFile(result.agentEntry, 'utf8'); - expect(agent).toContain('auto-generated by @eggjs/egg-bundler'); - expect(agent).toContain('Single-mode bundled apps run the agent in-process'); - expect(agent).not.toContain('startEgg'); - expect(agent).not.toContain('ManifestStore'); + await expect(fs.stat(path.join(result.entryDir, 'agent.entry.ts'))).rejects.toMatchObject({ code: 'ENOENT' }); }); it('honors a custom outputDir option', async () => { diff --git a/tools/egg-bundler/test/ExternalsResolver.test.ts b/tools/egg-bundler/test/ExternalsResolver.test.ts index 477d2ff1ca..d5b9690f76 100644 --- a/tools/egg-bundler/test/ExternalsResolver.test.ts +++ b/tools/egg-bundler/test/ExternalsResolver.test.ts @@ -1,3 +1,5 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -31,10 +33,15 @@ describe('ExternalsResolver', () => { }); }); - describe('tier 2: ESM-only detection', () => { - it('externalizes a pure-ESM package (type=module without require condition)', async () => { + describe('tier 2: ESM-only packages', () => { + it('does not externalize a pure-ESM package because externals are loaded through createRequire', async () => { const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); - expect(result['esm-only']).toBe('esm-only'); + expect(result['esm-only']).toBeUndefined(); + }); + + it('does not externalize a pure-ESM package whose exports field is a string', async () => { + const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); + expect(result['esm-string-export']).toBeUndefined(); }); it('does not externalize a dual-ESM package that exposes a require condition', async () => { @@ -43,16 +50,23 @@ describe('ExternalsResolver', () => { }); }); - describe('tier 3: hard-coded always-external', () => { - it('externalizes @eggjs/* packages by name alone', async () => { + describe('tier 3: dependency metadata', () => { + it('does not externalize framework or helper packages by name alone', async () => { const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); - expect(result['@eggjs/some-plugin']).toBe('@eggjs/some-plugin'); + expect(result.egg).toBeUndefined(); + expect(result['@swc/helpers']).toBeUndefined(); + expect(result['@eggjs/some-plugin']).toBeUndefined(); }); it('externalizes every peerDependency even if the package is not installed', async () => { const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); expect(result['peer-only']).toBe('peer-only'); }); + + it('externalizes every optionalDependency even if the package is not installed', async () => { + const result = await new ExternalsResolver({ baseDir: basicApp }).resolve(); + expect(result['optional-only']).toBe('optional-only'); + }); }); describe('negative cases', () => { @@ -70,6 +84,32 @@ describe('ExternalsResolver', () => { const resolver = new ExternalsResolver({ baseDir: path.join(fixtureBase, 'nonexistent') }); await expect(resolver.resolve()).resolves.toEqual({}); }); + + it('throws when an installed package has malformed package.json metadata', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-externals-')); + try { + await fs.mkdir(path.join(tempDir, 'node_modules/bad-json'), { recursive: true }); + await fs.writeFile( + path.join(tempDir, 'package.json'), + JSON.stringify({ + name: 'malformed-app', + version: '1.0.0', + private: true, + dependencies: { + 'bad-json': '1.0.0', + }, + }), + ); + await fs.writeFile(path.join(tempDir, 'node_modules/bad-json/package.json'), '{\n "name": "bad-json",'); + + const resolver = new ExternalsResolver({ baseDir: tempDir }); + await expect(resolver.resolve()).rejects.toThrow( + `[@eggjs/egg-bundler] failed to read ${path.join(tempDir, 'node_modules/bad-json/package.json')}`, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); describe('user overrides', () => { @@ -106,12 +146,22 @@ describe('ExternalsResolver', () => { expect(result['peer-only']).toBeUndefined(); }); - it('inline removes a hard-coded @eggjs/* package from externals', async () => { + it('inline removes an optionalDependency from externals', async () => { const result = await new ExternalsResolver({ baseDir: basicApp, - inline: ['@eggjs/some-plugin'], + inline: ['optional-only'], }).resolve(); - expect(result['@eggjs/some-plugin']).toBeUndefined(); + expect(result['optional-only']).toBeUndefined(); + }); + + it('force can still externalize framework and helper packages explicitly', async () => { + const result = await new ExternalsResolver({ + baseDir: basicApp, + force: ['egg', '@swc/helpers', '@eggjs/some-plugin'], + }).resolve(); + expect(result.egg).toBe('egg'); + expect(result['@swc/helpers']).toBe('@swc/helpers'); + expect(result['@eggjs/some-plugin']).toBe('@eggjs/some-plugin'); }); }); }); diff --git a/tools/egg-bundler/test/ManifestLoader.test.ts b/tools/egg-bundler/test/ManifestLoader.test.ts new file mode 100644 index 0000000000..16556a2e6b --- /dev/null +++ b/tools/egg-bundler/test/ManifestLoader.test.ts @@ -0,0 +1,537 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type { StartupManifest } from '@eggjs/core'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { ManifestLoader } from '../src/lib/ManifestLoader.ts'; + +const tempDirs: string[] = []; + +function createTempApp(): string { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'egg-bundler-manifest-loader-')); + tempDirs.push(baseDir); + return baseDir; +} + +function writeJson(filepath: string, data: unknown): void { + fs.mkdirSync(path.dirname(filepath), { recursive: true }); + fs.writeFileSync(filepath, JSON.stringify(data, null, 2)); +} + +function manifest(overrides: Partial = {}): StartupManifest { + return { + version: 1, + generatedAt: '2026-04-30T00:00:00.000Z', + invalidation: { + lockfileFingerprint: '', + configFingerprint: '', + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }, + extensions: {}, + resolveCache: {}, + fileDiscovery: {}, + ...overrides, + }; +} + +function generatedManifest(): StartupManifest { + return manifest({ + generatedAt: '2026-04-30T00:00:00.000Z', + invalidation: { + lockfileFingerprint: '', + configFingerprint: '', + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: false, + }, + extensions: { + generated: true, + }, + resolveCache: { + 'app/service/user': 'app/service/user.ts', + }, + fileDiscovery: { + 'app/service': ['user.ts'], + }, + }); +} + +async function createFrameworkFixture( + exportsField: unknown = { + import: './src/index.js', + }, +): Promise<{ appDir: string; frameworkDir: string }> { + const root = createTempApp(); + const appDir = path.join(root, 'app'); + const frameworkDir = path.join(root, 'framework'); + await fsp.mkdir(path.join(frameworkDir, 'src'), { recursive: true }); + await fsp.mkdir(appDir, { recursive: true }); + + await fsp.writeFile(path.join(appDir, 'package.json'), JSON.stringify({ name: 'app', dependencies: {} })); + await fsp.writeFile( + path.join(frameworkDir, 'package.json'), + JSON.stringify( + { + name: 'fake-framework', + type: 'module', + exports: exportsField, + dependencies: {}, + }, + null, + 2, + ), + ); + await fsp.writeFile( + path.join(frameworkDir, 'src/index.js'), + ` +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export async function start(options) { + await fs.writeFile(path.join(options.baseDir, 'framework-entry.txt'), import.meta.url); + await fs.writeFile(path.join(options.baseDir, 'exec-argv.json'), JSON.stringify(process.execArgv)); + return { + loader: { + generateManifest() { + return ${JSON.stringify(generatedManifest(), null, 10)}; + }, + }, + async close() { + await fs.writeFile(path.join(options.baseDir, 'closed.txt'), 'true'); + }, + }; +} +`, + ); + + return { appDir, frameworkDir }; +} + +async function loadFrameworkFixture(exportsField: unknown): Promise<{ appDir: string; frameworkDir: string }> { + const fixture = await createFrameworkFixture(exportsField); + const loader = new ManifestLoader({ + baseDir: fixture.appDir, + framework: fixture.frameworkDir, + autoGenerate: true, + env: 'prod', + execArgv: [], + }); + await loader.load(); + return fixture; +} + +async function createInstalledFrameworkFixture( + exportsField: unknown = { + '.': { + import: './src/index.js', + }, + }, +): Promise<{ appDir: string; frameworkDir: string }> { + const root = createTempApp(); + const appDir = path.join(root, 'app'); + const frameworkDir = path.join(appDir, 'node_modules/fake-framework'); + await fsp.mkdir(path.join(frameworkDir, 'src'), { recursive: true }); + + await fsp.writeFile( + path.join(appDir, 'package.json'), + JSON.stringify({ name: 'app', dependencies: { 'fake-framework': '1.0.0' } }), + ); + await fsp.writeFile( + path.join(frameworkDir, 'package.json'), + JSON.stringify( + { + name: 'fake-framework', + type: 'module', + exports: exportsField, + dependencies: {}, + }, + null, + 2, + ), + ); + await fsp.writeFile( + path.join(frameworkDir, 'src/index.js'), + ` +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export async function start(options) { + await fs.writeFile(path.join(options.baseDir, 'framework-entry.txt'), import.meta.url); + return { + loader: { + generateManifest() { + return ${JSON.stringify(generatedManifest(), null, 10)}; + }, + }, + async close() {}, + }; +} +`, + ); + + return { appDir, frameworkDir }; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('ManifestLoader', () => { + it('normalizes absolute manifest paths through nested dependency package roots', async () => { + const baseDir = createTempApp(); + const directRoot = path.join(baseDir, 'node_modules/direct'); + const transitiveRoot = path.join(directRoot, 'node_modules/transitive'); + const transitiveLib = path.join(transitiveRoot, 'lib'); + const transitiveFile = path.join(transitiveLib, 'svc.ts'); + const hoistedRoot = path.join(baseDir, 'node_modules/hoisted-transitive'); + const hoistedFile = path.join(hoistedRoot, 'index.ts'); + const optionalRoot = path.join(baseDir, 'node_modules/optional-native'); + const optionalFile = path.join(optionalRoot, 'index.ts'); + + writeJson(path.join(baseDir, 'package.json'), { + dependencies: { + direct: '1.0.0', + }, + optionalDependencies: { + 'optional-native': '1.0.0', + }, + }); + writeJson(path.join(directRoot, 'package.json'), { + name: 'direct', + version: '1.0.0', + dependencies: { + transitive: '1.0.0', + 'hoisted-transitive': '1.0.0', + }, + }); + writeJson(path.join(transitiveRoot, 'package.json'), { + name: 'transitive', + version: '1.0.0', + }); + writeJson(path.join(hoistedRoot, 'package.json'), { + name: 'hoisted-transitive', + version: '1.0.0', + }); + writeJson(path.join(optionalRoot, 'package.json'), { + name: 'optional-native', + version: '1.0.0', + }); + fs.mkdirSync(transitiveLib, { recursive: true }); + fs.writeFileSync(transitiveFile, 'export const value = 1;\n'); + fs.writeFileSync(hoistedFile, 'export const hoisted = true;\n'); + fs.writeFileSync(optionalFile, 'export const optional = true;\n'); + + const manifestPath = path.join(baseDir, '.egg/manifest.json'); + writeJson( + manifestPath, + manifest({ + fileDiscovery: { + [transitiveLib]: ['svc.ts'], + }, + resolveCache: { + [path.join(transitiveRoot, 'entry')]: transitiveFile, + [path.join(hoistedRoot, 'entry')]: hoistedFile, + [path.join(optionalRoot, 'entry')]: optionalFile, + }, + extensions: { + tegg: { + moduleDescriptors: [ + { + unitPath: transitiveRoot, + decoratedFiles: ['lib/svc.ts'], + }, + ], + }, + }, + }), + ); + + const loader = new ManifestLoader({ baseDir, manifestPath, autoGenerate: false }); + const loaded = await loader.load(); + + expect(loaded.fileDiscovery).toEqual({ + 'node_modules/direct/node_modules/transitive/lib': ['svc.ts'], + }); + expect(loaded.resolveCache).toEqual({ + 'node_modules/hoisted-transitive/entry': 'node_modules/hoisted-transitive/index.ts', + 'node_modules/direct/node_modules/transitive/entry': 'node_modules/direct/node_modules/transitive/lib/svc.ts', + 'node_modules/optional-native/entry': 'node_modules/optional-native/index.ts', + }); + expect(loaded.extensions.tegg).toEqual({ + moduleDescriptors: [ + { + unitPath: 'node_modules/direct/node_modules/transitive', + decoratedFiles: ['lib/svc.ts'], + }, + ], + }); + expect(loader.getAllDiscoveredFiles()).toEqual([transitiveFile]); + expect(loader.getTeggDecoratedFiles()).toEqual([transitiveFile]); + expect(loader.store.data).toBe(loaded); + }); + + it('merges file discovery entries that normalize to the same package path', async () => { + const baseDir = createTempApp(); + const directLib = path.join(baseDir, 'node_modules/direct/lib'); + fs.mkdirSync(directLib, { recursive: true }); + writeJson(path.join(baseDir, 'package.json'), { + dependencies: { + direct: '1.0.0', + }, + }); + writeJson(path.join(baseDir, 'node_modules/direct/package.json'), { + name: 'direct', + version: '1.0.0', + }); + + const manifestPath = path.join(baseDir, '.egg/manifest.json'); + writeJson( + manifestPath, + manifest({ + fileDiscovery: { + [directLib]: ['a.ts'], + 'node_modules/direct/lib': ['a.ts', 'b.ts'], + }, + }), + ); + + const loaded = await new ManifestLoader({ baseDir, manifestPath, autoGenerate: false }).load(); + + expect(loaded.fileDiscovery).toEqual({ + 'node_modules/direct/lib': ['a.ts', 'b.ts'], + }); + }); + + it('rejects conflicting resolve cache entries that normalize to the same key', async () => { + const baseDir = createTempApp(); + const directRoot = path.join(baseDir, 'node_modules/direct'); + fs.mkdirSync(directRoot, { recursive: true }); + writeJson(path.join(baseDir, 'package.json'), { + dependencies: { + direct: '1.0.0', + }, + }); + writeJson(path.join(directRoot, 'package.json'), { + name: 'direct', + version: '1.0.0', + }); + + const manifestPath = path.join(baseDir, '.egg/manifest.json'); + writeJson( + manifestPath, + manifest({ + resolveCache: { + [path.join(directRoot, 'entry')]: path.join(directRoot, 'a.ts'), + 'node_modules/direct/entry': 'node_modules/direct/b.ts', + }, + }), + ); + + await expect(new ManifestLoader({ baseDir, manifestPath, autoGenerate: false }).load()).rejects.toThrow( + 'conflicting normalized resolveCache entry for node_modules/direct/entry', + ); + }); + + it('loads older minimal manifests without extensions', async () => { + const baseDir = createTempApp(); + writeJson(path.join(baseDir, 'package.json'), {}); + const manifestPath = path.join(baseDir, '.egg/manifest.json'); + writeJson(manifestPath, { + ...manifest(), + extensions: undefined, + }); + + const loaded = await new ManifestLoader({ baseDir, manifestPath, autoGenerate: false }).load(); + + expect(loaded.extensions).toEqual({}); + }); + + it('does not auto-generate missing manifests by default', async () => { + const baseDir = createTempApp(); + writeJson(path.join(baseDir, 'package.json'), {}); + const manifestPath = path.join(baseDir, '.egg/manifest.json'); + + const loader = new ManifestLoader({ baseDir, manifestPath }); + + await expect(loader.load()).rejects.toThrow(`manifest not found at ${manifestPath}`); + }); + + it('auto-generates a missing manifest and loads the exact generated data', async () => { + const { appDir, frameworkDir } = await createFrameworkFixture(); + const manifestPath = path.join(appDir, '.egg/manifest.json'); + const expected = generatedManifest(); + + const loader = new ManifestLoader({ + baseDir: appDir, + framework: frameworkDir, + autoGenerate: true, + env: 'prod', + execArgv: [], + }); + const loaded = await loader.load(); + const written = JSON.parse(await fsp.readFile(manifestPath, 'utf-8')); + + expect(written).toEqual(expected); + expect(loaded).toEqual(expected); + expect(loader.store.data).toBe(loaded); + await expect(fsp.readFile(path.join(appDir, 'closed.txt'), 'utf-8')).resolves.toBe('true'); + await expect(fsp.readFile(path.join(appDir, 'framework-entry.txt'), 'utf-8')).resolves.toBe( + pathToFileURL(path.join(frameworkDir, 'src/index.js')).href, + ); + }); + + it('includes the manifest path when JSON parsing fails', async () => { + const baseDir = createTempApp(); + const manifestPath = path.join(baseDir, '.egg/manifest.json'); + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync(manifestPath, 'not json{{{'); + + const loader = new ManifestLoader({ baseDir, manifestPath, autoGenerate: false }); + + await expect(loader.load()).rejects.toThrow(`invalid manifest JSON at ${manifestPath}`); + }); + + it('surfaces non-ENOENT manifest read errors', async () => { + const baseDir = createTempApp(); + const manifestPath = path.join(baseDir, '.egg/manifest.json'); + fs.mkdirSync(manifestPath, { recursive: true }); + + const loader = new ManifestLoader({ baseDir, manifestPath, autoGenerate: false }); + + await expect(loader.load()).rejects.toMatchObject({ code: 'EISDIR' }); + }); + + it('surfaces malformed dependency package metadata during normalization', async () => { + const baseDir = createTempApp(); + const badPackageDir = path.join(baseDir, 'node_modules/bad-json'); + fs.mkdirSync(badPackageDir, { recursive: true }); + writeJson(path.join(baseDir, 'package.json'), { + dependencies: { + 'bad-json': '1.0.0', + }, + }); + fs.writeFileSync(path.join(badPackageDir, 'package.json'), '{\n "name": "bad-json",'); + const manifestPath = path.join(baseDir, '.egg/manifest.json'); + writeJson(manifestPath, manifest()); + + await expect(new ManifestLoader({ baseDir, manifestPath, autoGenerate: false }).load()).rejects.toThrow( + `Invalid package config ${path.join(badPackageDir, 'package.json')}`, + ); + }); + + it('does not cache a manifest when bundle store creation rejects it', async () => { + const baseDir = createTempApp(); + writeJson(path.join(baseDir, 'package.json'), {}); + const manifestPath = path.join(baseDir, '.egg/manifest.json'); + const data = manifest(); + delete (data as Partial).invalidation; + writeJson(manifestPath, data); + + const loader = new ManifestLoader({ baseDir, manifestPath, autoGenerate: false }); + + await expect(loader.load()).rejects.toThrow('bundled manifest missing invalidation data'); + expect(() => loader.manifest).toThrow('ManifestLoader.load() must be awaited'); + await expect(loader.load()).rejects.toThrow('bundled manifest missing invalidation data'); + }); + + it('resolves package exports shorthand condition maps in key order for frameworkEntry', async () => { + const { appDir, frameworkDir } = await loadFrameworkFixture({ + default: './src/index.js', + import: './src/missing.js', + }); + + const loadedEntry = await fsp.readFile(path.join(appDir, 'framework-entry.txt'), 'utf-8'); + expect(loadedEntry).toBe(pathToFileURL(path.join(frameworkDir, 'src/index.js')).href); + }); + + it('ignores inactive package exports conditions for frameworkEntry', async () => { + await expect(loadFrameworkFixture({ require: './src/index.js' })).rejects.toThrow(/has no resolvable entry/); + }); + + it('surfaces malformed framework package metadata with path context', async () => { + const { appDir, frameworkDir } = await createFrameworkFixture(); + const pkgJsonPath = path.join(frameworkDir, 'package.json'); + await fsp.writeFile(pkgJsonPath, '{\n "name": "fake-framework",'); + const loader = new ManifestLoader({ + baseDir: appDir, + framework: frameworkDir, + autoGenerate: true, + env: 'prod', + execArgv: [], + }); + + await expect(loader.load()).rejects.toThrow(`[@eggjs/egg-bundler] failed to read framework package ${pkgJsonPath}`); + }); + + it('resolves nested package exports conditions for frameworkEntry', async () => { + const { appDir, frameworkDir } = await loadFrameworkFixture({ + '.': { + import: { + default: './src/index.js', + }, + default: './src/missing.js', + }, + }); + + const loadedEntry = await fsp.readFile(path.join(appDir, 'framework-entry.txt'), 'utf-8'); + expect(loadedEntry).toBe(pathToFileURL(path.join(frameworkDir, 'src/index.js')).href); + }); + + it('does not treat package exports subpath maps as root condition maps', async () => { + const { appDir, frameworkDir } = await createFrameworkFixture({ + './feature': './src/index.js', + }); + const loader = new ManifestLoader({ + baseDir: appDir, + framework: frameworkDir, + autoGenerate: true, + env: 'prod', + execArgv: [], + }); + + await expect(loader.load()).rejects.toThrow(/has no resolvable entry/); + }); + + it('does not fall back to main when package exports has no root entry', async () => { + const { appDir, frameworkDir } = await createFrameworkFixture({ + './feature': './src/missing.js', + }); + const pkgJsonPath = path.join(frameworkDir, 'package.json'); + const pkg = JSON.parse(await fsp.readFile(pkgJsonPath, 'utf-8')); + pkg.main = './src/index.js'; + await fsp.writeFile(pkgJsonPath, JSON.stringify(pkg, null, 2)); + const loader = new ManifestLoader({ + baseDir: appDir, + framework: frameworkDir, + autoGenerate: true, + env: 'prod', + execArgv: [], + }); + + await expect(loader.load()).rejects.toThrow(/has no resolvable entry/); + }); + + it('resolves framework package roots when package.json is hidden by exports', async () => { + const { appDir, frameworkDir } = await createInstalledFrameworkFixture(); + const loader = new ManifestLoader({ + baseDir: appDir, + framework: 'fake-framework', + autoGenerate: true, + env: 'prod', + execArgv: [], + }); + + await loader.load(); + + const loadedEntry = await fsp.readFile(path.join(appDir, 'framework-entry.txt'), 'utf-8'); + expect(loadedEntry).toBe(pathToFileURL(path.join(frameworkDir, 'src/index.js')).href); + }); +}); diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/@swc/helpers/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/@swc/helpers/package.json new file mode 100644 index 0000000000..ce876be2de --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/@swc/helpers/package.json @@ -0,0 +1,4 @@ +{ + "name": "@swc/helpers", + "version": "1.0.0" +} diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/egg/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/egg/package.json new file mode 100644 index 0000000000..034e2664e4 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/egg/package.json @@ -0,0 +1,4 @@ +{ + "name": "egg", + "version": "1.0.0" +} diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/esm-string-export/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/esm-string-export/package.json new file mode 100644 index 0000000000..b02065d7a7 --- /dev/null +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/node_modules/esm-string-export/package.json @@ -0,0 +1,6 @@ +{ + "name": "esm-string-export", + "version": "1.0.0", + "type": "module", + "exports": "./index.js" +} diff --git a/tools/egg-bundler/test/fixtures/externals/basic-app/package.json b/tools/egg-bundler/test/fixtures/externals/basic-app/package.json index 07fab0b6e2..34d6d57c30 100644 --- a/tools/egg-bundler/test/fixtures/externals/basic-app/package.json +++ b/tools/egg-bundler/test/fixtures/externals/basic-app/package.json @@ -4,8 +4,11 @@ "private": true, "dependencies": { "@eggjs/some-plugin": "1.0.0", + "@swc/helpers": "1.0.0", + "egg": "1.0.0", "esm-dual": "1.0.0", "esm-only": "1.0.0", + "esm-string-export": "1.0.0", "missing-pkg": "1.0.0", "native-binding": "1.0.0", "native-dotnode": "1.0.0", @@ -15,5 +18,8 @@ }, "peerDependencies": { "peer-only": "1.0.0" + }, + "optionalDependencies": { + "optional-only": "1.0.0" } } diff --git a/tools/egg-bundler/test/integration.test.ts b/tools/egg-bundler/test/integration.test.ts index 69dec64d81..fe4730d917 100644 --- a/tools/egg-bundler/test/integration.test.ts +++ b/tools/egg-bundler/test/integration.test.ts @@ -151,9 +151,9 @@ describe('bundle() integration — minimal-app (Phase 1: mocked @utoo/pack)', () expect(bm.framework).toBe('egg'); expect(bm.entries).toEqual([{ name: 'worker', source: expect.stringContaining('worker.entry.ts') }]); expect(Array.isArray(bm.externals)).toBe(true); - // externals should be sorted and should contain at least egg (workspace dep) + // externals should be sorted; framework packages are bundled by default. expect([...bm.externals]).toEqual(sortStrings(bm.externals)); - expect(bm.externals).toContain('egg'); + expect(bm.externals).not.toContain('egg'); // chunks should be sorted and contain worker.js expect([...bm.chunks]).toEqual(sortStrings(bm.chunks)); expect(bm.chunks).toContain('worker.js');