-
-
Notifications
You must be signed in to change notification settings - Fork 419
fix(sdk): handle node builtins in ESM remote loader #4816
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3aae1d7
5fc8b29
e9f0106
2ff9f11
a8e5ff2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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 () => { | ||
| 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); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an HTTP remote calls 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: { | ||
|
|
@@ -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; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This new spec is not exercised by
pnpm --filter @module-federation/sdk test: I checkedpackages/sdk/jest.config.cjs, and itstestMatchis<rootDir>__tests__/**/**.spec.[jt]s?(x), which expands topackages/sdk__tests__rather than thispackages/sdk/__tests__directory; because the package script also uses--passWithNoTests, CI can pass without running these regression cases.Useful? React with 👍 / 👎.