diff --git a/packages/core/src/loader/egg_loader.ts b/packages/core/src/loader/egg_loader.ts index 771376ada4..f4add29cf3 100644 --- a/packages/core/src/loader/egg_loader.ts +++ b/packages/core/src/loader/egg_loader.ts @@ -8,6 +8,7 @@ import { Request, Response, Application, Context as KoaContext } from '@eggjs/ko import { pathMatching, type PathMatchingOptions } from '@eggjs/path-matching'; import { isESM, isSupportTypeScript } from '@eggjs/utils'; import type { Logger } from 'egg-logger'; +import globby from 'globby'; import { isAsyncFunction, isClass, isGeneratorFunction, isObject, isPromise } from 'is-type-of'; import { homedir } from 'node-homedir'; import { now, diff } from 'performance-ms'; @@ -26,6 +27,12 @@ import { type FileLoaderOptions, CaseStyle, FULLPATH, FileLoader } from './file_ import { ManifestStore, type StartupManifest } from './manifest.ts'; const debug = debuglog('egg/core/loader/egg_loader'); +const CONVENTIONAL_MANIFEST_LOADS = [ + { type: 'resolve', path: ['agent'] }, + { type: 'resolve', path: ['app'] }, + { type: 'discover', path: ['app', 'extend'], extensionlessResolve: true }, + { type: 'discover', path: ['app', 'middleware'] }, +] as const; const originalPrototypes: Record = { request: Request.prototype, @@ -1764,11 +1771,69 @@ export class EggLoader { * Should be called after all loading phases complete. */ generateManifest(): StartupManifest { - return this.manifest.generateManifest({ + const manifest = this.manifest.generateManifest({ serverEnv: this.serverEnv, serverScope: this.serverScope, typescriptEnabled: isSupportTypeScript(), }); + this.#collectConventionalDynamicFiles(manifest); + return manifest; + } + + /** + * metadataOnly startup intentionally skips the agent process, but bundled + * single-mode workers still load agent boot hooks and agent extends later. + * Record convention-based dynamic entry points so the bundle can satisfy + * those runtime lookups without running agent lifecycle hooks at manifest + * generation time. + */ + #collectConventionalDynamicFiles(manifest: StartupManifest): void { + for (const unit of this.getLoadUnits()) { + for (const load of CONVENTIONAL_MANIFEST_LOADS) { + const target = path.join(unit.path, ...load.path); + if (load.type === 'resolve') { + this.#collectConventionResolve(manifest, target); + } else if ('extensionlessResolve' in load && load.extensionlessResolve) { + this.#collectConventionFileResolves(manifest, target); + } else { + this.#collectConventionFileDiscovery(manifest, target); + } + } + } + } + + #collectConventionResolve(manifest: StartupManifest, request: string): void { + const requestKey = this.#toManifestRel(request); + if (Object.hasOwn(manifest.resolveCache, requestKey)) return; + + const resolved = this.#doResolveModule(request); + manifest.resolveCache[requestKey] = resolved ? this.#toManifestRel(resolved) : null; + } + + #collectConventionFileResolves(manifest: StartupManifest, directory: string): void { + const files = this.#collectConventionFileDiscovery(manifest, directory); + for (const file of files) { + const ext = path.extname(file); + if (!ext) continue; + const request = path.join(directory, file.slice(0, -ext.length)); + this.#collectConventionResolve(manifest, request); + } + } + + #collectConventionFileDiscovery(manifest: StartupManifest, directory: string): string[] { + const dirKey = this.#toManifestRel(directory); + if (Object.hasOwn(manifest.fileDiscovery, dirKey)) return manifest.fileDiscovery[dirKey]; + + manifest.fileDiscovery[dirKey] = + fs.existsSync(directory) && fs.statSync(directory).isDirectory() + ? globby.sync(FileLoader.getDefaultMatch(), { cwd: directory }).sort() + : []; + return manifest.fileDiscovery[dirKey]; + } + + #toManifestRel(filepath: string): string { + const rel = path.isAbsolute(filepath) ? path.relative(this.options.baseDir, filepath) : filepath; + return rel.replaceAll(path.sep, '/'); } } diff --git a/packages/core/src/loader/file_loader.ts b/packages/core/src/loader/file_loader.ts index 9a55d1e8b0..62375be1ae 100644 --- a/packages/core/src/loader/file_loader.ts +++ b/packages/core/src/loader/file_loader.ts @@ -60,6 +60,10 @@ export interface FileLoaderParseItem { exports: object | Fun; } +function getDefaultFileLoaderMatch(): string[] { + return isSupportTypeScript() ? ['**/*.(js|ts)', '!**/*.d.ts'] : ['**/*.js']; +} + /** * Load files from directory to target object. * @since 1.0.0 @@ -73,6 +77,10 @@ export class FileLoader { return EXPORTS; } + static getDefaultMatch(): string[] { + return getDefaultFileLoaderMatch(); + } + readonly options: FileLoaderOptions & Required>; /** @@ -181,7 +189,7 @@ export class FileLoader { if (files) { files = Array.isArray(files) ? files : [files]; } else { - files = isSupportTypeScript() ? ['**/*.(js|ts)', '!**/*.d.ts'] : ['**/*.js']; + files = FileLoader.getDefaultMatch(); } let ignore = this.options.ignore; diff --git a/packages/core/src/loader/manifest.ts b/packages/core/src/loader/manifest.ts index dcea3e6ca1..833be1ec4e 100644 --- a/packages/core/src/loader/manifest.ts +++ b/packages/core/src/loader/manifest.ts @@ -211,6 +211,12 @@ export class ManifestStore { return cached !== null ? this.#toAbsolute(cached) : undefined; } + const discovered = this.#resolveFromFileDiscovery(relKey); + if (discovered) { + debug('[resolveModule:fileDiscovery] %o => %o', filepath, discovered); + return discovered; + } + const result = fallback(); this.#resolveCacheCollector[relKey] = result !== undefined ? this.#toRelative(result) : null; return result; @@ -343,6 +349,30 @@ export class ManifestStore { return path.join(this.baseDir, relPath); } + #resolveFromFileDiscovery(relKey: string): string | undefined { + let matchedDir: string | undefined; + for (const dir of Object.keys(this.data.fileDiscovery)) { + if ((relKey === dir || relKey.startsWith(dir + '/')) && (!matchedDir || dir.length > matchedDir.length)) { + matchedDir = dir; + } + } + if (!matchedDir || relKey === matchedDir) return; + + const request = relKey.slice(matchedDir.length + 1); + const files = this.data.fileDiscovery[matchedDir]; + const matchedFile = files.find((file) => { + if (file === request) return true; + + const ext = path.posix.extname(file); + if (!ext || ext === '.map') return false; + + const extensionlessFile = file.slice(0, -ext.length); + return extensionlessFile === request || extensionlessFile === `${request}/index`; + }); + + return matchedFile ? this.#toAbsolute(path.posix.join(matchedDir, matchedFile)) : undefined; + } + // --- Fingerprint Utilities --- static #statFingerprint(filepath: string): string | null { diff --git a/packages/core/test/fixtures/manifest-dynamic-plugin/config/plugin.js b/packages/core/test/fixtures/manifest-dynamic-plugin/config/plugin.js new file mode 100644 index 0000000000..75cfe5921e --- /dev/null +++ b/packages/core/test/fixtures/manifest-dynamic-plugin/config/plugin.js @@ -0,0 +1,8 @@ +'use strict'; + +module.exports = { + security: { + enable: true, + package: '@eggjs/security', + }, +}; diff --git a/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/agent.js b/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/agent.js new file mode 100644 index 0000000000..7ba9e96c8f --- /dev/null +++ b/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/agent.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = class AgentBoot {}; diff --git a/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/app.js b/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/app.js new file mode 100644 index 0000000000..69b7557600 --- /dev/null +++ b/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/app.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = class AppBoot {}; diff --git a/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/app/extend/agent.js b/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/app/extend/agent.js new file mode 100644 index 0000000000..c56bcb8c13 --- /dev/null +++ b/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/app/extend/agent.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = { + securityAgentExtend: true, +}; diff --git a/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/app/extend/application.js b/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/app/extend/application.js new file mode 100644 index 0000000000..68aa5d1ca3 --- /dev/null +++ b/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/app/extend/application.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = { + securityApplicationExtend: true, +}; diff --git a/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/app/extend/filter.js b/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/app/extend/filter.js new file mode 100644 index 0000000000..20db1caed9 --- /dev/null +++ b/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/app/extend/filter.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = { + passthrough(value) { + return value; + }, +}; diff --git a/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/app/middleware/securities.js b/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/app/middleware/securities.js new file mode 100644 index 0000000000..59e2cd3ccd --- /dev/null +++ b/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/app/middleware/securities.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = function securities() { + return async function securityMiddleware(_ctx, next) { + await next(); + }; +}; diff --git a/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/package.json b/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/package.json new file mode 100644 index 0000000000..b294d746f2 --- /dev/null +++ b/packages/core/test/fixtures/manifest-dynamic-plugin/node_modules/@eggjs/security/package.json @@ -0,0 +1,6 @@ +{ + "name": "@eggjs/security", + "eggPlugin": { + "name": "security" + } +} diff --git a/packages/core/test/fixtures/manifest-dynamic-plugin/package.json b/packages/core/test/fixtures/manifest-dynamic-plugin/package.json new file mode 100644 index 0000000000..605735c396 --- /dev/null +++ b/packages/core/test/fixtures/manifest-dynamic-plugin/package.json @@ -0,0 +1,3 @@ +{ + "name": "manifest-dynamic-plugin" +} diff --git a/packages/core/test/loader/manifest.test.ts b/packages/core/test/loader/manifest.test.ts index 5457fe9840..ee55ca1ed1 100644 --- a/packages/core/test/loader/manifest.test.ts +++ b/packages/core/test/loader/manifest.test.ts @@ -590,6 +590,37 @@ describe('ManifestStore', () => { } }); + it('should resolve extensionless modules from cached fileDiscovery', async () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + collector.globFiles(path.join(baseDir, 'app/extend'), () => ['filter.js', 'nested/helper.ts']); + const manifest = collector.generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + await ManifestStore.write(baseDir, manifest); + + const store = ManifestStore.load(baseDir, 'prod', '')!; + const filter = store.resolveModule(path.join(baseDir, 'app/extend/filter'), () => { + throw new Error('should not fallback when fileDiscovery can resolve'); + }); + const nested = store.resolveModule(path.join(baseDir, 'app/extend/nested/helper'), () => { + throw new Error('should not fallback when nested fileDiscovery can resolve'); + }); + const missing = store.resolveModule(path.join(baseDir, 'app/extend/missing'), () => { + return path.join(baseDir, 'app/extend/missing.mjs'); + }); + + assert.equal(filter, path.join(baseDir, 'app/extend/filter.js')); + assert.equal(nested, path.join(baseDir, 'app/extend/nested/helper.ts')); + assert.equal(missing, path.join(baseDir, 'app/extend/missing.mjs')); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + it('should call fallback on cache miss and collect result', () => { const baseDir = setupBaseDir(); try { diff --git a/packages/core/test/loader/manifest_coverage.test.ts b/packages/core/test/loader/manifest_coverage.test.ts index 7b8cec42e8..23a66288e6 100644 --- a/packages/core/test/loader/manifest_coverage.test.ts +++ b/packages/core/test/loader/manifest_coverage.test.ts @@ -100,4 +100,83 @@ describe('ManifestStore coverage: FileLoader getter auto-injects manifest', () = await testApp.close(); }); + + it('should collect configured customLoader directories in metadataOnly startup', async () => { + const testApp = createApp('custom-loader', { metadataOnly: true }); + try { + await testApp.loader.loadPlugin(); + await testApp.loader.loadConfig(); + await testApp.loader.loadCustomLoader(); + + const manifest = testApp.loader.generateManifest(); + + assert.ok( + manifest.fileDiscovery['app/adapter']?.includes('docker.js'), + 'app customLoader directory should be included in manifest fileDiscovery', + ); + assert.ok( + manifest.fileDiscovery['app/util']?.includes('sub/fn.js'), + 'nested app customLoader directory should be included in manifest fileDiscovery', + ); + assert.ok( + manifest.fileDiscovery['app/repository']?.includes('user.js'), + 'ctx customLoader directory should be included in manifest fileDiscovery', + ); + assert.ok( + manifest.fileDiscovery['app/plugin']?.includes('a.js'), + 'app loadunit customLoader directory should be included in manifest fileDiscovery', + ); + assert.ok( + manifest.fileDiscovery['config/b/app/plugin']?.includes('b.js'), + 'plugin loadunit customLoader directory should be included in manifest fileDiscovery', + ); + } finally { + await testApp.close(); + } + }); + + it('should include plugin convention files that metadataOnly app loading does not execute', async () => { + const testApp = createApp('manifest-dynamic-plugin', { metadataOnly: true }); + try { + await testApp.loader.loadPlugin(); + await testApp.loader.loadConfig(); + await testApp.loader.loadApplicationExtend(); + await testApp.loader.loadContextExtend(); + await testApp.loader.loadRequestExtend(); + await testApp.loader.loadResponseExtend(); + await testApp.loader.loadHelperExtend(); + await testApp.loader.loadCustomApp(); + await testApp.loader.loadMiddleware(); + + const manifest = testApp.loader.generateManifest(); + + assert.equal( + manifest.resolveCache['node_modules/@eggjs/security/agent'], + 'node_modules/@eggjs/security/agent.js', + ); + assert.equal(manifest.resolveCache['node_modules/@eggjs/security/app'], 'node_modules/@eggjs/security/app.js'); + assert.equal( + manifest.resolveCache['node_modules/@eggjs/security/app/extend/agent'], + 'node_modules/@eggjs/security/app/extend/agent.js', + ); + assert.equal( + manifest.resolveCache['node_modules/@eggjs/security/app/extend/application'], + 'node_modules/@eggjs/security/app/extend/application.js', + ); + assert.equal( + manifest.resolveCache['node_modules/@eggjs/security/app/extend/filter'], + 'node_modules/@eggjs/security/app/extend/filter.js', + ); + assert.ok( + manifest.fileDiscovery['node_modules/@eggjs/security/app/extend']?.includes('filter.js'), + 'custom app/extend file should be included in manifest fileDiscovery', + ); + assert.ok( + manifest.fileDiscovery['node_modules/@eggjs/security/app/middleware']?.includes('securities.js'), + 'security middleware should be included in manifest fileDiscovery', + ); + } finally { + await testApp.close(); + } + }); }); diff --git a/tools/egg-bundler/src/lib/frameworkSpecifier.ts b/tools/egg-bundler/src/lib/frameworkSpecifier.ts index ec20a0cebd..fea9f037f2 100644 --- a/tools/egg-bundler/src/lib/frameworkSpecifier.ts +++ b/tools/egg-bundler/src/lib/frameworkSpecifier.ts @@ -23,7 +23,7 @@ function isPathLikeFrameworkSpecifier(framework: string): boolean { export function assertFrameworkPackageSpecifier(framework: string): void { if (!framework || isPathLikeFrameworkSpecifier(framework)) { throw new Error( - `[@eggjs/egg-bundler] framework must be a package specifier for bundled runtime, got path-like value: ${framework}`, + `[@eggjs/egg-bundler] framework must be a package specifier for bundled runtime, got path-like value: ${JSON.stringify(framework)}`, ); } }