Skip to content

Commit 6cf9a09

Browse files
killaguclaude
andauthored
feat(core): add ManifestStore for startup manifest caching (#5844)
## Summary - 新增 `ManifestStore` 类(`packages/core/src/loader/manifest.ts`),支持 `.egg/manifest.json` 的加载、验证、生成、写入和清理 - 集成到 `EggLoader`:启动时自动加载 manifest,`resolveModule` 优先查缓存,`FileLoader.parse` 跳过 globby 扫描 - 通过 stat-based fingerprint(lockfile mtime+size、config 目录递归 MD5)校验 manifest 有效性 - 新增 `metadataOnly` 模式和 `loadMetadata` 生命周期钩子,为后续 manifest 生成 CLI 做准备 - 38 个单元测试覆盖所有 ManifestStore API ### 主要优化点 | 层 | 消除的 I/O | 机制 | |---|-----------|------| | egg core resolveModule | 数百次 fs.existsSync | manifest resolveCache | | egg core FileLoader | 4-7 次 globby.sync | manifest fileDiscovery | ### 这个 PR 的范围 仅 `packages/core/` 的变更。只做"如果 `.egg/manifest.json` 存在就读取并使用",不涉及自动生成。合入后框架本身就能受益于手动放置的 manifest。 后续 PR: - PR 2: egg 包集成(dumpManifest、start.ts metadataOnly) - PR 3: tegg 集成(ModuleLoader 预计算文件列表) - PR 4: egg-bin manifest generate/validate/clean CLI ## Test plan - [x] `@eggjs/core` 389 tests passed(含 38 个新增 manifest 测试) - [x] TypeScript 类型检查通过 - [x] oxlint 检查通过 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Startup manifest caching for faster subsequent startups (module resolution and file discovery) with public APIs to generate, query, and export manifests. * Metadata-only initialization mode to produce manifests without running full bootstrap, and loader integration to support manifest-driven resolution and discovery. * **Tests** * Extensive test suites, fixtures, and helpers validating manifest generation, loading, fingerprinting, persistence, query behavior, round-trip correctness, and invalidation semantics. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0dc5dfb commit 6cf9a09

File tree

13 files changed

+1219
-6
lines changed

13 files changed

+1219
-6
lines changed

packages/core/src/egg.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export interface EggCoreOptions {
3131
plugins?: any;
3232
serverScope?: string;
3333
env?: string;
34+
/** Skip lifecycle hooks, only trigger loadMetadata for manifest generation */
35+
metadataOnly?: boolean;
3436
}
3537

3638
export type EggCoreInitOptions = Partial<EggCoreOptions>;
@@ -218,6 +220,7 @@ export class EggCore extends KoaApplication {
218220
serverScope: options.serverScope,
219221
env: options.env ?? '',
220222
EggCoreClass: EggCore,
223+
metadataOnly: options.metadataOnly,
221224
});
222225
}
223226

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from './singleton.ts';
99
export * from './loader/egg_loader.ts';
1010
export * from './loader/file_loader.ts';
1111
export * from './loader/context_loader.ts';
12+
export * from './loader/manifest.ts';
1213
export * from './utils/sequencify.ts';
1314
export * from './utils/timing.ts';
1415
export type * from './types.ts';

packages/core/src/lifecycle.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ export interface ILifecycleBoot {
5454
* Do some thing before app close
5555
*/
5656
beforeClose?(): Promise<void>;
57+
58+
/**
59+
* Collect metadata for manifest generation (metadataOnly mode).
60+
* Called instead of configWillLoad/configDidLoad/didLoad/willReady
61+
* when the application is started with metadataOnly: true.
62+
*/
63+
loadMetadata?(): Promise<void> | void;
5764
}
5865

5966
export type BootImplClass<T = ILifecycleBoot> = new (...args: any[]) => T;
@@ -72,6 +79,7 @@ export class Lifecycle extends EventEmitter {
7279
#bootHooks: (BootImplClass | ILifecycleBoot)[];
7380
#boots: ILifecycleBoot[];
7481
#isClosed: boolean;
82+
#metadataOnly: boolean;
7583
#closeFunctionSet: Set<FunWithFullPath>;
7684
loadReady: Ready;
7785
bootReady: Ready;
@@ -87,6 +95,7 @@ export class Lifecycle extends EventEmitter {
8795
this.#boots = [];
8896
this.#closeFunctionSet = new Set();
8997
this.#isClosed = false;
98+
this.#metadataOnly = false;
9099
this.#init = false;
91100

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

112121
this.ready((err) => {
113-
this.triggerDidReady(err);
122+
if (!this.#metadataOnly) {
123+
void this.triggerDidReady(err);
124+
}
114125
debug('app ready');
115126
this.timing.end(`${this.options.app.type} Start`);
116127
});
@@ -331,6 +342,27 @@ export class Lifecycle extends EventEmitter {
331342
})();
332343
}
333344

345+
async triggerLoadMetadata(): Promise<void> {
346+
this.#metadataOnly = true;
347+
debug('trigger loadMetadata start');
348+
let firstError: Error | undefined;
349+
for (const boot of this.#boots) {
350+
if (typeof boot.loadMetadata === 'function') {
351+
debug('trigger loadMetadata at %o', boot.fullPath);
352+
try {
353+
await boot.loadMetadata();
354+
} catch (err) {
355+
const error = err instanceof Error ? err : new Error(String(err));
356+
if (!firstError) firstError = error;
357+
debug('trigger loadMetadata error at %o, error: %s', boot.fullPath, error);
358+
this.emit('error', error);
359+
}
360+
}
361+
}
362+
debug('trigger loadMetadata end');
363+
this.ready(firstError ?? true);
364+
}
365+
334366
#initReady(): void {
335367
debug('loadReady init');
336368
this.loadReady = new Ready({ timeout: this.readyTimeout, lazyStart: true });

packages/core/src/loader/egg_loader.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { sequencify } from '../utils/sequencify.ts';
2323
import { Timing } from '../utils/timing.ts';
2424
import { type ContextLoaderOptions, ContextLoader } from './context_loader.ts';
2525
import { type FileLoaderOptions, CaseStyle, FULLPATH, FileLoader } from './file_loader.ts';
26+
import { ManifestStore, type StartupManifest } from './manifest.ts';
2627

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

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

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

7176
/**
7277
* @class
@@ -154,6 +159,11 @@ export class EggLoader {
154159
* @since 1.0.0
155160
*/
156161
this.appInfo = this.getAppInfo();
162+
163+
// Load pre-computed manifest or create a collector for future generation
164+
this.manifest =
165+
ManifestStore.load(this.options.baseDir, this.serverEnv, this.serverScope) ??
166+
ManifestStore.createCollector(this.options.baseDir);
157167
}
158168

159169
get app(): EggCore {
@@ -1233,15 +1243,23 @@ export class EggLoader {
12331243
*/
12341244
async loadCustomApp(): Promise<void> {
12351245
await this.#loadBootHook('app');
1236-
this.lifecycle.triggerConfigWillLoad();
1246+
if (this.options.metadataOnly) {
1247+
await this.lifecycle.triggerLoadMetadata();
1248+
} else {
1249+
this.lifecycle.triggerConfigWillLoad();
1250+
}
12371251
}
12381252

12391253
/**
12401254
* Load agent.js, same as {@link EggLoader#loadCustomApp}
12411255
*/
12421256
async loadCustomAgent(): Promise<void> {
12431257
await this.#loadBootHook('agent');
1244-
this.lifecycle.triggerConfigWillLoad();
1258+
if (this.options.metadataOnly) {
1259+
await this.lifecycle.triggerLoadMetadata();
1260+
} else {
1261+
this.lifecycle.triggerConfigWillLoad();
1262+
}
12451263
}
12461264

12471265
// FIXME: no logger used after egg removed
@@ -1627,6 +1645,7 @@ export class EggLoader {
16271645
directory: options?.directory ?? directory,
16281646
target,
16291647
inject: this.app,
1648+
manifest: this.manifest,
16301649
};
16311650

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

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

16901710
resolveModule(filepath: string): string | undefined {
1711+
return this.manifest.resolveModule(filepath, () => this.#doResolveModule(filepath));
1712+
}
1713+
1714+
#doResolveModule(filepath: string): string | undefined {
16911715
let fullPath: string | undefined;
16921716
try {
16931717
fullPath = utils.resolvePath(filepath);
16941718
} catch {
1695-
// debug('[resolveModule] Module %o resolve error: %s', filepath, err.stack);
1719+
// ignore resolve errors
16961720
}
16971721
if (!fullPath) {
16981722
fullPath = this.#resolveFromOutDir(filepath);
@@ -1734,6 +1758,19 @@ export class EggLoader {
17341758
}
17351759
}
17361760
}
1761+
1762+
/**
1763+
* Generate startup manifest from collected data.
1764+
* Should be called after all loading phases complete.
1765+
*/
1766+
generateManifest(extensions?: Record<string, unknown>): StartupManifest {
1767+
return this.manifest.generateManifest({
1768+
serverEnv: this.serverEnv,
1769+
serverScope: this.serverScope,
1770+
typescriptEnabled: isSupportTypeScript(),
1771+
extensions,
1772+
});
1773+
}
17371774
}
17381775

17391776
// convert dep to dependencies for compatibility

packages/core/src/loader/file_loader.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import globby from 'globby';
88
import { isClass, isGeneratorFunction, isAsyncFunction, isPrimitive } from 'is-type-of';
99

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

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

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

5457
export interface FileLoaderParseItem {
@@ -193,8 +196,11 @@ export class FileLoader {
193196
const items: FileLoaderParseItem[] = [];
194197
debug('[parse] parsing directories: %j', directories);
195198
for (const directory of directories) {
196-
const filepaths = globby.sync(files, { cwd: directory });
197-
debug('[parse] globby files: %o, cwd: %o => %o', files, directory, filepaths);
199+
const manifest = this.options.manifest;
200+
const filepaths = manifest
201+
? manifest.globFiles(directory, () => globby.sync(files, { cwd: directory }))
202+
: globby.sync(files, { cwd: directory });
203+
debug('[parse] files: %o, cwd: %o => %o', files, directory, filepaths);
198204
for (const filepath of filepaths) {
199205
const fullpath = path.join(directory, filepath);
200206
if (!fs.statSync(fullpath).isFile()) continue;

0 commit comments

Comments
 (0)