Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 3 additions & 1 deletion packages/typings/src/global.ts
Original file line number Diff line number Diff line change
@@ -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 {};
17 changes: 17 additions & 0 deletions packages/typings/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
*/
Comment on lines +11 to +22
export type ModuleImporter = (filePath: string) => Promise<unknown>;
Comment thread
elrrrrrrr marked this conversation as resolved.
10 changes: 10 additions & 0 deletions tegg/core/loader/src/LoaderUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure cross-platform consistency and prevent backslash escaping or path matching issues on Windows, the file path passed to __EGG_MODULE_IMPORTER__ should be normalized to use forward slashes (/). This matches the normalization pattern already used for __EGG_BUNDLE_MODULE_LOADER__ on line 93, maintaining symmetry between path resolution and module loading.

Suggested change
exports = await globalThis.__EGG_MODULE_IMPORTER__(originalFilePath);
exports = await globalThis.__EGG_MODULE_IMPORTER__(originalFilePath.split('\\').join('/'));
References
  1. Maintain symmetry between path resolution and module loading for bundle-only modules by using the same raw, unresolved path as the canonical key.

} catch (e: unknown) {
throw createLoadError(originalFilePath, e);
}
}
Comment thread
elrrrrrrr marked this conversation as resolved.
if (exports == null) {
if (process.platform === 'win32') {
// convert to file:// url
Expand Down
51 changes: 51 additions & 0 deletions tegg/core/loader/test/Loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
});

Expand Down Expand Up @@ -92,6 +93,56 @@ describe('core/loader/test/Loader.test.ts', () => {
},
);
});

it('should load through the async module importer when set', async () => {
Comment thread
elrrrrrrr marked this conversation as resolved.
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 () => {
Comment thread
elrrrrrrr marked this conversation as resolved.
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 () => {
Comment thread
elrrrrrrr marked this conversation as resolved.
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', () => {
Expand Down
Loading