-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
test(utils): formalize bundle/snapshot module-loader hooks #6002
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,23 +1,55 @@ | ||
| /** | ||
| * Module loader for bundled Egg apps. Called with the `importModule()` filepath | ||
| * after POSIX path normalization. Return `undefined` to fall through to the | ||
| * standard import path. | ||
| * Module loader for bundled Egg apps, registered on `globalThis` as | ||
| * `__EGG_BUNDLE_MODULE_LOADER__` (use `setBundleModuleLoader()` from | ||
| * `@eggjs/utils`). This is the highest-priority hook: it runs before on-disk | ||
| * resolution in both `importModule()` / `importResolve()` (`@eggjs/utils`) and | ||
| * the loaders in `@eggjs/core` and the tegg loader. | ||
| * | ||
| * It is called with the `importModule()` filepath (or a virtual specifier) | ||
| * after POSIX path normalization, and is meant to return a module already | ||
| * inlined into the bundle — typically a lookup into a static bundle map emitted | ||
| * by `egg-bundler`. Return `undefined` to fall through to the next hook (the | ||
| * snapshot loader registered via `setSnapshotModuleLoader()`, then | ||
| * `__EGG_MODULE_IMPORTER__`) or the standard `import()` / `require()` path. | ||
| * | ||
| * The non-undefined return value follows the same default-export unwrapping | ||
| * rules as a native import (double-default `__esModule` compatibility plus the | ||
| * caller's `importDefaultOnly` option). | ||
| */ | ||
| export type BundleModuleLoader = (filepath: string) => unknown; | ||
|
|
||
| /** | ||
| * Async module importer override for the tegg loader's file loading. | ||
| * Async module importer override, registered on `globalThis` as | ||
| * `__EGG_MODULE_IMPORTER__`. When set (and neither the bundle loader nor a | ||
| * snapshot loader registered via `setSnapshotModuleLoader()` already resolved | ||
| * the path), `importModule()` / the loaders delegate module loading to this | ||
| * importer instead of the built-in `await import(filePath)`. The return value | ||
| * is awaited and mirrors `await import()` (default unwrapping and | ||
| * `importDefaultOnly` apply); because it is awaited, a synchronous return value | ||
| * such as the result of `require()` is also valid. | ||
| * | ||
| * The path passed in depends on the caller: `@eggjs/utils` `importModule()` | ||
| * passes the resolved module path from `importResolve()` (OS-native separators, | ||
| * not normalized), while the tegg loader passes the original loader filepath | ||
| * with separators normalized to POSIX. Importers that care about separators | ||
| * should normalize defensively. | ||
| * | ||
| * Two main uses: | ||
| * | ||
| * 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". | ||
| * 1. 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 | ||
| * injects 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. | ||
| * | ||
| * 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()`. | ||
| * 2. V8 startup-snapshot restore: the deserialized main function runs without a | ||
| * host dynamic-import callback, so native `import()` throws. The snapshot | ||
| * entry generated by `egg-bundler` installs a synchronous `require()`-based | ||
| * importer (`createRequire()` over the bundle output dir); `require()` can | ||
| * load ESM on Node >= 22, so modules resolve without dynamic import. | ||
| */ | ||
| export type ModuleImporter = (filePath: string) => Promise<unknown>; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import { createRequire } from 'node:module'; | ||
| import path from 'node:path'; | ||
| import { fileURLToPath, pathToFileURL } from 'node:url'; | ||
| import vm from 'node:vm'; | ||
|
|
||
| import { importModule } from '../../../src/import.ts'; | ||
|
|
||
| // Reproduces the V8 startup-snapshot restore environment, where the deserialized | ||
| // main function runs without a host dynamic-import callback. In that environment | ||
| // `import()` is unusable, so the snapshot entry installs a synchronous | ||
| // `require()`-based `__EGG_MODULE_IMPORTER__` and relies on `importModule()` | ||
| // routing through it. This asserts that contract end to end. | ||
|
|
||
| const dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| const esmPath = path.resolve(dirname, '../esm/index.js'); | ||
|
|
||
| // 1) Establish the premise: a context with no `importModuleDynamically` callback | ||
| // rejects native `import()` with ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING — the | ||
| // same failure a snapshot-restore main function would hit. | ||
| const ctx = vm.createContext({}); | ||
| await assert.rejects( | ||
| () => vm.runInContext(`import(${JSON.stringify(pathToFileURL(esmPath).href)})`, ctx), | ||
| /dynamic import callback/i, | ||
| ); | ||
|
|
||
| // 2) Install a require-based importer, exactly like the snapshot entry generator | ||
| // does (createRequire over the bundle output dir). require() loads ESM | ||
| // synchronously on Node >= 22, so no dynamic import callback is needed. | ||
| const requireFromHere = createRequire(import.meta.url); | ||
| let importerCalls = 0; | ||
| globalThis.__EGG_MODULE_IMPORTER__ = (filepath) => { | ||
| importerCalls++; | ||
| return requireFromHere(filepath); | ||
| }; | ||
|
|
||
| try { | ||
| const result = await importModule(esmPath); | ||
| // The importer short-circuits before importModule's native `import()` branch, | ||
| // so a single importer call proves the ESM module was loaded via require(). | ||
| assert.equal(importerCalls, 1, 'importer must be used instead of native import()'); | ||
| assert.equal(result.one, 1); | ||
| assert.equal(result.default.foo, 'bar'); | ||
| console.log('IMPORTER_REQUIRE_ESM_OK'); | ||
| } finally { | ||
| globalThis.__EGG_MODULE_IMPORTER__ = undefined; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,8 @@ | ||
| import { strict as assert } from 'node:assert'; | ||
| import { createRequire } from 'node:module'; | ||
|
|
||
| import type {} from '@eggjs/typings/global'; | ||
| import coffee from 'coffee'; | ||
| import { afterEach, describe, it } from 'vitest'; | ||
|
|
||
| import { importModule } from '../src/import.ts'; | ||
|
|
@@ -53,4 +55,32 @@ describe('test/module-importer.test.ts', () => { | |
| const result = await importModule(getFilepath('esm')); | ||
| assert.deepEqual(result, { fromImporter: true }); | ||
| }); | ||
|
|
||
| it('loads a real ESM module through a synchronous require-based importer', async () => { | ||
| // The importer return value is awaited, so a synchronous `require()` (which | ||
| // returns the module synchronously) is a valid importer. require() can load | ||
| // ESM on Node >= 22, which is what the snapshot entry relies on. | ||
| const require = createRequire(import.meta.url); | ||
| let calls = 0; | ||
| globalThis.__EGG_MODULE_IMPORTER__ = ((filepath: string) => { | ||
| calls++; | ||
| return require(filepath); | ||
| }) as typeof globalThis.__EGG_MODULE_IMPORTER__; | ||
|
|
||
| const result = await importModule(getFilepath('esm')); | ||
| assert.equal(calls, 1); | ||
| assert.equal(result.one, 1); | ||
| assert.deepEqual(result.default, { foo: 'bar' }); | ||
| }); | ||
|
|
||
| it('uses a require-based importer when no dynamic import callback exists', async () => { | ||
| // Reproduces the V8 snapshot-restore environment in a child process: native | ||
| // import() has no host callback, so importModule() must route ESM loading | ||
| // through the require-based __EGG_MODULE_IMPORTER__. See the fixture. | ||
| await coffee | ||
| .spawn(process.execPath, ['--experimental-strip-types', getFilepath('module-importer-require-esm/run.mjs')]) | ||
| .expect('stdout', /IMPORTER_REQUIRE_ESM_OK/) | ||
| .expect('code', 0) | ||
| .end(); | ||
| }); | ||
|
Comment on lines
+59
to
+85
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These tests rely on experimental Node.js features that are not supported or enabled by default on all Node.js versions:
To prevent the test suite from failing when executed on older Node.js versions (e.g., in CI environments running Node 18 or 20), we should:
const [major, minor] = process.versions.node.split('.').map(Number);
const supportsRequireEsm =
major > 22 ||
(major === 22 && minor >= 12) ||
process.execArgv.includes('--experimental-require-module');
const supportsStripTypes = major > 22 || (major === 22 && minor >= 6);
(supportsRequireEsm ? it : it.skip)('loads a real ESM module through a synchronous require-based importer', async () => {
// The importer return value is awaited, so a synchronous require() (which
// returns the module synchronously) is a valid importer. require() can load
// ESM on Node >= 22, which is what the snapshot entry relies on.
const require = createRequire(import.meta.url);
let calls = 0;
globalThis.__EGG_MODULE_IMPORTER__ = ((filepath: string) => {
calls++;
return require(filepath);
}) as typeof globalThis.__EGG_MODULE_IMPORTER__;
const result = await importModule(getFilepath('esm'));
assert.equal(calls, 1);
assert.equal(result.one, 1);
assert.deepEqual(result.default, { foo: 'bar' });
});
(supportsStripTypes ? it : it.skip)('uses a require-based importer when no dynamic import callback exists', async () => {
// Reproduces the V8 snapshot-restore environment in a child process: native
// import() has no host callback, so importModule() must route ESM loading
// through the require-based __EGG_MODULE_IMPORTER__. See the fixture.
await coffee
.spawn(process.execPath, [
'--experimental-strip-types',
'--experimental-require-module',
getFilepath('module-importer-require-esm/run.mjs'),
])
.expect('stdout', /IMPORTER_REQUIRE_ESM_OK/)
.expect('code', 0)
.end();
});
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the careful read. This is intentional and safe for this repo's supported baseline, so I'm not adding the version guards:
Adding |
||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: eggjs/egg
Length of output: 223
🏁 Script executed:
Repository: eggjs/egg
Length of output: 10598
Widen
ModuleImporterto accept sync returnspackages/typings/src/index.ts:55still forcesPromise<unknown>, but the contract already allows synchronousrequire()-style importers. Change it tounknown | Promise<unknown>so callers don’t need a cast.🤖 Prompt for AI Agents