diff --git a/packages/utils/src/import.ts b/packages/utils/src/import.ts index 45cdeb199b..8313cfbf2c 100644 --- a/packages/utils/src/import.ts +++ b/packages/utils/src/import.ts @@ -5,6 +5,7 @@ import { pathToFileURL, fileURLToPath } from 'node:url'; import { debuglog } from 'node:util'; import type { BundleModuleLoader } from '@eggjs/typings'; +import type {} from '@eggjs/typings/global'; import { ImportResolveError } from './error/index.ts'; @@ -422,12 +423,6 @@ export function setSnapshotModuleLoader(loader: SnapshotModuleLoader): void { export type { BundleModuleLoader } from '@eggjs/typings'; -type BundleModuleGlobalThis = typeof globalThis & { - __EGG_BUNDLE_MODULE_LOADER__: BundleModuleLoader | undefined; -}; - -const bundleModuleGlobalThis = globalThis as BundleModuleGlobalThis; - function normalizeBundleModulePath(filepath: string): string { return filepath.split(path.win32.sep).join(path.posix.sep); } @@ -443,11 +438,11 @@ function normalizeBundleModulePath(filepath: string): string { * compatibility. */ export function setBundleModuleLoader(loader: BundleModuleLoader | undefined): void { - bundleModuleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = loader; + globalThis.__EGG_BUNDLE_MODULE_LOADER__ = loader; } export async function importModule(filepath: string, options?: ImportModuleOptions): Promise { - const _bundleModuleLoader = bundleModuleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__; + const _bundleModuleLoader = globalThis.__EGG_BUNDLE_MODULE_LOADER__; if (_bundleModuleLoader) { const hit = _bundleModuleLoader(normalizeBundleModulePath(filepath)); if (hit !== undefined) { diff --git a/plugins/mock/test/mock_service_cluster.test.ts b/plugins/mock/test/mock_service_cluster.test.ts index ccced26e74..9d76c493f5 100644 --- a/plugins/mock/test/mock_service_cluster.test.ts +++ b/plugins/mock/test/mock_service_cluster.test.ts @@ -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); diff --git a/tegg/core/loader/package.json b/tegg/core/loader/package.json index a86832e5bc..8aa8cfe817 100644 --- a/tegg/core/loader/package.json +++ b/tegg/core/loader/package.json @@ -44,6 +44,7 @@ "@eggjs/core-decorator": "workspace:*", "@eggjs/metadata": "workspace:*", "@eggjs/tegg-types": "workspace:*", + "@eggjs/typings": "workspace:*", "globby": "catalog:", "is-type-of": "catalog:" }, diff --git a/tegg/core/loader/src/LoaderUtil.ts b/tegg/core/loader/src/LoaderUtil.ts index f8331d50ba..1158c2b962 100644 --- a/tegg/core/loader/src/LoaderUtil.ts +++ b/tegg/core/loader/src/LoaderUtil.ts @@ -3,11 +3,19 @@ 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 { 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[]; } @@ -64,20 +72,23 @@ export class LoaderUtil { static async loadFile(filePath: string): Promise { 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.__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); + } } const clazzList: EggProtoImplClass[] = []; const exportNames = Object.keys(exports); diff --git a/tegg/core/loader/test/Loader.test.ts b/tegg/core/loader/test/Loader.test.ts index 980a526310..a2dfb0ed8d 100644 --- a/tegg/core/loader/test/Loader.test.ts +++ b/tegg/core/loader/test/Loader.test.ts @@ -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 {} from '@eggjs/typings/global'; +import { afterEach, describe, it } from 'vitest'; import { LoaderFactory, LoaderUtil } from '../src/index.ts'; describe('core/loader/test/Loader.test.ts', () => { + afterEach(() => { + globalThis.__EGG_BUNDLE_MODULE_LOADER__ = undefined; + LoaderUtil.setConfig({}); + }); + describe('module loader', () => { it('should load module', async () => { const repoModulePath = path.join(__dirname, './fixtures/modules/module-for-loader'); @@ -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.__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.__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.__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', () => { diff --git a/tools/egg-bundler/package.json b/tools/egg-bundler/package.json index 91e30e1df1..d17d9f67a5 100644 --- a/tools/egg-bundler/package.json +++ b/tools/egg-bundler/package.json @@ -87,6 +87,7 @@ }, "dependencies": { "@eggjs/core": "workspace:*", + "@eggjs/typings": "workspace:*", "@utoo/pack": "catalog:", "execa": "catalog:", "js-yaml": "catalog:", diff --git a/tools/egg-bundler/src/lib/EntryGenerator.ts b/tools/egg-bundler/src/lib/EntryGenerator.ts index 5c7f3fe3d9..b533f100ef 100644 --- a/tools/egg-bundler/src/lib/EntryGenerator.ts +++ b/tools/egg-bundler/src/lib/EntryGenerator.ts @@ -259,6 +259,7 @@ for (const [key, spec] of __EXTERNAL_SPECS) { import path from 'node:path'; import { ManifestStore } from '@eggjs/core'; +import type {} from '@eggjs/typings/global'; import { startEgg } from ${frameworkSpec}; import * as __frameworkModule from ${frameworkSpec}; @@ -314,11 +315,8 @@ for (const [appAbsRequest, targetRel] of __APP_RESOLVE_CACHE_ALIASES) { } } -const __bundleGlobalThis = globalThis as typeof globalThis & { - __EGG_BUNDLE_MODULE_LOADER__?: (filepath: string) => unknown; -}; ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA as any, __outputDir)); -__bundleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => { +globalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => { return __getBundleMap(filepath); }; diff --git a/tools/egg-bundler/src/lib/ManifestLoader.ts b/tools/egg-bundler/src/lib/ManifestLoader.ts index cbb1972822..eb12f6a80c 100644 --- a/tools/egg-bundler/src/lib/ManifestLoader.ts +++ b/tools/egg-bundler/src/lib/ManifestLoader.ts @@ -28,7 +28,15 @@ interface TeggModuleDescriptor { decoratedFiles?: string[]; } +interface TeggModuleReference { + name: string; + path: string; + optional?: boolean; + loaderType?: string; +} + interface TeggManifestExtension { + moduleReferences?: TeggModuleReference[]; moduleDescriptors?: TeggModuleDescriptor[]; } @@ -401,35 +409,48 @@ export class ManifestLoader { ): Promise> { const result: Record = { ...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 { + 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 { let dir = startDir; const nameSegments = name.split('/'); diff --git a/tools/egg-bundler/test/EntryGenerator.test.ts b/tools/egg-bundler/test/EntryGenerator.test.ts index ae74cb106c..6d3560c1d2 100644 --- a/tools/egg-bundler/test/EntryGenerator.test.ts +++ b/tools/egg-bundler/test/EntryGenerator.test.ts @@ -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: { diff --git a/tools/egg-bundler/test/ManifestLoader.test.ts b/tools/egg-bundler/test/ManifestLoader.test.ts index 69a7c0febf..1a506f165c 100644 --- a/tools/egg-bundler/test/ManifestLoader.test.ts +++ b/tools/egg-bundler/test/ManifestLoader.test.ts @@ -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'); diff --git a/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap b/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap index d2092a39ee..4b2f8e79e3 100644 --- a/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap +++ b/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap @@ -3,6 +3,7 @@ import path from 'node:path'; import { ManifestStore } from '@eggjs/core'; +import type {} from '@eggjs/typings/global'; import { startEgg } from "egg"; import * as __frameworkModule from "egg"; @@ -98,11 +99,8 @@ for (const [appAbsRequest, targetRel] of __APP_RESOLVE_CACHE_ALIASES) { } } -const __bundleGlobalThis = globalThis as typeof globalThis & { - __EGG_BUNDLE_MODULE_LOADER__?: (filepath: string) => unknown; -}; ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA as any, __outputDir)); -__bundleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => { +globalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => { return __getBundleMap(filepath); };