Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
29 changes: 20 additions & 9 deletions tegg/core/loader/src/LoaderUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import { isClass } from 'is-type-of';
// Guard against poorly mocked module constructors.
const Module = globalThis.module?.constructor?.length > 1 ? globalThis.module.constructor : BuiltinModule;

type BundleModuleLoader = (filepath: string) => unknown;

type BundleModuleGlobalThis = typeof globalThis & {
__EGG_BUNDLE_MODULE_LOADER__?: BundleModuleLoader;
};

interface LoaderUtilConfig {
extraFilePattern?: string[];
}
Expand Down Expand Up @@ -64,20 +70,25 @@ export class LoaderUtil {

static async loadFile(filePath: string): Promise<EggProtoImplClass[]> {
const originalFilePath = filePath;
let exports: any = (globalThis as BundleModuleGlobalThis).__EGG_BUNDLE_MODULE_LOADER__?.(
originalFilePath.split('\\').join('/'),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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();
}
Comment thread
killagu marked this conversation as resolved.
Outdated
let exports;
try {
exports = await import(filePath);
} catch (e: any) {
console.trace('[tegg/loader] loadFile %s error:', filePath);
console.error(e);
throw new Error(`[tegg/loader] load ${filePath} failed: ${e.message}`, {
cause: e,
});
if (exports == null) {
try {
exports = await import(filePath);
} catch (e: unknown) {
console.trace('[tegg/loader] loadFile %s error:', filePath);
console.error(e);
const message = e instanceof Error ? e.message : String(e);
throw new Error(`[tegg/loader] load ${filePath} failed: ${message}`, {
cause: e,
});
}
}
const clazzList: EggProtoImplClass[] = [];
const exportNames = Object.keys(exports);
Expand Down
41 changes: 40 additions & 1 deletion tegg/core/loader/test/Loader.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import assert from 'node:assert/strict';
import path from 'node:path';

import { PrototypeUtil, SingletonProto } from '@eggjs/core-decorator';
import { EggLoadUnitType } from '@eggjs/metadata';
import { describe, it } from 'vitest';
import { afterEach, describe, it } from 'vitest';

import { LoaderFactory, LoaderUtil } from '../src/index.ts';

type BundleModuleGlobalThis = typeof globalThis & {
__EGG_BUNDLE_MODULE_LOADER__?: (filepath: string) => unknown;
};

describe('core/loader/test/Loader.test.ts', () => {
afterEach(() => {
Comment thread
killagu marked this conversation as resolved.
delete (globalThis as BundleModuleGlobalThis).__EGG_BUNDLE_MODULE_LOADER__;
});

describe('module loader', () => {
it('should load module', async () => {
const repoModulePath = path.join(__dirname, './fixtures/modules/module-for-loader');
Expand Down Expand Up @@ -37,6 +46,36 @@ describe('core/loader/test/Loader.test.ts', () => {
const prototypes = await loader.load();
assert.equal(prototypes.length, 1);
});

it('should load pre-bundled files through the bundle module loader', async () => {
class BundledService {}
SingletonProto()(BundledService);
const bundledFile = '/bundle/app/port/manager/UserRoleManager.ts';
(globalThis as BundleModuleGlobalThis).__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => {
assert.equal(filepath, bundledFile);
return { BundledService };
};

const prototypes = await LoaderUtil.loadFile(bundledFile);

assert.deepEqual(
prototypes.map((proto) => proto.name),
['BundledService'],
);
assert.equal(PrototypeUtil.getFilePath(BundledService), bundledFile);
});

it('should fall back to dynamic import when the bundle module loader returns null', async () => {
const appRepoFile = path.join(__dirname, './fixtures/modules/module-for-loader/AppRepo.ts');
(globalThis as BundleModuleGlobalThis).__EGG_BUNDLE_MODULE_LOADER__ = () => null;

const prototypes = await LoaderUtil.loadFile(appRepoFile);

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

describe('file has tsc error', () => {
Expand Down
71 changes: 46 additions & 25 deletions tools/egg-bundler/src/lib/ManifestLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,15 @@ interface TeggModuleDescriptor {
decoratedFiles?: string[];
}

interface TeggModuleReference {
name: string;
path: string;
optional?: boolean;
loaderType?: string;
}

interface TeggManifestExtension {
moduleReferences?: TeggModuleReference[];
moduleDescriptors?: TeggModuleDescriptor[];
}

Expand Down Expand Up @@ -401,35 +409,48 @@ export class ManifestLoader {
): Promise<Record<string, unknown>> {
const result: Record<string, unknown> = { ...extensions };
const tegg = extensions?.tegg as TeggManifestExtension | undefined;
if (tegg?.moduleDescriptors) {
result.tegg = {
...tegg,
moduleDescriptors: await Promise.all(
tegg.moduleDescriptors.map(async (desc) => {
if (!path.isAbsolute(desc.unitPath)) return desc;
const real = await this.#realpath(desc.unitPath);
let best: ModuleMapEntry | undefined;
for (const entry of moduleMap) {
if (real === entry.realDir || real.startsWith(entry.realDir + path.sep)) {
best = entry;
break;
}
}
if (!best) {
// keep as relative-to-baseDir form so runtime can resolve via #resolveFromBase
const rel = path.relative(this.#baseDir, real).replaceAll(path.sep, '/');
return { ...desc, unitPath: rel };
}
const rest = real === best.realDir ? '' : real.slice(best.realDir.length + 1);
const unitPath = [best.normalizedDir, rest].filter(Boolean).join('/').replaceAll(path.sep, '/');
return { ...desc, unitPath };
}),
),
};
if (tegg?.moduleReferences || tegg?.moduleDescriptors) {
const normalizedTegg: TeggManifestExtension = { ...tegg };
if (tegg.moduleReferences) {
normalizedTegg.moduleReferences = await Promise.all(
tegg.moduleReferences.map(async (ref) => ({
...ref,
path: await this.#normalizeTeggUnitPath(ref.path, moduleMap),
})),
);
}
if (tegg.moduleDescriptors) {
normalizedTegg.moduleDescriptors = await Promise.all(
tegg.moduleDescriptors.map(async (desc) => ({
...desc,
unitPath: await this.#normalizeTeggUnitPath(desc.unitPath, moduleMap),
})),
);
}
result.tegg = normalizedTegg;
}
return result;
}

async #normalizeTeggUnitPath(unitPath: string, moduleMap: ModuleMapEntry[]): Promise<string> {
if (!path.isAbsolute(unitPath)) return unitPath;
const real = await this.#realpath(unitPath);
let best: ModuleMapEntry | undefined;
for (const entry of moduleMap) {
if (real === entry.realDir || real.startsWith(entry.realDir + path.sep)) {
best = entry;
break;
}
}
if (!best) {
// Keep local app modules relative to baseDir so bundled runtime can
// resolve them under outputDir, and keep descriptor/reference keys equal.
return path.relative(this.#baseDir, real).replaceAll(path.sep, '/');
}
const rest = real === best.realDir ? '' : real.slice(best.realDir.length + 1);
return [best.normalizedDir, rest].filter(Boolean).join('/').replaceAll(path.sep, '/');
}

async #findPackageJsonFromNodeModules(name: string, startDir: string): Promise<string | undefined> {
let dir = startDir;
const nameSegments = name.split('/');
Expand Down
32 changes: 32 additions & 0 deletions tools/egg-bundler/test/EntryGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,38 @@ describe('EntryGenerator', () => {
]);
});

it('includes app/port controller decorated files from tegg manifest descriptors', async () => {
const manifest = makeManifest({
extensions: {
tegg: {
moduleReferences: [
{
name: 'appPort',
path: 'app/port',
},
],
moduleDescriptors: [
{
unitPath: 'app/port',
decoratedFiles: ['controller/HomeController.ts', 'manager/UserRoleManager.ts'],
},
],
},
},
});

const gen = new EntryGenerator({ baseDir: tmpDir, manifestLoader: createFakeLoader(manifest) });
const result = await gen.generate();
const worker = await fs.readFile(result.workerEntry, 'utf8');

expect(extractImports(worker).map((i) => i.specifier)).toEqual([
'../../app/port/controller/HomeController.ts',
'../../app/port/manager/UserRoleManager.ts',
]);
expect(worker).toContain('"moduleReferences"');
expect(worker).toContain('"path": "app/port"');
});

it('skips resolveCache entries whose value is null', async () => {
const manifest = makeManifest({
resolveCache: {
Expand Down
62 changes: 62 additions & 0 deletions tools/egg-bundler/test/ManifestLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,68 @@ describe('ManifestLoader', () => {
expect(loader.store.data).toBe(loaded);
});

it('normalizes tegg moduleReferences and moduleDescriptors to matching app-relative paths', async () => {
const baseDir = createTempApp();
const portRoot = path.join(baseDir, 'app/port');
const controllerFile = path.join(portRoot, 'controller/HomeController.ts');
const managerFile = path.join(portRoot, 'manager/UserRoleManager.ts');
fs.mkdirSync(path.dirname(controllerFile), { recursive: true });
fs.mkdirSync(path.dirname(managerFile), { recursive: true });
writeJson(path.join(baseDir, 'package.json'), {});
writeJson(path.join(portRoot, 'package.json'), {
name: 'app-port',
eggModule: {
name: 'appPort',
},
});
fs.writeFileSync(controllerFile, 'export class HomeController {}\n');
fs.writeFileSync(managerFile, 'export class UserRoleManager {}\n');

const manifestPath = path.join(baseDir, '.egg/manifest.json');
writeJson(
manifestPath,
manifest({
extensions: {
tegg: {
moduleReferences: [
{
name: 'appPort',
path: portRoot,
},
],
moduleDescriptors: [
{
name: 'appPort',
unitPath: portRoot,
decoratedFiles: ['controller/HomeController.ts', 'manager/UserRoleManager.ts'],
},
],
},
},
}),
);

const loader = new ManifestLoader({ baseDir, manifestPath, autoGenerate: false });
const loaded = await loader.load();

expect(loaded.extensions.tegg).toEqual({
moduleReferences: [
{
name: 'appPort',
path: 'app/port',
},
],
moduleDescriptors: [
{
name: 'appPort',
unitPath: 'app/port',
decoratedFiles: ['controller/HomeController.ts', 'manager/UserRoleManager.ts'],
},
],
});
expect(loader.getTeggDecoratedFiles()).toEqual([controllerFile, managerFile]);
});

it('merges file discovery entries that normalize to the same package path', async () => {
const baseDir = createTempApp();
const directLib = path.join(baseDir, 'node_modules/direct/lib');
Expand Down
Loading