Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 () => {
Comment thread
ScriptedAlchemy marked this conversation as resolved.
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);
});
});
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 thread
ScriptedAlchemy marked this conversation as resolved.
Outdated
}

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
Loading