Skip to content

feat(core): support lazy_loaded_esm modules via import statements - #33873

Merged
bartlomieju merged 4 commits into
mainfrom
feat/lazy-esm-node-sys
May 6, 2026
Merged

bartlomieju merged 4 commits into
mainfrom
feat/lazy-esm-node-sys

Conversation

@bartlomieju

@bartlomieju bartlomieju commented May 6, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Teach deno_core to resolve lazy_loaded_esm modules through normal import statements (static, dynamic, and CJS require), not just createLazyLoader()
  • Add known_lazy_esm metadata set to ModuleMapData that persists in the snapshot sidecar, so the module system knows these modules exist without compiling/instantiating them during snapshotting
  • Add fast paths in load_dynamic_import, resolve_callback, and RecursiveModuleLoad to handle lazy ESM modules
  • Convert node:sys as a proof of concept: implementation in lazy_loaded_js IIFE, thin ESM wrapper in lazy_loaded_esm with node:sys specifier -- neither is snapshotted

Motivation

Snapshotting ES modules is expensive. For node:* modules that are thin re-export wrappers, we can avoid the snapshot cost by:

  1. Moving the implementation into a lazy_loaded_js IIFE (script in binary, not snapshotted)
  2. Providing a thin ESM re-export wrapper via lazy_loaded_esm (in binary, not snapshotted)
  3. Registering the module's existence in the snapshot metadata so all resolution paths work

Test plan

  • New deno_core unit tests: test_lazy_loaded_esm_aliased_specifier, test_lazy_loaded_esm_aliased_via_import
  • Existing test_lazy_loaded_esm tests still pass
  • node:sys works via static ESM import, dynamic import(), named import, and CJS require()
  • CI

Previously, `lazy_loaded_esm` modules could only be accessed via
`Deno.core.createLazyLoader()`. This change makes them resolvable
through normal `import` statements by checking `lazy_esm_sources` in
both the recursive module loader and V8's module resolve callback.

This enables a pattern where `node:*` module ESM wrappers can be stored
in the binary as raw text without being snapshotted (since snapshotting
ES modules is expensive). The implementation lives in a `lazy_loaded_js`
IIFE, and a thin ESM re-export wrapper in `lazy_loaded_esm` provides
the `node:*` specifier.

As a proof of concept, `node:sys` is converted to this pattern:
- `sys.ts`: lazy_loaded_js IIFE that loads node:util
- `sys_esm.js`: lazy_loaded_esm thin wrapper with `node:sys` specifier
- `01_require.js`: uses lazy getter with core.loadExtScript
- Add fast path in load_dynamic_import to resolve lazy ESM modules
  synchronously, avoiding the async RecursiveModuleLoad path entirely
- Skip prepare_load for lazy ESM sources in RecursiveModuleLoad
- Check lazy_esm_sources in the recursive load phase as fallback
- Fix 01_require.js to eagerly load sys module (required for
  assert.strictEqual(sys, util) identity check in Node compat tests)
- Add has_lazy_esm_source() helper on ModuleMap
Add a `known_lazy_esm` set to ModuleMapData that records which
lazy-loaded ESM specifiers exist. This set is serialized into the
snapshot sidecar data, so at runtime the module system knows these
modules exist without needing to check lazy_esm_sources directly.

This means resolve_callback, load_dynamic_import, and the recursive
module loader can all check the snapshotted metadata rather than
requiring scattered fallbacks.

Also fix 01_require.js to eagerly load sys (required for
assert.strictEqual(sys, util) identity check in Node compat tests),
and handle dynamic import() for lazy ESM modules.

@fibibot fibibot 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.

Substance of the deno_core change is interesting and well-motivated — letting lazy_loaded_esm modules participate in normal import resolution avoids the snapshot cost for thin re-export wrappers. The new known_lazy_esm metadata + the three fast paths in load_dynamic_import / resolve_callback / RecursiveModuleLoad are the right entry points to teach.

But the proof-of-concept conversion fails the proof. parallel/test-sys.js is the exact test that would catch "node:sys works through ESM and CJS" — and it fails on linux-aarch64:

error: Uncaught (in promise) AssertionError: Expected "actual" to be reference-equal to "expected":
+ [Module: null prototype] {
- {
    MIMEParams: [class MIMEParams],
    MIMEType: [class MIMEType],
    ...

require("sys") is now returning a Module namespace object ([Module: null prototype] {...}); the test expects it to be === to whatever require("util") returns (a plain object). Node.js's identity invariant require("sys") === require("util") is broken by the conversion.

Why

Looking at the new shape:

  • ext/node/polyfills/sys.ts (lazy_loaded_js): (function () { return core.createLazyLoader("node:util")(); })() — returns the util Module namespace.
  • ext/node/polyfills/sys_esm.js (lazy_loaded_esm): export * from "node:util"; export { default } from "node:util"; — re-exports util's bindings, but the resulting Module namespace is a different object than util's own.
  • 01_require.js:155-157: const sys = core.loadExtScript("ext:deno_node/sys.ts"); — wires up the CJS-require side, which now returns the lazy-loaded namespace, not the same object as require("util").

The PR description claims node:sys was tested via CJS require(), but the identity assertion (which is what this Node test exercises) fails.

Fix directions

Three options, increasing in scope:

  1. Don't convert node:sys — keep it as the existing thin alias and pick a different proof-of-concept module that doesn't have an identity invariant. The deno_core machinery still ships, just without this specific binding.

  2. Special-case node:sys in CJS require — when Module._resolveFilename("sys") resolves, return the same object that require("util") returns instead of going through the lazy_loaded_esm path. Brittle but preserves the identity contract.

  3. Make lazy_loaded_esm re-export wrappers preserve identity — when export * from "X" resolves, return the same Module namespace object as X's, not a fresh wrapper namespace. This is the most general fix but needs careful thought about V8 module-graph semantics (a re-exporting module is, by V8 spec, a distinct module from the source).

My hunch is #1 (or skip the test) is the cleanest path for landing the deno_core feature without getting stuck on node:sys's peculiar identity contract.

CI

61 success, 25 pending, 1 fail (test node_compat (2/3) debug linux-aarch64 on parallel/test-sys.js). The runner explicitly retried at concurrency=1 and the fail is deterministic — not a flake.

Clearing this REQUEST_CHANGES once the test-sys regression is resolved (whichever path you choose).

require("sys") must return the exact same object as require("util")
(Node compat test assert.strictEqual). Map sys to util directly in
01_require.js instead of going through a separate IIFE. The ESM path
(node:sys) still uses the lazy_loaded_esm wrapper (sys_esm.js) which
re-exports from node:util.

Also add snapshot test verifying lazy ESM metadata survives
snapshotting while the module itself is not in the V8 module map.

@fibibot fibibot 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.

LGTM — clearing my prior REQUEST_CHANGES at 0e4c059c. 75d4c932 (fix: use util directly for CJS sys, remove unused sys.ts IIFE) takes option (2) from my fix directions: keep the lazy_loaded_esm machinery for ESM import paths, but route CJS require("sys") directly at util's exports so the require("sys") === require("util") identity invariant holds.

What I verified

The previously-failing shard now passes: test node_compat (2/3) debug linux-aarch64 (which runs parallel/test-sys.js) green at this HEAD. All 15 test node_compat × {1/3, 2/3} × {debug, release} × 4 platforms shards I've checked are green.

The fix shape is right:

  • ext/node/polyfills/sys.ts: deleted (+0/-5) — the IIFE wrapper that returned createLazyLoader("node:util")() is gone.
  • ext/node/lib.rs: removed sys.ts from registration (+0/-1).
  • ext/node/polyfills/01_require.js: CJS require("sys") now resolves directly to util's object (+1/-2).
  • libs/core/runtime/tests/snapshot.rs: added a deno_core snapshot test for the lazy_loaded_esm flow (+78/-0) — locks in the new resolution paths so future regressions surface here, not via Node-compat tests.

The ESM-side sys_esm.js (export * from "node:util"; export { default } from "node:util";) remains, so static import sys from "node:sys" and await import("node:sys") still go through the new lazy_loaded_esm path — which was the point of this PR.

CI

104 success, 11 pending, 0 fail at this HEAD. The 11 pending are slow test specs / test integration / test unit / test libs debug shards on macos/windows — they don't exercise the node:sys → node:util aliasing. Substance is unambiguously correct.

@bartlomieju
bartlomieju merged commit b285828 into main May 6, 2026
136 checks passed
@bartlomieju
bartlomieju deleted the feat/lazy-esm-node-sys branch May 6, 2026 12:58
littledivy pushed a commit to crowlKats/deno that referenced this pull request Jun 10, 2026
…noland#33873)

- Teach `deno_core` to resolve `lazy_loaded_esm` modules through normal
`import` statements (static, dynamic, and CJS require), not just
`createLazyLoader()`
- Add `known_lazy_esm` metadata set to `ModuleMapData` that persists in
the snapshot sidecar, so the module system knows these modules exist
without compiling/instantiating them during snapshotting
- Add fast paths in `load_dynamic_import`, `resolve_callback`, and
`RecursiveModuleLoad` to handle lazy ESM modules
- Convert `node:sys` as a proof of concept: implementation in
`lazy_loaded_js` IIFE, thin ESM wrapper in `lazy_loaded_esm` with
`node:sys` specifier -- neither is snapshotted

## Motivation
Snapshotting ES modules is expensive. For `node:*` modules that are thin
re-export wrappers, we can avoid the snapshot cost by:
1. Moving the implementation into a `lazy_loaded_js` IIFE (script in
binary, not snapshotted)
2. Providing a thin ESM re-export wrapper via `lazy_loaded_esm` (in
binary, not snapshotted)
3. Registering the module's existence in the snapshot metadata so all
resolution paths work
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