diff --git a/packages/core/src/loader/egg_loader.ts b/packages/core/src/loader/egg_loader.ts index 36fcb1a77a..bc08a21904 100644 --- a/packages/core/src/loader/egg_loader.ts +++ b/packages/core/src/loader/egg_loader.ts @@ -792,10 +792,13 @@ export class EggLoader { } // In bundle mode, a plugin declared via `definePluginFactory({ path: import.meta.dirname })` - // had its `path` rewritten by the bundler to the bundle output dir (= baseDir). The original - // value was the directory of the plugin package's entry module (e.g. `/dist`), not the - // package root, so re-resolve to the entry module's directory rather than the package.json dir. - if (plugin.path && this.#isBundlePluginPathArtifact(plugin.path)) { + // has its `path` either rewritten by the bundler to the bundle output dir (= baseDir), or + // dropped entirely when the bundler compiles `import.meta.dirname` to `undefined` (e.g. + // @utoo/pack emits `__TURBOPACK__import$2e$meta__.dirname`, which is undefined at runtime, + // leaving the plugin with no usable `path`). In both cases the plugin's files are served + // from the manifest-backed bundle store, so re-resolve the plugin by package name — the + // conventional `@eggjs/` for built-in framework plugins — instead of the baked path. + if (this.#isBundleModeForThisApp() && (!plugin.path || this.#isBundlePluginPathArtifact(plugin.path))) { return this.#resolveBundlePluginPath(plugin); } @@ -808,13 +811,18 @@ export class EggLoader { * which does not contain the plugin's own files. Detect that case so the plugin is * re-resolved by package name instead. */ - #isBundlePluginPathArtifact(pluginPath: string): boolean { + /** + * Whether an active bundle store belongs to THIS app. The store is shared via globalThis + * across @eggjs/core copies, so one registered for a different app must not reinterpret this + * app's plugin paths — mirror `ManifestStore.load()`'s `bundleStore.baseDir === baseDir` gate. + */ + #isBundleModeForThisApp(): boolean { const bundleStore = ManifestStore.getBundleStore(); - // Only treat a path as a bundle artifact when the active bundle store belongs - // to this app. The store is shared via globalThis across @eggjs/core copies, - // so one registered for a different app must not reinterpret this app's plugin - // paths — mirror `ManifestStore.load()`'s `bundleStore.baseDir === baseDir` gate. - if (!bundleStore || path.resolve(bundleStore.baseDir) !== path.resolve(this.options.baseDir)) { + return !!bundleStore && path.resolve(bundleStore.baseDir) === path.resolve(this.options.baseDir); + } + + #isBundlePluginPathArtifact(pluginPath: string): boolean { + if (!this.#isBundleModeForThisApp()) { return false; } return path.resolve(pluginPath) === path.resolve(this.options.baseDir); @@ -836,9 +844,17 @@ export class EggLoader { for (const name of candidates) { try { // Resolve the package entry module (not package.json) so the returned directory matches - // the plugin package's runtime entry directory (e.g. `/dist`). - const entry = utils.resolvePath(name, { paths: [...this.lookupDirs] }); - const realDir = path.dirname(entry); + // the plugin package's runtime entry directory (e.g. `/dist`) — this mirrors the + // `import.meta.dirname` a `definePluginFactory` plugin captured at manifest-build time. + // Old-style plugins (e.g. `egg-view-nunjucks`) ship `app.js`/`config/` at the package + // root and declare no `main`, so entry resolution throws; fall back to `package.json`, + // whose directory is the package root the manifest keyed those files under. + let realDir: string; + try { + realDir = path.dirname(utils.resolvePath(name, { paths: [...this.lookupDirs] })); + } catch { + realDir = path.dirname(utils.resolvePath(`${name}/package.json`, { paths: [...this.lookupDirs] })); + } // Rebase under the bundle output baseDir so the manifest-backed loader fs // (keyed relative to baseDir) resolves the bundled plugin config/extend/app // files, mirroring how the framework eggPaths are rebased. Match the last @@ -847,7 +863,17 @@ export class EggLoader { const segments = realDir.split(/[/\\]/); const nmIdx = segments.lastIndexOf('node_modules'); if (nmIdx !== -1) { - return path.join(this.options.baseDir, ...segments.slice(nmIdx)); + const rebased = path.join(this.options.baseDir, ...segments.slice(nmIdx)); + // In a real bundle the externals are installed under the output baseDir's + // `node_modules`, so the rebased dir exists and holds the files the manifest + // keyed. When a bundle store is registered over a source checkout — e.g. an + // integration test that consumes a normally-collected manifest, or a dev + // bundle whose externals still resolve from the workspace — that rebased dir + // may not exist; fall back to the real resolved dir so the plugin's + // config/extend/app files still load. + if (fs.existsSync(rebased)) { + return rebased; + } } return realDir; } catch (err) { diff --git a/packages/egg/src/lib/egg.ts b/packages/egg/src/lib/egg.ts index f5ad811fb2..af72c71187 100644 --- a/packages/egg/src/lib/egg.ts +++ b/packages/egg/src/lib/egg.ts @@ -671,12 +671,22 @@ export class EggApplicationCore extends EggCore { // manifest-backed loader fs (keyed relative to the output baseDir) // resolves the bundled framework config files. const bundledFrameworkDir = path.join(bundleStore.baseDir, 'node_modules', 'egg', 'dist'); - // Guard on existence: a real bundler output physically copies egg here, but - // a bundle-mode app booted without the copied framework (e.g. integration - // tests that only inject a manifest-backed loaderFS) does not. In that case - // fall back to `import.meta.dirname`, which — when not actually rewritten by - // a bundler — still points at the real egg package dir. - if (fs.existsSync(bundledFrameworkDir)) { + // Use the rebased framework dir when EITHER: + // - egg is physically copied next to the bundle output (a deploy that ran + // `npm ci` into the output dir), OR + // - `import.meta.dirname` was actually rewritten by the bundler to the bundle + // output dir (it resolves to `bundleStore.baseDir` instead of the real egg + // package dir). In a real bundle the framework files are inlined and served + // by the manifest-backed loaderFS (keyed `node_modules/egg/dist/...`), so + // they need not exist on disk — `path.dirname(import.meta.dirname)` would + // otherwise point at the app's parent dir and the built-in framework plugins + // (security, session, view, …) would never load. + // Integration tests that inject a manifest-backed loaderFS while running from + // source keep a non-rewritten `import.meta.dirname` (the real egg dir), so they + // fall through to the import.meta.dirname branch below unchanged. + const importMetaRewritten = + !!import.meta.dirname && path.resolve(import.meta.dirname) === path.resolve(bundleStore.baseDir); + if (fs.existsSync(bundledFrameworkDir) || importMetaRewritten) { return [bundledFrameworkDir, ...super.customEggPaths()]; } } diff --git a/packages/utils/src/import.ts b/packages/utils/src/import.ts index b1a2fea133..ea859b834e 100644 --- a/packages/utils/src/import.ts +++ b/packages/utils/src/import.ts @@ -62,7 +62,15 @@ try { // state persists across test files. const detectedIsESM = isESM; const nodeMajorVersion = parseInt(process.versions.node.split('.', 1)[0], 10); -const supportImportMetaResolve = nodeMajorVersion >= 18; +// Feature-detect instead of gating on the Node version: when the code is shipped +// inside a bundle (e.g. @utoo/pack rewrites `import.meta` to a runtime shim that +// lacks `.resolve`), `import.meta.resolve` is not a function even on Node >= 18, so +// calling it throws. Detecting the actual capability lets us fall back to +// `require.resolve` in the bundled CommonJS runtime. +const supportImportMetaResolve = + nodeMajorVersion >= 18 && + typeof import.meta !== 'undefined' && + typeof (import.meta as { resolve?: unknown }).resolve === 'function'; let _customRequire: NodeRequire; export function getRequire(): NodeRequire { @@ -407,7 +415,10 @@ export function importResolve(filepath: string, options?: ImportResolveOptions): throw new TypeError(`Cannot find module ${filepath}, because ${moduleFilePath} does not exists`); } } else { - moduleFilePath = getRequire().resolve(filepath); + // Fallback when `import.meta.resolve` is unavailable (e.g. inside a bundle). + // Forward `paths` so package resolution still honours the caller's lookup + // dirs (the app baseDir / framework dirs), matching the on-disk attempts above. + moduleFilePath = getRequire().resolve(filepath, paths ? { paths } : undefined); } } debug('[importResolve:success] %o, options: %o => %o, isESM: %s', filepath, options, moduleFilePath, isESM); diff --git a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts index f7dae5843d..85f67b4958 100644 --- a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts +++ b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts @@ -1,6 +1,6 @@ import { EggLoadUnitType, LoadUnitFactory, GlobalGraph, ModuleDescriptorDumper } from '@eggjs/metadata'; import type { GlobalGraphBuildHook, ModuleDescriptor } from '@eggjs/metadata'; -import { LoaderFactory, TEGG_MANIFEST_KEY } from '@eggjs/tegg-loader'; +import { LoaderFactory, ModuleLoader, TEGG_MANIFEST_KEY } from '@eggjs/tegg-loader'; import type { TeggManifestExtension } from '@eggjs/tegg-loader'; import type { ModuleReference } from '@eggjs/tegg-types'; import type { Application } from 'egg'; @@ -11,6 +11,13 @@ export class EggModuleLoader { app: Application; globalGraph: GlobalGraph; private pendingBuildHooks: GlobalGraphBuildHook[] = []; + /** + * True when the app graph was built from a tegg manifest (bundle mode). In + * that case the module source files do not exist on disk, so module load + * units must reuse the manifest's precomputed decorated files instead of + * globbing the file system. + */ + private loadedFromManifest = false; constructor(app: Application) { this.app = app; @@ -39,6 +46,7 @@ export class EggModuleLoader { const manifest = this.app.loader.manifest; const manifestTegg = manifest.getExtension(TEGG_MANIFEST_KEY) as TeggManifestExtension | undefined; const loadAppManifest = manifestTegg?.moduleDescriptors?.length ? manifestTegg : undefined; + this.loadedFromManifest = !!loadAppManifest; // Reuse egg-core's loader fs so discovery goes through the shared VFS: // RealLoaderFS in normal mode (zero behavior change), ManifestLoaderFS in bundle mode. @@ -98,9 +106,28 @@ export class EggModuleLoader { this.globalGraph.sort(); const moduleConfigList = this.globalGraph.moduleConfigList; const loaderFS = this.app.loader.loaderFS; + + // In bundle mode the module source files are not present on disk, so a + // globbing loader returns nothing. Reuse the manifest's precomputed + // decorated files (the same list buildAppGraph loaded the graph from) so + // load-unit lifecycle hooks such as EggQualifierProtoHook still see the + // real decorated classes via `ctx.loader.load()`. + const decoratedFilesMap = new Map(); + if (this.loadedFromManifest) { + const manifestTegg = this.app.loader.manifest.getExtension(TEGG_MANIFEST_KEY) as + | TeggManifestExtension + | undefined; + for (const desc of manifestTegg?.moduleDescriptors ?? []) { + decoratedFilesMap.set(desc.unitPath, desc.decoratedFiles); + } + } + for (const moduleConfig of moduleConfigList) { const modulePath = moduleConfig.path; - const loader = LoaderFactory.createLoader(modulePath, EggLoadUnitType.MODULE, loaderFS); + const precomputedFiles = decoratedFilesMap.get(modulePath); + const loader = precomputedFiles + ? new ModuleLoader(modulePath, { precomputedFiles, loaderFS }) + : LoaderFactory.createLoader(modulePath, EggLoadUnitType.MODULE, loaderFS); const loadUnit = await LoadUnitFactory.createLoadUnit(modulePath, EggLoadUnitType.MODULE, loader); this.app.moduleHandler.loadUnits.push(loadUnit); } diff --git a/tegg/plugin/tegg/test/BundledAppBoot.test.ts b/tegg/plugin/tegg/test/BundledAppBoot.test.ts index 2575593199..08d8001d26 100644 --- a/tegg/plugin/tegg/test/BundledAppBoot.test.ts +++ b/tegg/plugin/tegg/test/BundledAppBoot.test.ts @@ -102,29 +102,41 @@ describe('plugin/tegg/test/BundledAppBoot.test.ts', () => { `expected ManifestLoaderFS, got ${app.loader.loaderFS?.constructor?.name}`, ); - // Core/framework discovery is fully served from the manifest — nothing outside - // tegg module dirs ever falls back to a real-fs glob. Match the `modules` path - // segment exactly rather than a substring, to avoid similarly named dirs. - const isUnderModulesDir = (cwd: string): boolean => cwd.split(path.sep).includes('modules'); - const nonModuleGlobs = bootFallbackGlobTargets.filter((cwd) => !isUnderModulesDir(cwd)); + // The app's own (first-party) core discovery is fully served from the manifest: + // no directory under baseDir (outside tegg module dirs) falls back to a real-fs + // glob. Match the `modules`/`node_modules` path segments exactly rather than a + // substring, to avoid similarly named dirs. + // + // Third-party plugin dirs under `node_modules` are intentionally excluded: a + // plugin whose `app/service` (etc.) directory is empty produces an empty-result + // glob that the manifest does not cache, so it legitimately falls back. That is + // environment-dependent (only the pnpm/CI layout materializes those dirs next to + // the app) and orthogonal to what this test asserts. + const hasSegment = (cwd: string, seg: string): boolean => cwd.split(path.sep).includes(seg); + const isUnderModulesDir = (cwd: string): boolean => hasSegment(cwd, 'modules'); + const firstPartyGlobs = bootFallbackGlobTargets.filter( + (cwd) => !isUnderModulesDir(cwd) && !hasSegment(cwd, 'node_modules'), + ); assert.deepEqual( - nonModuleGlobs, + firstPartyGlobs, [], - `core discovery should be fully manifest-served, but globbed: ${JSON.stringify(nonModuleGlobs)}`, + `app's own discovery should be fully manifest-served, but globbed: ${JSON.stringify(firstPartyGlobs)}`, ); - // The tegg module discovery that DOES run reaches the manifest VFS's fallback, - // which is only possible because EggModuleLoader now goes through - // app.loader.loaderFS (Theme F) — before, tegg used its own globby import. - assert.ok( - bootFallbackGlobTargets.some(isUnderModulesDir), - 'tegg module discovery should route through the injected loaderFS', + // In consume mode EggModuleLoader.loadModule now reuses the manifest's + // precomputed tegg `decoratedFiles` instead of re-globbing each module dir, + // so tegg module discovery is fully manifest-served and NO fallback glob runs + // under a module dir either. This is what lets the load-unit lifecycle hooks + // (e.g. EggQualifierProtoHook) still see the real decorated classes via + // `ctx.loader.load()` in a bundle, where the module source files do not exist + // on disk for a glob to find. + assert.deepEqual( + bootFallbackGlobTargets.filter(isUnderModulesDir), + [], + `tegg module discovery should be fully manifest-served, but globbed module dirs: ${JSON.stringify( + bootFallbackGlobTargets.filter(isUnderModulesDir), + )}`, ); - - // KNOWN RESIDUAL: in consume mode EggModuleLoader.loadModule still re-globs each - // module dir (manifest stores tegg decoratedFiles, not module dirs in core - // fileDiscovery, and loadModule does not pass decoratedFiles). Pre-existing, - // out of Theme F scope. See [[bundle-startup-loadmodule-residual-glob]]. }); it('should serve the full controller -> service -> cross-module repo DI chain over HTTP', async () => {