Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
16 changes: 11 additions & 5 deletions tegg/core/loader/src/LoaderFactory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PrototypeUtil } from '@eggjs/core-decorator';
import { type LoaderFS } from '@eggjs/loader-fs';
import type { ModuleDescriptor } from '@eggjs/metadata';
import {
EggLoadUnitType,
Expand All @@ -8,7 +9,11 @@ import {
type ModuleReference,
} from '@eggjs/tegg-types';

export type LoaderCreator = (unitPath: string) => Loader;
export interface LoaderOptions {
loaderFS?: LoaderFS;
}

export type LoaderCreator = (unitPath: string, options?: LoaderOptions) => Loader;

export interface ManifestModuleReference {
name: string;
Expand Down Expand Up @@ -40,12 +45,12 @@ export interface LoadAppManifest {
export class LoaderFactory {
private static loaderCreatorMap: Map<EggLoadUnitTypeLike, LoaderCreator> = new Map();

static createLoader(unitPath: string, type: EggLoadUnitTypeLike): Loader {
static createLoader(unitPath: string, type: EggLoadUnitTypeLike, options?: LoaderOptions): Loader {
const creator = this.loaderCreatorMap.get(type);
if (!creator) {
throw new Error(`not find creator for loader type ${type}`);
}
return creator(unitPath);
return creator(unitPath, options);
}

static registerLoader(type: EggLoadUnitTypeLike, creator: LoaderCreator): void {
Expand All @@ -55,6 +60,7 @@ export class LoaderFactory {
static async loadApp(
moduleReferences: readonly ModuleReference[],
manifest?: LoadAppManifest,
options?: LoaderOptions,
): Promise<ModuleDescriptor[]> {
const result: ModuleDescriptor[] = [];
const multiInstanceClazzList: EggProtoImplClass[] = [];
Expand All @@ -79,9 +85,9 @@ export class LoaderFactory {

let loader: Loader;
if (manifestDesc && ModuleLoaderClass && loaderType === EggLoadUnitType.MODULE) {
loader = new ModuleLoaderClass(moduleReference.path, manifestDesc.decoratedFiles);
loader = new ModuleLoaderClass(moduleReference.path, manifestDesc.decoratedFiles, options?.loaderFS);
} else {
loader = LoaderFactory.createLoader(moduleReference.path, loaderType);
loader = LoaderFactory.createLoader(moduleReference.path, loaderType, options);
}

const res: ModuleDescriptor = {
Expand Down
35 changes: 27 additions & 8 deletions tegg/core/loader/src/LoaderUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url';
import { PrototypeUtil } from '@eggjs/core-decorator';
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 Down Expand Up @@ -79,21 +80,20 @@ export class LoaderUtil {
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);
exports =
globalThis.__EGG_BUNDLE_MODULE_LOADER__ === undefined
? await importModule(filePath, { importDefaultOnly: false })
: await importFallback(filePath);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (e: unknown) {
throw createLoadError(filePath, e);
}
}
const normalizedExports = normalizeExports(exports);
const clazzList: EggProtoImplClass[] = [];
const exportNames = Object.keys(exports);
const exportNames = Object.keys(normalizedExports);
for (const exportName of exportNames) {
const clazz = exports[exportName];
const clazz = normalizedExports[exportName];
const isEggProto =
isClass(clazz) && (PrototypeUtil.isEggPrototype(clazz) || PrototypeUtil.isEggMultiInstancePrototype(clazz));
if (!isEggProto) {
Expand All @@ -108,3 +108,22 @@ export class LoaderUtil {
return clazzList;
}
}

async function importFallback(filePath: string): Promise<unknown> {
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();
}
return await import(filePath);
}

function normalizeExports(exports: unknown): Record<string, unknown> {
if (exports !== null && (typeof exports === 'object' || typeof exports === 'function')) {
if (isClass(exports)) {
return { default: exports };
}
return exports as Record<string, unknown>;
}
return {};
}
12 changes: 7 additions & 5 deletions tegg/core/loader/src/impl/ModuleLoader.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import path from 'node:path';
import { debuglog } from 'node:util';

import { RealLoaderFS, type LoaderFS } from '@eggjs/loader-fs';
import type { EggProtoImplClass, Loader } from '@eggjs/tegg-types';
import globby from 'globby';

import { LoaderFactory } from '../LoaderFactory.ts';
import { LoaderUtil } from '../LoaderUtil.ts';
Expand All @@ -11,13 +11,15 @@ const debug = debuglog('egg/tegg/loader/impl/ModuleLoader');

export class ModuleLoader implements Loader {
private readonly moduleDir: string;
private readonly loaderFS: LoaderFS;
private protoClazzList: EggProtoImplClass[];
/** Pre-computed file list from manifest (only decorated files) */
private readonly precomputedFiles?: string[];

constructor(moduleDir: string, precomputedFiles?: string[]) {
constructor(moduleDir: string, precomputedFiles?: string[], loaderFS: LoaderFS = new RealLoaderFS()) {
this.moduleDir = moduleDir;
this.precomputedFiles = precomputedFiles;
this.loaderFS = loaderFS;
}

async load(): Promise<EggProtoImplClass[]> {
Expand All @@ -33,7 +35,7 @@ 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 = this.loaderFS.glob(filePattern, { cwd: this.moduleDir });
debug('load files: %o, filePattern: %o, moduleDir: %o', files, filePattern, this.moduleDir);
}
for (const file of files) {
Expand All @@ -47,8 +49,8 @@ export class ModuleLoader implements Loader {
return this.protoClazzList;
}

static createModuleLoader(path: string): ModuleLoader {
return new ModuleLoader(path);
static createModuleLoader(path: string, options?: { loaderFS?: LoaderFS }): ModuleLoader {
return new ModuleLoader(path, undefined, options?.loaderFS);
}
}

Expand Down
11 changes: 11 additions & 0 deletions tegg/core/loader/test/Loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ describe('core/loader/test/Loader.test.ts', () => {
);
});

it('should load cjs default exported prototype class', async () => {
const cjsDefaultRepoFile = path.join(__dirname, './fixtures/modules/module-with-cjs-default/CjsDefaultRepo.cjs');

const prototypes = await LoaderUtil.loadFile(cjsDefaultRepoFile);

assert.deepEqual(
prototypes.map((proto) => proto.name),
['CjsDefaultRepo'],
);
});

it('should wrap bundle module loader errors', async () => {
const bundledFile = '/bundle/app/service.ts';
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = () => {
Expand Down
20 changes: 20 additions & 0 deletions tegg/core/loader/test/LoaderFactoryManifest.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import path from 'node:path';

import { RealLoaderFS, type LoaderFSGlobOptions } from '@eggjs/loader-fs';
import { ModuleDescriptorDumper } from '@eggjs/metadata';
import { describe, it } from 'vitest';

Expand Down Expand Up @@ -87,4 +88,23 @@ describe('core/loader/test/LoaderFactoryManifest.test.ts', () => {
assert.deepStrictEqual(secondNames, firstNames);
}
});

it('should pass loaderFS to module loaders', async () => {
const loaderFS = new RecordingLoaderFS();

const descriptors = await LoaderFactory.loadApp([moduleRef], undefined, { loaderFS });

assert.equal(descriptors.length, 1);
assert(descriptors[0].clazzList.length > 0);
assert.deepEqual(loaderFS.globCalls, [{ cwd: repoModulePath }]);
});
});

class RecordingLoaderFS extends RealLoaderFS {
readonly globCalls: Array<{ cwd: string | undefined }> = [];

glob(patterns: string | string[], options?: LoaderFSGlobOptions): string[] {
this.globCalls.push({ cwd: options?.cwd ? String(options.cwd) : undefined });
return super.glob(patterns, options);
}
}
57 changes: 56 additions & 1 deletion tegg/core/loader/test/ModuleLoaderManifest.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import assert from 'node:assert/strict';
import path from 'node:path';

import { Prototype, PrototypeUtil, SingletonProto } from '@eggjs/core-decorator';
import { RealLoaderFS, type LoaderFSGlobOptions } from '@eggjs/loader-fs';
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';

describe('core/loader/test/ModuleLoaderManifest.test.ts', () => {
const repoModulePath = path.join(__dirname, './fixtures/modules/module-for-loader');
const toBundlePath = (file: string) => file.split('\\').join('/');

afterEach(() => {
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = undefined;
});

it('should load only precomputed files when provided', async () => {
const loader = new ModuleLoader(repoModulePath, ['AppRepo.ts']);
Expand Down Expand Up @@ -49,4 +57,51 @@ describe('core/loader/test/ModuleLoaderManifest.test.ts', () => {
const second = await loader.load();
assert.strictEqual(first, second);
});

it('should use loaderFS for file discovery', async () => {
const loaderFS = new RecordingLoaderFS();
const loader = new ModuleLoader(repoModulePath, undefined, loaderFS);

const prototypes = await loader.load();

assert.equal(prototypes.length, 4);
assert.deepEqual(loaderFS.globCalls, [{ cwd: repoModulePath }]);
});

it('should load bundled service and repository files from manifest paths', async () => {
class BundledService {}
SingletonProto()(BundledService);
class BundledRepo {}
Prototype()(BundledRepo);

const bundledModuleDir = '/bundle/modules/order';
const serviceFile = path.join(bundledModuleDir, 'BundledService.ts');
const repoFile = path.join(bundledModuleDir, 'repository/BundledRepo.ts');
const normalizedServiceFile = toBundlePath(serviceFile);
const normalizedRepoFile = toBundlePath(repoFile);
const requestedFiles: string[] = [];
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath: string) => {
requestedFiles.push(filepath);
if (filepath === normalizedServiceFile) return { BundledService };
if (filepath === normalizedRepoFile) return { BundledRepo };
return undefined;
};
const loader = new ModuleLoader(bundledModuleDir, ['BundledService.ts', 'repository/BundledRepo.ts']);

const prototypes = await loader.load();

assert.deepEqual(prototypes.map((proto) => proto.name).sort(), ['BundledRepo', 'BundledService']);
assert.deepEqual(requestedFiles, [normalizedServiceFile, normalizedRepoFile]);
assert.equal(PrototypeUtil.getFilePath(BundledService), serviceFile);
assert.equal(PrototypeUtil.getFilePath(BundledRepo), repoFile);
});
});

class RecordingLoaderFS extends RealLoaderFS {
readonly globCalls: Array<{ cwd: string | undefined }> = [];

glob(patterns: string | string[], options?: LoaderFSGlobOptions): string[] {
this.globCalls.push({ cwd: options?.cwd ? String(options.cwd) : undefined });
return super.glob(patterns, options);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const { Prototype } = require('@eggjs/core-decorator');

class CjsDefaultRepo {}

Prototype()(CjsDefaultRepo);

module.exports = CjsDefaultRepo;
2 changes: 1 addition & 1 deletion tegg/plugin/controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"@eggjs/controller-decorator": "workspace:*",
"@eggjs/core-decorator": "workspace:*",
"@eggjs/lifecycle": "workspace:*",
"@eggjs/loader-fs": "workspace:*",
"@eggjs/metadata": "workspace:*",
"@eggjs/module-common": "workspace:*",
"@eggjs/router": "workspace:*",
Expand All @@ -107,7 +108,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
5 changes: 4 additions & 1 deletion tegg/plugin/controller/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ 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);
return new EggControllerLoader(unitPath, {
loaderFS: this.app.loader.loaderFS,
manifest: this.app.loader.manifest,
});
});
this.controllerRegisterFactory.registerControllerRegister(ControllerType.HTTP, HTTPControllerRegister.create);
this.app.loadUnitFactory.registerLoadUnitCreator(
Expand Down
22 changes: 17 additions & 5 deletions tegg/plugin/controller/src/lib/EggControllerLoader.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,36 @@
import path from 'node:path';

import type { EggProtoImplClass } from '@eggjs/core-decorator';
import { RealLoaderFS, type LoaderFS } from '@eggjs/loader-fs';
import { LoaderUtil } from '@eggjs/tegg-loader';
import type { Loader } from '@eggjs/tegg-types';
import globby from 'globby';

interface FileDiscoveryManifest {
globFiles?: (directory: string, fallback: () => string[]) => string[];
}

export class EggControllerLoader implements Loader {
private readonly controllerDir: string;
private readonly loaderFS: LoaderFS;
private readonly manifest?: FileDiscoveryManifest;

constructor(controllerDir: string) {
constructor(controllerDir: string, options?: { loaderFS?: LoaderFS; manifest?: FileDiscoveryManifest }) {
this.controllerDir = controllerDir;
this.loaderFS = options?.loaderFS ?? new RealLoaderFS();
this.manifest = options?.manifest;
}

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),
);
const discovered =
typeof this.manifest?.globFiles === 'function'
? this.manifest.globFiles(this.controllerDir, () =>
this.loaderFS.glob(filePattern, { cwd: this.controllerDir }),
)
: this.loaderFS.glob(filePattern, { cwd: this.controllerDir });
const httpControllers = discovered.map((file) => path.join(this.controllerDir, file));
files = httpControllers;
} catch {
files = [];
Expand Down
Loading
Loading