fix(node,sdk): compile remote code without eval and outside V8's compilation cache - #5030
fix(node,sdk): compile remote code without eval and outside V8's compilation cache#5030ScriptedAlchemy wants to merge 10 commits into
Conversation
Remote chunks fetched over HTTP by the node runtime plugin were executed via direct `eval` of a wrapper string. Functions created by direct eval capture the enclosing scope, so every module factory in the chunk kept the fetched `data` string alive in its closure context in addition to the flattened wrapper that V8 retains as script source. A live 3 MB chunk therefore cost 6 MB, and in long-running SSR hosts that re-execute remotes the duplicate copy came along with every generation. Introduce a shared `compileChunk` helper: on Node it compiles through `vm.Script` with the chunk URL as the filename (same code path the filesystem loader already used), and on runtimes without `vm` it uses `new Function`, which does not capture scope. The stringified `httpEvalStrategy` in the filesystem strategies gets the same treatment. Measured on a 3 MB chunk with a heap snapshot after GC: one chunk source string retained per live container instead of two, live-container heap 19.7 MB -> 16.7 MB, and 30 forced re-registrations stay flat at 17.7 MB. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: e14380a The changes in this PR will be included in the next version bump. This PR includes changesets to release 48 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
@module-federation/devtools
@module-federation/cli
create-module-federation
@module-federation/dts-plugin
@module-federation/enhanced
@module-federation/error-codes
@module-federation/esbuild
@module-federation/managers
@module-federation/manifest
@module-federation/metro
@module-federation/metro-plugin-rnc-cli
@module-federation/metro-plugin-rnef
@module-federation/metro-plugin-rock
@module-federation/modern-js
@module-federation/modern-js-v3
@module-federation/native-federation-tests
@module-federation/native-federation-typescript
@module-federation/nextjs-mf
@module-federation/node
@module-federation/observability-plugin
@module-federation/playground
@module-federation/retry-plugin
@module-federation/rsbuild-plugin
@module-federation/rspack
@module-federation/rspress-plugin
@module-federation/rstest
@module-federation/runtime
@module-federation/runtime-core
@module-federation/runtime-tools
@module-federation/sdk
@module-federation/storybook-addon
@module-federation/third-party-dts-extractor
@module-federation/treeshake-frontend
@module-federation/treeshake-server
@module-federation/typescript
@module-federation/utilities
@module-federation/webpack-bundler-runtime
@module-federation/bridge-react
@module-federation/bridge-react-webpack-plugin
@module-federation/bridge-shared
@module-federation/bridge-vue3
@module-federation/inject-external-runtime-core-plugin
commit: |
Bundle Size Report11 package(s) changed, 32 unchanged. Package dist + ESM entry
Bundle targets
Consumer scenarios
Total dist (raw): 36.23 MB (+68.8 kB (+0.2%)) Bundle sizes are generated with rslib (Rspack). Package-root metrics preserve the historical report. Tracked subpath exports such as |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 06082e3cb9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| filename, | ||
| importModuleDynamically: | ||
| //@ts-ignore | ||
| vm.constants?.USE_MAIN_CONTEXT_DEFAULT_LOADER ?? importNodeModule, |
There was a problem hiding this comment.
Preserve a local referrer for native imports
On Node 24, when an HTTP-fetched chunk contains a native dynamic import()—for example, from an ESM external or a webpack-ignored import—USE_MAIN_CONTEXT_DEFAULT_LOADER resolves it using this HTTP URL as the referrer. Node rejects bare packages with ERR_INVALID_URL and relative specifiers with ERR_UNSUPPORTED_ESM_URL_SCHEME, whereas the previous direct eval resolved them from the local host module. Use a file-based identifier for module resolution, or supply an import callback that resolves Node imports from a local base while preserving the URL separately for diagnostics.
AGENTS.md reference: AGENTS.md:L289-L289
Useful? React with 👍 / 👎.
Every distinct script V8 compiles lands in an isolate-wide compilation cache holding its source text and compiled code. The cache never ages out on its own (40 full GCs and 400 MB of unrelated churn left it intact) and is evicted only when the heap approaches V8's own limit, which defaults from physical memory rather than the container's cgroup. A host that force-registers a genuinely new remote build on each refresh therefore grew about 6.4 MB per refresh with a 3 MB chunk, regardless of whether the chunk was compiled with eval, vm.Script, new Function or vm.compileFunction, and heap snapshots never showed it because writing a snapshot clears the cache. Wrap the two places that compile remote code, loadScriptNode in the sdk and compileChunk in the node runtime plugin, in withoutCompilationCache(), which flips --no-compilation-cache around the synchronous compile call and restores the flag afterwards. FEDERATION_KEEP_COMPILATION_CACHE=true opts out. Measured (3 MB chunk, unique build per forced refresh, 40 refreshes): heap 16.8 MB -> 18.0 MB with the change, 16.8 MB -> 272.9 MB with the opt-out. Identical-build refreshes are unchanged at 18.0 MB. An uncached 2 MB compile costs about 37 ms against 4 ms cached, paid once per remote execution. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…flag is unavailable --no-compilation-cache is not accepted in NODE_OPTIONS, so hosts that only control environment variables (Lambda-style runtimes) cannot use the startup flag, and runtimes without v8.setFlagsFromString cannot use the scoped toggle. Add FEDERATION_COMPILATION_CACHE=flag|gc|off: flag (default) keeps the scoped toggle; gc compiles normally and then runs one full garbage collection through the inspector once the compile burst settles, which clears the whole cache (71 ms on a small heap, ~540 ms on a 500 MB heap, against 28 s for a heap snapshot); off leaves the cache alone. gc is also the automatic fallback when setFlagsFromString is missing. The GC timer is unref'd and the inspector session is disconnected after post() returns, since disconnecting from inside the synchronous callback deadlocks the session. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Measurements behind this PR, from the harness described in #4566 (3 MB exposed chunk, heap after two full GCs). Chunk loader. Direct Compilation cache. When each forced refresh loads a new build, V8 keeps every distinct compiled script; the growth is identical across compile APIs and invisible to heap snapshots (writing one clears the cache): With the bypass, the same loop is flat; |
…g the inspector session HeapProfiler.collectGarbage completes asynchronously; disconnecting right after post() cancelled it, which left the gc strategy ineffective. Disconnect on the next macrotask after the completion callback instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Node's permission model (--permission) denies inspector.Session.connect with ERR_ACCESS_DENIED. When that happens the gc strategy now uses an exposed gc() with V8's last-resort flavor, which also clears the compilation cache and can be enabled through NODE_OPTIONS=--expose-gc on env-only hosts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Drop the inspector-based and exposed-gc fallbacks; the compilation cache is handled solely by flipping --no-compilation-cache around the synchronous compile call, with FEDERATION_KEEP_COMPILATION_CACHE=true as the opt-out. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Separate compilation from policy with a single implementation in the sdk: `buildCommonJsWrapper` (wrapper shape), `compileCommonJsModule` (vm.Script when vm is obtainable, otherwise new Function; errors never retried on the other backend) and `withRemoteCompilationPolicy` (process-level manager for V8's compilation cache flag: shared globalThis depth counter, toggled only on 0->1 and 1->0, skipped when the process already runs with --no-compilation-cache or FEDERATION_REMOTE_COMPILATION_CACHE=default). The helpers are re-exported through runtime-core and runtime so the node plugin reaches them via __webpack_require__.federation.runtime, with a new Function fallback for older runtimes. FEDERATION_KEEP_COMPILATION_CACHE is removed. Adds sdk behaviour tests, node fallback and httpEvalStrategy conformance tests, and a memory benchmark harness. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| parameters: string[], | ||
| source: string, | ||
| ): string { | ||
| return `(function(${parameters.join(', ')}) {${source}\n})`; |
A direct eval is cached by V8 under the calling script. When the sdk runs
inside a remote entry, each new build's copy evaluating eval('require') left an
eval-cache entry that pinned that entry's source for the life of the process
(measured ~0.2 MB per forced refresh with new builds). Resolve builtins once
per process on a shared global, preferring process.getBuiltinModule.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…, not federation.runtime Remove the compile helpers from the runtime and runtime-core exports; they are Node execution infrastructure, not runtime APIs. The sdk gains compileRemoteCommonJsModule (policy + compile), which loadScriptNode and the node runtime plugin use directly. The plugin no longer looks the helpers up on __webpack_require__.federation.runtime and no longer carries a new Function fallback for older runtimes: it compiles through its own dependency. That also removes the silent fallback path where a remote bundled against an older runtime namespace compiled chunks with no cache policy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>




Why
Two related memory costs in long-running Node SSR hosts that re-execute remotes (see discussion #4566):
@module-federation/node's runtime plugin were executed with directevalof a(function(exports, require, __dirname, __filename) {…})wrapper (fetchAndRuninpackages/node/src/runtimePlugin.ts; present since the plugin's first commit in feat(node): runtime plugin for node envs #2361, and the only HTTP path since thehttp-vmimplementation was removed in fix(node): allow fetch override on runtime plugin #2603). Functions created by directevalcapture the enclosing scope, so every module factory in the chunk kept the fetcheddatastring alive on top of the flattened wrapper V8 retains as script source. A live 3 MB chunk cost 6 MB of strings.eval,vm.Script,vm.runInThisContext,new Functionandvm.compileFunction, and heap snapshots never show it because writing a snapshot clears the cache.What
Boundary: runtime-core and runtime know nothing about Node compilation; the sdk's Node code owns the compile primitives and the V8 policy;
@module-federation/nodedecides that fetched chunks are remote code and compiles them through its own dependency. Nothing is exported through__webpack_require__.federation.runtime.packages/sdk/src/node.ts:buildCommonJsWrapper(parameters, source)is the single wrapper shape;compileCommonJsModule({ source, filename, parameters, importModuleDynamically })compiles viavm.ScriptwhentryGetVm()obtains the module, otherwisenew Function, and a compile error from the chosen backend propagates (never retried on the other one);withRemoteCompilationPolicy(compile)flips--no-compilation-cachearound one synchronous compile and restores it infinally, with a depth counter on aglobalThissymbol shared by every copy of the package, no-op when the process already runs with the flag (execArgv/NODE_OPTIONS),FEDERATION_REMOTE_COMPILATION_CACHE=disable(default) |default, and no-op whenv8.setFlagsFromStringis missing or throws;compileRemoteCommonJsModule(options)composes them.loadScriptNodeuses the composition for remote entries. Builtin lookups (vm,v8) are memoised on a process-wide global and preferprocess.getBuiltinModule(see the eval-cache note under Measured).packages/node/src/runtimePlugin.ts:compileChunk(source, filename)callscompileRemoteCommonJsModuleimported from@module-federation/sdk, withCHUNK_WRAPPER_PARAMSand the plugin'simportModuleDynamicallyfallback.fetchAndRunandloadFromFsuse it; the stringifiedhttpEvalStrategykeeps its self-containednew Functionwith a conformance test on the parameter list. No lookup onfederation.runtime, no legacy fallback.node-compile.spec.tscovers the wrapper, both backends, error propagation, and the policy (initial state,execArgv, nesting, thrown error, missingsetFlagsFromString, envdefault), with the builtin memo andgetBuiltinModulecontrolled per test; node tests mock the sdk entry point and assert chunks compile through it with the chunk URL as filename.packages/node/__benchmarks__/remote-compilation-memory.mjs: reproducible heap benchmark (with policy ~flat, without ~170 MB across 40 unique 2 MB scripts on Node 22).@module-federation/nodepatch,@module-federation/sdkpatch.Measured
Host on
@module-federation/runtimeHEAD, enhanced-built async-node remote with a 3 MB exposed chunk, heap after two full GCs:FEDERATION_REMOTE_COMPILATION_CACHE=defaultCost of the toggle: one uncached compile per remote execution, ~37 ms for 2 MB against ~4 ms cached; in the full reload loop the median reload went from 67 ms to 68 ms. The toggle is process-wide for the duration of the compile, so a worker thread compiling in that window misses the cache once; no correctness impact. Its effect is verifiable at runtime (a repeat compile of the same source takes ~2,500 µs with the cache off versus ~30 µs on).
Alternatives evaluated and not adopted (kept here for the record)
The compilation cache has no per-script option (
vm.Scriptonly knowscachedData/produceCachedData, which are the unrelated serialized code cache) and no eviction API; per-context isolation does not help because the cache is per isolate. Ways to clear it after the fact, all measured on the same loop with 150 MB of live objects underneath (20 new-build refreshes):HeapProfiler.collectGarbageafter each reload--permissionmode (ERR_ACCESS_DENIED, permissionInspector); the collection is asynchronous, so the session must be disconnected on a macrotask after the callback, and disconnecting inside the callback deadlocksgc({ type: 'major', execution: 'sync', flavor: 'last-resort' })with--expose-gc--expose-gcis accepted inNODE_OPTIONS, so it is available on env-only hosts; still a stop-the-world per reloadv8.writeHeapSnapshotafter each reload--no-compilation-cacheat startupNODE_OPTIONS, so Lambda-style hosts cannot set it; disables the cache for all code--max-old-space-size=200An earlier revision of this branch shipped the inspector collection (with the exposed-gc fallback) as a
FEDERATION_COMPILATION_CACHE=gcstrategy; it was removed in favour of the flag toggle alone, which has no pause and works in every environment tested, including under--permission. Charts for these runs: https://gist.github.com/ScriptedAlchemy/775fa1d0f2ebee25c68558246b4fdb68🤖 Generated with Claude Code