Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion tegg/core/loader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
48 changes: 30 additions & 18 deletions tegg/core/loader/src/LoaderUtil.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<unknown> {
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')) {
Expand Down Expand Up @@ -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<EggProtoImplClass[]> {
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) {
Expand Down
5 changes: 2 additions & 3 deletions tegg/core/loader/src/impl/ModuleLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
58 changes: 56 additions & 2 deletions tegg/core/loader/test/Loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> {
this.loadFileCalls.push(filepath);
return await importModule(filepath);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

describe('core/loader/test/Loader.test.ts', () => {
afterEach(() => {
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = undefined;
Expand Down Expand Up @@ -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);
Expand All @@ -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);

Expand Down
39 changes: 37 additions & 2 deletions tegg/core/loader/test/ModuleLoaderManifest.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Comment thread
killagu marked this conversation as resolved.

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();
Expand Down Expand Up @@ -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',
]);
});
});
1 change: 0 additions & 1 deletion tegg/plugin/controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 18 additions & 1 deletion tegg/plugin/controller/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
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(
Expand Down
34 changes: 24 additions & 10 deletions tegg/plugin/controller/src/lib/EggControllerLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EggProtoImplClass[]> {
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) {
Expand Down
Loading
Loading