Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/node-chunk-no-eval.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@module-federation/node': patch
---

Compile remotely fetched chunks through the sdk's `compileCommonJsModule` under `withRemoteCompilationPolicy` (reached via the bundled runtime, like `loadScriptNode`) instead of direct `eval`. Functions created by direct eval capture the enclosing scope, which kept a second full copy of every chunk's source text alive for as long as the chunk was loaded and doubled the memory retained per live remote in long-running SSR hosts. Stack traces now carry the chunk URL as the script filename. Hosts bundled with an older runtime that lacks the helpers fall back to `new Function` with no cache policy.
5 changes: 5 additions & 0 deletions .changeset/sdk-remote-entry-no-compilation-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@module-federation/sdk': patch
---

Add Node compile primitives for remote code: `buildCommonJsWrapper` (the single wrapper shape), `compileCommonJsModule` (`vm.Script` when `vm` is obtainable, otherwise `new Function`; compile errors propagate, never retried on the other backend), `withRemoteCompilationPolicy` (flips `--no-compilation-cache` around one synchronous compile and restores it, with a process-wide depth counter, respect for a process already started with the flag, and `FEDERATION_REMOTE_COMPILATION_CACHE=disable|default`), and `compileRemoteCommonJsModule`, which composes the two and is what `loadScriptNode` and `@module-federation/node` use. Builtin lookups are memoised per process and prefer `process.getBuiltinModule`, because a direct `eval('require')` executed inside a remote entry pins that entry in V8's eval cache. V8 otherwise keeps every distinct compiled remote build until the heap nears its own limit, which is what made long-running SSR hosts that force-register new builds grow without bound.
64 changes: 64 additions & 0 deletions packages/node/__benchmarks__/remote-compilation-memory.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Memory retained by V8's compilation cache for remote code, with and without
// the sdk's remote compilation policy. Not part of CI. Run from the repo root:
//
// pnpm --filter @module-federation/sdk build
// node --expose-gc packages/node/__benchmarks__/remote-compilation-memory.mjs
//
// Each mode compiles 40 unique ~2 MB CommonJS-shaped scripts, drops every
// reference, forces a gc() and prints heapUsed. Expected on Node 22: "with
// policy" stays roughly flat (a few MB of noise); "without policy" grows by
// about 170 MB because the cache keeps each script's source and code alive.
import {
compileCommonJsModule,
withRemoteCompilationPolicy,
} from '../../sdk/dist/index.js';

if (typeof globalThis.gc !== 'function') {
console.error('run with --expose-gc');
process.exit(1);
}

const SCRIPTS = 40;
const PARAMETERS = ['exports', 'require', '__dirname', '__filename'];
const PADDING_LINES = 20_000;
const mb = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;

const makeSource = (label, index) => {
const lines = [`exports.id = ${JSON.stringify(`${label}-${index}`)};`];
for (let line = 0; line < PADDING_LINES; line++) {
lines.push(
`exports.m${line} = function () { return "${label}-${index}-${line}-${'x'.repeat(64)}"; };`,
);
}
return lines.join('\n');
};

const heapAfterGc = () => {
globalThis.gc();
globalThis.gc();
return process.memoryUsage().heapUsed;
};

const run = (label, wrap) => {
const before = heapAfterGc();
let sourceBytes = 0;
for (let index = 0; index < SCRIPTS; index++) {
const source = makeSource(label, index);
sourceBytes += source.length;
const chunk = wrap(() =>
compileCommonJsModule({
source,
filename: `${label}-${index}.js`,
parameters: PARAMETERS,
}),
);
chunk({}, () => ({}), '/remote', `${label}-${index}.js`);
}
const after = heapAfterGc();
console.log(
`${label.padEnd(15)} heapUsed ${mb(before)} -> ${mb(after)} (delta ${mb(after - before)}, ${mb(sourceBytes)} of source compiled)`,
);
};

run('with policy', (compile) => withRemoteCompilationPolicy(compile));
run('without policy', (compile) => compile());
62 changes: 62 additions & 0 deletions packages/node/src/__tests__/runtimePlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import runtimePlugin, {
setupChunkHandler,
setupWebpackRequirePatching,
nodeRuntimeImportCache,
CHUNK_WRAPPER_PARAMS,
} from '../runtimePlugin';
import { httpEvalStrategy } from '../filesystem/stratagies';
import type {
ModuleFederationRuntimePlugin,
ModuleFederation,
Expand All @@ -30,6 +32,28 @@ jest.mock('fs', () => ({
readFile: jest.fn(),
}));

jest.mock('@module-federation/sdk', () => ({
// the plugin compiles fetched chunks through the sdk's Node entry point;
// back it with the vm mock above so individual tests can shape the compile
compileRemoteCommonJsModule: jest.fn(
({
source,
filename,
parameters,
importModuleDynamically,
}: {
source: string;
filename: string;
parameters: string[];
importModuleDynamically?: any;
}) =>
new (require('vm').Script)(
`(function(${parameters.join(', ')}) {${source}\n})`,
{ filename, importModuleDynamically },
).runInThisContext(),
),
}));

jest.mock('vm', () => ({
Script: jest.fn().mockImplementation(() => ({
runInThisContext: jest.fn().mockReturnValue(() => {
Expand Down Expand Up @@ -438,6 +462,11 @@ describe('runtimePlugin', () => {
},
});
const callback = jest.fn();
// compile for real so the broken chunk throws a SyntaxError
require('vm').Script.mockImplementationOnce((code: string) => {
new Function(`return ${code}`);
return { runInThisContext: () => () => undefined };
});
const args = {
origin: {
options: {
Expand Down Expand Up @@ -659,6 +688,9 @@ describe('runtimePlugin', () => {
"exports.modules = {'test-module': {}}; exports.ids = ['test-chunk']; exports.runtime = null;",
),
});
require('vm').Script.mockImplementationOnce((code: string) => ({
runInThisContext: () => new Function(`return ${code}`)(),
}));
const args = {
origin: {
options: { name: 'test-host' },
Expand All @@ -684,6 +716,21 @@ describe('runtimePlugin', () => {
});

expect(result).toEqual(mockChunk);
// remote chunks go through the sdk compile helper under the cache policy,
// with the chunk URL as the script filename, never through direct eval
const { compileRemoteCommonJsModule } = require('@module-federation/sdk');
expect(compileRemoteCommonJsModule).toHaveBeenCalledWith(
expect.objectContaining({
filename: 'http://example.com/test-chunk',
parameters: CHUNK_WRAPPER_PARAMS,
}),
);
expect(require('vm').Script).toHaveBeenCalledWith(
expect.stringContaining(
'(function(exports, require, __dirname, __filename)',
),
expect.objectContaining({ filename: 'http://example.com/test-chunk' }),
);
(global as any).__webpack_require__.p = originalPublicPath;
});

Expand All @@ -710,6 +757,21 @@ describe('runtimePlugin', () => {
});
});

describe('httpEvalStrategy', () => {
it('wraps chunks with the same parameter list as compileChunk', () => {
// the strategy is stringified into generated code and cannot import the
// constant, so keep its inline parameter list in sync by inspection
const match = httpEvalStrategy
.toString()
.match(/new Function\(([^)]*?),\s*data\)/);
expect(match).not.toBeNull();
const parameters = match![1]
.split(',')
.map((parameter) => parameter.trim().replace(/['"]/g, ''));
expect(parameters).toEqual(CHUNK_WRAPPER_PARAMS);
});
});

describe('installChunk', () => {
it('should install modules and runtime from chunk', () => {
// Setup resolver function
Expand Down
11 changes: 8 additions & 3 deletions packages/node/src/filesystem/stratagies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,14 @@ export async function httpEvalStrategy(
try {
const urlDirname = url.pathname.split('/').slice(0, -1).join('/');

eval(
'(function(exports, require, __dirname, __filename) {' + data + '\n})',
)(chunk, require, urlDirname, chunkName);
// `new Function` instead of direct eval: eval'd functions capture this scope
// and keep the whole chunk source string alive with the chunk.
new Function('exports', 'require', '__dirname', '__filename', data)(
chunk,
require,
urlDirname,
chunkName,
);
callback(null, chunk);
} catch (e: any) {
callback(e, null);
Expand Down
52 changes: 40 additions & 12 deletions packages/node/src/runtimePlugin.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { compileRemoteCommonJsModule } from '@module-federation/sdk';
import type {
ModuleFederationRuntimePlugin,
ModuleFederation,
Expand Down Expand Up @@ -177,30 +178,57 @@ export const returnFromGlobalInstances = (
return null;
};

export const CHUNK_WRAPPER_PARAMS = [
'exports',
'require',
'__dirname',
'__filename',
];

type ChunkFunction = (
exports: any,
require: any,
dirname: string,
filename: string,
) => void;

const getVmConstants = ():
| { USE_MAIN_CONTEXT_DEFAULT_LOADER?: any }
| undefined => {
try {
return __non_webpack_require__('vm').constants;
} catch {
return undefined;
}
};

// Compiles a chunk body into a callable without direct `eval` (whose functions
// capture the enclosing scope and pin the chunk source string). The compile
// backend and V8 compilation-cache policy live in the sdk's Node code; this
// plugin only decides that fetched chunks are remote code that needs them.
export const compileChunk = (source: string, filename: string): ChunkFunction =>
compileRemoteCommonJsModule({
source,
filename,
parameters: CHUNK_WRAPPER_PARAMS,
importModuleDynamically:
getVmConstants()?.USE_MAIN_CONTEXT_DEFAULT_LOADER ?? importNodeModule,
}) as ChunkFunction;

// Hoisted utility function to load chunks from filesystem
export const loadFromFs = (
filename: string,
callback: (err: Error | null, chunk: any) => void,
): void => {
const fs = __non_webpack_require__('fs') as typeof import('fs');
const path = __non_webpack_require__('path') as typeof import('path');
const vm = __non_webpack_require__('vm') as typeof import('vm');

if (fs.existsSync(filename)) {
fs.readFile(filename, 'utf-8', (err, content) => {
if (err) return callback(err, null);
const chunk = {};
try {
const script = new vm.Script(
`(function(exports, require, __dirname, __filename) {${content}\n})`,
{
filename,
importModuleDynamically:
//@ts-ignore
vm.constants?.USE_MAIN_CONTEXT_DEFAULT_LOADER ?? importNodeModule,
},
);
script.runInThisContext()(
compileChunk(content, filename)(
chunk,
__non_webpack_require__,
path.dirname(filename),
Expand Down Expand Up @@ -253,7 +281,7 @@ export const fetchAndRun = (
const resolution = (url as URL & { mfMetadata?: ChunkUrlMetadata })
.mfMetadata;
try {
eval(`(function(exports, require, __dirname, __filename) {${data}\n})`)(
compileChunk(data, url.href)(
chunk,
__non_webpack_require__,
url.pathname.split('/').slice(0, -1).join('/'),
Expand Down
Loading
Loading