Skip to content
Open
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
3 changes: 3 additions & 0 deletions packages/core/src/egg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export interface EggCoreOptions {
plugins?: any;
serverScope?: string;
env?: string;
/** Skip lifecycle hooks, only trigger loadMetadata for manifest generation */
metadataOnly?: boolean;
}

export type EggCoreInitOptions = Partial<EggCoreOptions>;
Expand Down Expand Up @@ -218,6 +220,7 @@ export class EggCore extends KoaApplication {
serverScope: options.serverScope,
env: options.env ?? '',
EggCoreClass: EggCore,
metadataOnly: options.metadataOnly,
});
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './singleton.ts';
export * from './loader/egg_loader.ts';
export * from './loader/file_loader.ts';
export * from './loader/context_loader.ts';
export * from './loader/manifest.ts';
export * from './utils/sequencify.ts';
export * from './utils/timing.ts';
export type * from './types.ts';
34 changes: 33 additions & 1 deletion packages/core/src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ export interface ILifecycleBoot {
* Do some thing before app close
*/
beforeClose?(): Promise<void>;

/**
* Collect metadata for manifest generation (metadataOnly mode).
* Called instead of configWillLoad/configDidLoad/didLoad/willReady
* when the application is started with metadataOnly: true.
*/
loadMetadata?(): Promise<void> | void;
}

export type BootImplClass<T = ILifecycleBoot> = new (...args: any[]) => T;
Expand All @@ -72,6 +79,7 @@ export class Lifecycle extends EventEmitter {
#bootHooks: (BootImplClass | ILifecycleBoot)[];
#boots: ILifecycleBoot[];
#isClosed: boolean;
#metadataOnly: boolean;
#closeFunctionSet: Set<FunWithFullPath>;
loadReady: Ready;
bootReady: Ready;
Expand All @@ -87,6 +95,7 @@ export class Lifecycle extends EventEmitter {
this.#boots = [];
this.#closeFunctionSet = new Set();
this.#isClosed = false;
this.#metadataOnly = false;
this.#init = false;

this.timing.start(`${this.options.app.type} Start`);
Expand All @@ -110,7 +119,9 @@ export class Lifecycle extends EventEmitter {
});

this.ready((err) => {
this.triggerDidReady(err);
if (!this.#metadataOnly) {
void this.triggerDidReady(err);
}
debug('app ready');
this.timing.end(`${this.options.app.type} Start`);
});
Expand Down Expand Up @@ -331,6 +342,27 @@ export class Lifecycle extends EventEmitter {
})();
}

async triggerLoadMetadata(): Promise<void> {
this.#metadataOnly = true;
debug('trigger loadMetadata start');
let firstError: Error | undefined;
for (const boot of this.#boots) {
if (typeof boot.loadMetadata === 'function') {
debug('trigger loadMetadata at %o', boot.fullPath);
try {
await boot.loadMetadata();
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
if (!firstError) firstError = error;
debug('trigger loadMetadata error at %o, error: %s', boot.fullPath, error);
this.emit('error', error);
}
}
}
debug('trigger loadMetadata end');
this.ready(firstError ?? true);
}

#initReady(): void {
debug('loadReady init');
this.loadReady = new Ready({ timeout: this.readyTimeout, lazyStart: true });
Expand Down
43 changes: 40 additions & 3 deletions packages/core/src/loader/egg_loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { sequencify } from '../utils/sequencify.ts';
import { Timing } from '../utils/timing.ts';
import { type ContextLoaderOptions, ContextLoader } from './context_loader.ts';
import { type FileLoaderOptions, CaseStyle, FULLPATH, FileLoader } from './file_loader.ts';
import { ManifestStore, type StartupManifest } from './manifest.ts';

const debug = debuglog('egg/core/loader/egg_loader');

Expand All @@ -47,6 +48,8 @@ export interface EggLoaderOptions {
serverScope?: string;
/** custom plugins */
plugins?: Record<string, EggPluginInfo>;
/** Skip lifecycle hooks, only trigger loadMetadata for manifest generation */
metadataOnly?: boolean;
}

export type EggDirInfoType = 'app' | 'plugin' | 'framework';
Expand All @@ -67,6 +70,8 @@ export class EggLoader {
readonly appInfo: EggAppInfo;
readonly outDir?: string;
dirs?: EggDirInfo[];
/** Startup manifest — loaded from cache or collecting for generation */
readonly manifest: ManifestStore;

/**
* @class
Expand Down Expand Up @@ -154,6 +159,11 @@ export class EggLoader {
* @since 1.0.0
*/
this.appInfo = this.getAppInfo();

// Load pre-computed manifest or create a collector for future generation
this.manifest =
ManifestStore.load(this.options.baseDir, this.serverEnv, this.serverScope) ??
ManifestStore.createCollector(this.options.baseDir);
}

get app(): EggCore {
Expand Down Expand Up @@ -1233,15 +1243,23 @@ export class EggLoader {
*/
async loadCustomApp(): Promise<void> {
await this.#loadBootHook('app');
this.lifecycle.triggerConfigWillLoad();
if (this.options.metadataOnly) {
await this.lifecycle.triggerLoadMetadata();
} else {
this.lifecycle.triggerConfigWillLoad();
}
}

/**
* Load agent.js, same as {@link EggLoader#loadCustomApp}
*/
async loadCustomAgent(): Promise<void> {
await this.#loadBootHook('agent');
this.lifecycle.triggerConfigWillLoad();
if (this.options.metadataOnly) {
await this.lifecycle.triggerLoadMetadata();
} else {
this.lifecycle.triggerConfigWillLoad();
}
}

// FIXME: no logger used after egg removed
Expand Down Expand Up @@ -1627,6 +1645,7 @@ export class EggLoader {
directory: options?.directory ?? directory,
target,
inject: this.app,
manifest: this.manifest,
};

const timingKey = `Load "${String(property)}" to Application`;
Expand All @@ -1652,6 +1671,7 @@ export class EggLoader {
directory: options?.directory || directory,
property,
inject: this.app,
manifest: this.manifest,
};

const timingKey = `Load "${String(property)}" to Context`;
Expand Down Expand Up @@ -1688,11 +1708,15 @@ export class EggLoader {
}

resolveModule(filepath: string): string | undefined {
return this.manifest.resolveModule(filepath, () => this.#doResolveModule(filepath));
}

#doResolveModule(filepath: string): string | undefined {
let fullPath: string | undefined;
try {
fullPath = utils.resolvePath(filepath);
} catch {
// debug('[resolveModule] Module %o resolve error: %s', filepath, err.stack);
// ignore resolve errors
}
if (!fullPath) {
fullPath = this.#resolveFromOutDir(filepath);
Expand Down Expand Up @@ -1734,6 +1758,19 @@ export class EggLoader {
}
}
}

/**
* Generate startup manifest from collected data.
* Should be called after all loading phases complete.
*/
generateManifest(extensions?: Record<string, unknown>): StartupManifest {
return this.manifest.generateManifest({
serverEnv: this.serverEnv,
serverScope: this.serverScope,
typescriptEnabled: isSupportTypeScript(),
extensions,
});
}
}

// convert dep to dependencies for compatibility
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/loader/file_loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import globby from 'globby';
import { isClass, isGeneratorFunction, isAsyncFunction, isPrimitive } from 'is-type-of';

import utils, { type Fun } from '../utils/index.ts';
import type { ManifestStore } from './manifest.ts';

const debug = debuglog('egg/core/file_loader');

Expand Down Expand Up @@ -49,6 +50,8 @@ export interface FileLoaderOptions {
/** set property's case when converting a filepath to property list. */
caseStyle?: CaseStyle | CaseStyleFunction;
lowercaseFirst?: boolean;
/** Startup manifest for caching globby scans and collecting results */
manifest?: ManifestStore;
}

export interface FileLoaderParseItem {
Expand Down Expand Up @@ -193,8 +196,11 @@ export class FileLoader {
const items: FileLoaderParseItem[] = [];
debug('[parse] parsing directories: %j', directories);
for (const directory of directories) {
const filepaths = globby.sync(files, { cwd: directory });
debug('[parse] globby files: %o, cwd: %o => %o', files, directory, filepaths);
const manifest = this.options.manifest;
const filepaths = manifest
? manifest.globFiles(directory, () => globby.sync(files, { cwd: directory }))
: globby.sync(files, { cwd: directory });
debug('[parse] files: %o, cwd: %o => %o', files, directory, filepaths);
for (const filepath of filepaths) {
const fullpath = path.join(directory, filepath);
if (!fs.statSync(fullpath).isFile()) continue;
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

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

With manifest caching, filepaths may be stale (e.g. manifest from a previous build) and include entries that no longer exist; fs.statSync(fullpath) will throw on ENOENT and abort the entire load. Wrap the stat call in a try/catch (or validate cached entries/fallback to globby on miss) so a bad/stale manifest cannot crash startup.

Suggested change
if (!fs.statSync(fullpath).isFile()) continue;
let stats: fs.Stats;
try {
stats = fs.statSync(fullpath);
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'ENOENT') {
debug('[parse] skip missing file from manifest: %s', fullpath);
continue;
}
throw err;
}
if (!stats.isFile()) continue;

Copilot uses AI. Check for mistakes.
Expand Down
Loading
Loading