Skip to content

test(utils): formalize bundle/snapshot module-loader hooks#6002

Merged
killagu merged 1 commit into
eggjs:nextfrom
killagu:pr5-bundle-module-loader-hooks
Jun 27, 2026
Merged

test(utils): formalize bundle/snapshot module-loader hooks#6002
killagu merged 1 commit into
eggjs:nextfrom
killagu:pr5-bundle-module-loader-hooks

Conversation

@killagu

@killagu killagu commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

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

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.

Document and add regression coverage for the `__EGG_BUNDLE_MODULE_LOADER__`
→ `__EGG_MODULE_IMPORTER__` → native priority used by bundle/snapshot mode.

- cover the V8 snapshot-restore path where `__EGG_MODULE_IMPORTER__ = require`
  loads ESM with no dynamic-import callback (inline sync-require test plus a
  spawned `node:vm` fixture that asserts the no-callback premise)
- expand JSDoc on `BundleModuleLoader` / `ModuleImporter` to spell out the
  contract for egg-bundler generated entries
- document the hook priority in the @eggjs/utils README

No load-semantics change; types/declarations already existed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 27, 2026 05:59

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR expands module-loader hook documentation and typings, adds a ModuleImporter type, and adds regression tests plus a fixture for require-based ESM loading in snapshot-like environments.

Changes

Module-loader hook contract

Layer / File(s) Summary
Public hook typings
packages/typings/src/index.ts
Expands the bundle and importer hook comments and adds the exported ModuleImporter type alias for the async importer override.
Module-loading docs
packages/utils/README.md, wiki/packages/utils.md, wiki/log.md
Adds documentation for the ordered hook chain, updates package metadata, and records the hook contract in the wiki log.
Require-based importer tests
packages/utils/test/fixtures/module-importer-require-esm/run.mjs, packages/utils/test/module-importer.test.ts
Adds a fixture and tests that register globalThis.__EGG_MODULE_IMPORTER__, load an ESM module through createRequire, and assert the observed exports and process output.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • eggjs/egg#5989: Implements the __EGG_MODULE_IMPORTER__ hook and loader path that this PR now documents and covers with tests.
  • eggjs/egg#5992: Adds importModule() consumption of globalThis.__EGG_MODULE_IMPORTER__, matching the contract and regression coverage updated here.
  • eggjs/egg#5903: Documents the bundle module loader hook that this PR further expands into the bundle/snapshot hook chain.

Suggested reviewers

  • elrrrrrrr

Poem

I sniffed the path with twitchy nose,
Three little hooks in tidy rows.
Bundle, snapshot, importer, too—
Hop! importModule() works right through.
🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: formalizing bundle/snapshot module-loader hooks in utils.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jun 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.88%. Comparing base (e76a4d2) to head (a335bed).

Additional details and impacted files
@@           Coverage Diff           @@
##             next    #6002   +/-   ##
=======================================
  Coverage   84.88%   84.88%           
=======================================
  Files         674      674           
  Lines       20269    20269           
  Branches     4039     4039           
=======================================
  Hits        17205    17205           
  Misses       2633     2633           
  Partials      431      431           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request formalizes and documents the priority and behavior of the bundle and snapshot module-loading hooks, including EGG_BUNDLE_MODULE_LOADER, the snapshot loader, and EGG_MODULE_IMPORTER. It also adds test coverage for loading ESM modules synchronously via require() to simulate V8 startup-snapshot restore environments. The reviewer pointed out that the new tests rely on experimental Node.js features (such as require() of ESM and --experimental-strip-types) only available in Node.js >= 22, which could cause CI failures on older Node versions. They suggested conditionally skipping these tests based on the running Node.js version.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +59 to +85
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();
});

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.

high

These tests rely on experimental Node.js features that are not supported or enabled by default on all Node.js versions:

  1. require() of ESM modules (used in both tests) is only supported on Node.js >= 22.0.0, and is only enabled by default starting from Node.js v22.12.0. On Node.js versions between v22.0.0 and v22.11.0, it requires the --experimental-require-module flag. On older Node.js versions (v18/v20), it is not supported at all.
  2. --experimental-strip-types (used in the second test) was introduced in Node.js v22.6.0.

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:

  • Dynamically detect support for these features and conditionally skip the tests if they are not supported/enabled.
  • Explicitly pass the --experimental-require-module flag to the spawned child process to ensure it works on Node.js versions between v22.6.0 and v22.11.0.
  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();
  });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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:

  • Node baseline. Both the root and @eggjs/utils package.json declare engines.node: ">=22.18.0", and the CI matrix runs only Node 22 and 24 (.github/workflows/ci.yml). Node 18/20 and 22.x < 22.12 are not supported targets, so the tests never run there.
  • require(ESM) is enabled by default since Node 22.12 — well below the >=22.18.0 floor — so no --experimental-require-module flag is needed.
  • Type stripping is on by default since Node 22.18, which is exactly the engines floor. The explicit --experimental-strip-types flag is harmless on 22.18+ and matches the existing precedent in the same package: packages/utils/test/bundle-import.test.ts already spawns fixtures with --experimental-strip-types and no version guards.

Adding it.skip guards only here would be inconsistent with that sibling test and would mask a real failure if the supported baseline ever regressed. If the repo later lowers engines below 22.12, the guards (and the spawned-process flag) would be the right move — but that's a repo-wide policy change, out of scope for this PR.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/typings/src/index.ts`:
- Line 55: The ModuleImporter type is too narrow because it only allows async
importers, while the contract also supports sync require-style importers. Update
the ModuleImporter alias in the typings entrypoint to accept both synchronous
and asynchronous return values, using the ModuleImporter symbol so callers can
pass either form without casting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 71f5f357-8ab2-441d-97ed-731c75fada99

📥 Commits

Reviewing files that changed from the base of the PR and between e76a4d2 and a335bed.

📒 Files selected for processing (6)
  • packages/typings/src/index.ts
  • packages/utils/README.md
  • packages/utils/test/fixtures/module-importer-require-esm/run.mjs
  • packages/utils/test/module-importer.test.ts
  • wiki/log.md
  • wiki/packages/utils.md

* 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>;

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP --type=ts -C3 '__EGG_MODULE_IMPORTER__' 
ast-grep run --pattern '$X.__EGG_MODULE_IMPORTER__($_)' --lang typescript

Repository: eggjs/egg

Length of output: 223


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- packages/typings/src/index.ts (relevant section) ---'
sed -n '1,120p' packages/typings/src/index.ts

echo
echo '--- search ModuleImporter / __EGG_MODULE_IMPORTER__ ---'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' 'ModuleImporter|__EGG_MODULE_IMPORTER__' packages

echo
echo '--- search related importer usage in packages/utils and packages/typings ---'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' 'module[- ]importer|importer|require\(' packages/utils packages/typings

Repository: eggjs/egg

Length of output: 10598


Widen ModuleImporter to accept sync returns

packages/typings/src/index.ts:55 still forces Promise<unknown>, but the contract already allows synchronous require()-style importers. Change it to unknown | Promise<unknown> so callers don’t need a cast.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/typings/src/index.ts` at line 55, The ModuleImporter type is too
narrow because it only allows async importers, while the contract also supports
sync require-style importers. Update the ModuleImporter alias in the typings
entrypoint to accept both synchronous and asynchronous return values, using the
ModuleImporter symbol so callers can pass either form without casting.

@killagu killagu merged commit 42f7a14 into eggjs:next Jun 27, 2026
21 checks passed
killagu added a commit that referenced this pull request Jun 28, 2026
…nd cnpmcore e2e (#6003)

## Motivation

Restoring a V8 startup snapshot requires **Node.js >= 24**: Node.js 22
aborts during deserialization of a non-trivial Egg heap with the native
fatal `Check failed: current == end_slot_index` (a V8 bug). Building a
snapshot still works on Node.js >= 22. Today nothing enforces or
documents this, and there is no regression coverage. This PR adds a
runtime gate, documentation, and an e2e regression — without changing
the snapshot mechanism itself.

Builds on the snapshot work already on `next` (#5998 entry/prelude +
`egg-bin snapshot` command, #5999 lazy-external network stack, #6001
logger reopen, #6002 module-loader hooks).

## Scope

**Runtime gate (restore ≥ 24; build stays ≥ 22)**
- `@eggjs/scripts`: `egg-scripts start --snapshot-blob` refuses to
launch on Node.js < 24 with a clear error *before* spawning, checking
the major version of the resolved `--node` target binary (not just the
egg-scripts runtime). Also adds `allowNo: true` to the `sourcemap` flag
so `--no-sourcemap` is accepted.
- `@eggjs/egg-bundler`: a defense-in-depth guard in the generated
deserialize-main for direct `node --snapshot-blob` launches that manage
to deserialize on an unsupported runtime.
- `@eggjs/bin`: `snapshot build` prints a note that restoring needs
Node.js >= 24.

**Docs**
- Enrich `site/docs/advanced/snapshot.md` (EN + ZH): Node version
requirements, the CLI workflow (`egg-bin snapshot build` → `egg-scripts
start --snapshot-blob`), how it works (load module graph → run to
`configWillLoad` → freeze; restore = `didReady` + listen), performance
(~233ms vs ~942ms, ~4× on cnpmcore), and known limitations.
- Wire the page into the VitePress sidebar (EN + ZH) — it existed but
was unreachable.

**CI**
- Add a blocking `cnpmcore-snapshot` ecosystem-ci e2e (Node 24):
snapshot build → restore via `egg-scripts start --snapshot-blob` → `curl
/-/ping` == 200 → stop. Wires `repo.json`, `patch-project.ts`,
`.gitignore`.
- Extract the shared health-check poll into
`ecosystem-ci/wait-health.sh`, used by both the `cnpmcore` and
`cnpmcore-snapshot` jobs.

## Test evidence

- `pnpm --filter=@eggjs/scripts --filter=@eggjs/egg-bundler
--filter=@eggjs/bin run typecheck` — clean.
- `@eggjs/scripts` `snapshot-start.test.ts` (4 tests, incl. a Node<24
gate test and a `--no-sourcemap` parse regression test) +
`start-unit.test.ts` — pass.
- `@eggjs/bin` `snapshot.test.ts` — pass.
- `@eggjs/egg-bundler` `EntryGenerator` canonical snapshot regenerated
for the new guard; the rest of the suite matches the pre-change baseline
(a few pre-existing macOS/Node-22 path-resolution failures are
unrelated).
- A multi-agent diff review was run and all confirmed findings addressed
(most notably: `--no-sourcemap` is now parseable via `allowNo: true` —
without it the e2e job would have failed 100%).

## Notes

- The `cnpmcore-snapshot` job is correct-by-construction but **could not
be validated on the author's machine** (Node 22, no MySQL+cnpmcore
build); it relies on the supported path (lazy-external from #5999, no
manual stubs) and is validated by this PR's CI run.
- The job is intentionally a **separate matrix project** (not folded
into the existing cnpmcore job) for failure isolation.

🤖 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 “V8 Startup Snapshot” documentation navigation.
* Added support for snapshot project configuration (shared project-root
patching).

* **Bug Fixes**
* Enforced Node.js version gating for V8 snapshot restore (requires
Node.js ≥ 24) and improved snapshot restore safety.
* Improved snapshot lazy-external behavior, including correct external
named-export handling.
  * Updated the start command to allow `--no-sourcemap`.

* **Documentation**
* Expanded snapshot docs with requirements, workflow details,
performance, and limitations.

* **Tests**
* Updated snapshot start/version-gating and lazy-external readiness
assertions.

* **Chores**
* Improved E2E readiness checks with consistent polling, timeouts, and
error log output.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants