diff --git a/.changeset/node-esm-builtin-loader.md b/.changeset/node-esm-builtin-loader.md new file mode 100644 index 00000000000..808ae572569 --- /dev/null +++ b/.changeset/node-esm-builtin-loader.md @@ -0,0 +1,5 @@ +--- +"@module-federation/sdk": patch +--- + +Handle Node.js built-in imports in the Node ESM remote loader. diff --git a/packages/sdk/__tests__/node-builtin-esm.spec.ts b/packages/sdk/__tests__/node-builtin-esm.spec.ts new file mode 100644 index 00000000000..f447213aa07 --- /dev/null +++ b/packages/sdk/__tests__/node-builtin-esm.spec.ts @@ -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 () => { + 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; + }; + + await expect(scriptContext.chunkValuePromise).resolves.toBe('loaded chunk'); + expect(fetchMock).toHaveBeenCalledWith(remoteEntryUrl); + expect(fetchMock).toHaveBeenCalledWith(chunkUrl); + }); +}); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index fbe44b0bb47..5f2cde5a55b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -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" } } diff --git a/packages/sdk/src/node.ts b/packages/sdk/src/node.ts index c51fc91ebab..ee186fd000f 100644 --- a/packages/sdk/src/node.ts +++ b/packages/sdk/src/node.ts @@ -256,6 +256,149 @@ export const loadScriptNode = const esmModuleCache = new Map(); +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)}`; +} + +async function isNodeBuiltinSpecifier(specifier: string): Promise { + if (specifier.startsWith('node:')) { + return true; + } + + if (!isBareSpecifier(specifier)) { + return false; + } + + const nodeModule = + await importNodeModule('node:module'); + + return nodeModule.builtinModules.includes(specifier); +} + +function getSyntheticModuleExports(moduleExports: any): Record { + 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: { @@ -269,14 +412,29 @@ 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), + ); }, }); @@ -284,9 +442,7 @@ async function loadModule( 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;