diff --git a/packages/core/src/egg.ts b/packages/core/src/egg.ts index c0b61a6b39..2596923430 100644 --- a/packages/core/src/egg.ts +++ b/packages/core/src/egg.ts @@ -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; @@ -218,6 +220,7 @@ export class EggCore extends KoaApplication { serverScope: options.serverScope, env: options.env ?? '', EggCoreClass: EggCore, + metadataOnly: options.metadataOnly, }); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4392e3826d..3f6716628f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/lifecycle.ts b/packages/core/src/lifecycle.ts index 3efc02caaa..e2e4c8bd8d 100644 --- a/packages/core/src/lifecycle.ts +++ b/packages/core/src/lifecycle.ts @@ -54,6 +54,13 @@ export interface ILifecycleBoot { * Do some thing before app close */ beforeClose?(): Promise; + + /** + * 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; } export type BootImplClass = new (...args: any[]) => T; @@ -72,6 +79,7 @@ export class Lifecycle extends EventEmitter { #bootHooks: (BootImplClass | ILifecycleBoot)[]; #boots: ILifecycleBoot[]; #isClosed: boolean; + #metadataOnly: boolean; #closeFunctionSet: Set; loadReady: Ready; bootReady: Ready; @@ -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`); @@ -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`); }); @@ -331,6 +342,27 @@ export class Lifecycle extends EventEmitter { })(); } + async triggerLoadMetadata(): Promise { + this.#metadataOnly = true; + debug('trigger loadMetadata start'); + let firstError: Error | undefined; + for (const boot of this.#boots) { + if (typeof boot.loadMetadata === 'function') { + debug('trigger loadMetadata at %o', boot.fullPath); + try { + await boot.loadMetadata(); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + if (!firstError) firstError = error; + debug('trigger loadMetadata error at %o, error: %s', boot.fullPath, error); + this.emit('error', error); + } + } + } + debug('trigger loadMetadata end'); + this.ready(firstError ?? true); + } + #initReady(): void { debug('loadReady init'); this.loadReady = new Ready({ timeout: this.readyTimeout, lazyStart: true }); diff --git a/packages/core/src/loader/egg_loader.ts b/packages/core/src/loader/egg_loader.ts index 8e1785647b..f3e7e597e2 100644 --- a/packages/core/src/loader/egg_loader.ts +++ b/packages/core/src/loader/egg_loader.ts @@ -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 StartupManifest } from './manifest.ts'; const debug = debuglog('egg/core/loader/egg_loader'); @@ -47,6 +48,8 @@ export interface EggLoaderOptions { serverScope?: string; /** custom plugins */ plugins?: Record; + /** Skip lifecycle hooks, only trigger loadMetadata for manifest generation */ + metadataOnly?: boolean; } export type EggDirInfoType = 'app' | 'plugin' | 'framework'; @@ -67,6 +70,8 @@ export class EggLoader { readonly appInfo: EggAppInfo; readonly outDir?: string; dirs?: EggDirInfo[]; + /** Startup manifest — loaded from cache or collecting for generation */ + readonly manifest: ManifestStore; /** * @class @@ -154,6 +159,11 @@ export class EggLoader { * @since 1.0.0 */ this.appInfo = this.getAppInfo(); + + // Load pre-computed manifest or create a collector for future generation + this.manifest = + ManifestStore.load(this.options.baseDir, this.serverEnv, this.serverScope) ?? + ManifestStore.createCollector(this.options.baseDir); } get app(): EggCore { @@ -1233,7 +1243,11 @@ export class EggLoader { */ async loadCustomApp(): Promise { await this.#loadBootHook('app'); - this.lifecycle.triggerConfigWillLoad(); + if (this.options.metadataOnly) { + await this.lifecycle.triggerLoadMetadata(); + } else { + this.lifecycle.triggerConfigWillLoad(); + } } /** @@ -1241,7 +1255,11 @@ export class EggLoader { */ async loadCustomAgent(): Promise { 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 @@ -1627,6 +1645,7 @@ export class EggLoader { directory: options?.directory ?? directory, target, inject: this.app, + manifest: this.manifest, }; const timingKey = `Load "${String(property)}" to Application`; @@ -1652,6 +1671,7 @@ export class EggLoader { directory: options?.directory || directory, property, inject: this.app, + manifest: this.manifest, }; const timingKey = `Load "${String(property)}" to Context`; @@ -1688,11 +1708,15 @@ export class EggLoader { } resolveModule(filepath: string): string | undefined { + return this.manifest.resolveModule(filepath, () => this.#doResolveModule(filepath)); + } + + #doResolveModule(filepath: string): string | undefined { let fullPath: string | undefined; try { fullPath = utils.resolvePath(filepath); } catch { - // debug('[resolveModule] Module %o resolve error: %s', filepath, err.stack); + // ignore resolve errors } if (!fullPath) { fullPath = this.#resolveFromOutDir(filepath); @@ -1734,6 +1758,19 @@ export class EggLoader { } } } + + /** + * Generate startup manifest from collected data. + * Should be called after all loading phases complete. + */ + generateManifest(extensions?: Record): StartupManifest { + return this.manifest.generateManifest({ + serverEnv: this.serverEnv, + serverScope: this.serverScope, + typescriptEnabled: isSupportTypeScript(), + extensions, + }); + } } // convert dep to dependencies for compatibility diff --git a/packages/core/src/loader/file_loader.ts b/packages/core/src/loader/file_loader.ts index f852f578be..afb9e664e9 100644 --- a/packages/core/src/loader/file_loader.ts +++ b/packages/core/src/loader/file_loader.ts @@ -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'); @@ -49,6 +50,8 @@ export interface FileLoaderOptions { /** set property's case when converting a filepath to property list. */ caseStyle?: CaseStyle | CaseStyleFunction; lowercaseFirst?: boolean; + /** Startup manifest for caching globby scans and collecting results */ + manifest?: ManifestStore; } export interface FileLoaderParseItem { @@ -193,8 +196,11 @@ 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 manifest = this.options.manifest; + const filepaths = manifest + ? manifest.globFiles(directory, () => globby.sync(files, { cwd: directory })) + : globby.sync(files, { cwd: directory }); + debug('[parse] files: %o, cwd: %o => %o', files, directory, filepaths); for (const filepath of filepaths) { const fullpath = path.join(directory, filepath); if (!fs.statSync(fullpath).isFile()) continue; diff --git a/packages/core/src/loader/manifest.ts b/packages/core/src/loader/manifest.ts new file mode 100644 index 0000000000..8a6d4040b4 --- /dev/null +++ b/packages/core/src/loader/manifest.ts @@ -0,0 +1,313 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { debuglog } from 'node:util'; + +import { isSupportTypeScript } from '@eggjs/utils'; + +const debug = debuglog('egg/core/loader/manifest'); + +const MANIFEST_VERSION = 1; + +const LOCKFILE_NAMES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'] as const; + +export interface ManifestInvalidation { + lockfileFingerprint: string; + configFingerprint: string; + serverEnv: string; + serverScope: string; + typescriptEnabled: boolean; +} + +export interface StartupManifest { + version: number; + generatedAt: string; + invalidation: ManifestInvalidation; + /** Plugin-specific manifest data, keyed by plugin name */ + extensions: Record; + /** resolveModule cache: relative filepath -> resolved relative path | null */ + resolveCache: Record; + /** relative directory path -> file relative paths */ + fileDiscovery: Record; +} + +export class ManifestStore { + readonly data: StartupManifest; + readonly baseDir: string; + + // Collectors for manifest generation (populated during loading) + readonly #resolveCacheCollector: Record = {}; + readonly #fileDiscoveryCollector: Record = {}; + + private constructor(data: StartupManifest, baseDir: string) { + this.data = data; + this.baseDir = baseDir; + } + + // --- Factory Methods --- + + /** + * Load and validate manifest from `.egg/manifest.json`. + * Returns null if manifest doesn't exist or is invalid. + */ + static load(baseDir: string, serverEnv: string, serverScope: string): ManifestStore | null { + if (serverEnv === 'local' && process.env.EGG_MANIFEST !== 'true') { + debug('skip manifest in local env (set EGG_MANIFEST=true to enable)'); + return null; + } + + const manifestPath = path.join(baseDir, '.egg', 'manifest.json'); + let raw: string; + try { + raw = fs.readFileSync(manifestPath, 'utf-8'); + } catch { + debug('manifest not found at %s', manifestPath); + return null; + } + + let data: StartupManifest; + try { + data = JSON.parse(raw); + } catch (e) { + debug('failed to parse manifest: %s', e); + return null; + } + + if (!ManifestStore.#validate(data, baseDir, serverEnv, serverScope)) { + return null; + } + + debug('manifest loaded successfully'); + return new ManifestStore(data, baseDir); + } + + /** + * Create a collector-only ManifestStore (no cached data). + * Used during normal startup to collect data for future manifest generation. + */ + static createCollector(baseDir: string): ManifestStore { + const emptyData: StartupManifest = { + version: MANIFEST_VERSION, + generatedAt: '', + invalidation: { + lockfileFingerprint: '', + configFingerprint: '', + serverEnv: '', + serverScope: '', + typescriptEnabled: false, + }, + extensions: {}, + resolveCache: {}, + fileDiscovery: {}, + }; + return new ManifestStore(emptyData, baseDir); + } + + static #validate(data: StartupManifest, baseDir: string, serverEnv: string, serverScope: string): boolean { + if (data.version !== MANIFEST_VERSION) { + debug('manifest version mismatch: expected %d, got %d', MANIFEST_VERSION, data.version); + return false; + } + + const inv = data.invalidation; + if (!inv) { + debug('manifest missing invalidation data'); + return false; + } + + if (inv.serverEnv !== serverEnv) { + debug('manifest serverEnv mismatch: expected %s, got %s', serverEnv, inv.serverEnv); + return false; + } + + if (inv.serverScope !== serverScope) { + debug('manifest serverScope mismatch: expected %s, got %s', serverScope, inv.serverScope); + return false; + } + + const currentTypescriptEnabled = isSupportTypeScript(); + if (inv.typescriptEnabled !== currentTypescriptEnabled) { + debug( + 'manifest typescriptEnabled mismatch: expected %s, got %s', + currentTypescriptEnabled, + inv.typescriptEnabled, + ); + return false; + } + + const currentLockfileFingerprint = ManifestStore.#lockfileFingerprint(baseDir); + if (inv.lockfileFingerprint !== currentLockfileFingerprint) { + debug('manifest lockfileFingerprint mismatch'); + return false; + } + + const currentConfigFingerprint = ManifestStore.#directoryFingerprint(path.join(baseDir, 'config')); + if (inv.configFingerprint !== currentConfigFingerprint) { + debug('manifest configFingerprint mismatch'); + return false; + } + + return true; + } + + // --- High-level APIs (cache + collect) --- + + /** + * Resolve a module path. Checks cache first, falls back to resolver, collects result. + */ + resolveModule(filepath: string, fallback: () => string | undefined): string | undefined { + const relKey = this.#toRelative(filepath); + const cache = this.data.resolveCache; + if (cache && relKey in cache) { + const cached = cache[relKey]; + debug('[resolveModule:manifest] %o => %o', filepath, cached); + return cached !== null ? this.#toAbsolute(cached) : undefined; + } + + const result = fallback(); + this.#resolveCacheCollector[relKey] = result !== undefined ? this.#toRelative(result) : null; + return result; + } + + /** + * Get file list for a directory. Checks cache first, falls back to globber, collects result. + */ + globFiles(directory: string, fallback: () => string[]): string[] { + const relKey = this.#toRelative(directory); + const cache = this.data.fileDiscovery; + if (cache && relKey in cache) { + const cached = cache[relKey]; + debug('[globFiles:manifest] using cached files for %o, count: %d', directory, cached.length); + return cached; + } + + const result = fallback(); + this.#fileDiscoveryCollector[relKey] = result; + return result; + } + + /** + * Look up a plugin extension by name. + */ + getExtension(name: string): unknown { + return this.data.extensions?.[name]; + } + + // --- Generation APIs --- + + /** + * Generate a StartupManifest from collected data. + */ + generateManifest(options: ManifestGenerateOptions): StartupManifest { + return { + version: MANIFEST_VERSION, + generatedAt: new Date().toISOString(), + invalidation: { + lockfileFingerprint: ManifestStore.#lockfileFingerprint(this.baseDir), + configFingerprint: ManifestStore.#directoryFingerprint(path.join(this.baseDir, 'config')), + serverEnv: options.serverEnv, + serverScope: options.serverScope, + typescriptEnabled: options.typescriptEnabled, + }, + extensions: options.extensions ?? {}, + resolveCache: this.#resolveCacheCollector, + fileDiscovery: this.#fileDiscoveryCollector, + }; + } + + static async write(baseDir: string, manifest: StartupManifest): Promise { + const dir = path.join(baseDir, '.egg'); + await fsp.mkdir(dir, { recursive: true }); + const manifestPath = path.join(dir, 'manifest.json'); + await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); + debug('manifest written to %s', manifestPath); + } + + static clean(baseDir: string): void { + const manifestPath = path.join(baseDir, '.egg', 'manifest.json'); + try { + fs.unlinkSync(manifestPath); + debug('manifest removed: %s', manifestPath); + } catch (err: any) { + if (err.code !== 'ENOENT') throw err; + } + } + + // --- Path Utilities --- + + #toRelative(absPath: string): string { + const rel = path.isAbsolute(absPath) ? path.relative(this.baseDir, absPath) : absPath; + return rel.replaceAll(path.sep, '/'); + } + + #toAbsolute(relPath: string): string { + if (path.isAbsolute(relPath)) { + return relPath; + } + return path.join(this.baseDir, relPath); + } + + // --- Fingerprint Utilities --- + + static #statFingerprint(filepath: string): string | null { + try { + const stat = fs.statSync(filepath); + return `${stat.mtimeMs}:${stat.size}`; + } catch { + return null; + } + } + + static #lockfileFingerprint(baseDir: string): string { + for (const name of LOCKFILE_NAMES) { + const fp = ManifestStore.#statFingerprint(path.join(baseDir, name)); + if (fp) return `${name}:${fp}`; + } + return ''; + } + + static #directoryFingerprint(dirpath: string): string { + const hash = createHash('md5'); + const visited = new Set(); + ManifestStore.#fingerprintRecursive(dirpath, hash, visited); + return hash.digest('hex'); + } + + static #fingerprintRecursive(dirpath: string, hash: ReturnType, visited: Set): void { + let realPath: string; + try { + realPath = fs.realpathSync(dirpath); + } catch { + return; + } + if (visited.has(realPath)) return; + visited.add(realPath); + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dirpath, { withFileTypes: true }); + } catch { + return; + } + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const fullPath = path.join(dirpath, entry.name); + if (entry.isDirectory()) { + hash.update(`dir:${entry.name}\n`); + ManifestStore.#fingerprintRecursive(fullPath, hash, visited); + } else if (entry.isFile()) { + const fp = ManifestStore.#statFingerprint(fullPath); + hash.update(`file:${entry.name}:${fp ?? 'missing'}\n`); + } + } + } +} + +export interface ManifestGenerateOptions { + serverEnv: string; + serverScope: string; + typescriptEnabled: boolean; + extensions?: Record; +} diff --git a/packages/core/test/__snapshots__/index.test.ts.snap b/packages/core/test/__snapshots__/index.test.ts.snap index 27071368f5..9594df5a13 100644 --- a/packages/core/test/__snapshots__/index.test.ts.snap +++ b/packages/core/test/__snapshots__/index.test.ts.snap @@ -18,6 +18,7 @@ exports[`should expose properties 1`] = ` "KoaRequest", "KoaResponse", "Lifecycle", + "ManifestStore", "Request", "Response", "Router", diff --git a/packages/core/test/fixtures/manifest/expected-manifest.json b/packages/core/test/fixtures/manifest/expected-manifest.json new file mode 100644 index 0000000000..fa3de6c277 --- /dev/null +++ b/packages/core/test/fixtures/manifest/expected-manifest.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "generatedAt": "2026-03-28T04:00:00.000Z", + "invalidation": { + "lockfileFingerprint": "pnpm-lock.yaml:1711584000000:128", + "configFingerprint": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", + "serverEnv": "prod", + "serverScope": "", + "typescriptEnabled": true + }, + "extensions": { + "tegg": { + "moduleReferences": [ + { "name": "app", "path": "app" }, + { "name": "my-plugin", "path": "node_modules/my-plugin", "optional": true } + ], + "moduleDescriptors": [ + { + "name": "app", + "unitPath": "app", + "decoratedFiles": ["controller/home.ts", "service/user.ts"] + } + ] + } + }, + "resolveCache": { + "config/plugin.default": "config/plugin.default.ts", + "config/plugin": null, + "config/config.default": "config/config.default.ts", + "config/config.prod": "config/config.prod.ts", + "app/router": "app/router.ts", + "node_modules/egg-security/config/config.default": "node_modules/egg-security/config/config.default.js", + "node_modules/egg-security/app": "node_modules/egg-security/app.js", + "node_modules/egg-session/config/config.default": null, + "app/app": null + }, + "fileDiscovery": { + "app/controller": ["home.ts", "user.ts", "admin/dashboard.ts"], + "app/service": ["user.ts", "auth.ts"], + "app/middleware": ["auth.ts", "error_handler.ts"], + "node_modules/egg-security/app/middleware": ["csrf.js", "xframe.js"] + } +} diff --git a/packages/core/test/loader/manifest.test.ts b/packages/core/test/loader/manifest.test.ts new file mode 100644 index 0000000000..dbb4168bc6 --- /dev/null +++ b/packages/core/test/loader/manifest.test.ts @@ -0,0 +1,477 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import mm from 'mm'; +import { describe, it, beforeEach, afterEach } from 'vitest'; + +import { ManifestStore } from '../../src/loader/manifest.ts'; +import { createTmpDir, setupBaseDir, generateAndWrite } from './manifest_helper.ts'; + +let tmpDir: string; + +describe('ManifestStore', () => { + beforeEach(() => { + tmpDir = createTmpDir(); + }); + + afterEach(() => { + mm.restore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('generateManifest()', () => { + it('should generate manifest with correct structure', () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + const manifest = collector.generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + + assert.equal(manifest.version, 1); + assert.ok(manifest.generatedAt); + assert.ok(new Date(manifest.generatedAt).getTime() > 0); + assert.equal(manifest.invalidation.serverEnv, 'prod'); + assert.equal(manifest.invalidation.serverScope, ''); + assert.equal(manifest.invalidation.typescriptEnabled, true); + assert.ok(manifest.invalidation.lockfileFingerprint); + assert.ok(manifest.invalidation.configFingerprint); + // No baseDir in invalidation + assert.equal('baseDir' in manifest.invalidation, false); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should default optional fields to empty', () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + const manifest = collector.generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: false, + }); + + assert.deepStrictEqual(manifest.extensions, {}); + assert.deepStrictEqual(manifest.resolveCache, {}); + assert.deepStrictEqual(manifest.fileDiscovery, {}); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should preserve provided extensions', () => { + const baseDir = setupBaseDir(); + try { + const extensions = { tegg: { moduleReferences: [{ name: 'mod', path: '/tmp/mod' }] } }; + const collector = ManifestStore.createCollector(baseDir); + const manifest = collector.generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + extensions, + }); + + assert.deepStrictEqual(manifest.extensions, extensions); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should collect resolveModule results as relative paths', () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + + // Simulate resolveModule calls + collector.resolveModule(path.join(baseDir, 'config/plugin'), () => path.join(baseDir, 'config/plugin.ts')); + collector.resolveModule(path.join(baseDir, 'app/missing'), () => undefined); + + const manifest = collector.generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + + assert.equal(manifest.resolveCache['config/plugin'], 'config/plugin.ts'); + assert.equal(manifest.resolveCache['app/missing'], null); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should collect globFiles results as relative paths', () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + + collector.globFiles(path.join(baseDir, 'app/service'), () => ['user.ts', 'post.ts']); + + const manifest = collector.generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + + assert.deepStrictEqual(manifest.fileDiscovery['app/service'], ['user.ts', 'post.ts']); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should detect pnpm lockfile', () => { + const baseDir = setupBaseDir({ lockfile: 'pnpm' }); + try { + const manifest = ManifestStore.createCollector(baseDir).generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + assert.ok(manifest.invalidation.lockfileFingerprint.startsWith('pnpm-lock.yaml:')); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should detect npm lockfile', () => { + const baseDir = setupBaseDir({ lockfile: 'npm' }); + try { + const manifest = ManifestStore.createCollector(baseDir).generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + assert.ok(manifest.invalidation.lockfileFingerprint.startsWith('package-lock.json:')); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should detect yarn lockfile', () => { + const baseDir = setupBaseDir({ lockfile: 'yarn' }); + try { + const manifest = ManifestStore.createCollector(baseDir).generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + assert.ok(manifest.invalidation.lockfileFingerprint.startsWith('yarn.lock:')); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should return empty lockfile fingerprint when no lockfile', () => { + const baseDir = setupBaseDir({ lockfile: 'none' }); + try { + const manifest = ManifestStore.createCollector(baseDir).generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + assert.equal(manifest.invalidation.lockfileFingerprint, ''); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should produce deterministic config fingerprint', () => { + const baseDir = setupBaseDir({ configFiles: { 'a.ts': 'const a = 1;' } }); + try { + const m1 = ManifestStore.createCollector(baseDir).generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + const m2 = ManifestStore.createCollector(baseDir).generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + assert.equal(m1.invalidation.configFingerprint, m2.invalidation.configFingerprint); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + }); + + describe('write() and clean()', () => { + it('should create .egg/ directory and write manifest', async () => { + const baseDir = setupBaseDir(); + try { + await generateAndWrite(baseDir); + + const manifestPath = path.join(baseDir, '.egg', 'manifest.json'); + assert.ok(fs.existsSync(manifestPath)); + + const written = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + assert.equal(written.version, 1); + assert.equal(written.invalidation.serverEnv, 'prod'); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should overwrite existing manifest', async () => { + const baseDir = setupBaseDir(); + try { + await generateAndWrite(baseDir); + + const m2 = ManifestStore.createCollector(baseDir).generateManifest({ + serverEnv: 'test', + serverScope: '', + typescriptEnabled: true, + }); + await ManifestStore.write(baseDir, m2); + + const written = JSON.parse(fs.readFileSync(path.join(baseDir, '.egg', 'manifest.json'), 'utf-8')); + assert.equal(written.invalidation.serverEnv, 'test'); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should clean manifest file', async () => { + const baseDir = setupBaseDir(); + try { + await generateAndWrite(baseDir); + assert.ok(fs.existsSync(path.join(baseDir, '.egg', 'manifest.json'))); + + ManifestStore.clean(baseDir); + assert.ok(!fs.existsSync(path.join(baseDir, '.egg', 'manifest.json'))); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should not throw when cleaning non-existent manifest', () => { + assert.doesNotThrow(() => { + ManifestStore.clean(tmpDir); + }); + }); + }); + + describe('load()', () => { + it('should load a valid manifest', async () => { + const baseDir = setupBaseDir(); + try { + await generateAndWrite(baseDir); + const store = ManifestStore.load(baseDir, 'prod', ''); + assert.ok(store); + assert.equal(store.data.version, 1); + assert.equal(store.baseDir, baseDir); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should return null when manifest file does not exist', () => { + const store = ManifestStore.load(tmpDir, 'prod', ''); + assert.equal(store, null); + }); + + it('should return null for invalid JSON', () => { + const eggDir = path.join(tmpDir, '.egg'); + fs.mkdirSync(eggDir, { recursive: true }); + fs.writeFileSync(path.join(eggDir, 'manifest.json'), 'not json{{{'); + const store = ManifestStore.load(tmpDir, 'prod', ''); + assert.equal(store, null); + }); + + it('should return null when version mismatches', async () => { + const baseDir = setupBaseDir(); + try { + await generateAndWrite(baseDir, { version: 999 }); + assert.equal(ManifestStore.load(baseDir, 'prod', ''), null); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should return null when serverEnv mismatches', async () => { + const baseDir = setupBaseDir(); + try { + await generateAndWrite(baseDir); + assert.equal(ManifestStore.load(baseDir, 'test', ''), null); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should return null when serverScope mismatches', async () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + const manifest = collector.generateManifest({ + serverEnv: 'prod', + serverScope: 'scopeA', + typescriptEnabled: true, + }); + await ManifestStore.write(baseDir, manifest); + assert.equal(ManifestStore.load(baseDir, 'prod', 'scopeB'), null); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should return null when lockfile changes', async () => { + const baseDir = setupBaseDir({ lockfile: 'pnpm' }); + try { + await generateAndWrite(baseDir); + fs.writeFileSync(path.join(baseDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9\nmodified: true\n'); + assert.equal(ManifestStore.load(baseDir, 'prod', ''), null); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should return null when config directory changes', async () => { + const baseDir = setupBaseDir({ configFiles: { 'config.default.ts': 'export default {};' } }); + try { + await generateAndWrite(baseDir); + fs.writeFileSync(path.join(baseDir, 'config', 'config.prod.ts'), 'export default { port: 8080 };'); + assert.equal(ManifestStore.load(baseDir, 'prod', ''), null); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should return null in local env by default', async () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + const manifest = collector.generateManifest({ + serverEnv: 'local', + serverScope: '', + typescriptEnabled: true, + }); + await ManifestStore.write(baseDir, manifest); + assert.equal(ManifestStore.load(baseDir, 'local', ''), null); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should load in local env when EGG_MANIFEST=true', async () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + const manifest = collector.generateManifest({ + serverEnv: 'local', + serverScope: '', + typescriptEnabled: true, + }); + await ManifestStore.write(baseDir, manifest); + mm(process.env, 'EGG_MANIFEST', 'true'); + const store = ManifestStore.load(baseDir, 'local', ''); + assert.ok(store); + assert.equal(store.data.invalidation.serverEnv, 'local'); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + }); + + describe('resolveModule()', () => { + it('should return cached result from loaded manifest', async () => { + const baseDir = setupBaseDir(); + try { + // Generate manifest with a resolve cache entry + const collector = ManifestStore.createCollector(baseDir); + collector.resolveModule(path.join(baseDir, 'config/plugin'), () => path.join(baseDir, 'config/plugin.ts')); + const manifest = collector.generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + await ManifestStore.write(baseDir, manifest); + + // Load and verify cache hit + const store = ManifestStore.load(baseDir, 'prod', '')!; + let fallbackCalled = false; + const result = store.resolveModule(path.join(baseDir, 'config/plugin'), () => { + fallbackCalled = true; + return undefined; + }); + assert.equal(result, path.join(baseDir, 'config/plugin.ts')); + assert.equal(fallbackCalled, false); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should return undefined for null-cached entry', async () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + collector.resolveModule(path.join(baseDir, 'app/missing'), () => undefined); + const manifest = collector.generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + await ManifestStore.write(baseDir, manifest); + + const store = ManifestStore.load(baseDir, 'prod', '')!; + const result = store.resolveModule(path.join(baseDir, 'app/missing'), () => { + throw new Error('should not be called'); + }); + assert.equal(result, undefined); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should call fallback on cache miss and collect result', () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + const result = collector.resolveModule(path.join(baseDir, 'config/plugin'), () => + path.join(baseDir, 'config/plugin.ts'), + ); + assert.equal(result, path.join(baseDir, 'config/plugin.ts')); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + }); + + describe('globFiles()', () => { + it('should return cached result from loaded manifest', async () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + collector.globFiles(path.join(baseDir, 'app/service'), () => ['user.ts', 'post.ts']); + const manifest = collector.generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + await ManifestStore.write(baseDir, manifest); + + const store = ManifestStore.load(baseDir, 'prod', '')!; + const result = store.globFiles(path.join(baseDir, 'app/service'), () => { + throw new Error('should not be called'); + }); + assert.deepStrictEqual(result, ['user.ts', 'post.ts']); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should call fallback on cache miss and collect result', () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + const result = collector.globFiles(path.join(baseDir, 'app/controller'), () => ['home.ts']); + assert.deepStrictEqual(result, ['home.ts']); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/packages/core/test/loader/manifest_fingerprint.test.ts b/packages/core/test/loader/manifest_fingerprint.test.ts new file mode 100644 index 0000000000..83e0c960a7 --- /dev/null +++ b/packages/core/test/loader/manifest_fingerprint.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { describe, it, beforeEach, afterEach } from 'vitest'; + +import { ManifestStore } from '../../src/loader/manifest.ts'; +import { createTmpDir, setupBaseDir } from './manifest_helper.ts'; + +const generateOpts = { serverEnv: 'prod', serverScope: '', typescriptEnabled: true } as const; + +let tmpDir: string; + +describe('ManifestStore fingerprint stability', () => { + beforeEach(() => { + tmpDir = createTmpDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should produce same fingerprint for unchanged file', () => { + fs.writeFileSync(path.join(tmpDir, 'test.txt'), 'hello'); + const m1 = ManifestStore.createCollector(tmpDir).generateManifest(generateOpts); + const m2 = ManifestStore.createCollector(tmpDir).generateManifest(generateOpts); + assert.equal(m1.invalidation.configFingerprint, m2.invalidation.configFingerprint); + }); + + it('should change config fingerprint when file is added', async () => { + const baseDir = setupBaseDir({ configFiles: { 'a.ts': 'const a = 1;' } }); + try { + const m1 = ManifestStore.createCollector(baseDir).generateManifest(generateOpts); + await new Promise((resolve) => setTimeout(resolve, 50)); + fs.writeFileSync(path.join(baseDir, 'config', 'b.ts'), 'const b = 2;'); + const m2 = ManifestStore.createCollector(baseDir).generateManifest(generateOpts); + assert.notEqual(m1.invalidation.configFingerprint, m2.invalidation.configFingerprint); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should change config fingerprint when file is deleted', () => { + const baseDir = setupBaseDir({ configFiles: { 'a.ts': '1', 'b.ts': '2' } }); + try { + const m1 = ManifestStore.createCollector(baseDir).generateManifest(generateOpts); + fs.unlinkSync(path.join(baseDir, 'config', 'b.ts')); + const m2 = ManifestStore.createCollector(baseDir).generateManifest(generateOpts); + assert.notEqual(m1.invalidation.configFingerprint, m2.invalidation.configFingerprint); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should produce deterministic fingerprint for non-existent config directory', () => { + const m1 = ManifestStore.createCollector(tmpDir).generateManifest(generateOpts); + const m2 = ManifestStore.createCollector(tmpDir).generateManifest(generateOpts); + assert.equal(m1.invalidation.configFingerprint, m2.invalidation.configFingerprint); + }); + + it('should handle symlink cycles without infinite recursion', () => { + const baseDir = setupBaseDir({ configFiles: { 'a.ts': '1' } }); + try { + const configDir = path.join(baseDir, 'config'); + try { + fs.symlinkSync(configDir, path.join(configDir, 'loop')); + } catch { + return; // Symlinks may not be supported + } + const manifest = ManifestStore.createCollector(baseDir).generateManifest(generateOpts); + assert.ok(manifest.invalidation.configFingerprint); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/test/loader/manifest_helper.ts b/packages/core/test/loader/manifest_helper.ts new file mode 100644 index 0000000000..50769343b2 --- /dev/null +++ b/packages/core/test/loader/manifest_helper.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { ManifestStore } from '../../src/loader/manifest.ts'; +import type { StartupManifest } from '../../src/loader/manifest.ts'; + +export function createTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'egg-manifest-test-')); +} + +export function setupBaseDir(options?: { + lockfile?: 'pnpm' | 'npm' | 'yarn' | 'none'; + configFiles?: Record; +}): string { + const baseDir = createTmpDir(); + const lockfile = options?.lockfile ?? 'pnpm'; + if (lockfile === 'pnpm') { + fs.writeFileSync(path.join(baseDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n'); + } else if (lockfile === 'npm') { + fs.writeFileSync(path.join(baseDir, 'package-lock.json'), '{"lockfileVersion": 3}'); + } else if (lockfile === 'yarn') { + fs.writeFileSync(path.join(baseDir, 'yarn.lock'), '# yarn lockfile v1\n'); + } + + const configDir = path.join(baseDir, 'config'); + fs.mkdirSync(configDir, { recursive: true }); + if (options?.configFiles) { + for (const [name, content] of Object.entries(options.configFiles)) { + fs.writeFileSync(path.join(configDir, name), content); + } + } else { + fs.writeFileSync(path.join(configDir, 'config.default.ts'), 'export default {};\n'); + } + + return baseDir; +} + +/** Generate a manifest from a collector and write it to disk. */ +export async function generateAndWrite( + baseDir: string, + overrides?: Partial, +): Promise { + const collector = ManifestStore.createCollector(baseDir); + const manifest = collector.generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + Object.assign(manifest, overrides); + await ManifestStore.write(baseDir, manifest); + return manifest; +} diff --git a/packages/core/test/loader/manifest_query.test.ts b/packages/core/test/loader/manifest_query.test.ts new file mode 100644 index 0000000000..3b949e88c2 --- /dev/null +++ b/packages/core/test/loader/manifest_query.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +import { describe, it, beforeEach, afterEach } from 'vitest'; + +import { ManifestStore } from '../../src/loader/manifest.ts'; +import { createTmpDir, setupBaseDir, generateAndWrite } from './manifest_helper.ts'; + +let tmpDir: string; + +describe('ManifestStore query APIs', () => { + beforeEach(() => { + tmpDir = createTmpDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('getExtension()', () => { + it('should return extension data by name', async () => { + const baseDir = setupBaseDir(); + const extensions = { tegg: { moduleReferences: [{ name: 'myModule', path: '/tmp/myModule' }] } }; + try { + await generateAndWrite(baseDir, { extensions }); + const store = ManifestStore.load(baseDir, 'prod', '')!; + assert.deepStrictEqual(store.getExtension('tegg'), extensions.tegg); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should return undefined for unknown extension', async () => { + const baseDir = setupBaseDir(); + try { + await generateAndWrite(baseDir, { extensions: {} }); + const store = ManifestStore.load(baseDir, 'prod', '')!; + assert.equal(store.getExtension('nonexistent'), undefined); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/packages/core/test/loader/manifest_roundtrip.test.ts b/packages/core/test/loader/manifest_roundtrip.test.ts new file mode 100644 index 0000000000..8e4534f180 --- /dev/null +++ b/packages/core/test/loader/manifest_roundtrip.test.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, it, beforeEach, afterEach } from 'vitest'; + +import { ManifestStore } from '../../src/loader/manifest.ts'; +import { createTmpDir, setupBaseDir, generateAndWrite } from './manifest_helper.ts'; + +let tmpDir: string; + +describe('ManifestStore roundtrip: generate → write → load', () => { + beforeEach(() => { + tmpDir = createTmpDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should round-trip manifest data correctly', async () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + + // Collect some data + collector.resolveModule(path.join(baseDir, 'some/path'), () => path.join(baseDir, 'resolved/path')); + collector.resolveModule(path.join(baseDir, 'missing'), () => undefined); + collector.globFiles(path.join(baseDir, 'app/controller'), () => ['home.ts', 'user.ts']); + + const extensions = { tegg: { moduleReferences: [{ name: 'foo', path: '/tmp/foo' }] } }; + const original = collector.generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + extensions, + }); + await ManifestStore.write(baseDir, original); + + // Load and verify + const store = ManifestStore.load(baseDir, 'prod', '')!; + assert.ok(store); + + // resolveCache uses relative paths internally + assert.equal(original.resolveCache['some/path'], 'resolved/path'); + assert.equal(original.resolveCache['missing'], null); + assert.deepStrictEqual(original.fileDiscovery['app/controller'], ['home.ts', 'user.ts']); + assert.deepStrictEqual(store.data.extensions, extensions); + assert.equal(store.data.version, original.version); + assert.equal(store.data.generatedAt, original.generatedAt); + + // Loaded store converts back to absolute paths + const resolved = store.resolveModule(path.join(baseDir, 'some/path'), () => { + throw new Error('should not be called'); + }); + assert.equal(resolved, path.join(baseDir, 'resolved/path')); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should invalidate after lockfile modification', async () => { + const baseDir = setupBaseDir({ lockfile: 'pnpm' }); + try { + await generateAndWrite(baseDir); + assert.ok(ManifestStore.load(baseDir, 'prod', '')); + + fs.appendFileSync(path.join(baseDir, 'pnpm-lock.yaml'), '\n# modified'); + assert.equal(ManifestStore.load(baseDir, 'prod', ''), null); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should invalidate after config file modification', async () => { + const baseDir = setupBaseDir({ configFiles: { 'config.default.ts': 'export default {};' } }); + try { + await generateAndWrite(baseDir); + assert.ok(ManifestStore.load(baseDir, 'prod', '')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + fs.writeFileSync(path.join(baseDir, 'config', 'config.prod.ts'), 'export default { port: 3000 };'); + assert.equal(ManifestStore.load(baseDir, 'prod', ''), null); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should remain valid when nothing changes', async () => { + const baseDir = setupBaseDir(); + try { + await generateAndWrite(baseDir); + assert.ok(ManifestStore.load(baseDir, 'prod', '')); + assert.ok(ManifestStore.load(baseDir, 'prod', '')); + assert.ok(ManifestStore.load(baseDir, 'prod', '')); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should match expected manifest fixture structure', () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const fixturePath = path.join(currentDir, '../fixtures/manifest/expected-manifest.json'); + const expected = JSON.parse(fs.readFileSync(fixturePath, 'utf-8')); + + assert.equal(expected.version, 1); + assert.ok(expected.generatedAt); + assert.ok(expected.invalidation); + assert.equal(expected.invalidation.serverEnv, 'prod'); + assert.equal('baseDir' in expected.invalidation, false); + assert.ok(typeof expected.invalidation.lockfileFingerprint === 'string'); + assert.ok(typeof expected.invalidation.configFingerprint === 'string'); + + // All paths in resolveCache and fileDiscovery should be relative + for (const key of Object.keys(expected.resolveCache)) { + assert.ok(!path.isAbsolute(key), `resolveCache key should be relative: ${key}`); + const value = expected.resolveCache[key]; + if (value !== null) { + assert.ok(!path.isAbsolute(value), `resolveCache value should be relative: ${value}`); + } + } + for (const key of Object.keys(expected.fileDiscovery)) { + assert.ok(!path.isAbsolute(key), `fileDiscovery key should be relative: ${key}`); + } + }); +});