diff --git a/tegg/core/loader/package.json b/tegg/core/loader/package.json index 8aa8cfe817..91d0d6f4f2 100644 --- a/tegg/core/loader/package.json +++ b/tegg/core/loader/package.json @@ -42,10 +42,11 @@ }, "dependencies": { "@eggjs/core-decorator": "workspace:*", + "@eggjs/loader-fs": "workspace:*", "@eggjs/metadata": "workspace:*", "@eggjs/tegg-types": "workspace:*", "@eggjs/typings": "workspace:*", - "globby": "catalog:", + "@eggjs/utils": "workspace:*", "is-type-of": "catalog:" }, "devDependencies": { diff --git a/tegg/core/loader/src/LoaderUtil.ts b/tegg/core/loader/src/LoaderUtil.ts index 1158c2b962..03f97fc995 100644 --- a/tegg/core/loader/src/LoaderUtil.ts +++ b/tegg/core/loader/src/LoaderUtil.ts @@ -1,9 +1,10 @@ import BuiltinModule from 'node:module'; -import { pathToFileURL } from 'node:url'; import { PrototypeUtil } from '@eggjs/core-decorator'; +import { RealLoaderFS, type LoaderFS, type LoaderFSGlobOptions } from '@eggjs/loader-fs'; import type { EggProtoImplClass } from '@eggjs/tegg-types'; import type {} from '@eggjs/typings/global'; +import { importModule } from '@eggjs/utils'; import { isClass } from 'is-type-of'; // Guard against poorly mocked module constructors. @@ -18,14 +19,27 @@ function createLoadError(filePath: string, e: unknown): Error { interface LoaderUtilConfig { extraFilePattern?: string[]; + loaderFS?: LoaderFS; +} + +class TeggLoaderFS extends RealLoaderFS { + override async loadFile(filepath: string): Promise { + return await importModule(filepath); + } } export class LoaderUtil { static config: LoaderUtilConfig = {}; + static #defaultLoaderFS = new TeggLoaderFS(); + static setConfig(config: LoaderUtilConfig): void { this.config = config; } + static get loaderFS(): LoaderFS { + return this.config.loaderFS ?? this.#defaultLoaderFS; + } + static supportExtensions(): string[] { const extensions = Object.keys((Module as any)._extensions); if (process.env.VITEST === 'true' && !extensions.includes('.ts')) { @@ -70,30 +84,28 @@ export class LoaderUtil { return filePattern; } + static globFiles(patterns: string | string[], options?: LoaderFSGlobOptions): string[] { + return this.loaderFS.glob(patterns, options); + } + static async loadFile(filePath: string): Promise { const originalFilePath = filePath; - let exports: any; + let exports: unknown; try { - exports = globalThis.__EGG_BUNDLE_MODULE_LOADER__?.(originalFilePath.split('\\').join('/')); + exports = await this.loaderFS.loadFile(originalFilePath); } catch (e: unknown) { throw createLoadError(originalFilePath, e); } - if (exports == null) { - if (process.platform === 'win32') { - // convert to file:// url - // avoid windows path issue: Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'd:' - filePath = pathToFileURL(filePath).toString(); - } - try { - exports = await import(filePath); - } catch (e: unknown) { - throw createLoadError(filePath, e); - } - } + const clazzList: EggProtoImplClass[] = []; - const exportNames = Object.keys(exports); - for (const exportName of exportNames) { - const clazz = exports[exportName]; + const candidates = + exports && (typeof exports === 'object' || typeof exports === 'function') ? Object.values(exports) : []; + + if (exports && isClass(exports)) { + candidates.push(exports); + } + + for (const clazz of candidates) { const isEggProto = isClass(clazz) && (PrototypeUtil.isEggPrototype(clazz) || PrototypeUtil.isEggMultiInstancePrototype(clazz)); if (!isEggProto) { diff --git a/tegg/core/loader/src/impl/ModuleLoader.ts b/tegg/core/loader/src/impl/ModuleLoader.ts index 4d20d5b542..285edda5c1 100644 --- a/tegg/core/loader/src/impl/ModuleLoader.ts +++ b/tegg/core/loader/src/impl/ModuleLoader.ts @@ -2,7 +2,6 @@ import path from 'node:path'; import { debuglog } from 'node:util'; import type { EggProtoImplClass, Loader } from '@eggjs/tegg-types'; -import globby from 'globby'; import { LoaderFactory } from '../LoaderFactory.ts'; import { LoaderUtil } from '../LoaderUtil.ts'; @@ -33,11 +32,11 @@ export class ModuleLoader implements Loader { debug('load from manifest, files: %o, moduleDir: %o', files, this.moduleDir); } else { const filePattern = LoaderUtil.filePattern(); - files = await globby(filePattern, { cwd: this.moduleDir }); + files = LoaderUtil.globFiles(filePattern, { cwd: this.moduleDir }); debug('load files: %o, filePattern: %o, moduleDir: %o', files, filePattern, this.moduleDir); } for (const file of files) { - const realPath = path.join(this.moduleDir, file); + const realPath = path.isAbsolute(file) ? file : path.join(this.moduleDir, file); const fileClazzList = await LoaderUtil.loadFile(realPath); for (const clazz of fileClazzList) { protoClassList.push(clazz); diff --git a/tegg/core/loader/test/Loader.test.ts b/tegg/core/loader/test/Loader.test.ts index a2dfb0ed8d..6271a43f3e 100644 --- a/tegg/core/loader/test/Loader.test.ts +++ b/tegg/core/loader/test/Loader.test.ts @@ -2,12 +2,29 @@ import assert from 'node:assert/strict'; import path from 'node:path'; import { PrototypeUtil, SingletonProto } from '@eggjs/core-decorator'; +import { RealLoaderFS, type LoaderFSGlobOptions } from '@eggjs/loader-fs'; import { EggLoadUnitType } from '@eggjs/metadata'; import type {} from '@eggjs/typings/global'; +import { importModule } from '@eggjs/utils'; import { afterEach, describe, it } from 'vitest'; import { LoaderFactory, LoaderUtil } from '../src/index.ts'; +class RecordingLoaderFS extends RealLoaderFS { + readonly globCalls: Array<{ patterns: string | string[]; cwd: string | undefined }> = []; + readonly loadFileCalls: string[] = []; + + override glob(patterns: string | string[], options?: LoaderFSGlobOptions): string[] { + this.globCalls.push({ patterns, cwd: options?.cwd ? String(options.cwd) : undefined }); + return super.glob(patterns, options); + } + + override async loadFile(filepath: string): Promise { + this.loadFileCalls.push(filepath); + return await importModule(filepath); + } +} + describe('core/loader/test/Loader.test.ts', () => { afterEach(() => { globalThis.__EGG_BUNDLE_MODULE_LOADER__ = undefined; @@ -45,6 +62,20 @@ describe('core/loader/test/Loader.test.ts', () => { assert.equal(prototypes.length, 1); }); + it('should use configured LoaderFS for file discovery and loading', async () => { + const loaderFS = new RecordingLoaderFS(); + LoaderUtil.setConfig({ loaderFS }); + const repoModulePath = path.join(__dirname, './fixtures/modules/module-for-loader'); + const loader = LoaderFactory.createLoader(repoModulePath, EggLoadUnitType.MODULE); + + const prototypes = await loader.load(); + + assert.equal(prototypes.length, 4); + assert.equal(loaderFS.globCalls.length, 1); + assert.equal(loaderFS.globCalls[0].cwd, repoModulePath); + assert(loaderFS.loadFileCalls.some((file) => file.endsWith('AppRepo.ts'))); + }); + it('should load pre-bundled files through the bundle module loader', async () => { class BundledService {} SingletonProto()(BundledService); @@ -63,9 +94,32 @@ describe('core/loader/test/Loader.test.ts', () => { assert.equal(PrototypeUtil.getFilePath(BundledService), bundledFile); }); - it('should fall back to dynamic import when the bundle module loader returns null', async () => { + it('should load a direct class export from the bundle module loader', async () => { + class DirectBundledService {} + SingletonProto()(DirectBundledService); + const bundledFile = '/bundle/app/service.ts'; + globalThis.__EGG_BUNDLE_MODULE_LOADER__ = () => DirectBundledService; + + const prototypes = await LoaderUtil.loadFile(bundledFile); + + assert.deepEqual( + prototypes.map((proto) => proto.name), + ['DirectBundledService'], + ); + }); + + it('should ignore non-egg classes from loaded modules', async () => { + class PlainClass {} + const bundledFile = '/bundle/app/plain.ts'; + globalThis.__EGG_BUNDLE_MODULE_LOADER__ = () => ({ PlainClass }); + + const prototypes = await LoaderUtil.loadFile(bundledFile); + + assert.deepEqual(prototypes, []); + }); + + it('should load regular files when no bundle module loader is registered', async () => { const appRepoFile = path.join(__dirname, './fixtures/modules/module-for-loader/AppRepo.ts'); - globalThis.__EGG_BUNDLE_MODULE_LOADER__ = () => null; const prototypes = await LoaderUtil.loadFile(appRepoFile); diff --git a/tegg/core/loader/test/ModuleLoaderManifest.test.ts b/tegg/core/loader/test/ModuleLoaderManifest.test.ts index b797d96144..bba2334d36 100644 --- a/tegg/core/loader/test/ModuleLoaderManifest.test.ts +++ b/tegg/core/loader/test/ModuleLoaderManifest.test.ts @@ -1,15 +1,22 @@ import assert from 'node:assert/strict'; import path from 'node:path'; +import { SingletonProto } from '@eggjs/core-decorator'; import { EggLoadUnitType } from '@eggjs/metadata'; -import { describe, it } from 'vitest'; +import type {} from '@eggjs/typings/global'; +import { afterEach, describe, it } from 'vitest'; import { ModuleLoader } from '../src/impl/ModuleLoader.ts'; -import { LoaderFactory } from '../src/index.ts'; +import { LoaderFactory, LoaderUtil } from '../src/index.ts'; describe('core/loader/test/ModuleLoaderManifest.test.ts', () => { const repoModulePath = path.join(__dirname, './fixtures/modules/module-for-loader'); + afterEach(() => { + globalThis.__EGG_BUNDLE_MODULE_LOADER__ = undefined; + LoaderUtil.setConfig({}); + }); + it('should load only precomputed files when provided', async () => { const loader = new ModuleLoader(repoModulePath, ['AppRepo.ts']); const prototypes = await loader.load(); @@ -49,4 +56,32 @@ describe('core/loader/test/ModuleLoaderManifest.test.ts', () => { const second = await loader.load(); assert.strictEqual(first, second); }); + + it('should load precomputed bundled module files without disk discovery', async () => { + class BundledService {} + class BundledRepository {} + SingletonProto()(BundledService); + SingletonProto()(BundledRepository); + + const moduleDir = '/bundle/app/modules/foo'; + const hits: string[] = []; + globalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath: string) => { + hits.push(filepath); + if (filepath === '/bundle/app/modules/foo/FooService.ts') { + return { BundledService }; + } + if (filepath === '/bundle/app/modules/foo/repository/FooRepository.ts') { + return { BundledRepository }; + } + }; + + const loader = new ModuleLoader(moduleDir, ['FooService.ts', 'repository/FooRepository.ts']); + const prototypes = await loader.load(); + + assert.deepEqual(prototypes.map((proto) => proto.name).sort(), ['BundledRepository', 'BundledService']); + assert.deepEqual(hits, [ + '/bundle/app/modules/foo/FooService.ts', + '/bundle/app/modules/foo/repository/FooRepository.ts', + ]); + }); }); diff --git a/tegg/plugin/controller/package.json b/tegg/plugin/controller/package.json index ad62e9f2ce..a5fe429e9f 100644 --- a/tegg/plugin/controller/package.json +++ b/tegg/plugin/controller/package.json @@ -107,7 +107,6 @@ "await-event": "catalog:", "content-type": "catalog:", "egg-errors": "catalog:", - "globby": "catalog:", "koa-compose": "catalog:", "path-to-regexp": "catalog:path-to-regexp1", "raw-body": "^2.5.2", diff --git a/tegg/plugin/controller/src/app.ts b/tegg/plugin/controller/src/app.ts index 837c46fdef..a0288b8b69 100644 --- a/tegg/plugin/controller/src/app.ts +++ b/tegg/plugin/controller/src/app.ts @@ -2,6 +2,7 @@ import assert from 'node:assert'; import { ControllerMetaBuilderFactory, ControllerType } from '@eggjs/controller-decorator'; import { GlobalGraph, type LoadUnitLifecycleContext } from '@eggjs/metadata'; +import { LoaderUtil } from '@eggjs/tegg-loader'; import { type LoadUnitInstanceLifecycleContext, ModuleLoadUnitInstance } from '@eggjs/tegg-runtime'; import { AGENT_CONTROLLER_PROTO_IMPL_TYPE } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; @@ -20,6 +21,10 @@ import { MCPControllerRegister } from './lib/impl/mcp/MCPControllerRegister.ts'; import { middlewareGraphHook } from './lib/MiddlewareGraphHook.ts'; import { RootProtoManager } from './lib/RootProtoManager.ts'; +function isMissingDirectoryError(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; +} + // Load Controller process // 1. await add load unit is ready, controller may depend other load unit // 2. load ${app_base_dir}app/controller file @@ -52,7 +57,19 @@ export default class ControllerAppBootHook implements ILifecycleBoot { this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.controllerPrototypeHook); this.app.eggObjectFactory.registerEggObjectCreateMethod(AgentControllerProto, AgentControllerObject.createObject); this.app.loaderFactory.registerLoader(CONTROLLER_LOAD_UNIT, (unitPath) => { - return new EggControllerLoader(unitPath); + const filePattern = LoaderUtil.filePattern(); + const discoverFiles = () => { + try { + return LoaderUtil.globFiles(filePattern, { cwd: unitPath }); + } catch (error) { + if (isMissingDirectoryError(error)) { + return []; + } + throw error; + } + }; + const files = this.app.loader.manifest?.globFiles(unitPath, discoverFiles) ?? discoverFiles(); + return new EggControllerLoader(unitPath, files); }); this.controllerRegisterFactory.registerControllerRegister(ControllerType.HTTP, HTTPControllerRegister.create); this.app.loadUnitFactory.registerLoadUnitCreator( diff --git a/tegg/plugin/controller/src/lib/EggControllerLoader.ts b/tegg/plugin/controller/src/lib/EggControllerLoader.ts index 51438673d1..c4bc907340 100644 --- a/tegg/plugin/controller/src/lib/EggControllerLoader.ts +++ b/tegg/plugin/controller/src/lib/EggControllerLoader.ts @@ -3,26 +3,40 @@ import path from 'node:path'; import type { EggProtoImplClass } from '@eggjs/core-decorator'; import { LoaderUtil } from '@eggjs/tegg-loader'; import type { Loader } from '@eggjs/tegg-types'; -import globby from 'globby'; + +function isMissingDirectoryError(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; +} + +function resolveControllerFile(controllerDir: string, file: string): string { + return path.normalize(path.isAbsolute(file) ? file : path.join(controllerDir, file)); +} export class EggControllerLoader implements Loader { private readonly controllerDir: string; + private readonly precomputedFiles?: string[]; - constructor(controllerDir: string) { + constructor(controllerDir: string, precomputedFiles?: string[]) { this.controllerDir = controllerDir; + this.precomputedFiles = precomputedFiles; } async load(): Promise { const filePattern = LoaderUtil.filePattern(); let files: string[]; - try { - const httpControllers = (await globby(filePattern, { cwd: this.controllerDir })).map((file) => - path.join(this.controllerDir, file), - ); - files = httpControllers; - } catch { - files = []; - // app/controller dir not exists + if (this.precomputedFiles) { + files = this.precomputedFiles.map((file) => resolveControllerFile(this.controllerDir, file)); + } else { + try { + files = LoaderUtil.globFiles(filePattern, { cwd: this.controllerDir }).map((file) => + resolveControllerFile(this.controllerDir, file), + ); + } catch (error) { + if (!isMissingDirectoryError(error)) { + throw error; + } + files = []; + } } const protoClassList: EggProtoImplClass[] = []; for (const file of files) { diff --git a/tegg/plugin/controller/test/app.test.ts b/tegg/plugin/controller/test/app.test.ts new file mode 100644 index 0000000000..32f07521aa --- /dev/null +++ b/tegg/plugin/controller/test/app.test.ts @@ -0,0 +1,138 @@ +import { LoaderUtil } from '@eggjs/tegg-loader'; +import type { Application } from 'egg'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import ControllerAppBootHook from '../src/app.ts'; +import { CONTROLLER_LOAD_UNIT } from '../src/lib/ControllerLoadUnit.ts'; + +type ControllerLoaderCreator = (unitPath: string) => unknown; +type GlobOptions = { cwd?: string | URL }; + +class RecordingLoaderFS { + readonly globCalls: Array<{ patterns: string | string[]; cwd: string | undefined }> = []; + + glob(patterns: string | string[], options?: GlobOptions): string[] { + this.globCalls.push({ patterns, cwd: options?.cwd ? String(options.cwd) : undefined }); + return ['home.ts']; + } + + async loadFile(): Promise { + return {}; + } +} + +class ThrowingLoaderFS { + private readonly error: NodeJS.ErrnoException; + + constructor(error: NodeJS.ErrnoException) { + this.error = error; + } + + glob(): string[] { + throw this.error; + } + + async loadFile(): Promise { + return {}; + } +} + +function createApp(manifest?: { globFiles(unitPath: string, discoverFiles: () => string[]): string[] }) { + let registeredLoader: ControllerLoaderCreator | undefined; + const app = { + config: { + coreMiddleware: [], + security: { + csrf: false, + }, + }, + plugins: { + mcpProxy: { + enable: false, + }, + }, + loader: { + manifest, + }, + logger: {}, + eggPrototypeCreatorFactory: { + registerPrototypeCreator: vi.fn(), + }, + loadUnitLifecycleUtil: { + registerLifecycle: vi.fn(), + }, + eggPrototypeLifecycleUtil: { + registerLifecycle: vi.fn(), + }, + eggObjectFactory: { + registerEggObjectCreateMethod: vi.fn(), + }, + loaderFactory: { + registerLoader: vi.fn((type: string, loaderCreator: ControllerLoaderCreator) => { + if (type === CONTROLLER_LOAD_UNIT) { + registeredLoader = loaderCreator; + } + }), + }, + loadUnitFactory: { + registerLoadUnitCreator: vi.fn(), + }, + loadUnitInstanceFactory: { + registerLoadUnitInstanceClass: vi.fn(), + }, + } as unknown as Application; + + return { + app, + getRegisteredLoader() { + if (!registeredLoader) { + throw new Error('controller loader was not registered'); + } + return registeredLoader; + }, + }; +} + +describe('plugin/controller/test/app.test.ts', () => { + afterEach(() => { + LoaderUtil.setConfig({}); + }); + + it('should register controller loader with manifest-backed discovery', () => { + const loaderFS = new RecordingLoaderFS(); + LoaderUtil.setConfig({ loaderFS: loaderFS as any }); + const manifest = { + globFiles: vi.fn((_unitPath: string, discoverFiles: () => string[]) => discoverFiles()), + }; + const { app, getRegisteredLoader } = createApp(manifest); + + new ControllerAppBootHook(app).configWillLoad(); + const loader = getRegisteredLoader()('/app/controller'); + + expect(loader).toBeDefined(); + expect(manifest.globFiles).toHaveBeenCalledWith('/app/controller', expect.any(Function)); + expect(loaderFS.globCalls).toHaveLength(1); + expect(loaderFS.globCalls[0].cwd).toBe('/app/controller'); + }); + + it('should treat missing controller directory as an empty file list', () => { + const error = Object.assign(new Error('missing'), { code: 'ENOENT' }); + LoaderUtil.setConfig({ loaderFS: new ThrowingLoaderFS(error) as any }); + const { app, getRegisteredLoader } = createApp(); + + new ControllerAppBootHook(app).configWillLoad(); + const loader = getRegisteredLoader()('/missing/app/controller'); + + expect(loader).toBeDefined(); + }); + + it('should rethrow non-missing controller discovery errors', () => { + const error = Object.assign(new Error('denied'), { code: 'EACCES' }); + LoaderUtil.setConfig({ loaderFS: new ThrowingLoaderFS(error) as any }); + const { app, getRegisteredLoader } = createApp(); + + new ControllerAppBootHook(app).configWillLoad(); + + expect(() => getRegisteredLoader()('/denied/app/controller')).toThrow('denied'); + }); +}); diff --git a/tegg/plugin/controller/test/lib/EggControllerLoader.test.ts b/tegg/plugin/controller/test/lib/EggControllerLoader.test.ts index 6f5072d91e..1053757f4f 100644 --- a/tegg/plugin/controller/test/lib/EggControllerLoader.test.ts +++ b/tegg/plugin/controller/test/lib/EggControllerLoader.test.ts @@ -1,10 +1,37 @@ +import { Prototype } from '@eggjs/core-decorator'; import { ControllerMetadataUtil } from '@eggjs/tegg'; -import { describe, it, expect } from 'vitest'; +import { LoaderUtil } from '@eggjs/tegg-loader'; +import { afterEach, describe, it, expect } from 'vitest'; import { EggControllerLoader } from '../../src/lib/EggControllerLoader.ts'; import { getFixtures } from '../utils.ts'; +const bundleGlobal = globalThis as typeof globalThis & { + __EGG_BUNDLE_MODULE_LOADER__?: (filepath: string) => unknown; +}; + +class ThrowingLoaderFS { + private readonly error: NodeJS.ErrnoException; + + constructor(error: NodeJS.ErrnoException) { + this.error = error; + } + + glob(): string[] { + throw this.error; + } + + async loadFile(): Promise { + return {}; + } +} + describe('plugin/controller/test/lib/EggModuleLoader.test.ts', () => { + afterEach(() => { + bundleGlobal.__EGG_BUNDLE_MODULE_LOADER__ = undefined; + LoaderUtil.setConfig({}); + }); + it('should work', async () => { const controllerDir = getFixtures('apps/controller-app/app/controller'); const loader = new EggControllerLoader(controllerDir); @@ -15,4 +42,38 @@ describe('plugin/controller/test/lib/EggModuleLoader.test.ts', () => { const metadata = ControllerMetadataUtil.getControllerMetadata(AppController); expect(metadata).toBeDefined(); }); + + it('should load precomputed bundled controller files without disk discovery', async () => { + class BundledController {} + Prototype()(BundledController); + + bundleGlobal.__EGG_BUNDLE_MODULE_LOADER__ = (filepath: string) => { + if (filepath === '/bundle/app/controller/home.ts') { + return { BundledController }; + } + }; + + const loader = new EggControllerLoader('/bundle/app/controller', ['home.ts']); + const classes = await loader.load(); + + expect(classes.map((clazz) => clazz.name)).toEqual(['BundledController']); + }); + + it('should treat missing controller directory as empty', async () => { + const error = Object.assign(new Error('missing'), { code: 'ENOENT' }); + LoaderUtil.setConfig({ loaderFS: new ThrowingLoaderFS(error) as any }); + const loader = new EggControllerLoader('/missing/app/controller'); + + const classes = await loader.load(); + + expect(classes).toEqual([]); + }); + + it('should rethrow non-missing controller discovery errors', async () => { + const error = Object.assign(new Error('denied'), { code: 'EACCES' }); + LoaderUtil.setConfig({ loaderFS: new ThrowingLoaderFS(error) as any }); + const loader = new EggControllerLoader('/denied/app/controller'); + + await expect(loader.load()).rejects.toThrow('denied'); + }); });