Skip to content

Commit cad05e6

Browse files
fix: prevent infinite recursion in importNodeModule functions
- Add import caching to SDK importNodeModule to prevent circular calls - Add import caching to node runtime plugin importNodeModule - Add ESM module caching to prevent loadModule recursion - Use direct dynamic import in vm.Script fallback to avoid cycles - Add regression test for maximum call stack exceeded scenario Fixes RangeError: Maximum call stack size exceeded when NextJS apps use Module Federation with both SDK and node runtime plugin importing each other's modules through importModuleDynamically callbacks. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 440216b commit cad05e6

File tree

3 files changed

+202
-3
lines changed

3 files changed

+202
-3
lines changed

packages/node/src/runtimePlugin.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,31 @@ type WebpackRequire = {
3939
declare const __webpack_require__: WebpackRequire;
4040
declare const __non_webpack_require__: (id: string) => any;
4141

42+
const nodeRuntimeImportCache = new Map<string, Promise<any>>();
43+
4244
export function importNodeModule<T>(name: string): Promise<T> {
4345
if (!name) {
4446
throw new Error('import specifier is required');
4547
}
48+
49+
// Check cache to prevent infinite recursion
50+
if (nodeRuntimeImportCache.has(name)) {
51+
return nodeRuntimeImportCache.get(name)!;
52+
}
53+
4654
const importModule = new Function('name', `return import(name)`);
47-
return importModule(name)
55+
const promise = importModule(name)
4856
.then((res: any) => res.default as T)
4957
.catch((error: any) => {
5058
console.error(`Error importing module ${name}:`, error);
59+
// Remove from cache on error so it can be retried
60+
nodeRuntimeImportCache.delete(name);
5161
throw error;
5262
});
63+
64+
// Cache the promise to prevent recursive calls
65+
nodeRuntimeImportCache.set(name, promise);
66+
return promise;
5367
}
5468

5569
// Hoisted utility function to resolve file paths for chunks
@@ -106,7 +120,15 @@ export const loadFromFs = (
106120
filename,
107121
importModuleDynamically:
108122
//@ts-ignore
109-
vm.constants?.USE_MAIN_CONTEXT_DEFAULT_LOADER ?? importNodeModule,
123+
vm.constants?.USE_MAIN_CONTEXT_DEFAULT_LOADER ??
124+
((specifier: string) => {
125+
// Use direct dynamic import to avoid recursion
126+
const dynamicImport = new Function(
127+
'specifier',
128+
'return import(specifier)',
129+
);
130+
return dynamicImport(specifier);
131+
}),
110132
},
111133
);
112134
script.runInThisContext()(
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* Test to reproduce the Maximum call stack size exceeded error
3+
* when there are circular dependencies in module loading
4+
*/
5+
6+
import { jest } from '@jest/globals';
7+
8+
// Mock vm and fetch to simulate the error scenario
9+
const mockVm = {
10+
Script: jest.fn(),
11+
SourceTextModule: jest.fn(),
12+
constants: {
13+
USE_MAIN_CONTEXT_DEFAULT_LOADER: undefined,
14+
},
15+
};
16+
17+
const mockFetch = jest.fn();
18+
19+
// Mock the modules that would be imported
20+
jest.mock('vm', () => mockVm, { virtual: true });
21+
jest.mock(
22+
'path',
23+
() => ({
24+
basename: jest.fn(),
25+
join: jest.fn(),
26+
}),
27+
{ virtual: true },
28+
);
29+
30+
describe('Node importNodeModule recursion test', () => {
31+
beforeEach(() => {
32+
jest.clearAllMocks();
33+
});
34+
35+
it('should reproduce maximum call stack size exceeded', async () => {
36+
// Mock a scenario where importNodeModule gets called recursively
37+
let callCount = 0;
38+
const maxCalls = 10000; // Set high to trigger stack overflow
39+
40+
// Create a mock script that will trigger recursive importModuleDynamically calls
41+
const mockScript = {
42+
runInThisContext: jest.fn(() => {
43+
return (
44+
exports: any,
45+
module: any,
46+
require: any,
47+
dirname: string,
48+
filename: string,
49+
) => {
50+
// Simulate module execution that triggers another import
51+
module.exports = {};
52+
};
53+
}),
54+
};
55+
56+
// Set up the vm.Script mock to simulate importModuleDynamically calls
57+
mockVm.Script.mockImplementation((code: string, options: any) => {
58+
// Simulate the importModuleDynamically option being called
59+
if (options.importModuleDynamically && callCount < maxCalls) {
60+
callCount++;
61+
// This simulates a circular dependency where modules import each other
62+
setTimeout(() => {
63+
options.importModuleDynamically('circular-module').catch(() => {});
64+
}, 0);
65+
}
66+
return mockScript;
67+
});
68+
69+
// Mock fetch to return some JavaScript code
70+
mockFetch.mockResolvedValue({
71+
text: () => Promise.resolve('module.exports = {};'),
72+
});
73+
74+
// Simulate the createScriptNode function call that leads to recursion
75+
const { createScriptNode } = await import('../node');
76+
77+
// This should trigger the recursive calls and eventually cause a stack overflow
78+
const promise = new Promise((resolve, reject) => {
79+
createScriptNode(
80+
'http://example.com/test.js',
81+
(error, scriptContext) => {
82+
if (error) {
83+
reject(error);
84+
} else {
85+
resolve(scriptContext);
86+
}
87+
},
88+
{},
89+
{
90+
fetch: mockFetch,
91+
},
92+
);
93+
});
94+
95+
// The test should catch the stack overflow error
96+
await expect(promise).rejects.toThrow();
97+
98+
// Verify that multiple calls were made (indicating recursion)
99+
expect(callCount).toBeGreaterThan(1);
100+
}, 30000); // Increased timeout for this test
101+
102+
it('should reproduce ESM module loading recursion', async () => {
103+
// Mock SourceTextModule for ESM loading test
104+
let linkCallCount = 0;
105+
const mockModule = {
106+
link: jest.fn(async (linker) => {
107+
linkCallCount++;
108+
if (linkCallCount < 5) {
109+
// Simulate circular dependency by calling linker with same module
110+
await linker('circular-esm-module');
111+
}
112+
}),
113+
evaluate: jest.fn(),
114+
};
115+
116+
mockVm.SourceTextModule.mockImplementation((code: string, options: any) => {
117+
// Trigger importModuleDynamically recursion
118+
if (options.importModuleDynamically && linkCallCount < 3) {
119+
setTimeout(() => {
120+
options.importModuleDynamically('circular-esm', {}).catch(() => {});
121+
}, 0);
122+
}
123+
return mockModule;
124+
});
125+
126+
mockFetch.mockResolvedValue({
127+
text: () => Promise.resolve('export default {};'),
128+
});
129+
130+
const { createScriptNode } = await import('../node');
131+
132+
const promise = new Promise((resolve, reject) => {
133+
createScriptNode(
134+
'http://example.com/test.mjs',
135+
(error, scriptContext) => {
136+
if (error) {
137+
reject(error);
138+
} else {
139+
resolve(scriptContext);
140+
}
141+
},
142+
{ type: 'esm' },
143+
{
144+
fetch: mockFetch,
145+
},
146+
);
147+
});
148+
149+
// This test demonstrates the ESM loading recursion scenario
150+
await expect(promise).rejects.toThrow();
151+
expect(linkCallCount).toBeGreaterThan(1);
152+
}, 30000);
153+
});

packages/sdk/src/node.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,31 @@ import { CreateScriptHookNode, FetchHook } from './types';
33
// Declare the ENV_TARGET constant that will be defined by DefinePlugin
44
declare const ENV_TARGET: 'web' | 'node';
55

6+
const sdkImportCache = new Map<string, Promise<any>>();
7+
68
function importNodeModule<T>(name: string): Promise<T> {
79
if (!name) {
810
throw new Error('import specifier is required');
911
}
12+
13+
// Check cache to prevent infinite recursion
14+
if (sdkImportCache.has(name)) {
15+
return sdkImportCache.get(name)!;
16+
}
17+
1018
const importModule = new Function('name', `return import(name)`);
11-
return importModule(name)
19+
const promise = importModule(name)
1220
.then((res: any) => res as T)
1321
.catch((error: any) => {
1422
console.error(`Error importing module ${name}:`, error);
23+
// Remove from cache on error so it can be retried
24+
sdkImportCache.delete(name);
1525
throw error;
1626
});
27+
28+
// Cache the promise to prevent recursive calls
29+
sdkImportCache.set(name, promise);
30+
return promise;
1731
}
1832

1933
const loadNodeFetch = async (): Promise<typeof fetch> => {
@@ -225,13 +239,20 @@ export const loadScriptNode =
225239
);
226240
};
227241

242+
const esmModuleCache = new Map<string, any>();
243+
228244
async function loadModule(
229245
url: string,
230246
options: {
231247
vm: any;
232248
fetch: any;
233249
},
234250
) {
251+
// Check cache to prevent infinite recursion in ESM loading
252+
if (esmModuleCache.has(url)) {
253+
return esmModuleCache.get(url)!;
254+
}
255+
235256
const { fetch, vm } = options;
236257
const response = await fetch(url);
237258
const code = await response.text();
@@ -244,6 +265,9 @@ async function loadModule(
244265
},
245266
});
246267

268+
// Cache the module before linking to prevent cycles
269+
esmModuleCache.set(url, module);
270+
247271
await module.link(async (specifier: string) => {
248272
const resolvedUrl = new URL(specifier, url).href;
249273
const module = await loadModule(resolvedUrl, options);

0 commit comments

Comments
 (0)