Skip to content
Draft
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
9 changes: 8 additions & 1 deletion .github/workflows/e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ jobs:
CNPMCORE_DATABASE_NAME=cnpmcore_unittest bash ./prepare-database-mysql.sh
CNPMCORE_DATABASE_NAME=cnpmcore bash ./prepare-database-mysql.sh

# Generate startup manifest for faster cold start
EGG_TS_ENABLE=false npx egg-bin manifest generate --env=unittest
EGG_TS_ENABLE=false npx egg-bin manifest validate --env=unittest

EGG_TS_ENABLE=false npx eggctl start --port=7001 --env=unittest --daemon
HEALTH_URL="http://127.0.0.1:7001/"
START_TIME=$(date +%s)
Expand All @@ -85,7 +89,6 @@ jobs:

echo "Waiting for application to become healthy at ${HEALTH_URL} (timeout: ${TIMEOUT}s)..."
while true; do
# Capture response body and status code
STATUS=$(curl -s -o /tmp/cnpmcore-health-response -w "%{http_code}" "${HEALTH_URL}" || echo "000")
echo "Health check attempt at $(date): status=${STATUS}"

Expand Down Expand Up @@ -115,6 +118,10 @@ jobs:
cat logs/cnpmcore/common-error.log 2>/dev/null || true
exit 1
fi

# Verify manifest is loaded and used (zero extra resolve I/O)
cp ../scripts/verify-manifest.mjs .
EGG_TS_ENABLE=false EGG_SERVER_ENV=unittest node verify-manifest.mjs
- name: examples
node-version: 24
command: |
Expand Down
42 changes: 42 additions & 0 deletions ecosystem-ci/scripts/verify-manifest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Verify that the startup manifest is loaded and used during application boot.
* Asserts that resolveModule reads from cache with zero extra file I/O.
*
* Usage: EGG_SERVER_ENV=<env> node ecosystem-ci/scripts/verify-manifest.mjs
* Must be run from the application's baseDir (e.g., ecosystem-ci/cnpmcore/).
*/
async function main() {
const { startEgg } = await import('egg');

const app = await startEgg({
baseDir: process.cwd(),
mode: 'single',
metadataOnly: true,
});

if (!app.loader.manifest) {
console.error('FAIL: manifest not loaded');
process.exit(1);
}

const { resolveCache, fileDiscovery, tegg } = app.loader.manifest.data;
console.log('Manifest loaded successfully');
console.log(' resolveCache entries:', Object.keys(resolveCache).length);
console.log(' fileDiscovery entries:', Object.keys(fileDiscovery).length);
console.log(' tegg moduleRefs:', tegg?.moduleReferences?.length ?? 0);

// After ready(), the loader has run all loading phases with metadataOnly.
// With a valid manifest, resolveModule should have used the cache
// and produced zero new collector entries.
const newEntries = Object.keys(app.loader.resolveCacheCollector).length;
if (newEntries > 0) {
console.error('FAIL: resolve produced %d new I/O entries instead of using cache', newEntries);
process.exit(1);
}
console.log(' resolve cache: all hits, zero extra I/O');

await app.close();
process.exit(0);
}

void main();
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';
31 changes: 30 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,24 @@ export class Lifecycle extends EventEmitter {
})();
}

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

#initReady(): void {
debug('loadReady init');
this.loadReady = new Ready({ timeout: this.readyTimeout, lazyStart: true });
Expand Down
62 changes: 60 additions & 2 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 ManifestTegg, 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,14 @@ export class EggLoader {
readonly appInfo: EggAppInfo;
readonly outDir?: string;
dirs?: EggDirInfo[];
/** Pre-computed startup manifest for skipping file I/O */
readonly manifest: ManifestStore | null;
/** Collected resolveModule results for manifest generation */
readonly resolveCacheCollector: Record<string, string | null> = {};
/** Collected file discovery results for manifest generation */
readonly fileDiscoveryCollector: Record<string, string[]> = {};
/** Collected tegg manifest data (populated by tegg plugin) */
teggManifestCollector?: ManifestTegg;

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

// Load pre-computed startup manifest if available
this.manifest = ManifestStore.load(this.options.baseDir, this.serverEnv, this.serverScope);
if (this.manifest) {
debug('startup manifest loaded, will skip redundant file I/O');
}
}

get app(): EggCore {
Expand Down Expand Up @@ -1233,15 +1250,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 +1652,8 @@ export class EggLoader {
directory: options?.directory ?? directory,
target,
inject: this.app,
manifest: this.manifest,
fileDiscoveryCollector: this.fileDiscoveryCollector,
};

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

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

resolveModule(filepath: string): string | undefined {
// Check manifest cache first
if (this.manifest) {
const cached = this.manifest.getResolveCache(filepath);
if (cached !== undefined) {
debug('[resolveModule:manifest] %o => %o', filepath, cached);
return cached ?? undefined;
}
}

let fullPath: string | undefined;
try {
fullPath = utils.resolvePath(filepath);
Expand All @@ -1697,6 +1735,10 @@ export class EggLoader {
if (!fullPath) {
fullPath = this.#resolveFromOutDir(filepath);
}

// Collect for manifest generation
this.resolveCacheCollector[filepath] = fullPath ?? null;

return fullPath;
}

Expand Down Expand Up @@ -1734,6 +1776,22 @@ export class EggLoader {
}
}
}

/**
* Generate startup manifest from collected data.
* Should be called after all loading phases complete.
*/
generateManifest(tegg?: ManifestTegg): StartupManifest {
return ManifestStore.generate({
baseDir: this.options.baseDir,
serverEnv: this.serverEnv,
serverScope: this.serverScope,
typescriptEnabled: isSupportTypeScript(),
tegg,
resolveCache: this.resolveCacheCollector,
fileDiscovery: this.fileDiscoveryCollector,
});
}
}

// convert dep to dependencies for compatibility
Expand Down
18 changes: 16 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,10 @@ export interface FileLoaderOptions {
/** set property's case when converting a filepath to property list. */
caseStyle?: CaseStyle | CaseStyleFunction;
lowercaseFirst?: boolean;
/** Pre-computed startup manifest for skipping globby scans */
manifest?: ManifestStore | null;
/** Collector for file discovery results during manifest generation */
fileDiscoveryCollector?: Record<string, string[]>;
}

export interface FileLoaderParseItem {
Expand Down Expand Up @@ -193,8 +198,17 @@ 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 cachedFiles = this.options.manifest?.getFileDiscovery(directory);
const filepaths = cachedFiles ?? globby.sync(files, { cwd: directory });
if (cachedFiles) {
debug('[parse:manifest] using cached files for %o, count: %d', directory, cachedFiles.length);
} else {
debug('[parse] globby files: %o, cwd: %o => %o', files, directory, filepaths);
// Collect for manifest generation
if (this.options.fileDiscoveryCollector) {
this.options.fileDiscoveryCollector[directory] = filepaths;
}
}
for (const filepath of filepaths) {
const fullpath = path.join(directory, filepath);
if (!fs.statSync(fullpath).isFile()) continue;
Expand Down
Loading
Loading