diff --git a/packages/typings/src/index.ts b/packages/typings/src/index.ts index 6668182ace..8027c00b83 100644 --- a/packages/typings/src/index.ts +++ b/packages/typings/src/index.ts @@ -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; diff --git a/packages/utils/README.md b/packages/utils/README.md index 4a9f15c82c..059ffa683f 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -58,6 +58,49 @@ The bundle loader is stored on `globalThis`, so bundled and external copies of default export unwrapping rules as `importModule()`, including `importDefaultOnly`. +### Bundle / snapshot module-loading hooks + +`importModule()` resolves a module through the following hooks, in order. The +first one that produces a value wins; otherwise it falls back to the native +`import()` / `require()` path: + +1. **`globalThis.__EGG_BUNDLE_MODULE_LOADER__`** — set via + `setBundleModuleLoader()`. Looks up a module already inlined into the bundle + (typically a static bundle map emitted by `egg-bundler`). Runs before on-disk + resolution and receives the POSIX-normalized `importModule()` filepath or a + virtual specifier. Return `undefined` to fall through. +2. **Snapshot module loader** — set via `setSnapshotModuleLoader()`. This is a + module-local hook (not a `globalThis` global) used by the V8 snapshot entry + generator to serve pre-bundled modules synchronously, keyed by the resolved + path. Once registered it handles every load that reaches it, so the importer + below is not consulted while it is active. +3. **`globalThis.__EGG_MODULE_IMPORTER__`** — an async (or sync, since the value + is awaited) importer that receives the resolved file path (the + `importResolve()` result, with OS-native separators — not normalized). When + set, and the two hooks above did not resolve the module, it replaces the + native `await import(filePath)`. + +The bundle loader and importer globals are typed in `@eggjs/typings` +(`BundleModuleLoader` / `ModuleImporter`); import `@eggjs/typings/global` to pick +up the `declare global` augmentation. These hooks are the contract that +`egg-bundler`'s generated entry relies on. `@eggjs/core`'s `ManifestLoaderFS` +consults `__EGG_BUNDLE_MODULE_LOADER__` directly; its importer/native fallback is +reached through `@eggjs/loader-fs`, which calls back into `importModule()`. The +tegg loader (`LoaderUtil.loadFile`) consults both globals directly, passing the +loader filepath with separators normalized to POSIX. + +`__EGG_MODULE_IMPORTER__` has two main uses: + +- **Bundler-based test runners (e.g. Vitest):** route module loading through the + runner's own module graph so the loader and the test file share a single + module instance (otherwise `ctx.getEggObject(ClassRef)` fails with + "can not get proto"). +- **V8 startup-snapshot restore:** the deserialized main function runs without a + host dynamic-import callback, so native `import()` throws. The snapshot entry + 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. + ## License [MIT](LICENSE) diff --git a/packages/utils/test/fixtures/module-importer-require-esm/run.mjs b/packages/utils/test/fixtures/module-importer-require-esm/run.mjs new file mode 100644 index 0000000000..1305b09b06 --- /dev/null +++ b/packages/utils/test/fixtures/module-importer-require-esm/run.mjs @@ -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; +} diff --git a/packages/utils/test/module-importer.test.ts b/packages/utils/test/module-importer.test.ts index ab62584e5f..454b8c75aa 100644 --- a/packages/utils/test/module-importer.test.ts +++ b/packages/utils/test/module-importer.test.ts @@ -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(); + }); }); diff --git a/wiki/log.md b/wiki/log.md index b6758db7e0..580e0266f1 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -72,3 +72,9 @@ Dates use the workspace-local Asia/Shanghai calendar date. - sources touched: `packages/typings/package.json`, `packages/typings/src/index.ts`, `packages/typings/src/global.ts`, `AGENTS.md`, `CLAUDE.md` - pages updated: `wiki/index.md`, `wiki/log.md`, `wiki/packages/typings.md` - note: Recorded `@eggjs/typings` as the shared home for cross-package global typing contracts. + +## [2026-06-27] api | formalize bundle/snapshot module-loader hooks + +- 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` +- pages updated: `wiki/log.md`, `wiki/packages/utils.md` +- 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. diff --git a/wiki/packages/utils.md b/wiki/packages/utils.md index 1a184b11b3..da4b9e564f 100644 --- a/wiki/packages/utils.md +++ b/wiki/packages/utils.md @@ -6,7 +6,10 @@ source_files: - packages/utils/README.md - packages/utils/src/import.ts - packages/utils/test/bundle-import.test.ts -updated_at: 2026-05-06 + - packages/utils/test/module-importer.test.ts + - packages/typings/src/index.ts + - packages/typings/src/global.ts +updated_at: 2026-06-27 status: active --- @@ -36,3 +39,43 @@ ESM loading, `packages/utils/src/import.ts` uses an opaque native dynamic import created with `new Function('specifier', 'return import(specifier);')`. This prevents bundlers such as Turbopack from rewriting non-static dynamic import expressions and preserves Node's native fallback for external ESM modules. + +## Module-loader hook priority + +`importModule()` resolves through three hooks before the native `import()` / +`require()` fallback (see `packages/utils/src/import.ts`): + +1. **`__EGG_BUNDLE_MODULE_LOADER__`** (`setBundleModuleLoader`) — synchronous + `globalThis` bundle-map lookup, runs before on-disk resolution and receives + the POSIX-normalized filepath. Returning `undefined` falls through. +2. **Snapshot loader** (`setSnapshotModuleLoader`) — a module-local hook (not a + `globalThis` global) used by the V8 snapshot entry generator, keyed by the + resolved path. Once registered it handles every load that reaches it, so the + importer below is bypassed while it is active. +3. **`__EGG_MODULE_IMPORTER__`** — async (or sync, since awaited) `globalThis` + importer that receives the resolved path (the `importResolve()` result, with + OS-native separators — _not_ normalized) and replaces native `await import()`. + +The bundle loader and importer globals are typed in `@eggjs/typings` +(`BundleModuleLoader`, `ModuleImporter`) and augmented onto `globalThis` in +`@eggjs/typings/global`. `@eggjs/core`'s `ManifestLoaderFS` (`#loadBundledModule`) +consults **only** `__EGG_BUNDLE_MODULE_LOADER__`; the importer/native path is +reached transitively through its `@eggjs/loader-fs` fallback, which calls back +into `importModule()`. The tegg loader (`LoaderUtil.loadFile`) consults both +globals directly, but passes the _original_ loader filepath separator-normalized +to POSIX — not the `importResolve()` output — so the two callers differ in both +which path they pass and whether it is normalized. + +`__EGG_MODULE_IMPORTER__` has two contract uses: + +- **Bundler-based test runners (Vitest):** route loading through the runner's + module graph so loader and test file share one module instance (otherwise + `ctx.getEggObject(ClassRef)` throws "can not get proto"). +- **V8 startup-snapshot restore:** the deserialized main function has no host + dynamic-import callback, so native `import()` throws + (`ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`). The snapshot entry installs a + synchronous `require()`-based importer (`createRequire()` over the output + dir); `require()` loads ESM on Node >= 22. Covered by + `test/module-importer.test.ts` (inline require importer + + `fixtures/module-importer-require-esm/run.mjs`, which asserts the no-callback + premise via `node:vm`).