From 765fedf4e02c259b127182b971366c39dc1dc437 Mon Sep 17 00:00:00 2001 From: killagu Date: Sun, 29 Mar 2026 21:00:41 +0800 Subject: [PATCH 1/5] feat(egg-bin): add manifest CLI command for startup manifest management Add `egg-bin manifest` command with three actions: - `generate`: fork a child process to boot app in metadataOnly mode and write .egg/manifest.json with resolveCache, fileDiscovery, and tegg extension data - `validate`: in-process ManifestStore.load() check with metadata output - `clean`: remove .egg/manifest.json Extract buildRequiresExecArgv() from dev.ts into BaseCommand for reuse. Add E2E verification script (ecosystem-ci/scripts/verify-manifest.mjs) that tests the full manifest lifecycle including app boot with manifest. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/e2e-test.yml | 4 + ecosystem-ci/scripts/verify-manifest.mjs | 128 ++++++++++++++++++++ pnpm-lock.yaml | 3 + tools/egg-bin/package.json | 3 + tools/egg-bin/scripts/manifest-generate.mjs | 54 +++++++++ tools/egg-bin/src/baseCommand.ts | 16 +++ tools/egg-bin/src/commands/dev.ts | 14 +-- tools/egg-bin/src/commands/manifest.ts | 125 +++++++++++++++++++ tools/egg-bin/src/index.ts | 3 +- 9 files changed, 336 insertions(+), 14 deletions(-) create mode 100644 ecosystem-ci/scripts/verify-manifest.mjs create mode 100644 tools/egg-bin/scripts/manifest-generate.mjs create mode 100644 tools/egg-bin/src/commands/manifest.ts diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 969c3b9526..24b4414dd4 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -132,6 +132,10 @@ jobs: npm run lint npm run test npm run prepublishOnly + + # Manifest E2E: generate, validate, boot with manifest, clean + node ../../ecosystem-ci/scripts/verify-manifest.mjs + cd .. steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - uses: ./.github/actions/clone diff --git a/ecosystem-ci/scripts/verify-manifest.mjs b/ecosystem-ci/scripts/verify-manifest.mjs new file mode 100644 index 0000000000..87f720c99d --- /dev/null +++ b/ecosystem-ci/scripts/verify-manifest.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node + +/** + * E2E verification script for the egg-bin manifest CLI. + * + * Run this inside a project directory that has egg-bin and egg installed. + * It tests the full manifest lifecycle: + * 1. generate — creates .egg/manifest.json via metadataOnly boot + * 2. validate — verifies the manifest is structurally valid + * 3. boot with manifest — starts the app using the manifest and health-checks it + * 4. clean — removes .egg/manifest.json + */ + +import { execSync } from 'node:child_process'; +import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; + +const projectDir = process.cwd(); +const manifestPath = join(projectDir, '.egg', 'manifest.json'); +const env = process.env.MANIFEST_VERIFY_ENV || 'unittest'; +const healthPort = process.env.MANIFEST_VERIFY_PORT || '7002'; +const healthTimeout = parseInt(process.env.MANIFEST_VERIFY_TIMEOUT || '30', 10); + +function run(cmd) { + console.log(`\n$ ${cmd}`); + execSync(cmd, { stdio: 'inherit', cwd: projectDir }); +} + +function runCapture(cmd) { + console.log(`\n$ ${cmd}`); + return execSync(cmd, { cwd: projectDir, encoding: 'utf-8' }); +} + +function assert(condition, message) { + if (!condition) { + console.error(`FAIL: ${message}`); + process.exit(1); + } + console.log(`PASS: ${message}`); +} + +console.log('=== Manifest E2E Verification ==='); +console.log('Project: %s', projectDir); +console.log('Env: %s', env); + +// Step 1: Clean any pre-existing manifest +if (existsSync(manifestPath)) { + rmSync(manifestPath); + console.log('Cleaned pre-existing manifest'); +} + +// Step 2: Generate manifest +console.log('\n--- Step 1: Generate manifest ---'); +run(`npx egg-bin manifest generate --env=${env}`); + +// Step 3: Verify manifest file exists and has valid structure +console.log('\n--- Step 2: Verify manifest structure ---'); +assert(existsSync(manifestPath), '.egg/manifest.json exists after generate'); + +const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); +assert(manifest.version === 1, 'manifest version is 1'); +assert(typeof manifest.generatedAt === 'string' && manifest.generatedAt.length > 0, 'manifest has generatedAt'); +assert(typeof manifest.invalidation === 'object', 'manifest has invalidation'); +assert(manifest.invalidation.serverEnv === env, `manifest serverEnv matches "${env}"`); +assert(typeof manifest.resolveCache === 'object', 'manifest has resolveCache'); +assert(typeof manifest.fileDiscovery === 'object', 'manifest has fileDiscovery'); +assert(typeof manifest.extensions === 'object', 'manifest has extensions'); + +const resolveCacheCount = Object.keys(manifest.resolveCache).length; +const fileDiscoveryCount = Object.keys(manifest.fileDiscovery).length; +console.log(' resolveCache: %d entries', resolveCacheCount); +console.log(' fileDiscovery: %d entries', fileDiscoveryCount); + +// Step 4: Validate manifest via CLI +console.log('\n--- Step 3: Validate manifest via CLI ---'); +run(`npx egg-bin manifest validate --env=${env}`); + +// Step 5: Boot the app with manifest and verify it starts correctly +console.log('\n--- Step 4: Boot app with manifest ---'); +try { + run(`npx eggctl start --port=${healthPort} --env=${env} --daemon`); + + const healthUrl = `http://127.0.0.1:${healthPort}/`; + const startTime = Date.now(); + let ready = false; + + console.log(`Waiting for app at ${healthUrl} (timeout: ${healthTimeout}s)...`); + while (true) { + try { + const output = runCapture(`curl -s -o /dev/null -w "%{http_code}" "${healthUrl}"`); + const status = output.trim(); + console.log(' Health check: status=%s', status); + if (status === '200') { + ready = true; + break; + } + } catch { + console.log(' Health check: connection refused, retrying...'); + } + + const elapsed = (Date.now() - startTime) / 1000; + if (elapsed >= healthTimeout) { + console.log(' Health check timed out after %ds', elapsed); + break; + } + + execSync('sleep 2'); + } + + run(`npx eggctl stop`); + assert(ready, 'App booted successfully with manifest'); +} catch (err) { + // Try to stop if started + try { + run(`npx eggctl stop`); + } catch { + /* ignore */ + } + console.error('Boot test failed:', err.message); + process.exit(1); +} + +// Step 6: Clean manifest +console.log('\n--- Step 5: Clean manifest ---'); +run(`npx egg-bin manifest clean`); +assert(!existsSync(manifestPath), '.egg/manifest.json removed after clean'); + +console.log('\n=== All manifest E2E checks passed! ==='); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e83bab78ea..8d5f89d26e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3655,6 +3655,9 @@ importers: tools/egg-bin: dependencies: + '@eggjs/core': + specifier: workspace:* + version: link:../../packages/core '@eggjs/tegg-vitest': specifier: workspace:* version: link:../../tegg/core/vitest diff --git a/tools/egg-bin/package.json b/tools/egg-bin/package.json index cddb2b5669..81802fabe6 100644 --- a/tools/egg-bin/package.json +++ b/tools/egg-bin/package.json @@ -30,6 +30,7 @@ "./baseCommand": "./src/baseCommand.ts", "./commands/cov": "./src/commands/cov.ts", "./commands/dev": "./src/commands/dev.ts", + "./commands/manifest": "./src/commands/manifest.ts", "./commands/test": "./src/commands/test.ts", "./types": "./src/types.ts", "./utils": "./src/utils.ts", @@ -42,6 +43,7 @@ "./baseCommand": "./dist/baseCommand.js", "./commands/cov": "./dist/commands/cov.js", "./commands/dev": "./dist/commands/dev.js", + "./commands/manifest": "./dist/commands/manifest.js", "./commands/test": "./dist/commands/test.js", "./types": "./dist/types.js", "./utils": "./dist/utils.js", @@ -56,6 +58,7 @@ "ci": "npm run cov" }, "dependencies": { + "@eggjs/core": "workspace:*", "@eggjs/tegg-vitest": "workspace:*", "@eggjs/utils": "workspace:*", "@oclif/core": "catalog:", diff --git a/tools/egg-bin/scripts/manifest-generate.mjs b/tools/egg-bin/scripts/manifest-generate.mjs new file mode 100644 index 0000000000..7c650976b0 --- /dev/null +++ b/tools/egg-bin/scripts/manifest-generate.mjs @@ -0,0 +1,54 @@ +import { debuglog } from 'node:util'; + +import { importModule } from '@eggjs/utils'; + +const debug = debuglog('egg/bin/scripts/manifest-generate'); + +async function main() { + debug('argv: %o', process.argv); + const options = JSON.parse(process.argv[2]); + debug('manifest generate options: %o', options); + + // Set server env before importing framework + if (options.env) { + process.env.EGG_SERVER_ENV = options.env; + } + + const framework = await importModule(options.framework); + const app = await framework.start({ + baseDir: options.baseDir, + framework: options.framework, + env: options.env, + metadataOnly: true, + }); + + // Generate manifest from collected metadata + const manifest = app.loader.generateManifest(); + + // Write manifest to .egg/manifest.json + // Resolve @eggjs/core from the framework path (not the project root), + // because pnpm strict mode may not hoist it to the project's node_modules. + const { ManifestStore } = await importModule('@eggjs/core', { + paths: [options.framework], + }); + await ManifestStore.write(options.baseDir, manifest); + + // Log stats + const resolveCacheCount = Object.keys(manifest.resolveCache).length; + const fileDiscoveryCount = Object.keys(manifest.fileDiscovery).length; + const extensionCount = Object.keys(manifest.extensions).length; + console.log('[manifest] Generated manifest v%d at %s', manifest.version, manifest.generatedAt); + console.log('[manifest] resolveCache entries: %d', resolveCacheCount); + console.log('[manifest] fileDiscovery entries: %d', fileDiscoveryCount); + console.log('[manifest] extension entries: %d', extensionCount); + console.log('[manifest] Written to %s/.egg/manifest.json', options.baseDir); + + // Clean up and exit + await app.close(); + process.exit(0); +} + +main().catch((err) => { + console.error('[manifest] Generation failed:', err); + process.exit(1); +}); diff --git a/tools/egg-bin/src/baseCommand.ts b/tools/egg-bin/src/baseCommand.ts index 447a115e88..65cc7da836 100644 --- a/tools/egg-bin/src/baseCommand.ts +++ b/tools/egg-bin/src/baseCommand.ts @@ -325,6 +325,22 @@ export abstract class BaseCommand extends Command { } } + protected async buildRequiresExecArgv(): Promise { + const requires = await this.formatRequires(); + const execArgv: string[] = []; + for (const r of requires) { + const module = this.formatImportModule(r); + // Remove the quotes from the path + // --require "module path" -> ['--require', 'module path'] + // --import "module path" -> ['--import', 'module path'] + const splitIndex = module.indexOf(' '); + if (splitIndex !== -1) { + execArgv.push(module.slice(0, splitIndex), module.slice(splitIndex + 2, -1)); + } + } + return execArgv; + } + protected async forkNode(modulePath: string, forkArgs: string[], options: ForkNodeOptions = {}) { const env = { ...this.env, diff --git a/tools/egg-bin/src/commands/dev.ts b/tools/egg-bin/src/commands/dev.ts index 3a2a1b9e53..285f0ee9ca 100644 --- a/tools/egg-bin/src/commands/dev.ts +++ b/tools/egg-bin/src/commands/dev.ts @@ -41,19 +41,7 @@ export default class Dev extends BaseCommand { const serverBin = getSourceFilename(`../scripts/start-cluster.${ext}`); const eggStartOptions = await this.formatEggStartOptions(); const args = [JSON.stringify(eggStartOptions)]; - const requires = await this.formatRequires(); - const execArgv: string[] = []; - for (const r of requires) { - const module = this.formatImportModule(r); - - // Remove the quotes from the path - // --require "module path" -> ['--require', 'module path'] - // --import "module path" -> ['--import', 'module path'] - const splitIndex = module.indexOf(' '); - if (splitIndex !== -1) { - execArgv.push(module.slice(0, splitIndex), module.slice(splitIndex + 2, -1)); - } - } + const execArgv = await this.buildRequiresExecArgv(); await this.forkNode(serverBin, args, { execArgv }); } diff --git a/tools/egg-bin/src/commands/manifest.ts b/tools/egg-bin/src/commands/manifest.ts new file mode 100644 index 0000000000..7388e43fcc --- /dev/null +++ b/tools/egg-bin/src/commands/manifest.ts @@ -0,0 +1,125 @@ +import { debuglog } from 'node:util'; + +import { getFrameworkPath } from '@eggjs/utils'; +import { Args, Flags } from '@oclif/core'; + +import { BaseCommand } from '../baseCommand.ts'; +import { getSourceFilename } from '../utils.ts'; + +const debug = debuglog('egg/bin/commands/manifest'); + +export default class Manifest extends BaseCommand { + static override description = 'Generate, validate, or clean the startup manifest for faster cold starts'; + + static override examples = [ + '<%= config.bin %> <%= command.id %> generate', + '<%= config.bin %> <%= command.id %> generate --env=prod', + '<%= config.bin %> <%= command.id %> validate --env=prod', + '<%= config.bin %> <%= command.id %> clean', + ]; + + static override args = { + action: Args.string({ + required: true, + description: 'Action to perform', + options: ['generate', 'validate', 'clean'], + }), + }; + + static override flags = { + framework: Flags.string({ + description: 'specify framework that can be absolute path or npm package', + }), + env: Flags.string({ + description: 'server environment for manifest generation/validation', + default: 'prod', + }), + scope: Flags.string({ + description: 'server scope for manifest validation', + default: '', + }), + }; + + public async run(): Promise { + const { action } = this.args; + switch (action) { + case 'generate': + await this.runGenerate(); + break; + case 'validate': + await this.runValidate(); + break; + case 'clean': + await this.runClean(); + break; + } + } + + private async runGenerate(): Promise { + const { flags } = this; + const framework = getFrameworkPath({ + framework: flags.framework, + baseDir: flags.base, + }); + debug('generate manifest: baseDir=%s, framework=%s, env=%s', flags.base, framework, flags.env); + + const options = { + baseDir: flags.base, + framework, + env: flags.env, + }; + + const serverBin = getSourceFilename('../scripts/manifest-generate.mjs'); + const args = [JSON.stringify(options)]; + const execArgv = await this.buildRequiresExecArgv(); + await this.forkNode(serverBin, args, { execArgv }); + } + + private async runValidate(): Promise { + const { flags } = this; + debug('validate manifest: baseDir=%s, env=%s, scope=%s', flags.base, flags.env, flags.scope); + + // Bypass the local-env guard since the user explicitly asked to validate + const savedEggManifest = process.env.EGG_MANIFEST; + process.env.EGG_MANIFEST = 'true'; + + try { + const { ManifestStore } = await import('@eggjs/core'); + const store = ManifestStore.load(flags.base, flags.env, flags.scope); + + if (!store) { + console.error('[manifest] Manifest is invalid or does not exist'); + return this.exit(1); + } + + const { data } = store; + const resolveCacheCount = Object.keys(data.resolveCache).length; + const fileDiscoveryCount = Object.keys(data.fileDiscovery).length; + const extensionCount = Object.keys(data.extensions).length; + console.log('[manifest] Manifest is valid'); + console.log('[manifest] version: %d', data.version); + console.log('[manifest] generatedAt: %s', data.generatedAt); + console.log('[manifest] serverEnv: %s', data.invalidation.serverEnv); + console.log('[manifest] serverScope: %s', data.invalidation.serverScope); + console.log('[manifest] resolveCache entries: %d', resolveCacheCount); + console.log('[manifest] fileDiscovery entries: %d', fileDiscoveryCount); + console.log('[manifest] extension entries: %d', extensionCount); + } finally { + // Restore original env + if (savedEggManifest === undefined) { + delete process.env.EGG_MANIFEST; + } else { + process.env.EGG_MANIFEST = savedEggManifest; + } + } + } + + private async runClean(): Promise { + const { flags } = this; + debug('clean manifest: baseDir=%s', flags.base); + + const { ManifestStore } = await import('@eggjs/core'); + ManifestStore.clean(flags.base); + console.log('[manifest] Manifest cleaned'); + } +} diff --git a/tools/egg-bin/src/index.ts b/tools/egg-bin/src/index.ts index ba244025f2..c53db0f5b1 100644 --- a/tools/egg-bin/src/index.ts +++ b/tools/egg-bin/src/index.ts @@ -1,8 +1,9 @@ import Cov from './commands/cov.ts'; import Dev from './commands/dev.ts'; +import Manifest from './commands/manifest.ts'; import Test from './commands/test.ts'; -export { Test, Cov, Dev }; +export { Test, Cov, Dev, Manifest }; export * from './baseCommand.ts'; export * from './types.ts'; From e720d3e6d02f7d3bed886c3e1c3d237c3a83316a Mon Sep 17 00:00:00 2001 From: killagu Date: Mon, 30 Mar 2026 19:36:33 +0800 Subject: [PATCH 2/5] fix(ci): correct verify-manifest.mjs path in e2e workflow The working directory in CI is ecosystem-ci/examples/hello-tegg, so the path to ecosystem-ci/scripts/ is ../../scripts/, not ../../ecosystem-ci/scripts/. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/e2e-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 24b4414dd4..053f2a2b59 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -134,7 +134,7 @@ jobs: npm run prepublishOnly # Manifest E2E: generate, validate, boot with manifest, clean - node ../../ecosystem-ci/scripts/verify-manifest.mjs + node ../../scripts/verify-manifest.mjs cd .. steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 From ee4cc011587a196587265239130b60199f8117de Mon Sep 17 00:00:00 2001 From: killagu Date: Mon, 30 Mar 2026 20:23:03 +0800 Subject: [PATCH 3/5] fix(ci): accept any HTTP response in manifest E2E health check hello-tegg has no `/` route (only `/hello`), so the health check returns 404. Change the check to accept any HTTP response (status != "000") as proof the app booted successfully. Co-Authored-By: Claude Opus 4.6 (1M context) --- ecosystem-ci/scripts/verify-manifest.mjs | 8 +++++--- tools/egg-bin/src/commands/manifest.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ecosystem-ci/scripts/verify-manifest.mjs b/ecosystem-ci/scripts/verify-manifest.mjs index 87f720c99d..5a1c47fdaf 100644 --- a/ecosystem-ci/scripts/verify-manifest.mjs +++ b/ecosystem-ci/scripts/verify-manifest.mjs @@ -19,7 +19,7 @@ const projectDir = process.cwd(); const manifestPath = join(projectDir, '.egg', 'manifest.json'); const env = process.env.MANIFEST_VERIFY_ENV || 'unittest'; const healthPort = process.env.MANIFEST_VERIFY_PORT || '7002'; -const healthTimeout = parseInt(process.env.MANIFEST_VERIFY_TIMEOUT || '30', 10); +const healthTimeout = parseInt(process.env.MANIFEST_VERIFY_TIMEOUT || '60', 10); function run(cmd) { console.log(`\n$ ${cmd}`); @@ -90,7 +90,9 @@ try { const output = runCapture(`curl -s -o /dev/null -w "%{http_code}" "${healthUrl}"`); const status = output.trim(); console.log(' Health check: status=%s', status); - if (status === '200') { + // Any HTTP response (not connection refused) means the app is up. + // Not all apps have a route on `/`, so we accept any status code. + if (status !== '000') { ready = true; break; } @@ -125,4 +127,4 @@ console.log('\n--- Step 5: Clean manifest ---'); run(`npx egg-bin manifest clean`); assert(!existsSync(manifestPath), '.egg/manifest.json removed after clean'); -console.log('\n=== All manifest E2E checks passed! ==='); +console.log('\n=== All manifest E2E checks passed ==='); diff --git a/tools/egg-bin/src/commands/manifest.ts b/tools/egg-bin/src/commands/manifest.ts index 7388e43fcc..78b641076c 100644 --- a/tools/egg-bin/src/commands/manifest.ts +++ b/tools/egg-bin/src/commands/manifest.ts @@ -9,7 +9,7 @@ import { getSourceFilename } from '../utils.ts'; const debug = debuglog('egg/bin/commands/manifest'); export default class Manifest extends BaseCommand { - static override description = 'Generate, validate, or clean the startup manifest for faster cold starts'; + static override description = 'Manage the startup manifest for faster cold starts'; static override examples = [ '<%= config.bin %> <%= command.id %> generate', From 98cc5939c6203b94bf85249eb37ad721b7e8d565 Mon Sep 17 00:00:00 2001 From: killagu Date: Mon, 30 Mar 2026 20:44:13 +0800 Subject: [PATCH 4/5] fix(egg-bin): address review feedback on manifest CLI - Propagate --scope flag to manifest generate subprocess - Add ?? {} fallback for malformed manifest in validate - Clean existing manifest before generate to avoid incomplete data - Replace execSync('sleep 2') with portable await setTimeout Co-Authored-By: Claude Opus 4.6 (1M context) --- ecosystem-ci/scripts/verify-manifest.mjs | 3 ++- tools/egg-bin/scripts/manifest-generate.mjs | 17 +++++++++++------ tools/egg-bin/src/commands/manifest.ts | 15 +++++++++++---- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/ecosystem-ci/scripts/verify-manifest.mjs b/ecosystem-ci/scripts/verify-manifest.mjs index 5a1c47fdaf..4483cbbf30 100644 --- a/ecosystem-ci/scripts/verify-manifest.mjs +++ b/ecosystem-ci/scripts/verify-manifest.mjs @@ -14,6 +14,7 @@ import { execSync } from 'node:child_process'; import { existsSync, readFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; const projectDir = process.cwd(); const manifestPath = join(projectDir, '.egg', 'manifest.json'); @@ -106,7 +107,7 @@ try { break; } - execSync('sleep 2'); + await sleep(2000); } run(`npx eggctl stop`); diff --git a/tools/egg-bin/scripts/manifest-generate.mjs b/tools/egg-bin/scripts/manifest-generate.mjs index 7c650976b0..794d07ac73 100644 --- a/tools/egg-bin/scripts/manifest-generate.mjs +++ b/tools/egg-bin/scripts/manifest-generate.mjs @@ -9,10 +9,20 @@ async function main() { const options = JSON.parse(process.argv[2]); debug('manifest generate options: %o', options); - // Set server env before importing framework + // Set server env/scope before importing framework if (options.env) { process.env.EGG_SERVER_ENV = options.env; } + if (options.scope) { + process.env.EGG_SERVER_SCOPE = options.scope; + } + + // Clean any existing manifest before generation to ensure the collector + // captures all lookups (not just cache misses from a stale manifest). + const { ManifestStore } = await importModule('@eggjs/core', { + paths: [options.framework], + }); + ManifestStore.clean(options.baseDir); const framework = await importModule(options.framework); const app = await framework.start({ @@ -26,11 +36,6 @@ async function main() { const manifest = app.loader.generateManifest(); // Write manifest to .egg/manifest.json - // Resolve @eggjs/core from the framework path (not the project root), - // because pnpm strict mode may not hoist it to the project's node_modules. - const { ManifestStore } = await importModule('@eggjs/core', { - paths: [options.framework], - }); await ManifestStore.write(options.baseDir, manifest); // Log stats diff --git a/tools/egg-bin/src/commands/manifest.ts b/tools/egg-bin/src/commands/manifest.ts index 78b641076c..b89e3d39c1 100644 --- a/tools/egg-bin/src/commands/manifest.ts +++ b/tools/egg-bin/src/commands/manifest.ts @@ -61,12 +61,19 @@ export default class Manifest extends BaseCommand framework: flags.framework, baseDir: flags.base, }); - debug('generate manifest: baseDir=%s, framework=%s, env=%s', flags.base, framework, flags.env); + debug( + 'generate manifest: baseDir=%s, framework=%s, env=%s, scope=%s', + flags.base, + framework, + flags.env, + flags.scope, + ); const options = { baseDir: flags.base, framework, env: flags.env, + scope: flags.scope, }; const serverBin = getSourceFilename('../scripts/manifest-generate.mjs'); @@ -93,9 +100,9 @@ export default class Manifest extends BaseCommand } const { data } = store; - const resolveCacheCount = Object.keys(data.resolveCache).length; - const fileDiscoveryCount = Object.keys(data.fileDiscovery).length; - const extensionCount = Object.keys(data.extensions).length; + const resolveCacheCount = Object.keys(data.resolveCache ?? {}).length; + const fileDiscoveryCount = Object.keys(data.fileDiscovery ?? {}).length; + const extensionCount = Object.keys(data.extensions ?? {}).length; console.log('[manifest] Manifest is valid'); console.log('[manifest] version: %d', data.version); console.log('[manifest] generatedAt: %s', data.generatedAt); From 173f4e0d769077673b4752f7aab4b2f112f76bfc Mon Sep 17 00:00:00 2001 From: killagu Date: Tue, 31 Mar 2026 09:38:50 +0800 Subject: [PATCH 5/5] docs: add manifest documentation and remove Node.js 20 from CI - Add startup manifest docs (zh-CN and English) under core/ covering: usage, CLI commands, invalidation, deployment tips - Add manifest to sidebar navigation - Remove Node.js 20 from CI test matrix (LTS ending soon) - Simplify example test condition (no longer need Node 20 check) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 4 +- site/.vitepress/config.mts | 2 + site/docs/core/manifest.md | 141 ++++++++++++++++++++++++++ site/docs/zh-CN/core/manifest.md | 167 +++++++++++++++++++++++++++++++ 4 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 site/docs/core/manifest.md create mode 100644 site/docs/zh-CN/core/manifest.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02fe03e5f5..3db485d85a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: fail-fast: false matrix: os: ['ubuntu-latest', 'macos-latest', 'windows-latest'] - node: ['20', '22', '24'] + node: ['22', '24'] name: Test (${{ matrix.os }}, ${{ matrix.node }}) runs-on: ${{ matrix.os }} @@ -170,7 +170,7 @@ jobs: run: pnpm run ci - name: Run example tests - if: ${{ matrix.node != '20' && matrix.os != 'windows-latest' }} + if: ${{ matrix.os != 'windows-latest' }} run: | pnpm run example:test:all diff --git a/site/.vitepress/config.mts b/site/.vitepress/config.mts index 385e032013..3a429375a1 100644 --- a/site/.vitepress/config.mts +++ b/site/.vitepress/config.mts @@ -312,6 +312,7 @@ function sidebarCore(): DefaultTheme.SidebarItem[] { { text: 'Internationalization', link: 'i18n' }, { text: 'View Template', link: 'view' }, { text: 'Security', link: 'security' }, + { text: 'Startup Manifest', link: 'manifest' }, ], }, ]; @@ -422,6 +423,7 @@ function sidebarCoreZhCN(): DefaultTheme.SidebarItem[] { { text: '国际化', link: 'i18n' }, { text: '模板渲染', link: 'view' }, { text: '安全', link: 'security' }, + { text: '启动清单', link: 'manifest' }, ], }, ]; diff --git a/site/docs/core/manifest.md b/site/docs/core/manifest.md new file mode 100644 index 0000000000..37ad92bcb1 --- /dev/null +++ b/site/docs/core/manifest.md @@ -0,0 +1,141 @@ +# Startup Manifest + +Egg provides a startup manifest mechanism that caches file discovery and module resolution results to accelerate application cold starts. + +## How It Works + +Every time an application starts, the framework performs extensive filesystem operations: + +- **Module resolution**: Hundreds of `fs.existsSync` calls probing `.ts`, `.js`, `.mjs` extensions +- **File discovery**: Multiple `globby.sync` scans across plugin, config, and extension directories +- **tegg module scanning**: Traversing module directories and `import()`-ing decorator files to collect metadata + +The manifest mechanism collects these results on the first startup and writes them to `.egg/manifest.json`. Subsequent startups read from this cache, skipping redundant file I/O. + +## Performance Improvement + +Measured on cnpmcore in a container cold-start scenario (no filesystem page cache): + +| Metric | No Manifest | With Manifest | Improvement | +| ----------- | ----------- | ------------- | ----------- | +| App Start | ~980ms | ~780ms | **~20%** | +| Load Files | ~660ms | ~490ms | **~26%** | +| Load app.js | ~280ms | ~150ms | **~46%** | + +> Note: In local development, the OS page cache makes file I/O nearly zero-cost, so the improvement is negligible. The manifest primarily optimizes container cold starts and CI/CD environments without warm caches. + +## Usage + +### CLI Management (Recommended) + +`egg-bin` provides a `manifest` command to manage the startup manifest: + +#### Generate + +```bash +# Generate for production +$ egg-bin manifest generate --env=prod + +# Specify environment and scope +$ egg-bin manifest generate --env=prod --scope=aliyun + +# Specify framework +$ egg-bin manifest generate --env=prod --framework=yadan +``` + +The generation process boots the app in `metadataOnly` mode (skipping lifecycle hooks, only collecting metadata), then writes the results to `.egg/manifest.json`. + +#### Validate + +```bash +$ egg-bin manifest validate --env=prod +``` + +Example output: + +``` +[manifest] Manifest is valid +[manifest] version: 1 +[manifest] generatedAt: 2026-03-29T12:13:18.039Z +[manifest] serverEnv: prod +[manifest] serverScope: +[manifest] resolveCache entries: 416 +[manifest] fileDiscovery entries: 31 +[manifest] extension entries: 1 +``` + +If the manifest is invalid or missing, the command exits with a non-zero code. + +#### Clean + +```bash +$ egg-bin manifest clean +``` + +### Automatic Generation + +After a normal startup, the framework automatically generates a manifest during the `ready` phase (via `dumpManifest`). On the next startup, if the manifest is valid, it is automatically used. + +## Invalidation + +The manifest includes fingerprint data and is automatically invalidated when: + +- **Lockfile changes**: `pnpm-lock.yaml`, `package-lock.json`, or `yarn.lock` mtime or size changes +- **Config directory changes**: Files in `config/` change (MD5 fingerprint) +- **Environment mismatch**: `serverEnv` or `serverScope` differs from the manifest +- **TypeScript state change**: `EGG_TYPESCRIPT` enabled/disabled state changes +- **Version mismatch**: Manifest format version differs + +When the manifest is invalid, the framework falls back to normal file discovery — startup is never blocked. + +## Environment Variables + +| Variable | Description | Default | +| -------------- | ---------------------------- | ----------------------------------------------------- | +| `EGG_MANIFEST` | Enable manifest in local env | `false` (manifest not loaded in local env by default) | + +> Local development (`serverEnv=local`) does not load the manifest by default, since files change frequently. Set `EGG_MANIFEST=true` to force-enable. + +## Deployment Recommendations + +### Container Deployment + +Generate the manifest in your Dockerfile after building: + +```dockerfile +# Install dependencies and build +RUN npm install --production +RUN npm run build + +# Generate startup manifest +RUN npx egg-bin manifest generate --env=prod + +# Start the app (manifest is used automatically) +CMD ["npm", "start"] +``` + +### CI/CD Pipelines + +Generate the manifest during the build stage and deploy it with the artifact: + +```bash +# Build +npm run build + +# Generate manifest +npx egg-bin manifest generate --env=prod + +# Validate manifest +npx egg-bin manifest validate --env=prod + +# Package (includes .egg/manifest.json) +tar -zcvf release.tgz . +``` + +## Important Notes + +1. **Environment-bound**: The manifest is bound to `serverEnv` and `serverScope` (deployment type, e.g. `aliyun`). Different environments or deployment types require separate manifests. +2. **Regenerate after dependency changes**: Installing or updating dependencies changes the lockfile, which automatically invalidates the manifest. +3. **`.egg` directory**: The manifest is stored at `.egg/manifest.json`. Consider adding `.egg/` to `.gitignore`. +4. **Safe fallback**: A missing or invalid manifest causes the framework to fall back to normal discovery — startup is never broken. +5. **metadataOnly mode**: `manifest generate` does not run the full application lifecycle — no database connections or external services are started. diff --git a/site/docs/zh-CN/core/manifest.md b/site/docs/zh-CN/core/manifest.md new file mode 100644 index 0000000000..aff7e5c68a --- /dev/null +++ b/site/docs/zh-CN/core/manifest.md @@ -0,0 +1,167 @@ +# 启动清单(Startup Manifest) + +Egg 提供了启动清单(Manifest)机制,通过缓存文件发现和模块解析结果来加速应用的冷启动。 + +## 原理 + +应用每次启动时,框架需要执行大量文件系统操作: + +- **模块解析**:数百次 `fs.existsSync` 调用,探测 `.ts`、`.js`、`.mjs` 等后缀 +- **文件发现**:多次 `globby.sync` 扫描插件、配置、扩展等目录 +- **tegg 模块扫描**:遍历模块目录,`import()` 装饰器文件收集元数据 + +Manifest 机制在首次启动时收集这些结果,写入 `.egg/manifest.json`。后续启动直接读取缓存,跳过重复的文件 I/O。 + +## 性能提升 + +在容器冷启动场景下(无文件系统缓存),以 cnpmcore 项目实测: + +| 指标 | 无 Manifest | 有 Manifest | 提升 | +| ----------- | ----------- | ----------- | -------- | +| 应用启动 | ~980ms | ~780ms | **~20%** | +| 文件加载 | ~660ms | ~490ms | **~26%** | +| 加载 app.js | ~280ms | ~150ms | **~46%** | + +> 注意:在本地开发环境中,操作系统的页面缓存(page cache)会使文件 I/O 接近零开销,因此提升不明显。Manifest 主要优化的是容器冷启动、CI/CD 等无缓存场景。 + +## 使用方式 + +### 通过 CLI 管理(推荐) + +`egg-bin` 提供了 `manifest` 命令来管理启动清单: + +#### 生成清单 + +```bash +# 为生产环境生成 +$ egg-bin manifest generate --env=prod + +# 指定环境和作用域 +$ egg-bin manifest generate --env=prod --scope=aliyun + +# 指定框架 +$ egg-bin manifest generate --env=prod --framework=yadan +``` + +生成过程会以 `metadataOnly` 模式启动应用(跳过生命周期钩子,只收集元数据),然后将结果写入 `.egg/manifest.json`。 + +#### 验证清单 + +```bash +$ egg-bin manifest validate --env=prod +``` + +输出示例: + +``` +[manifest] Manifest is valid +[manifest] version: 1 +[manifest] generatedAt: 2026-03-29T12:13:18.039Z +[manifest] serverEnv: prod +[manifest] serverScope: +[manifest] resolveCache entries: 416 +[manifest] fileDiscovery entries: 31 +[manifest] extension entries: 1 +``` + +如果清单无效或不存在,命令将以非零退出码退出。 + +#### 清理清单 + +```bash +$ egg-bin manifest clean +``` + +### 自动生成 + +应用正常启动后,框架会在 `ready` 阶段自动生成清单(通过 `dumpManifest`)。下次启动时如果清单有效,则自动使用。 + +## 清单失效机制 + +清单包含指纹信息,以下情况会自动失效: + +- **锁文件变更**:`pnpm-lock.yaml`、`package-lock.json` 或 `yarn.lock` 的修改时间或大小变化 +- **配置目录变更**:`config/` 目录下的文件发生变化(基于 MD5 指纹) +- **环境不匹配**:`serverEnv`、`serverScope` 与清单中记录的不一致 +- **TypeScript 状态变更**:`EGG_TYPESCRIPT` 启用/禁用状态发生变化 +- **版本不匹配**:清单格式版本号不一致 + +清单失效时,框架会回退到正常的文件发现流程,不会影响启动。 + +## 环境变量 + +| 变量 | 说明 | 默认值 | +| -------------- | ------------------ | --------------------------------- | +| `EGG_MANIFEST` | 在本地环境启用清单 | `false`(本地环境默认不加载清单) | + +> 本地开发环境(`serverEnv=local`)默认不加载清单,因为开发时文件频繁变更,清单容易失效。设置 `EGG_MANIFEST=true` 可强制启用。 + +## 部署建议 + +### 容器化部署 + +在 Dockerfile 中,构建完成后生成清单: + +```dockerfile +# 安装依赖并构建 +RUN npm install --production +RUN npm run build + +# 生成启动清单 +RUN npx egg-bin manifest generate --env=prod + +# 启动应用(将自动使用清单) +CMD ["npm", "start"] +``` + +### CI/CD 流水线 + +在构建阶段生成清单,随制品一起部署: + +```bash +# 构建 +npm run build + +# 生成清单 +npx egg-bin manifest generate --env=prod + +# 验证清单 +npx egg-bin manifest validate --env=prod + +# 打包(包含 .egg/manifest.json) +tar -zcvf release.tgz . +``` + +## 注意事项 + +1. **清单与环境绑定**:清单绑定了 `serverEnv` 和 `serverScope`(部署类型,如 `aliyun`),不同环境或部署类型需要分别生成 +2. **依赖变更后需重新生成**:安装或更新依赖后,锁文件变更会自动使清单失效 +3. **`.egg` 目录**:清单存储在 `.egg/manifest.json`,建议将 `.egg/` 加入 `.gitignore` +4. **回退安全**:清单缺失或无效时,框架会自动回退到正常流程,不会导致启动失败 +5. **metadataOnly 模式**:`manifest generate` 不会启动完整的应用生命周期,不会连接数据库或外部服务 + +## 清单结构 + +```json +{ + "version": 1, + "generatedAt": "2026-03-29T12:13:18.039Z", + "invalidation": { + "lockfileFingerprint": "pnpm-lock.yaml:1711234567890:12345", + "configFingerprint": "a1b2c3d4e5f6...", + "serverEnv": "prod", + "serverScope": "", + "typescriptEnabled": true + }, + "extensions": { + "tegg": { ... } + }, + "resolveCache": { + "config/plugin": "config/plugin.ts", + "config/plugin.default": null + }, + "fileDiscovery": { + "app/middleware": ["trace.ts", "auth.ts"] + } +} +```