From 9a1719b89a76a06234b76171686d4c3c37d1a394 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Tue, 23 Jun 2026 21:05:59 +0800 Subject: [PATCH 1/3] feat(tegg-loader): support async module importer override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional `__EGG_MODULE_IMPORTER__` global hook to `LoaderUtil.loadFile`, mirroring the existing `__EGG_BUNDLE_MODULE_LOADER__` bundle hook. When set, the loader delegates module loading to it instead of the built-in `await import()`. This is an extension point for bundler-based test runners (e.g. Vitest). When an app is tested as published packages (the loader externalized to native `import()`) while the test file imports the same fixture source through the runner's module graph, the two resolve to different module instances — so a class registered as an egg proto by the loader is not the same class the test references, and `ctx.getEggObject(ClassRef)` throws "can not get proto for clazz". Routing the loader's import through the runner (e.g. `filePath => import(filePath)` evaluated in the runner context) keeps a single module instance. eggjs's own tests don't hit this because tegg packages resolve to workspace source (inlined by Vite), so the loader already imports through Vite. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/typings/src/global.ts | 4 ++- packages/typings/src/index.ts | 17 ++++++++++ tegg/core/loader/src/LoaderUtil.ts | 10 ++++++ tegg/core/loader/test/Loader.test.ts | 51 ++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/packages/typings/src/global.ts b/packages/typings/src/global.ts index dc7116fa91..594e2f9677 100644 --- a/packages/typings/src/global.ts +++ b/packages/typings/src/global.ts @@ -1,8 +1,10 @@ -import type { BundleModuleLoader } from './index.ts'; +import type { BundleModuleLoader, ModuleImporter } from './index.ts'; declare global { // eslint-disable-next-line no-var var __EGG_BUNDLE_MODULE_LOADER__: BundleModuleLoader | undefined; + // eslint-disable-next-line no-var + var __EGG_MODULE_IMPORTER__: ModuleImporter | undefined; } export {}; diff --git a/packages/typings/src/index.ts b/packages/typings/src/index.ts index 3211d7e3da..6668182ace 100644 --- a/packages/typings/src/index.ts +++ b/packages/typings/src/index.ts @@ -4,3 +4,20 @@ * standard import path. */ export type BundleModuleLoader = (filepath: string) => unknown; + +/** + * Async module importer override for the tegg loader's file loading. + * + * When set, the loader delegates module loading to this importer instead of the + * built-in `await import(filePath)`. Its main use is testing with a bundler-based + * test runner (e.g. Vitest): when an app's egg modules are loaded by the loader + * via the native `import()` while the test file imports the same source through + * the runner's module graph, the two resolve to *different* module instances — + * so a class decorated as an egg proto by the loader is not the same class the + * test references, and `ctx.getEggObject(ClassRef)` fails with "can not get proto". + * + * A test runner can inject an importer that routes loading through its own module + * graph (e.g. `filePath => import(filePath)` evaluated inside the runner context), + * keeping a single module instance. Return value mirrors `await import()`. + */ +export type ModuleImporter = (filePath: string) => Promise; diff --git a/tegg/core/loader/src/LoaderUtil.ts b/tegg/core/loader/src/LoaderUtil.ts index 093c8b8a96..07d36d8832 100644 --- a/tegg/core/loader/src/LoaderUtil.ts +++ b/tegg/core/loader/src/LoaderUtil.ts @@ -94,6 +94,16 @@ export class LoaderUtil { } catch (e: unknown) { throw createLoadError(originalFilePath, e); } + // Async module importer override (e.g. a Vitest runner that loads the module + // through its own module graph), so the proto class registered here is the + // same instance the test file imports. See `ModuleImporter` in @eggjs/typings. + if (exports == null && typeof globalThis.__EGG_MODULE_IMPORTER__ === 'function') { + try { + exports = await globalThis.__EGG_MODULE_IMPORTER__(originalFilePath); + } catch (e: unknown) { + throw createLoadError(originalFilePath, e); + } + } if (exports == null) { if (process.platform === 'win32') { // convert to file:// url diff --git a/tegg/core/loader/test/Loader.test.ts b/tegg/core/loader/test/Loader.test.ts index a2dfb0ed8d..95bdd39804 100644 --- a/tegg/core/loader/test/Loader.test.ts +++ b/tegg/core/loader/test/Loader.test.ts @@ -11,6 +11,7 @@ import { LoaderFactory, LoaderUtil } from '../src/index.ts'; describe('core/loader/test/Loader.test.ts', () => { afterEach(() => { globalThis.__EGG_BUNDLE_MODULE_LOADER__ = undefined; + globalThis.__EGG_MODULE_IMPORTER__ = undefined; LoaderUtil.setConfig({}); }); @@ -92,6 +93,56 @@ describe('core/loader/test/Loader.test.ts', () => { }, ); }); + + it('should load through the async module importer when set', async () => { + class ImportedService {} + SingletonProto()(ImportedService); + const importedFile = '/imported/app/manager/ImportedService.ts'; + let importerArg: string | undefined; + globalThis.__EGG_MODULE_IMPORTER__ = async (filePath: string) => { + importerArg = filePath; + return { ImportedService }; + }; + + const prototypes = await LoaderUtil.loadFile(importedFile); + + assert.equal(importerArg, importedFile); + assert.deepEqual( + prototypes.map((proto) => proto.name), + ['ImportedService'], + ); + assert.equal(PrototypeUtil.getFilePath(ImportedService), importedFile); + }); + + it('should fall back to dynamic import when the module importer returns null', async () => { + const appRepoFile = path.join(__dirname, './fixtures/modules/module-for-loader/AppRepo.ts'); + globalThis.__EGG_MODULE_IMPORTER__ = async () => null; + + const prototypes = await LoaderUtil.loadFile(appRepoFile); + + assert.deepEqual( + prototypes.map((proto) => proto.name), + ['AppRepo', 'AppRepo2'], + ); + }); + + it('should wrap module importer errors', async () => { + const importedFile = '/imported/app/service.ts'; + globalThis.__EGG_MODULE_IMPORTER__ = async () => { + throw 'importer failed'; + }; + + await assert.rejects( + async () => { + await LoaderUtil.loadFile(importedFile); + }, + (err: Error & { cause?: unknown }) => { + assert.equal(err.message, '[tegg/loader] load /imported/app/service.ts failed: importer failed'); + assert.equal(err.cause, 'importer failed'); + return true; + }, + ); + }); }); describe('file has tsc error', () => { From 3cd661b443be5e442f38bb14b5192f13a4dce401 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Tue, 23 Jun 2026 21:15:46 +0800 Subject: [PATCH 2/3] refactor(tegg-loader): POSIX-normalize module importer path Address review: pass the importer a forward-slash path, mirroring __EGG_BUNDLE_MODULE_LOADER__, for cross-platform consistency on Windows. Co-Authored-By: Claude Opus 4.8 (1M context) --- tegg/core/loader/src/LoaderUtil.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tegg/core/loader/src/LoaderUtil.ts b/tegg/core/loader/src/LoaderUtil.ts index 07d36d8832..b622489d39 100644 --- a/tegg/core/loader/src/LoaderUtil.ts +++ b/tegg/core/loader/src/LoaderUtil.ts @@ -99,7 +99,9 @@ export class LoaderUtil { // same instance the test file imports. See `ModuleImporter` in @eggjs/typings. if (exports == null && typeof globalThis.__EGG_MODULE_IMPORTER__ === 'function') { try { - exports = await globalThis.__EGG_MODULE_IMPORTER__(originalFilePath); + // Pass a POSIX-normalized path, mirroring __EGG_BUNDLE_MODULE_LOADER__, + // so importers behave consistently across platforms (e.g. on Windows). + exports = await globalThis.__EGG_MODULE_IMPORTER__(originalFilePath.split('\\').join('/')); } catch (e: unknown) { throw createLoadError(originalFilePath, e); } From dc5c25efd5601928a81db7c7f9ae521fcd898ad9 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Tue, 23 Jun 2026 21:25:20 +0800 Subject: [PATCH 3/3] chore(site): fix oxfmt formatting in bundle.md Drive-by: `oxfmt --check .` (the repo-wide fmtcheck) was failing on site/docs/zh-CN/core/bundle.md (table alignment) on `next`, turning the check red for every PR. Reformat so fmtcheck passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- site/docs/zh-CN/core/bundle.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/site/docs/zh-CN/core/bundle.md b/site/docs/zh-CN/core/bundle.md index d171211179..3f1a56d1b0 100644 --- a/site/docs/zh-CN/core/bundle.md +++ b/site/docs/zh-CN/core/bundle.md @@ -13,13 +13,13 @@ $ egg-bin bundle 默认产物输出到 `./dist-bundle`。常用参数: -| 参数 | 说明 | -| ------------------- | ---------------------------------------------------------- | -| `--output ` | 输出目录,默认 `./dist-bundle`。 | -| `--mode ` | `production`(默认)或 `development`。 | -| `--framework ` | 框架包名,默认 `egg`(或读取 `pkg.egg.framework`)。 | -| `--force-external` | 始终保持为 external 的包名(可重复)。 | -| `--inline-external` | 即使被自动识别为 external 也强制内联的包名。 | +| 参数 | 说明 | +| ------------------- | ---------------------------------------------------- | +| `--output ` | 输出目录,默认 `./dist-bundle`。 | +| `--mode ` | `production`(默认)或 `development`。 | +| `--framework ` | 框架包名,默认 `egg`(或读取 `pkg.egg.framework`)。 | +| `--force-external` | 始终保持为 external 的包名(可重复)。 | +| `--inline-external` | 即使被自动识别为 external 也强制内联的包名。 | 大多数应用无需任何 `--force-external`:打包器会自动识别必须保持 external 的包(原生 addon、可选平台包、带原生绑定的包、无法解析的可选 peer 依赖),并内联其余所有内容,