Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 40 additions & 14 deletions packages/core/src/loader/egg_loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<pkg>/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/<name>` for built-in framework plugins — instead of the baked path.
if (this.#isBundleModeForThisApp() && (!plugin.path || this.#isBundlePluginPathArtifact(plugin.path))) {
return this.#resolveBundlePluginPath(plugin);
}

Expand All @@ -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);
Expand All @@ -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. `<pkg>/dist`).
const entry = utils.resolvePath(name, { paths: [...this.lookupDirs] });
const realDir = path.dirname(entry);
// the plugin package's runtime entry directory (e.g. `<pkg>/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
Expand All @@ -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) {
Expand Down
22 changes: 16 additions & 6 deletions packages/egg/src/lib/egg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()];
}
}
Expand Down
15 changes: 13 additions & 2 deletions packages/utils/src/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 29 additions & 2 deletions tegg/plugin/tegg/src/lib/EggModuleLoader.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, string[]>();
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);
}
Expand Down
48 changes: 30 additions & 18 deletions tegg/plugin/tegg/test/BundledAppBoot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading