Skip to content

Commit 60902a3

Browse files
killaguclaude
andcommitted
feat(egg,tegg): add manifest write-side + tegg collection
Write-side: - ManifestStore.setExtension() for plugins to register manifest data - dumpManifest() in ready hook auto-generates .egg/manifest.json - metadataOnly mode in startEgg() skips agent for manifest generation Tegg collection: - ModuleDescriptorDumper.getDecoratedFiles() extracts decorated file paths - LoaderFactory.loadApp() accepts LoadAppManifest to skip globby - ModuleLoader supports precomputedFiles to skip file discovery - tegg-config reads moduleReferences from manifest extension - EggModuleLoader.collectTeggManifest() stores tegg data in manifest - TeggManifestExtension type + TEGG_MANIFEST_KEY constant Tests: - ModuleLoader precomputedFiles, LoaderFactory manifest roundtrip - ModuleDescriptorDumper.getDecoratedFiles, ManifestCollection integration - ManifestStore.setExtension roundtrip Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6cf9a09 commit 60902a3

File tree

20 files changed

+626
-54
lines changed

20 files changed

+626
-54
lines changed

packages/core/src/loader/egg_loader.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1763,12 +1763,11 @@ export class EggLoader {
17631763
* Generate startup manifest from collected data.
17641764
* Should be called after all loading phases complete.
17651765
*/
1766-
generateManifest(extensions?: Record<string, unknown>): StartupManifest {
1766+
generateManifest(): StartupManifest {
17671767
return this.manifest.generateManifest({
17681768
serverEnv: this.serverEnv,
17691769
serverScope: this.serverScope,
17701770
typescriptEnabled: isSupportTypeScript(),
1771-
extensions,
17721771
});
17731772
}
17741773
}

packages/core/src/loader/manifest.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export class ManifestStore {
3939
// Collectors for manifest generation (populated during loading)
4040
readonly #resolveCacheCollector: Record<string, string | null> = {};
4141
readonly #fileDiscoveryCollector: Record<string, string[]> = {};
42+
readonly #extensionCollector: Record<string, unknown> = {};
4243

4344
private constructor(data: StartupManifest, baseDir: string) {
4445
this.data = data;
@@ -194,6 +195,13 @@ export class ManifestStore {
194195
return this.data.extensions?.[name];
195196
}
196197

198+
/**
199+
* Register plugin extension data for manifest generation.
200+
*/
201+
setExtension(name: string, data: unknown): void {
202+
this.#extensionCollector[name] = data;
203+
}
204+
197205
// --- Generation APIs ---
198206

199207
/**
@@ -210,7 +218,7 @@ export class ManifestStore {
210218
serverScope: options.serverScope,
211219
typescriptEnabled: options.typescriptEnabled,
212220
},
213-
extensions: options.extensions ?? {},
221+
extensions: this.#extensionCollector,
214222
resolveCache: this.#resolveCacheCollector,
215223
fileDiscovery: this.#fileDiscoveryCollector,
216224
};
@@ -309,5 +317,4 @@ export interface ManifestGenerateOptions {
309317
serverEnv: string;
310318
serverScope: string;
311319
typescriptEnabled: boolean;
312-
extensions?: Record<string, unknown>;
313320
}

packages/core/test/loader/manifest.test.ts

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,19 +64,19 @@ describe('ManifestStore', () => {
6464
}
6565
});
6666

67-
it('should preserve provided extensions', () => {
67+
it('should preserve extensions set via setExtension', () => {
6868
const baseDir = setupBaseDir();
6969
try {
70-
const extensions = { tegg: { moduleReferences: [{ name: 'mod', path: '/tmp/mod' }] } };
70+
const teggData = { moduleReferences: [{ name: 'mod', path: '/tmp/mod' }] };
7171
const collector = ManifestStore.createCollector(baseDir);
72+
collector.setExtension('tegg', teggData);
7273
const manifest = collector.generateManifest({
7374
serverEnv: 'prod',
7475
serverScope: '',
7576
typescriptEnabled: true,
76-
extensions,
7777
});
7878

79-
assert.deepStrictEqual(manifest.extensions, extensions);
79+
assert.deepStrictEqual(manifest.extensions, { tegg: teggData });
8080
} finally {
8181
fs.rmSync(baseDir, { recursive: true, force: true });
8282
}
@@ -474,4 +474,80 @@ describe('ManifestStore', () => {
474474
}
475475
});
476476
});
477+
478+
describe('setExtension()', () => {
479+
it('should store and retrieve extension data', () => {
480+
const baseDir = setupBaseDir();
481+
try {
482+
const collector = ManifestStore.createCollector(baseDir);
483+
const data = { modules: ['a', 'b'] };
484+
collector.setExtension('tegg', data);
485+
// Not accessible via getExtension until generateManifest
486+
// (getExtension reads from data, not collector)
487+
const manifest = collector.generateManifest({
488+
serverEnv: 'prod',
489+
serverScope: '',
490+
typescriptEnabled: true,
491+
});
492+
assert.deepStrictEqual(manifest.extensions.tegg, data);
493+
} finally {
494+
fs.rmSync(baseDir, { recursive: true, force: true });
495+
}
496+
});
497+
498+
it('should support multiple extension keys', () => {
499+
const baseDir = setupBaseDir();
500+
try {
501+
const collector = ManifestStore.createCollector(baseDir);
502+
collector.setExtension('tegg', { a: 1 });
503+
collector.setExtension('custom', { b: 2 });
504+
const manifest = collector.generateManifest({
505+
serverEnv: 'prod',
506+
serverScope: '',
507+
typescriptEnabled: true,
508+
});
509+
assert.deepStrictEqual(manifest.extensions.tegg, { a: 1 });
510+
assert.deepStrictEqual(manifest.extensions.custom, { b: 2 });
511+
} finally {
512+
fs.rmSync(baseDir, { recursive: true, force: true });
513+
}
514+
});
515+
516+
it('should overwrite previous value for same key', () => {
517+
const baseDir = setupBaseDir();
518+
try {
519+
const collector = ManifestStore.createCollector(baseDir);
520+
collector.setExtension('tegg', { old: true });
521+
collector.setExtension('tegg', { new: true });
522+
const manifest = collector.generateManifest({
523+
serverEnv: 'prod',
524+
serverScope: '',
525+
typescriptEnabled: true,
526+
});
527+
assert.deepStrictEqual(manifest.extensions.tegg, { new: true });
528+
} finally {
529+
fs.rmSync(baseDir, { recursive: true, force: true });
530+
}
531+
});
532+
533+
it('should survive write → load roundtrip', async () => {
534+
const baseDir = setupBaseDir();
535+
try {
536+
const collector = ManifestStore.createCollector(baseDir);
537+
collector.setExtension('tegg', { roundtrip: true });
538+
const manifest = collector.generateManifest({
539+
serverEnv: 'prod',
540+
serverScope: '',
541+
typescriptEnabled: true,
542+
});
543+
await ManifestStore.write(baseDir, manifest);
544+
545+
const store = ManifestStore.load(baseDir, 'prod', '')!;
546+
assert.ok(store);
547+
assert.deepStrictEqual(store.getExtension('tegg'), { roundtrip: true });
548+
} finally {
549+
fs.rmSync(baseDir, { recursive: true, force: true });
550+
}
551+
});
552+
});
477553
});

packages/core/test/loader/manifest_roundtrip.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ describe('ManifestStore roundtrip: generate → write → load', () => {
2929
collector.resolveModule(path.join(baseDir, 'missing'), () => undefined);
3030
collector.globFiles(path.join(baseDir, 'app/controller'), () => ['home.ts', 'user.ts']);
3131

32-
const extensions = { tegg: { moduleReferences: [{ name: 'foo', path: '/tmp/foo' }] } };
32+
const teggData = { moduleReferences: [{ name: 'foo', path: '/tmp/foo' }] };
33+
collector.setExtension('tegg', teggData);
3334
const original = collector.generateManifest({
3435
serverEnv: 'prod',
3536
serverScope: '',
3637
typescriptEnabled: true,
37-
extensions,
3838
});
3939
await ManifestStore.write(baseDir, original);
4040

@@ -46,7 +46,7 @@ describe('ManifestStore roundtrip: generate → write → load', () => {
4646
assert.equal(original.resolveCache['some/path'], 'resolved/path');
4747
assert.equal(original.resolveCache['missing'], null);
4848
assert.deepStrictEqual(original.fileDiscovery['app/controller'], ['home.ts', 'user.ts']);
49-
assert.deepStrictEqual(store.data.extensions, extensions);
49+
assert.deepStrictEqual(store.data.extensions, { tegg: teggData });
5050
assert.equal(store.data.version, original.version);
5151
assert.equal(store.data.generatedAt, original.generatedAt);
5252

packages/egg/src/lib/egg.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import path from 'node:path';
77
import { performance } from 'node:perf_hooks';
88

99
import { Cookies as ContextCookies } from '@eggjs/cookies';
10-
import { EggCore, Router } from '@eggjs/core';
10+
import { EggCore, Router, ManifestStore } from '@eggjs/core';
1111
import type { EggCoreOptions, Next, MiddlewareFunc as EggCoreMiddlewareFunc, ILifecycleBoot } from '@eggjs/core';
1212
import { utils as eggUtils } from '@eggjs/core';
1313
import { extend } from '@eggjs/extend2';
@@ -190,6 +190,7 @@ export class EggApplicationCore extends EggCore {
190190
const dumpStartTime = Date.now();
191191
this.dumpConfig();
192192
this.dumpTiming();
193+
this.dumpManifest();
193194
this.coreLogger.info('[egg] dump config after ready, %sms', Date.now() - dumpStartTime);
194195
}),
195196
);
@@ -214,7 +215,7 @@ export class EggApplicationCore extends EggCore {
214215

215216
// single process mode will close agent before app close
216217
if (this.type === 'application' && this.options.mode === 'single') {
217-
await this.agent!.close();
218+
await this.agent?.close();
218219
}
219220

220221
for (const logger of this.loggers.values()) {
@@ -533,6 +534,25 @@ export class EggApplicationCore extends EggCore {
533534
}
534535
}
535536

537+
/**
538+
* Generate and save startup manifest for faster subsequent startups.
539+
* Only generates when no valid manifest was loaded (avoids overwriting during manifest-accelerated starts).
540+
*/
541+
dumpManifest(): void {
542+
try {
543+
// Skip if we loaded from a valid manifest (generatedAt is truthy)
544+
if (this.loader.manifest.data.generatedAt) {
545+
return;
546+
}
547+
const manifest = this.loader.generateManifest();
548+
ManifestStore.write(this.baseDir, manifest).catch((err: Error) => {
549+
this.coreLogger.warn('[egg] dumpManifest write error: %s', err.message);
550+
});
551+
} catch (err: any) {
552+
this.coreLogger.warn('[egg] dumpManifest error: %s', err.message);
553+
}
554+
}
555+
536556
protected override customEggPaths(): string[] {
537557
return [path.dirname(import.meta.dirname), ...super.customEggPaths()];
538558
}

packages/egg/src/lib/start.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export interface StartEggOptions {
1717
mode?: 'single';
1818
env?: string;
1919
plugins?: EggPlugin;
20+
/** Skip lifecycle hooks, only trigger loadMetadata for manifest generation */
21+
metadataOnly?: boolean;
2022
}
2123

2224
export interface SingleModeApplication extends Application {
@@ -53,18 +55,27 @@ export async function startEgg(options: StartEggOptions = {}): Promise<SingleMod
5355
ApplicationClass = framework.Application;
5456
}
5557

56-
const agent = new AgentClass({
57-
...options,
58-
}) as SingleModeAgent;
59-
await agent.ready();
58+
// In metadataOnly mode, skip agent entirely — only app metadata is needed
59+
let agent: SingleModeAgent | undefined;
60+
if (!options.metadataOnly) {
61+
agent = new AgentClass({
62+
...options,
63+
}) as SingleModeAgent;
64+
await agent.ready();
65+
}
66+
6067
const application = new ApplicationClass({
6168
...options,
6269
}) as SingleModeApplication;
63-
application.agent = agent;
64-
agent.application = application;
70+
if (agent) {
71+
application.agent = agent;
72+
agent.application = application;
73+
}
6574
await application.ready();
6675

67-
// emit egg-ready message in agent and application
68-
application.messenger.broadcast('egg-ready');
76+
if (!options.metadataOnly) {
77+
// emit egg-ready message in agent and application
78+
application.messenger.broadcast('egg-ready');
79+
}
6980
return application;
7081
}

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tegg/core/loader/src/LoaderFactory.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,32 @@ import {
1010

1111
export type LoaderCreator = (unitPath: string) => Loader;
1212

13+
export interface ManifestModuleReference {
14+
name: string;
15+
path: string;
16+
optional?: boolean;
17+
}
18+
19+
export interface ManifestModuleDescriptor {
20+
name: string;
21+
unitPath: string;
22+
optional?: boolean;
23+
/** Files containing decorated classes, relative to unitPath */
24+
decoratedFiles: string[];
25+
}
26+
27+
/** Shape of the 'tegg' manifest extension stored via ManifestStore.setExtension() */
28+
export interface TeggManifestExtension {
29+
moduleReferences: ManifestModuleReference[];
30+
moduleDescriptors: ManifestModuleDescriptor[];
31+
}
32+
33+
export const TEGG_MANIFEST_KEY = 'tegg';
34+
35+
export interface LoadAppManifest {
36+
moduleDescriptors: ManifestModuleDescriptor[];
37+
}
38+
1339
export class LoaderFactory {
1440
private static loaderCreatorMap: Map<EggLoadUnitTypeLike, LoaderCreator> = new Map();
1541

@@ -25,14 +51,38 @@ export class LoaderFactory {
2551
this.loaderCreatorMap.set(type, creator);
2652
}
2753

28-
static async loadApp(moduleReferences: readonly ModuleReference[]): Promise<ModuleDescriptor[]> {
54+
static async loadApp(
55+
moduleReferences: readonly ModuleReference[],
56+
manifest?: LoadAppManifest,
57+
): Promise<ModuleDescriptor[]> {
2958
const result: ModuleDescriptor[] = [];
3059
const multiInstanceClazzList: EggProtoImplClass[] = [];
60+
61+
const manifestMap = new Map<string, ManifestModuleDescriptor>();
62+
if (manifest?.moduleDescriptors) {
63+
for (const desc of manifest.moduleDescriptors) {
64+
manifestMap.set(desc.unitPath, desc);
65+
}
66+
}
67+
68+
// Lazy-load ModuleLoader to avoid circular dependency
69+
// (ModuleLoader.ts calls LoaderFactory.registerLoader at module scope)
70+
let ModuleLoaderClass: (typeof import('./impl/ModuleLoader.ts'))['ModuleLoader'] | undefined;
71+
if (manifestMap.size > 0) {
72+
ModuleLoaderClass = (await import('./impl/ModuleLoader.ts')).ModuleLoader;
73+
}
74+
3175
for (const moduleReference of moduleReferences) {
32-
const loader = LoaderFactory.createLoader(
33-
moduleReference.path,
34-
moduleReference.loaderType || EggLoadUnitType.MODULE,
35-
);
76+
const manifestDesc = manifestMap.get(moduleReference.path);
77+
const loaderType = moduleReference.loaderType || EggLoadUnitType.MODULE;
78+
79+
let loader: Loader;
80+
if (manifestDesc && ModuleLoaderClass && loaderType === EggLoadUnitType.MODULE) {
81+
loader = new ModuleLoaderClass(moduleReference.path, manifestDesc.decoratedFiles);
82+
} else {
83+
loader = LoaderFactory.createLoader(moduleReference.path, loaderType);
84+
}
85+
3686
const res: ModuleDescriptor = {
3787
name: moduleReference.name,
3888
unitPath: moduleReference.path,

tegg/core/loader/src/impl/ModuleLoader.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ const debug = debuglog('egg/tegg/loader/impl/ModuleLoader');
1212
export class ModuleLoader implements Loader {
1313
private readonly moduleDir: string;
1414
private protoClazzList: EggProtoImplClass[];
15+
/** Pre-computed file list from manifest (only decorated files) */
16+
private readonly precomputedFiles?: string[];
1517

16-
constructor(moduleDir: string) {
18+
constructor(moduleDir: string, precomputedFiles?: string[]) {
1719
this.moduleDir = moduleDir;
20+
this.precomputedFiles = precomputedFiles;
1821
}
1922

2023
async load(): Promise<EggProtoImplClass[]> {
@@ -23,10 +26,16 @@ export class ModuleLoader implements Loader {
2326
return this.protoClazzList;
2427
}
2528
const protoClassList: EggProtoImplClass[] = [];
26-
const filePattern = LoaderUtil.filePattern();
2729

28-
const files = await globby(filePattern, { cwd: this.moduleDir });
29-
debug('load files: %o, filePattern: %o, moduleDir: %o', files, filePattern, this.moduleDir);
30+
let files: string[];
31+
if (this.precomputedFiles) {
32+
files = this.precomputedFiles;
33+
debug('load from manifest, files: %o, moduleDir: %o', files, this.moduleDir);
34+
} else {
35+
const filePattern = LoaderUtil.filePattern();
36+
files = await globby(filePattern, { cwd: this.moduleDir });
37+
debug('load files: %o, filePattern: %o, moduleDir: %o', files, filePattern, this.moduleDir);
38+
}
3039
for (const file of files) {
3140
const realPath = path.join(this.moduleDir, file);
3241
const fileClazzList = await LoaderUtil.loadFile(realPath);

0 commit comments

Comments
 (0)