Skip to content

Commit 42f7a14

Browse files
killaguclaude
andauthored
test(utils): formalize bundle/snapshot module-loader hooks (#6002)
## Motivation Bundle / snapshot mode loads modules through two `globalThis` extension points: - `__EGG_BUNDLE_MODULE_LOADER__` β€” the inlined bundle map (set via `setBundleModuleLoader()`). - `__EGG_MODULE_IMPORTER__` β€” an injectable importer that replaces native `await import()`. The second one is load-bearing for **V8 startup-snapshot restore**: the deserialized main function runs with no host dynamic-import callback, so `import()` throws (`ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`). The snapshot entry generated by `egg-bundler` installs a synchronous `require()`-based importer (`require()` can load ESM on Node >= 22), so modules resolve without dynamic import. These hooks already existed (types in `@eggjs/typings`, `setBundleModuleLoader` + basic importer tests) but the snapshot-restore path was **untested** and the contract was **undocumented**. This PR formalizes them as a documented, tested extension point β€” no load-semantics change. ## Scope - **Tests** (`packages/utils`): add coverage for the snapshot-restore path where `__EGG_MODULE_IMPORTER__ = require` loads a real ESM module β€” an inline synchronous require-based importer plus a spawned `node:vm` fixture that first asserts the no-dynamic-import-callback premise, then proves `importModule()` routes through the require importer and bypasses native `import()`. - **Docs** (JSDoc on `BundleModuleLoader` / `ModuleImporter`, `@eggjs/utils` README, wiki): document the full resolution order β€” `__EGG_BUNDLE_MODULE_LOADER__` β†’ snapshot loader (`setSnapshotModuleLoader`) β†’ `__EGG_MODULE_IMPORTER__` β†’ native β€” including which path each caller passes (`@eggjs/utils` passes the un-normalized `importResolve()` result; the tegg loader passes the original filepath POSIX-normalized) and that `@eggjs/core`'s `ManifestLoaderFS` consults only the bundle loader directly (importer/native via the `@eggjs/loader-fs` fallback). No production code changed β€” `packages/utils/src/import.ts` and the loaders are untouched. ## Test evidence - `pnpm --filter=@eggjs/utils exec vitest run` β€” 77 passed, 13 skipped. - `packages/core` `manifest_loader_fs.test.ts` (existing bundle-mode routing coverage) β€” 7 passed. - `typecheck` (utils + typings), `oxlint --type-aware --type-check`, `oxfmt --check` β€” all clean. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a typed API for configuring a custom module importer hook. * Documented the module-loading hook order for bundle, snapshot, and fallback loading. * **Bug Fixes** * Improved support for loading ESM modules in environments where native dynamic import is unavailable. * Added coverage for require-based module loading during snapshot-restore style execution. * **Documentation** * Clarified how module-loading hooks behave, their priority, and common usage scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e76a4d2 commit 42f7a14

6 files changed

Lines changed: 216 additions & 15 deletions

File tree

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,55 @@
11
/**
2-
* Module loader for bundled Egg apps. Called with the `importModule()` filepath
3-
* after POSIX path normalization. Return `undefined` to fall through to the
4-
* standard import path.
2+
* Module loader for bundled Egg apps, registered on `globalThis` as
3+
* `__EGG_BUNDLE_MODULE_LOADER__` (use `setBundleModuleLoader()` from
4+
* `@eggjs/utils`). This is the highest-priority hook: it runs before on-disk
5+
* resolution in both `importModule()` / `importResolve()` (`@eggjs/utils`) and
6+
* the loaders in `@eggjs/core` and the tegg loader.
7+
*
8+
* It is called with the `importModule()` filepath (or a virtual specifier)
9+
* after POSIX path normalization, and is meant to return a module already
10+
* inlined into the bundle β€” typically a lookup into a static bundle map emitted
11+
* by `egg-bundler`. Return `undefined` to fall through to the next hook (the
12+
* snapshot loader registered via `setSnapshotModuleLoader()`, then
13+
* `__EGG_MODULE_IMPORTER__`) or the standard `import()` / `require()` path.
14+
*
15+
* The non-undefined return value follows the same default-export unwrapping
16+
* rules as a native import (double-default `__esModule` compatibility plus the
17+
* caller's `importDefaultOnly` option).
518
*/
619
export type BundleModuleLoader = (filepath: string) => unknown;
720

821
/**
9-
* Async module importer override for the tegg loader's file loading.
22+
* Async module importer override, registered on `globalThis` as
23+
* `__EGG_MODULE_IMPORTER__`. When set (and neither the bundle loader nor a
24+
* snapshot loader registered via `setSnapshotModuleLoader()` already resolved
25+
* the path), `importModule()` / the loaders delegate module loading to this
26+
* importer instead of the built-in `await import(filePath)`. The return value
27+
* is awaited and mirrors `await import()` (default unwrapping and
28+
* `importDefaultOnly` apply); because it is awaited, a synchronous return value
29+
* such as the result of `require()` is also valid.
30+
*
31+
* The path passed in depends on the caller: `@eggjs/utils` `importModule()`
32+
* passes the resolved module path from `importResolve()` (OS-native separators,
33+
* not normalized), while the tegg loader passes the original loader filepath
34+
* with separators normalized to POSIX. Importers that care about separators
35+
* should normalize defensively.
36+
*
37+
* Two main uses:
1038
*
11-
* When set, the loader delegates module loading to this importer instead of the
12-
* built-in `await import(filePath)`. Its main use is testing with a bundler-based
13-
* test runner (e.g. Vitest): when an app's egg modules are loaded by the loader
14-
* via the native `import()` while the test file imports the same source through
15-
* the runner's module graph, the two resolve to *different* module instances β€”
16-
* so a class decorated as an egg proto by the loader is not the same class the
17-
* test references, and `ctx.getEggObject(ClassRef)` fails with "can not get proto".
39+
* 1. Testing with a bundler-based test runner (e.g. Vitest): when an app's egg
40+
* modules are loaded by the loader via the native `import()` while the test
41+
* file imports the same source through the runner's module graph, the two
42+
* resolve to *different* module instances β€” so a class decorated as an egg
43+
* proto by the loader is not the same class the test references, and
44+
* `ctx.getEggObject(ClassRef)` fails with "can not get proto". A test runner
45+
* injects an importer that routes loading through its own module graph
46+
* (e.g. `filePath => import(filePath)` evaluated inside the runner context),
47+
* keeping a single module instance.
1848
*
19-
* A test runner can inject an importer that routes loading through its own module
20-
* graph (e.g. `filePath => import(filePath)` evaluated inside the runner context),
21-
* keeping a single module instance. Return value mirrors `await import()`.
49+
* 2. V8 startup-snapshot restore: the deserialized main function runs without a
50+
* host dynamic-import callback, so native `import()` throws. The snapshot
51+
* entry generated by `egg-bundler` installs a synchronous `require()`-based
52+
* importer (`createRequire()` over the bundle output dir); `require()` can
53+
* load ESM on Node >= 22, so modules resolve without dynamic import.
2254
*/
2355
export type ModuleImporter = (filePath: string) => Promise<unknown>;

β€Žpackages/utils/README.mdβ€Ž

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,49 @@ The bundle loader is stored on `globalThis`, so bundled and external copies of
5858
default export unwrapping rules as `importModule()`, including
5959
`importDefaultOnly`.
6060

61+
### Bundle / snapshot module-loading hooks
62+
63+
`importModule()` resolves a module through the following hooks, in order. The
64+
first one that produces a value wins; otherwise it falls back to the native
65+
`import()` / `require()` path:
66+
67+
1. **`globalThis.__EGG_BUNDLE_MODULE_LOADER__`** β€” set via
68+
`setBundleModuleLoader()`. Looks up a module already inlined into the bundle
69+
(typically a static bundle map emitted by `egg-bundler`). Runs before on-disk
70+
resolution and receives the POSIX-normalized `importModule()` filepath or a
71+
virtual specifier. Return `undefined` to fall through.
72+
2. **Snapshot module loader** β€” set via `setSnapshotModuleLoader()`. This is a
73+
module-local hook (not a `globalThis` global) used by the V8 snapshot entry
74+
generator to serve pre-bundled modules synchronously, keyed by the resolved
75+
path. Once registered it handles every load that reaches it, so the importer
76+
below is not consulted while it is active.
77+
3. **`globalThis.__EGG_MODULE_IMPORTER__`** β€” an async (or sync, since the value
78+
is awaited) importer that receives the resolved file path (the
79+
`importResolve()` result, with OS-native separators β€” not normalized). When
80+
set, and the two hooks above did not resolve the module, it replaces the
81+
native `await import(filePath)`.
82+
83+
The bundle loader and importer globals are typed in `@eggjs/typings`
84+
(`BundleModuleLoader` / `ModuleImporter`); import `@eggjs/typings/global` to pick
85+
up the `declare global` augmentation. These hooks are the contract that
86+
`egg-bundler`'s generated entry relies on. `@eggjs/core`'s `ManifestLoaderFS`
87+
consults `__EGG_BUNDLE_MODULE_LOADER__` directly; its importer/native fallback is
88+
reached through `@eggjs/loader-fs`, which calls back into `importModule()`. The
89+
tegg loader (`LoaderUtil.loadFile`) consults both globals directly, passing the
90+
loader filepath with separators normalized to POSIX.
91+
92+
`__EGG_MODULE_IMPORTER__` has two main uses:
93+
94+
- **Bundler-based test runners (e.g. Vitest):** route module loading through the
95+
runner's own module graph so the loader and the test file share a single
96+
module instance (otherwise `ctx.getEggObject(ClassRef)` fails with
97+
"can not get proto").
98+
- **V8 startup-snapshot restore:** the deserialized main function runs without a
99+
host dynamic-import callback, so native `import()` throws. The snapshot entry
100+
installs a synchronous `require()`-based importer (`createRequire()` over the
101+
bundle output dir); `require()` can load ESM on Node >= 22, so modules resolve
102+
without dynamic import.
103+
61104
## License
62105

63106
[MIT](LICENSE)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import assert from 'node:assert/strict';
2+
import { createRequire } from 'node:module';
3+
import path from 'node:path';
4+
import { fileURLToPath, pathToFileURL } from 'node:url';
5+
import vm from 'node:vm';
6+
7+
import { importModule } from '../../../src/import.ts';
8+
9+
// Reproduces the V8 startup-snapshot restore environment, where the deserialized
10+
// main function runs without a host dynamic-import callback. In that environment
11+
// `import()` is unusable, so the snapshot entry installs a synchronous
12+
// `require()`-based `__EGG_MODULE_IMPORTER__` and relies on `importModule()`
13+
// routing through it. This asserts that contract end to end.
14+
15+
const dirname = path.dirname(fileURLToPath(import.meta.url));
16+
const esmPath = path.resolve(dirname, '../esm/index.js');
17+
18+
// 1) Establish the premise: a context with no `importModuleDynamically` callback
19+
// rejects native `import()` with ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING β€” the
20+
// same failure a snapshot-restore main function would hit.
21+
const ctx = vm.createContext({});
22+
await assert.rejects(
23+
() => vm.runInContext(`import(${JSON.stringify(pathToFileURL(esmPath).href)})`, ctx),
24+
/dynamic import callback/i,
25+
);
26+
27+
// 2) Install a require-based importer, exactly like the snapshot entry generator
28+
// does (createRequire over the bundle output dir). require() loads ESM
29+
// synchronously on Node >= 22, so no dynamic import callback is needed.
30+
const requireFromHere = createRequire(import.meta.url);
31+
let importerCalls = 0;
32+
globalThis.__EGG_MODULE_IMPORTER__ = (filepath) => {
33+
importerCalls++;
34+
return requireFromHere(filepath);
35+
};
36+
37+
try {
38+
const result = await importModule(esmPath);
39+
// The importer short-circuits before importModule's native `import()` branch,
40+
// so a single importer call proves the ESM module was loaded via require().
41+
assert.equal(importerCalls, 1, 'importer must be used instead of native import()');
42+
assert.equal(result.one, 1);
43+
assert.equal(result.default.foo, 'bar');
44+
console.log('IMPORTER_REQUIRE_ESM_OK');
45+
} finally {
46+
globalThis.__EGG_MODULE_IMPORTER__ = undefined;
47+
}

β€Žpackages/utils/test/module-importer.test.tsβ€Ž

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { strict as assert } from 'node:assert';
2+
import { createRequire } from 'node:module';
23

34
import type {} from '@eggjs/typings/global';
5+
import coffee from 'coffee';
46
import { afterEach, describe, it } from 'vitest';
57

68
import { importModule } from '../src/import.ts';
@@ -53,4 +55,32 @@ describe('test/module-importer.test.ts', () => {
5355
const result = await importModule(getFilepath('esm'));
5456
assert.deepEqual(result, { fromImporter: true });
5557
});
58+
59+
it('loads a real ESM module through a synchronous require-based importer', async () => {
60+
// The importer return value is awaited, so a synchronous `require()` (which
61+
// returns the module synchronously) is a valid importer. require() can load
62+
// ESM on Node >= 22, which is what the snapshot entry relies on.
63+
const require = createRequire(import.meta.url);
64+
let calls = 0;
65+
globalThis.__EGG_MODULE_IMPORTER__ = ((filepath: string) => {
66+
calls++;
67+
return require(filepath);
68+
}) as typeof globalThis.__EGG_MODULE_IMPORTER__;
69+
70+
const result = await importModule(getFilepath('esm'));
71+
assert.equal(calls, 1);
72+
assert.equal(result.one, 1);
73+
assert.deepEqual(result.default, { foo: 'bar' });
74+
});
75+
76+
it('uses a require-based importer when no dynamic import callback exists', async () => {
77+
// Reproduces the V8 snapshot-restore environment in a child process: native
78+
// import() has no host callback, so importModule() must route ESM loading
79+
// through the require-based __EGG_MODULE_IMPORTER__. See the fixture.
80+
await coffee
81+
.spawn(process.execPath, ['--experimental-strip-types', getFilepath('module-importer-require-esm/run.mjs')])
82+
.expect('stdout', /IMPORTER_REQUIRE_ESM_OK/)
83+
.expect('code', 0)
84+
.end();
85+
});
5686
});

β€Žwiki/log.mdβ€Ž

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,9 @@ Dates use the workspace-local Asia/Shanghai calendar date.
7272
- sources touched: `packages/typings/package.json`, `packages/typings/src/index.ts`, `packages/typings/src/global.ts`, `AGENTS.md`, `CLAUDE.md`
7373
- pages updated: `wiki/index.md`, `wiki/log.md`, `wiki/packages/typings.md`
7474
- note: Recorded `@eggjs/typings` as the shared home for cross-package global typing contracts.
75+
76+
## [2026-06-27] api | formalize bundle/snapshot module-loader hooks
77+
78+
- sources touched: `packages/utils/src/import.ts`, `packages/utils/README.md`, `packages/utils/test/module-importer.test.ts`, `packages/utils/test/fixtures/module-importer-require-esm/run.mjs`, `packages/typings/src/index.ts`
79+
- pages updated: `wiki/log.md`, `wiki/packages/utils.md`
80+
- note: Documented the `__EGG_BUNDLE_MODULE_LOADER__` β†’ snapshot loader (`setSnapshotModuleLoader`) β†’ `__EGG_MODULE_IMPORTER__` β†’ native priority as a formal contract (JSDoc on `BundleModuleLoader`/`ModuleImporter` + README). Added regression coverage for the V8 snapshot-restore path where `__EGG_MODULE_IMPORTER__ = require` loads ESM with no dynamic-import callback (inline sync-require test + spawned `node:vm` fixture). No load-semantics change β€” types/declarations already existed.

β€Žwiki/packages/utils.mdβ€Ž

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ source_files:
66
- packages/utils/README.md
77
- packages/utils/src/import.ts
88
- packages/utils/test/bundle-import.test.ts
9-
updated_at: 2026-05-06
9+
- packages/utils/test/module-importer.test.ts
10+
- packages/typings/src/index.ts
11+
- packages/typings/src/global.ts
12+
updated_at: 2026-06-27
1013
status: active
1114
---
1215

@@ -36,3 +39,43 @@ ESM loading, `packages/utils/src/import.ts` uses an opaque native dynamic import
3639
created with `new Function('specifier', 'return import(specifier);')`. This
3740
prevents bundlers such as Turbopack from rewriting non-static dynamic import
3841
expressions and preserves Node's native fallback for external ESM modules.
42+
43+
## Module-loader hook priority
44+
45+
`importModule()` resolves through three hooks before the native `import()` /
46+
`require()` fallback (see `packages/utils/src/import.ts`):
47+
48+
1. **`__EGG_BUNDLE_MODULE_LOADER__`** (`setBundleModuleLoader`) β€” synchronous
49+
`globalThis` bundle-map lookup, runs before on-disk resolution and receives
50+
the POSIX-normalized filepath. Returning `undefined` falls through.
51+
2. **Snapshot loader** (`setSnapshotModuleLoader`) β€” a module-local hook (not a
52+
`globalThis` global) used by the V8 snapshot entry generator, keyed by the
53+
resolved path. Once registered it handles every load that reaches it, so the
54+
importer below is bypassed while it is active.
55+
3. **`__EGG_MODULE_IMPORTER__`** β€” async (or sync, since awaited) `globalThis`
56+
importer that receives the resolved path (the `importResolve()` result, with
57+
OS-native separators β€” _not_ normalized) and replaces native `await import()`.
58+
59+
The bundle loader and importer globals are typed in `@eggjs/typings`
60+
(`BundleModuleLoader`, `ModuleImporter`) and augmented onto `globalThis` in
61+
`@eggjs/typings/global`. `@eggjs/core`'s `ManifestLoaderFS` (`#loadBundledModule`)
62+
consults **only** `__EGG_BUNDLE_MODULE_LOADER__`; the importer/native path is
63+
reached transitively through its `@eggjs/loader-fs` fallback, which calls back
64+
into `importModule()`. The tegg loader (`LoaderUtil.loadFile`) consults both
65+
globals directly, but passes the _original_ loader filepath separator-normalized
66+
to POSIX β€” not the `importResolve()` output β€” so the two callers differ in both
67+
which path they pass and whether it is normalized.
68+
69+
`__EGG_MODULE_IMPORTER__` has two contract uses:
70+
71+
- **Bundler-based test runners (Vitest):** route loading through the runner's
72+
module graph so loader and test file share one module instance (otherwise
73+
`ctx.getEggObject(ClassRef)` throws "can not get proto").
74+
- **V8 startup-snapshot restore:** the deserialized main function has no host
75+
dynamic-import callback, so native `import()` throws
76+
(`ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`). The snapshot entry installs a
77+
synchronous `require()`-based importer (`createRequire()` over the output
78+
dir); `require()` loads ESM on Node >= 22. Covered by
79+
`test/module-importer.test.ts` (inline require importer +
80+
`fixtures/module-importer-require-esm/run.mjs`, which asserts the no-callback
81+
premise via `node:vm`).

0 commit comments

Comments
Β (0)