Skip to content
Merged
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
2 changes: 1 addition & 1 deletion tegg/core/loader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@
},
"dependencies": {
"@eggjs/core-decorator": "workspace:*",
"@eggjs/loader-fs": "workspace:*",
"@eggjs/metadata": "workspace:*",
"@eggjs/tegg-types": "workspace:*",
"@eggjs/typings": "workspace:*",
"globby": "catalog:",
"is-type-of": "catalog:"
},
"devDependencies": {
Expand Down
15 changes: 10 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,7 @@ import {
type ModuleReference,
} from '@eggjs/tegg-types';

export type LoaderCreator = (unitPath: string) => Loader;
export type LoaderCreator = (unitPath: string, loaderFS?: LoaderFS) => Loader;

export interface ManifestModuleReference {
name: string;
Expand Down Expand Up @@ -40,12 +41,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, loaderFS?: LoaderFS): 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, loaderFS);
}

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

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

const res: ModuleDescriptor = {
Expand Down
22 changes: 15 additions & 7 deletions tegg/core/loader/src/impl/ModuleLoader.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
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';

const debug = debuglog('egg/tegg/loader/impl/ModuleLoader');

export interface ModuleLoaderOptions {
/** Pre-computed file list from manifest (only decorated files) */
precomputedFiles?: string[];
/** File system abstraction used for discovery; manifest-backed in bundle mode */
loaderFS?: LoaderFS;
}

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

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

async load(): Promise<EggProtoImplClass[]> {
Expand All @@ -33,7 +41,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 +55,8 @@ export class ModuleLoader implements Loader {
return this.protoClazzList;
}

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

Expand Down
19 changes: 19 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 @@ -61,6 +62,24 @@ describe('core/loader/test/LoaderFactoryManifest.test.ts', () => {
assert(descriptors[0].clazzList.length > 0);
});

it('should route discovery through an injected LoaderFS (non-manifest branch)', async () => {
class StubLoaderFS extends RealLoaderFS {
globCalls = 0;
glob(_patterns: string | string[], _options?: LoaderFSGlobOptions): string[] {
this.globCalls++;
return ['UserRepo.ts'];
}
}
const loaderFS = new StubLoaderFS();
const descriptors = await LoaderFactory.loadApp([moduleRef], undefined, loaderFS);

assert.equal(descriptors.length, 1);
// Only the file the stub returned is loaded.
const names = descriptors[0].clazzList.map((c) => c.name).sort();
assert.deepStrictEqual(names, ['UserRepo']);
assert.equal(loaderFS.globCalls, 1);
});

it('should roundtrip: loadApp → getDecoratedFiles → loadApp(manifest)', async () => {
// Step 1: normal load
const firstDescs = await LoaderFactory.loadApp([moduleRef]);
Expand Down
67 changes: 67 additions & 0 deletions tegg/core/loader/test/ModuleLoaderLoaderFS.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import path from 'node:path';

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

import { ModuleLoader } from '../src/impl/ModuleLoader.ts';

describe('core/loader/test/ModuleLoaderLoaderFS.test.ts', () => {
const repoModulePath = path.join(__dirname, './fixtures/modules/module-for-loader');

/** A LoaderFS whose glob returns a fixed list; everything else delegates to the real fs. */
class StubLoaderFS extends RealLoaderFS implements LoaderFS {
globCalls: Array<{ patterns: string | string[]; options?: LoaderFSGlobOptions }> = [];
private readonly files: string[];
constructor(files: string[]) {
super();
this.files = files;
}
glob(patterns: string | string[], options?: LoaderFSGlobOptions): string[] {
this.globCalls.push({ patterns, options });
return this.files;
}
}

it('should discover files through the injected LoaderFS.glob', async () => {
const loaderFS = new StubLoaderFS(['UserRepo.ts']);
const loader = new ModuleLoader(repoModulePath, { loaderFS });
const prototypes = await loader.load();

// Only the single file the stub returned is loaded (UserRepo).
assert.equal(prototypes.length, 1);
assert(prototypes.find((t) => t.name === 'UserRepo'));
// Discovery went through the injected fs with the module dir as cwd.
assert.equal(loaderFS.globCalls.length, 1);
assert.equal(loaderFS.globCalls[0].options?.cwd, repoModulePath);
});

it('should not call glob when precomputed files are provided', async () => {
const loaderFS = new StubLoaderFS(['UserRepo.ts']);
const loader = new ModuleLoader(repoModulePath, { precomputedFiles: ['AppRepo.ts'], loaderFS });
const prototypes = await loader.load();

// AppRepo.ts has 2 decorated classes; glob is never consulted.
assert.equal(prototypes.length, 2);
assert.equal(loaderFS.globCalls.length, 0);
});

it('should default to RealLoaderFS discovery when no LoaderFS is injected', async () => {
const loader = new ModuleLoader(repoModulePath);
const prototypes = await loader.load();

// Real globby-equivalent discovery finds every decorated class in the fixture.
const names = prototypes.map((p) => p.name).sort();
assert.deepStrictEqual(names, ['AppRepo', 'AppRepo2', 'SprintRepo', 'UserRepo']);
});

it('createModuleLoader should forward the injected LoaderFS', async () => {
const loaderFS = new StubLoaderFS(['SprintRepo.ts']);
const loader = ModuleLoader.createModuleLoader(repoModulePath, loaderFS);
const prototypes = await loader.load();

assert.equal(prototypes.length, 1);
assert(prototypes.find((t) => t.name === 'SprintRepo'));
assert.equal(loaderFS.globCalls.length, 1);
});
});
8 changes: 4 additions & 4 deletions tegg/core/loader/test/ModuleLoaderManifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe('core/loader/test/ModuleLoaderManifest.test.ts', () => {
const repoModulePath = path.join(__dirname, './fixtures/modules/module-for-loader');

it('should load only precomputed files when provided', async () => {
const loader = new ModuleLoader(repoModulePath, ['AppRepo.ts']);
const loader = new ModuleLoader(repoModulePath, { precomputedFiles: ['AppRepo.ts'] });
const prototypes = await loader.load();
// AppRepo.ts has 2 decorated classes: AppRepo and AppRepo2
assert.equal(prototypes.length, 2);
Expand All @@ -27,7 +27,7 @@ describe('core/loader/test/ModuleLoaderManifest.test.ts', () => {
// Load via precomputed files (manifest path)
// Get the file list from normal loading to ensure consistency
const fileNames = ['AppRepo.ts', 'SprintRepo.ts', 'UserRepo.ts'];
const manifestLoader = new ModuleLoader(repoModulePath, fileNames);
const manifestLoader = new ModuleLoader(repoModulePath, { precomputedFiles: fileNames });
const manifestProtos = await manifestLoader.load();

// Same number and same class names
Expand All @@ -38,13 +38,13 @@ describe('core/loader/test/ModuleLoaderManifest.test.ts', () => {
});

it('should return empty list for empty precomputedFiles', async () => {
const loader = new ModuleLoader(repoModulePath, []);
const loader = new ModuleLoader(repoModulePath, { precomputedFiles: [] });
const prototypes = await loader.load();
assert.equal(prototypes.length, 0);
});

it('should cache result on subsequent calls', async () => {
const loader = new ModuleLoader(repoModulePath, ['AppRepo.ts']);
const loader = new ModuleLoader(repoModulePath, { precomputedFiles: ['AppRepo.ts'] });
const first = await loader.load();
const second = await loader.load();
assert.strictEqual(first, second);
Expand Down
1 change: 1 addition & 0 deletions tegg/core/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@eggjs/tegg-types": "workspace:*"
},
"devDependencies": {
"@eggjs/loader-fs": "workspace:*",
"@eggjs/module-test-util": "workspace:*",
"@eggjs/tegg-loader": "workspace:*",
"@types/node": "catalog:",
Expand Down
126 changes: 126 additions & 0 deletions tegg/core/runtime/test/BundledDI.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import path from 'node:path';

import { type EggProtoImplClass, PrototypeUtil } from '@eggjs/core-decorator';
import { RealLoaderFS, type LoaderFS, type LoaderFSGlobOptions } from '@eggjs/loader-fs';
import {
EggLoadUnitType,
EggPrototypeFactory,
GlobalGraph,
GlobalModuleNodeBuilder,
LoadUnitFactory,
} from '@eggjs/metadata';
import { ModuleConfigUtil } from '@eggjs/tegg-common-util';
import { ModuleLoader } from '@eggjs/tegg-loader';
import { type LoadUnitInstance } from '@eggjs/tegg-types';
import { describe, beforeAll, afterAll, it } from 'vitest';

import { EggContainerFactory, LoadUnitInstanceFactory } from '../src/index.ts';
import { ContextHandler } from '../src/model/ContextHandler.ts';
import { EggContextStorage } from './fixtures/EggContextStorage.ts';
import { EggTestContext } from './fixtures/EggTestContext.ts';
import AppService from './fixtures/modules/multi-module/multi-module-service/AppService.ts';

/**
* Theme G (attempt) — bundled cross-module DI regression.
*
* Drives the same multi-module fixtures as LoadUnitInstance.test.ts, but loads
* every module through the manifest path (precomputed `decoratedFiles`) instead
* of globby discovery. A LoaderFS whose `glob` throws guarantees the whole chain
* never falls back to a real filesystem scan, mirroring how a bundled app loads
* from a manifest-backed VFS. Verifies that cross-module dependency injection
* (service -> public repo -> private persistence) still resolves end to end.
*/
describe('test/BundledDI.test.ts', () => {
const fixturesRoot = path.join(__dirname, 'fixtures/modules/multi-module');

// The "manifest": files that contain decorated classes, relative to each module.
const manifest: Array<{ dir: string; decoratedFiles: string[] }> = [
{ dir: 'multi-module-common', decoratedFiles: [] },
{ dir: 'multi-module-repo', decoratedFiles: ['AppRepo.ts', 'PersistenceService.ts'] },
{ dir: 'multi-module-service', decoratedFiles: ['AppService.ts'] },
];

// A LoaderFS that serves everything from the real fs EXCEPT discovery: any
// glob call means we fell off the manifest path, which must not happen.
class NoGlobLoaderFS extends RealLoaderFS implements LoaderFS {
glob(_patterns: string | string[], _options?: LoaderFSGlobOptions): string[] {
throw new Error('discovery must come from the manifest, glob should never be called');
}
}
const loaderFS = new NoGlobLoaderFS();

const loadUnitInstances: LoadUnitInstance[] = [];

async function loadModuleProtos(absPath: string, decoratedFiles: string[]): Promise<EggProtoImplClass[]> {
return new ModuleLoader(absPath, { precomputedFiles: decoratedFiles, loaderFS }).load();
}

beforeAll(async () => {
EggContextStorage.register();

const absModules = manifest.map((m) => ({ ...m, absPath: path.join(fixturesRoot, m.dir) }));

// Build the global module graph from manifest-discovered protos.
GlobalGraph.instance = new GlobalGraph();
for (const { absPath, decoratedFiles } of absModules) {
const clazzList = await loadModuleProtos(absPath, decoratedFiles);
const eggProtoClass = clazzList.filter((clazz) => PrototypeUtil.isEggPrototype(clazz));
const builder = GlobalModuleNodeBuilder.create(absPath, false);
for (const clazz of eggProtoClass) {
builder.addClazz(clazz);
}
GlobalGraph.instance.addModuleNode(builder.build());
}
// build()/sort() validate cross-module dependency resolution; they throw if
// AppService cannot reach AppRepo across the module boundary.
GlobalGraph.instance.build();
GlobalGraph.instance.sort();

// Create runtime load unit instances, again via the manifest loaders.
for (const { absPath, decoratedFiles } of absModules) {
const loader = new ModuleLoader(absPath, { precomputedFiles: decoratedFiles, loaderFS });
const loadUnit = await LoadUnitFactory.createLoadUnit(absPath, EggLoadUnitType.MODULE, loader);
loadUnitInstances.push(await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit));
}
});

afterAll(async () => {
for (const instance of loadUnitInstances) {
await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance);
await LoadUnitFactory.destroyLoadUnit(instance.loadUnit);
}
});

it('should resolve module names from the real package.json without globbing', () => {
// Sanity: non-discovery fs operations still work through the real fs.
const repoName = ModuleConfigUtil.readModuleNameSync(path.join(fixturesRoot, 'multi-module-repo'));
assert.equal(repoName, 'multi-module-repo');
});

it('should inject a public proto across module boundaries (manifest path)', async () => {
const serviceInstance = loadUnitInstances.find((i) => i.loadUnit.unitPath.endsWith('multi-module-service'));
assert.ok(serviceInstance, 'service load unit instance should exist');

const appServiceProto = EggPrototypeFactory.instance.getPrototype('appService', serviceInstance.loadUnit);
assert.ok(appServiceProto, 'appService prototype should be discovered from manifest decoratedFiles');

const saveCtx = new EggTestContext();
const findCtx = new EggTestContext();

await ContextHandler.run(saveCtx, async () => {
const obj = await EggContainerFactory.getOrCreateEggObject(appServiceProto, appServiceProto.name);
const appService = obj.obj as AppService;
// appService -> appRepo (other module, PUBLIC) -> persistenceService injected and usable
await appService.save({ name: 'bundled-app', desc: 'loaded-from-manifest' });
});

const found = await ContextHandler.run(findCtx, async () => {
const obj = await EggContainerFactory.getOrCreateEggObject(appServiceProto, appServiceProto.name);
const appService = obj.obj as AppService;
return appService.findApp('bundled-app');
});

assert.deepStrictEqual(found, { name: 'bundled-app', desc: 'loaded-from-manifest' });
});
});
1 change: 1 addition & 0 deletions tegg/plugin/tegg/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
"sdk-base": "catalog:"
},
"devDependencies": {
"@eggjs/core": "workspace:*",
"@eggjs/mock": "workspace:*",
"@eggjs/tegg": "workspace:*",
"@eggjs/tegg-config": "workspace:*",
Expand Down
Loading
Loading