Skip to content

Commit aa1c3b2

Browse files
killaguclaude
andauthored
feat(egg,tegg): add manifest write-side + tegg collection (#5846)
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 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Metadata-only startup option; app can generate and persist a startup manifest and load metadata without starting the agent. * Module loading can consume manifest-provided file lists to speed discovery and avoid full filesystem scans. * Runtime API to register and persist extension metadata for reuse. * **Tests** * Added suites validating manifest-driven loading, manifest roundtrips, decorated-file extraction, and extension registration persistence. * **Chores** * Added a loader dependency for manifest integration. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6cf9a09 commit aa1c3b2

File tree

20 files changed

+632
-54
lines changed

20 files changed

+632
-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: 26 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,29 @@ 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 in local env (manifest is not loaded there unless EGG_MANIFEST=true)
544+
if (this.loader.serverEnv === 'local' && process.env.EGG_MANIFEST !== 'true') {
545+
return;
546+
}
547+
// Skip if we loaded from a valid manifest (generatedAt is truthy)
548+
if (this.loader.manifest.data.generatedAt) {
549+
return;
550+
}
551+
const manifest = this.loader.generateManifest();
552+
ManifestStore.write(this.baseDir, manifest).catch((err: Error) => {
553+
this.coreLogger.warn('[egg] dumpManifest write error: %s', err.message);
554+
});
555+
} catch (err: any) {
556+
this.coreLogger.warn('[egg] dumpManifest error: %s', err.message);
557+
}
558+
}
559+
536560
protected override customEggPaths(): string[] {
537561
return [path.dirname(import.meta.dirname), ...super.customEggPaths()];
538562
}

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: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,33 @@ import {
1010

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

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

@@ -25,14 +52,38 @@ export class LoaderFactory {
2552
this.loaderCreatorMap.set(type, creator);
2653
}
2754

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

0 commit comments

Comments
 (0)