Skip to content

Commit f6c7b7c

Browse files
authored
fix(bundler): normalize tegg manifest paths (#5932)
## Summary - Normalize tegg manifest moduleReferences paths with moduleDescriptors unitPath so LoaderFactory can match precomputed decoratedFiles in bundled runtime. - Include app/port decorated files in EntryGenerator coverage. - Let tegg LoaderUtil load pre-bundled files through the registered bundle module loader before falling back to dynamic import. ## Tests - pnpm -C tools/egg-bundler exec vitest run test/ManifestLoader.test.ts test/EntryGenerator.test.ts - pnpm exec vitest run tegg/core/loader/test/Loader.test.ts - pnpm -C tools/egg-bundler run typecheck - pnpm -C tegg/core/loader run typecheck - pnpm -C tools/egg-bundler run lint - pnpm exec oxlint --type-aware tegg/core/loader/src/LoaderUtil.ts tegg/core/loader/test/Loader.test.ts - pnpm exec oxfmt --check tools/egg-bundler/src/lib/ManifestLoader.ts tools/egg-bundler/test/ManifestLoader.test.ts tools/egg-bundler/test/EntryGenerator.test.ts tegg/core/loader/src/LoaderUtil.ts tegg/core/loader/test/Loader.test.ts - git diff --check <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Enhanced module loading with improved bundler support for better module resolution and TEgg manifest handling. * **Bug Fixes** * Improved error handling for module loading failures with clearer error messages. * **Tests** * Added comprehensive test coverage for bundled module loading scenarios and manifest normalization. * **Chores** * Added type definitions workspace dependency. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 0389f2d commit f6c7b7c

11 files changed

Lines changed: 230 additions & 56 deletions

File tree

packages/utils/src/import.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { pathToFileURL, fileURLToPath } from 'node:url';
55
import { debuglog } from 'node:util';
66

77
import type { BundleModuleLoader } from '@eggjs/typings';
8+
import type {} from '@eggjs/typings/global';
89

910
import { ImportResolveError } from './error/index.ts';
1011

@@ -422,12 +423,6 @@ export function setSnapshotModuleLoader(loader: SnapshotModuleLoader): void {
422423

423424
export type { BundleModuleLoader } from '@eggjs/typings';
424425

425-
type BundleModuleGlobalThis = typeof globalThis & {
426-
__EGG_BUNDLE_MODULE_LOADER__: BundleModuleLoader | undefined;
427-
};
428-
429-
const bundleModuleGlobalThis = globalThis as BundleModuleGlobalThis;
430-
431426
function normalizeBundleModulePath(filepath: string): string {
432427
return filepath.split(path.win32.sep).join(path.posix.sep);
433428
}
@@ -443,11 +438,11 @@ function normalizeBundleModulePath(filepath: string): string {
443438
* compatibility.
444439
*/
445440
export function setBundleModuleLoader(loader: BundleModuleLoader | undefined): void {
446-
bundleModuleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = loader;
441+
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = loader;
447442
}
448443

449444
export async function importModule(filepath: string, options?: ImportModuleOptions): Promise<any> {
450-
const _bundleModuleLoader = bundleModuleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__;
445+
const _bundleModuleLoader = globalThis.__EGG_BUNDLE_MODULE_LOADER__;
451446
if (_bundleModuleLoader) {
452447
const hit = _bundleModuleLoader(normalizeBundleModulePath(filepath));
453448
if (hit !== undefined) {

plugins/mock/test/mock_service_cluster.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ describe('test/mock_service_cluster.test.ts', () => {
1212
baseDir: getFixtures('demo_mock_service_cluster'),
1313
});
1414
await app.ready();
15-
});
15+
}, 60000);
1616
afterAll(() => app.close());
1717

1818
afterEach(mm.restore);

tegg/core/loader/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@eggjs/core-decorator": "workspace:*",
4545
"@eggjs/metadata": "workspace:*",
4646
"@eggjs/tegg-types": "workspace:*",
47+
"@eggjs/typings": "workspace:*",
4748
"globby": "catalog:",
4849
"is-type-of": "catalog:"
4950
},

tegg/core/loader/src/LoaderUtil.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,19 @@ import { pathToFileURL } from 'node:url';
33

44
import { PrototypeUtil } from '@eggjs/core-decorator';
55
import type { EggProtoImplClass } from '@eggjs/tegg-types';
6+
import type {} from '@eggjs/typings/global';
67
import { isClass } from 'is-type-of';
78

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

12+
function createLoadError(filePath: string, e: unknown): Error {
13+
const message = e instanceof Error ? e.message : String(e);
14+
return new Error(`[tegg/loader] load ${filePath} failed: ${message}`, {
15+
cause: e,
16+
});
17+
}
18+
1119
interface LoaderUtilConfig {
1220
extraFilePattern?: string[];
1321
}
@@ -64,20 +72,23 @@ export class LoaderUtil {
6472

6573
static async loadFile(filePath: string): Promise<EggProtoImplClass[]> {
6674
const originalFilePath = filePath;
67-
if (process.platform === 'win32') {
68-
// convert to file:// url
69-
// 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:'
70-
filePath = pathToFileURL(filePath).toString();
71-
}
72-
let exports;
75+
let exports: any;
7376
try {
74-
exports = await import(filePath);
75-
} catch (e: any) {
76-
console.trace('[tegg/loader] loadFile %s error:', filePath);
77-
console.error(e);
78-
throw new Error(`[tegg/loader] load ${filePath} failed: ${e.message}`, {
79-
cause: e,
80-
});
77+
exports = globalThis.__EGG_BUNDLE_MODULE_LOADER__?.(originalFilePath.split('\\').join('/'));
78+
} catch (e: unknown) {
79+
throw createLoadError(originalFilePath, e);
80+
}
81+
if (exports == null) {
82+
if (process.platform === 'win32') {
83+
// convert to file:// url
84+
// 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:'
85+
filePath = pathToFileURL(filePath).toString();
86+
}
87+
try {
88+
exports = await import(filePath);
89+
} catch (e: unknown) {
90+
throw createLoadError(filePath, e);
91+
}
8192
}
8293
const clazzList: EggProtoImplClass[] = [];
8394
const exportNames = Object.keys(exports);

tegg/core/loader/test/Loader.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import assert from 'node:assert/strict';
22
import path from 'node:path';
33

4+
import { PrototypeUtil, SingletonProto } from '@eggjs/core-decorator';
45
import { EggLoadUnitType } from '@eggjs/metadata';
5-
import { describe, it } from 'vitest';
6+
import type {} from '@eggjs/typings/global';
7+
import { afterEach, describe, it } from 'vitest';
68

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

911
describe('core/loader/test/Loader.test.ts', () => {
12+
afterEach(() => {
13+
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = undefined;
14+
LoaderUtil.setConfig({});
15+
});
16+
1017
describe('module loader', () => {
1118
it('should load module', async () => {
1219
const repoModulePath = path.join(__dirname, './fixtures/modules/module-for-loader');
@@ -37,6 +44,54 @@ describe('core/loader/test/Loader.test.ts', () => {
3744
const prototypes = await loader.load();
3845
assert.equal(prototypes.length, 1);
3946
});
47+
48+
it('should load pre-bundled files through the bundle module loader', async () => {
49+
class BundledService {}
50+
SingletonProto()(BundledService);
51+
const bundledFile = '/bundle/app/port/manager/UserRoleManager.ts';
52+
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath: string) => {
53+
assert.equal(filepath, bundledFile);
54+
return { BundledService };
55+
};
56+
57+
const prototypes = await LoaderUtil.loadFile(bundledFile);
58+
59+
assert.deepEqual(
60+
prototypes.map((proto) => proto.name),
61+
['BundledService'],
62+
);
63+
assert.equal(PrototypeUtil.getFilePath(BundledService), bundledFile);
64+
});
65+
66+
it('should fall back to dynamic import when the bundle module loader returns null', async () => {
67+
const appRepoFile = path.join(__dirname, './fixtures/modules/module-for-loader/AppRepo.ts');
68+
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = () => null;
69+
70+
const prototypes = await LoaderUtil.loadFile(appRepoFile);
71+
72+
assert.deepEqual(
73+
prototypes.map((proto) => proto.name),
74+
['AppRepo', 'AppRepo2'],
75+
);
76+
});
77+
78+
it('should wrap bundle module loader errors', async () => {
79+
const bundledFile = '/bundle/app/service.ts';
80+
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = () => {
81+
throw 'bundle loader failed';
82+
};
83+
84+
await assert.rejects(
85+
async () => {
86+
await LoaderUtil.loadFile(bundledFile);
87+
},
88+
(err: Error & { cause?: unknown }) => {
89+
assert.equal(err.message, '[tegg/loader] load /bundle/app/service.ts failed: bundle loader failed');
90+
assert.equal(err.cause, 'bundle loader failed');
91+
return true;
92+
},
93+
);
94+
});
4095
});
4196

4297
describe('file has tsc error', () => {

tools/egg-bundler/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
},
8888
"dependencies": {
8989
"@eggjs/core": "workspace:*",
90+
"@eggjs/typings": "workspace:*",
9091
"@utoo/pack": "catalog:",
9192
"execa": "catalog:",
9293
"js-yaml": "catalog:",

tools/egg-bundler/src/lib/EntryGenerator.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ for (const [key, spec] of __EXTERNAL_SPECS) {
259259
import path from 'node:path';
260260
261261
import { ManifestStore } from '@eggjs/core';
262+
import type {} from '@eggjs/typings/global';
262263
import { startEgg } from ${frameworkSpec};
263264
import * as __frameworkModule from ${frameworkSpec};
264265
@@ -314,11 +315,8 @@ for (const [appAbsRequest, targetRel] of __APP_RESOLVE_CACHE_ALIASES) {
314315
}
315316
}
316317
317-
const __bundleGlobalThis = globalThis as typeof globalThis & {
318-
__EGG_BUNDLE_MODULE_LOADER__?: (filepath: string) => unknown;
319-
};
320318
ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA as any, __outputDir));
321-
__bundleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => {
319+
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => {
322320
return __getBundleMap(filepath);
323321
};
324322

tools/egg-bundler/src/lib/ManifestLoader.ts

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,15 @@ interface TeggModuleDescriptor {
2828
decoratedFiles?: string[];
2929
}
3030

31+
interface TeggModuleReference {
32+
name: string;
33+
path: string;
34+
optional?: boolean;
35+
loaderType?: string;
36+
}
37+
3138
interface TeggManifestExtension {
39+
moduleReferences?: TeggModuleReference[];
3240
moduleDescriptors?: TeggModuleDescriptor[];
3341
}
3442

@@ -401,35 +409,48 @@ export class ManifestLoader {
401409
): Promise<Record<string, unknown>> {
402410
const result: Record<string, unknown> = { ...extensions };
403411
const tegg = extensions?.tegg as TeggManifestExtension | undefined;
404-
if (tegg?.moduleDescriptors) {
405-
result.tegg = {
406-
...tegg,
407-
moduleDescriptors: await Promise.all(
408-
tegg.moduleDescriptors.map(async (desc) => {
409-
if (!path.isAbsolute(desc.unitPath)) return desc;
410-
const real = await this.#realpath(desc.unitPath);
411-
let best: ModuleMapEntry | undefined;
412-
for (const entry of moduleMap) {
413-
if (real === entry.realDir || real.startsWith(entry.realDir + path.sep)) {
414-
best = entry;
415-
break;
416-
}
417-
}
418-
if (!best) {
419-
// keep as relative-to-baseDir form so runtime can resolve via #resolveFromBase
420-
const rel = path.relative(this.#baseDir, real).replaceAll(path.sep, '/');
421-
return { ...desc, unitPath: rel };
422-
}
423-
const rest = real === best.realDir ? '' : real.slice(best.realDir.length + 1);
424-
const unitPath = [best.normalizedDir, rest].filter(Boolean).join('/').replaceAll(path.sep, '/');
425-
return { ...desc, unitPath };
426-
}),
427-
),
428-
};
412+
if (tegg?.moduleReferences || tegg?.moduleDescriptors) {
413+
const normalizedTegg: TeggManifestExtension = { ...tegg };
414+
if (tegg.moduleReferences) {
415+
normalizedTegg.moduleReferences = await Promise.all(
416+
tegg.moduleReferences.map(async (ref) => ({
417+
...ref,
418+
path: await this.#normalizeTeggUnitPath(ref.path, moduleMap),
419+
})),
420+
);
421+
}
422+
if (tegg.moduleDescriptors) {
423+
normalizedTegg.moduleDescriptors = await Promise.all(
424+
tegg.moduleDescriptors.map(async (desc) => ({
425+
...desc,
426+
unitPath: await this.#normalizeTeggUnitPath(desc.unitPath, moduleMap),
427+
})),
428+
);
429+
}
430+
result.tegg = normalizedTegg;
429431
}
430432
return result;
431433
}
432434

435+
async #normalizeTeggUnitPath(unitPath: string, moduleMap: ModuleMapEntry[]): Promise<string> {
436+
if (!path.isAbsolute(unitPath)) return unitPath;
437+
const real = await this.#realpath(unitPath);
438+
let best: ModuleMapEntry | undefined;
439+
for (const entry of moduleMap) {
440+
if (real === entry.realDir || real.startsWith(entry.realDir + path.sep)) {
441+
best = entry;
442+
break;
443+
}
444+
}
445+
if (!best) {
446+
// Keep local app modules relative to baseDir so bundled runtime can
447+
// resolve them under outputDir, and keep descriptor/reference keys equal.
448+
return path.relative(this.#baseDir, real).replaceAll(path.sep, '/');
449+
}
450+
const rest = real === best.realDir ? '' : real.slice(best.realDir.length + 1);
451+
return [best.normalizedDir, rest].filter(Boolean).join('/').replaceAll(path.sep, '/');
452+
}
453+
433454
async #findPackageJsonFromNodeModules(name: string, startDir: string): Promise<string | undefined> {
434455
let dir = startDir;
435456
const nameSegments = name.split('/');

tools/egg-bundler/test/EntryGenerator.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,38 @@ describe('EntryGenerator', () => {
152152
]);
153153
});
154154

155+
it('includes app/port controller decorated files from tegg manifest descriptors', async () => {
156+
const manifest = makeManifest({
157+
extensions: {
158+
tegg: {
159+
moduleReferences: [
160+
{
161+
name: 'appPort',
162+
path: 'app/port',
163+
},
164+
],
165+
moduleDescriptors: [
166+
{
167+
unitPath: 'app/port',
168+
decoratedFiles: ['controller/HomeController.ts', 'manager/UserRoleManager.ts'],
169+
},
170+
],
171+
},
172+
},
173+
});
174+
175+
const gen = new EntryGenerator({ baseDir: tmpDir, manifestLoader: createFakeLoader(manifest) });
176+
const result = await gen.generate();
177+
const worker = await fs.readFile(result.workerEntry, 'utf8');
178+
179+
expect(extractImports(worker).map((i) => i.specifier)).toEqual([
180+
'../../app/port/controller/HomeController.ts',
181+
'../../app/port/manager/UserRoleManager.ts',
182+
]);
183+
expect(worker).toContain('"moduleReferences"');
184+
expect(worker).toContain('"path": "app/port"');
185+
});
186+
155187
it('skips resolveCache entries whose value is null', async () => {
156188
const manifest = makeManifest({
157189
resolveCache: {

0 commit comments

Comments
 (0)