Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
4 changes: 4 additions & 0 deletions packages/typings/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@
* standard import path.
*/
export type BundleModuleLoader = (filepath: string) => unknown;

export type BundleModuleGlobalThis = typeof globalThis & {
__EGG_BUNDLE_MODULE_LOADER__?: BundleModuleLoader;
};
2 changes: 1 addition & 1 deletion plugins/mock/test/mock_service_cluster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ describe('test/mock_service_cluster.test.ts', () => {
baseDir: getFixtures('demo_mock_service_cluster'),
});
await app.ready();
});
}, 60000);
afterAll(() => app.close());

afterEach(mm.restore);
Expand Down
1 change: 1 addition & 0 deletions tegg/core/loader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@eggjs/core-decorator": "workspace:*",
"@eggjs/metadata": "workspace:*",
"@eggjs/tegg-types": "workspace:*",
"@eggjs/typings": "workspace:*",
"globby": "catalog:",
"is-type-of": "catalog:"
},
Expand Down
39 changes: 26 additions & 13 deletions tegg/core/loader/src/LoaderUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@ import { pathToFileURL } from 'node:url';

import { PrototypeUtil } from '@eggjs/core-decorator';
import type { EggProtoImplClass } from '@eggjs/tegg-types';
import type { BundleModuleGlobalThis } from '@eggjs/typings';
import { isClass } from 'is-type-of';

// Guard against poorly mocked module constructors.
const Module = globalThis.module?.constructor?.length > 1 ? globalThis.module.constructor : BuiltinModule;

function createLoadError(filePath: string, e: unknown): Error {
const message = e instanceof Error ? e.message : String(e);
return new Error(`[tegg/loader] load ${filePath} failed: ${message}`, {
cause: e,
});
}

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

static async loadFile(filePath: string): Promise<EggProtoImplClass[]> {
const originalFilePath = filePath;
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();
}
let exports;
let exports: any;
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,
});
exports = (globalThis as BundleModuleGlobalThis).__EGG_BUNDLE_MODULE_LOADER__?.(
originalFilePath.split('\\').join('/'),
);
} 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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
const clazzList: EggProtoImplClass[] = [];
const exportNames = Object.keys(exports);
Expand Down
57 changes: 56 additions & 1 deletion tegg/core/loader/test/Loader.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
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 type { BundleModuleGlobalThis } from '@eggjs/typings';
import { afterEach, describe, it } from 'vitest';

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

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

describe('module loader', () => {
it('should load module', async () => {
const repoModulePath = path.join(__dirname, './fixtures/modules/module-for-loader');
Expand Down Expand Up @@ -37,6 +44,54 @@ 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: string) => {
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'],
);
});

it('should wrap bundle module loader errors', async () => {
const bundledFile = '/bundle/app/service.ts';
(globalThis as BundleModuleGlobalThis).__EGG_BUNDLE_MODULE_LOADER__ = () => {
throw 'bundle loader failed';
};

await assert.rejects(
async () => {
await LoaderUtil.loadFile(bundledFile);
},
(err: Error & { cause?: unknown }) => {
assert.equal(err.message, '[tegg/loader] load /bundle/app/service.ts failed: bundle loader failed');
assert.equal(err.cause, 'bundle loader failed');
return true;
},
);
});
});

describe('file has tsc error', () => {
Expand Down
1 change: 1 addition & 0 deletions tools/egg-bundler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
},
"dependencies": {
"@eggjs/core": "workspace:*",
"@eggjs/typings": "workspace:*",
"@utoo/pack": "catalog:",
"execa": "catalog:",
"js-yaml": "catalog:",
Expand Down
5 changes: 2 additions & 3 deletions tools/egg-bundler/src/lib/EntryGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ for (const [key, spec] of __EXTERNAL_SPECS) {
import path from 'node:path';

import { ManifestStore } from '@eggjs/core';
import type { BundleModuleGlobalThis } from '@eggjs/typings';
import { startEgg } from ${frameworkSpec};
import * as __frameworkModule from ${frameworkSpec};

Expand Down Expand Up @@ -314,9 +315,7 @@ for (const [appAbsRequest, targetRel] of __APP_RESOLVE_CACHE_ALIASES) {
}
}

const __bundleGlobalThis = globalThis as typeof globalThis & {
__EGG_BUNDLE_MODULE_LOADER__?: (filepath: string) => unknown;
};
const __bundleGlobalThis = globalThis as BundleModuleGlobalThis;
ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA as any, __outputDir));
__bundleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => {
return __getBundleMap(filepath);
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
Loading