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-esm-builtin-loader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@module-federation/sdk": patch
---

Handle Node.js built-in imports in the Node ESM remote loader.
111 changes: 111 additions & 0 deletions packages/sdk/__tests__/node-builtin-esm.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { jest } from '@jest/globals';

const createResponse = (body: string): Response =>
({
ok: true,
status: 200,
statusText: 'OK',
text: jest.fn().mockResolvedValue(body),
}) as unknown as Response;

const loadNodeEsmScript = (url = 'http://example.com/remoteEntry.js') =>
new Promise((resolve, reject) => {
import('../src/node').then(({ createScriptNode }) => {
createScriptNode(
url,
(error, scriptContext) => {
if (error) {
reject(error);
return;
}

resolve(scriptContext);
},
{ type: 'module' },
);
}, reject);
});

describe('Node ESM builtin loading', () => {
const originalFetch = globalThis.fetch;

beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

it('loads node: builtin imports without fetching them as remote chunks', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the new SDK spec discoverable by Jest

This new spec is not exercised by pnpm --filter @module-federation/sdk test: I checked packages/sdk/jest.config.cjs, and its testMatch is <rootDir>__tests__/**/**.spec.[jt]s?(x), which expands to packages/sdk__tests__ rather than this packages/sdk/__tests__ directory; because the package script also uses --passWithNoTests, CI can pass without running these regression cases.

Useful? React with 👍 / 👎.

const fetchMock = jest.fn(async (url: string) => {
if (url === 'node:url') {
throw new Error('node:url should not be fetched');
}

return createResponse(
"import { pathToFileURL } from 'node:url'; export const marker = pathToFileURL('/tmp/module-federation').href; export default {};",
);
});
globalThis.fetch = fetchMock as unknown as typeof fetch;

const scriptContext = (await loadNodeEsmScript()) as {
marker: string;
};

expect(scriptContext.marker).toBe('file:///tmp/module-federation');
expect(fetchMock).toHaveBeenCalledWith('http://example.com/remoteEntry.js');
expect(fetchMock).not.toHaveBeenCalledWith('node:url');
});

it('initializes import.meta.url for remote entries that create a Node.js require function', async () => {
const remoteEntryUrl = 'http://example.com/server/remoteEntry.js';
const fetchMock = jest.fn(async (url: string) => {
if (url !== remoteEntryUrl) {
throw new Error(`${url} should not be fetched`);
}

return createResponse(
"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const path = require('node:path'); export const separator = path.sep; export const metaUrl = import.meta.url; export default {};",
);
});
globalThis.fetch = fetchMock as unknown as typeof fetch;

const scriptContext = (await loadNodeEsmScript(remoteEntryUrl)) as {
metaUrl: string;
separator: string;
};

expect(scriptContext.separator).toBe('/');
expect(scriptContext.metaUrl).toMatch(/^file:\/\/\//);
expect(fetchMock).toHaveBeenCalledWith(remoteEntryUrl);
});

it('evaluates dynamically imported ESM chunks before their namespace is consumed', async () => {
const remoteEntryUrl = 'http://example.com/server/remoteEntry.js';
const chunkUrl = 'http://example.com/server/chunk.mjs';
const fetchMock = jest.fn(async (url: string) => {
if (url === remoteEntryUrl) {
return createResponse(
"export const chunkValuePromise = import('./chunk.mjs').then((chunk) => chunk.value); export default {};",
);
}

if (url === chunkUrl) {
return createResponse("export const value = 'loaded chunk';");
}

throw new Error(`${url} should not be fetched`);
});
globalThis.fetch = fetchMock as unknown as typeof fetch;

const scriptContext = (await loadNodeEsmScript(remoteEntryUrl)) as {
chunkValuePromise: Promise<string>;
};

await expect(scriptContext.chunkValuePromise).resolves.toBe('loaded chunk');
expect(fetchMock).toHaveBeenCalledWith(remoteEntryUrl);
expect(fetchMock).toHaveBeenCalledWith(chunkUrl);
});
});
4 changes: 2 additions & 2 deletions packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@
"scripts": {
"build": "tsdown --config tsdown.config.ts",
"lint": "ESLINT_USE_FLAT_CONFIG=false pnpm exec eslint --ignore-pattern node_modules \"**/*.ts\" \"package.json\"",
"test": "pnpm exec jest --config jest.config.cjs --passWithNoTests",
"test:ci": "pnpm exec jest --config jest.config.cjs --passWithNoTests --ci --coverage",
"test": "NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config jest.config.cjs --passWithNoTests",
"test:ci": "NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config jest.config.cjs --passWithNoTests --ci --coverage",
"pre-release": "pnpm run test && pnpm run build"
}
}
166 changes: 161 additions & 5 deletions packages/sdk/src/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,149 @@ export const loadScriptNode =

const esmModuleCache = new Map<string, any>();

const isFetchableRemoteModuleUrl = (url: string): boolean =>
url.startsWith('http:') || url.startsWith('https:');

const isBareSpecifier = (specifier: string): boolean =>
!specifier.startsWith('./') &&
!specifier.startsWith('../') &&
!specifier.startsWith('/') &&
!specifier.includes(':');

function encodeRemoteModulePath(url: string): string {
const remoteUrl = new URL(url);
const encodedProtocol = encodeURIComponent(remoteUrl.protocol.slice(0, -1));
const encodedHost = encodeURIComponent(remoteUrl.host);
const encodedPathname = remoteUrl.pathname
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');

return `/${encodedProtocol}/${encodedHost}${encodedPathname}`;
}

function createImportMetaUrl(url: string): string {
return `file:///__module_federation_remote__${encodeRemoteModulePath(url)}`;
Comment on lines +280 to +281

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Base synthetic import.meta.url where require can resolve

When an HTTP remote calls createRequire(import.meta.url) and then requires any non-builtin dependency, this fake file:///__module_federation_remote__/... location makes Node search from /__module_federation_remote__ and then /node_modules, not from the consuming app's project. That means bundled ESM remotes with externalized packages such as require('react') still fail even though the CJS path above intentionally bases createRequire on process.cwd() for non-file remotes.

Useful? React with 👍 / 👎.

}

async function isNodeBuiltinSpecifier(specifier: string): Promise<boolean> {
if (specifier.startsWith('node:')) {
return true;
}

if (!isBareSpecifier(specifier)) {
return false;
}

const nodeModule =
await importNodeModule<typeof import('node:module')>('node:module');

return nodeModule.builtinModules.includes(specifier);
}

function getSyntheticModuleExports(moduleExports: any): Record<string, any> {
const namespaceObject =
moduleExports &&
(typeof moduleExports === 'object' || typeof moduleExports === 'function')
? moduleExports
: { default: moduleExports };
const effectiveExports = { ...namespaceObject };

if (!Object.prototype.hasOwnProperty.call(effectiveExports, 'default')) {
effectiveExports.default = namespaceObject;
}

return effectiveExports;
}

async function createSyntheticModuleFromExports(
identifier: string,
moduleExports: any,
vm: any,
) {
if (typeof vm.SyntheticModule !== 'function') {
throw new Error(
'vm.SyntheticModule is required to load Node.js built-in modules in ESM remote entries.',
);
}

const effectiveExports = getSyntheticModuleExports(moduleExports);
const exportNames = Object.keys(effectiveExports).filter(
(name) => name !== 'constructor',
);
const syntheticModule = new vm.SyntheticModule(
exportNames,
function setSyntheticModuleExports(this: {
setExport: (name: string, value: any) => void;
}) {
for (const name of exportNames) {
this.setExport(name, effectiveExports[name]);
}
},
{ identifier },
);

esmModuleCache.set(identifier, syntheticModule);
await syntheticModule.link(async () => {
throw new Error(
`Node.js built-in module "${identifier}" should not request child modules.`,
);
});
await syntheticModule.evaluate();

return syntheticModule;
}

async function loadNodeBuiltinModule(
specifier: string,
options: {
vm: any;
fetch: any;
},
) {
const cacheKey = `node-builtin:${specifier}`;
if (esmModuleCache.has(cacheKey)) {
return esmModuleCache.get(cacheKey)!;
}

const moduleExports = await importNodeModule(specifier);
return createSyntheticModuleFromExports(cacheKey, moduleExports, options.vm);
}

async function loadResolvedModule(
specifier: string,
parentUrl: string,
options: {
vm: any;
fetch: any;
},
) {
if (await isNodeBuiltinSpecifier(specifier)) {
return loadNodeBuiltinModule(specifier, options);
}

const resolvedUrl = new URL(specifier, parentUrl).href;
if (!isFetchableRemoteModuleUrl(resolvedUrl)) {
throw new Error(
`Unsupported ESM module specifier "${specifier}" resolved to "${resolvedUrl}". Only http(s) remote modules and Node.js built-in modules are supported.`,
);
}

return loadModule(resolvedUrl, options);
}

async function evaluateDynamicModule(module: any) {
if (module.status === 'linked') {
await module.evaluate();
}

if (module.status === 'errored') {
throw module.error;
}

return module;
}

async function loadModule(
url: string,
options: {
Expand All @@ -269,24 +412,37 @@ async function loadModule(
}

const { fetch, vm } = options;
if (await isNodeBuiltinSpecifier(url)) {
return loadNodeBuiltinModule(url, options);
}

if (!isFetchableRemoteModuleUrl(url)) {
throw new Error(
`Unsupported ESM module URL "${url}". Only http(s) remote modules and Node.js built-in modules are supported.`,
);
}

const response = await fetch(url);
const code = await response.text();

const module: any = new vm.SourceTextModule(code, {
identifier: url,
initializeImportMeta: (meta: { url: string }) => {
meta.url = createImportMetaUrl(url);
},
// @ts-ignore
importModuleDynamically: async (specifier, script) => {
const resolvedUrl = new URL(specifier, url).href;
return loadModule(resolvedUrl, options);
return evaluateDynamicModule(
await loadResolvedModule(specifier, url, options),
);
},
});

// Cache the module before linking to prevent cycles
esmModuleCache.set(url, module);

await module.link(async (specifier: string) => {
const resolvedUrl = new URL(specifier, url).href;
const module = await loadModule(resolvedUrl, options);
return module;
return loadResolvedModule(specifier, url, options);
});

return module;
Expand Down