diff --git a/packages/node/src/__tests__/runtimePlugin.test.ts b/packages/node/src/__tests__/runtimePlugin.test.ts index 757f9738e1a..af9a9362872 100644 --- a/packages/node/src/__tests__/runtimePlugin.test.ts +++ b/packages/node/src/__tests__/runtimePlugin.test.ts @@ -46,7 +46,7 @@ global.fetch = jest.fn().mockResolvedValue({ text: jest.fn().mockResolvedValue('// mock chunk content'), }); -const mockWebpackRequire = { +const mockWebpackRequire = Object.assign(jest.fn(), { u: jest.fn((chunkId: string) => `/chunks/${chunkId}.js`), p: 'http://localhost:3000/', m: {}, @@ -72,7 +72,7 @@ const mockWebpackRequire = { require: jest.fn(), readFileVm: jest.fn(), }, -}; +}); const mockNonWebpackRequire = jest.fn().mockImplementation((id: string) => { if (id === 'path') return require('path'); @@ -682,7 +682,7 @@ describe('runtimePlugin', () => { beforeEach(() => { jest.clearAllMocks(); // Set up webpack_require properties needed for the test - (global as any).__webpack_require__ = { + (global as any).__webpack_require__ = Object.assign(jest.fn(), { ...mockWebpackRequire, federation: { chunkMatcher: jest.fn().mockReturnValue(true), @@ -693,7 +693,7 @@ describe('runtimePlugin', () => { require: undefined, readFileVm: undefined, }, - }; + }); }); it('should return a handler for chunk loading and reuse existing promises', () => { @@ -755,13 +755,13 @@ describe('runtimePlugin', () => { beforeEach(() => { jest.clearAllMocks(); // Reset webpack require to ensure f exists with require already defined - (global as any).__webpack_require__ = { + (global as any).__webpack_require__ = Object.assign(jest.fn(), { ...mockWebpackRequire, f: { require: jest.fn(), // This needs to exist for the function to patch it readFileVm: jest.fn(), // This needs to exist for the function to patch it }, - }; + }); // Mock console.warn for testing console.warn = jest.fn(); }); @@ -804,7 +804,7 @@ describe('runtimePlugin', () => { beforeEach(() => { jest.clearAllMocks(); // Reset webpack require to ensure clean state - (global as any).__webpack_require__ = { + (global as any).__webpack_require__ = Object.assign(jest.fn(), { ...mockWebpackRequire, federation: { ...mockWebpackRequire.federation, @@ -819,7 +819,7 @@ describe('runtimePlugin', () => { require: undefined, readFileVm: undefined, }, - }; + }); }); it('should return the provided args', () => { @@ -880,7 +880,7 @@ describe('runtimePlugin', () => { describe('webpack chunk loading', () => { beforeEach(() => { // Reset the webpack require object to ensure it's properly initialized - (global as any).__webpack_require__ = { + (global as any).__webpack_require__ = Object.assign(jest.fn(), { ...mockWebpackRequire, federation: { ...mockWebpackRequire.federation, @@ -895,7 +895,7 @@ describe('runtimePlugin', () => { require: jest.fn(), readFileVm: jest.fn(), }, - }; + }); const mockArgs = { origin: { @@ -1018,7 +1018,7 @@ describe('runtimePlugin', () => { describe('Webpack require functionality', () => { beforeEach(() => { // Ensure the webpack require object is properly initialized - (global as any).__webpack_require__ = { + (global as any).__webpack_require__ = Object.assign(jest.fn(), { ...mockWebpackRequire, federation: { ...mockWebpackRequire.federation, @@ -1033,7 +1033,7 @@ describe('runtimePlugin', () => { require: jest.fn(), readFileVm: jest.fn(), }, - }; + }); const mockArgs = { origin: { @@ -1122,7 +1122,7 @@ describe('runtimePlugin', () => { describe('Remote entry loading', () => { beforeEach(() => { // Ensure the webpack require object is properly initialized - (global as any).__webpack_require__ = { + (global as any).__webpack_require__ = Object.assign(jest.fn(), { ...mockWebpackRequire, federation: { ...mockWebpackRequire.federation, @@ -1137,7 +1137,7 @@ describe('runtimePlugin', () => { require: jest.fn(), readFileVm: jest.fn(), }, - }; + }); const mockArgs = { origin: { diff --git a/packages/node/src/__tests__/stratagies.test.ts b/packages/node/src/__tests__/stratagies.test.ts new file mode 100644 index 00000000000..96f90abfb56 --- /dev/null +++ b/packages/node/src/__tests__/stratagies.test.ts @@ -0,0 +1,17 @@ +import { + fileSystemRunInContextStrategy, + httpEvalStrategy, + httpVmStrategy, +} from '../filesystem/stratagies'; + +describe('filesystem chunk loading strategies', () => { + test.each([fileSystemRunInContextStrategy, httpEvalStrategy, httpVmStrategy])( + '%p resolves webpack require via sdk bundler in emitted source', + (strategyFn) => { + const source = strategyFn.toString(); + + expect(source).toContain("'@module-federation/sdk/bundler'"); + expect(source).toContain('getWebpackRequireOrThrow'); + }, + ); +}); diff --git a/packages/node/src/filesystem/stratagies.ts b/packages/node/src/filesystem/stratagies.ts index be3ba07004b..6fb856772de 100644 --- a/packages/node/src/filesystem/stratagies.ts +++ b/packages/node/src/filesystem/stratagies.ts @@ -5,12 +5,16 @@ export async function fileSystemRunInContextStrategy( remotes: Remotes, callback: CallbackFunction, ) { + const { + getWebpackRequireOrThrow, + } = require('@module-federation/sdk/bundler'); + const webpackRequire = getWebpackRequireOrThrow() as any; const fs = require('fs'); const path = require('path'); const vm = require('vm'); const filename = path.join( __dirname, - rootOutputDir + __webpack_require__.u(chunkId), + rootOutputDir + webpackRequire.u(chunkId), ); if (fs.existsSync(filename)) { fs.readFile(filename, 'utf-8', (err: Error, content: string) => { @@ -45,9 +49,13 @@ export async function httpEvalStrategy( remotes: Remotes, callback: CallbackFunction, ) { + const { + getWebpackRequireOrThrow, + } = require('@module-federation/sdk/bundler'); + const webpackRequire = getWebpackRequireOrThrow() as any; let url; try { - url = new URL(chunkName, __webpack_require__.p); + url = new URL(chunkName, webpackRequire.p); } catch (e) { console.error( 'module-federation: failed to construct absolute chunk path of', @@ -102,6 +110,10 @@ export async function httpVmStrategy( remotes: Remotes, callback: CallbackFunction, ): Promise { + const { + getWebpackRequireOrThrow, + } = require('@module-federation/sdk/bundler'); + const webpackRequire = getWebpackRequireOrThrow() as any; const http = require('http') as typeof import('http'); const https = require('https') as typeof import('https'); const vm = require('vm') as typeof import('vm'); @@ -110,7 +122,7 @@ export async function httpVmStrategy( const globalThisVal = new Function('return globalThis')(); try { - url = new URL(chunkName, __webpack_require__.p); + url = new URL(chunkName, webpackRequire.p); } catch (e) { console.error( 'module-federation: failed to construct absolute chunk path of', diff --git a/packages/node/src/runtimePlugin.ts b/packages/node/src/runtimePlugin.ts index e11c19306a7..e170d5998e0 100644 --- a/packages/node/src/runtimePlugin.ts +++ b/packages/node/src/runtimePlugin.ts @@ -2,6 +2,7 @@ import type { ModuleFederationRuntimePlugin, ModuleFederation, } from '@module-federation/runtime'; +import { getWebpackRequireOrThrow } from '@module-federation/sdk/bundler'; type WebpackRequire = { (id: string): any; u: (chunkId: string) => string; @@ -35,9 +36,9 @@ type WebpackRequire = { readFileVm?: (chunkId: string, promises: any[]) => void; }; }; - -declare const __webpack_require__: WebpackRequire; declare const __non_webpack_require__: (id: string) => any; +const getWebpackRequire = (): WebpackRequire => + getWebpackRequireOrThrow() as unknown as WebpackRequire; export const nodeRuntimeImportCache = new Map>(); @@ -69,7 +70,8 @@ export function importNodeModule(name: string): Promise { // Hoisted utility function to resolve file paths for chunks export const resolveFile = (rootOutputDir: string, chunkId: string): string => { const path = __non_webpack_require__('path'); - return path.join(__dirname, rootOutputDir + __webpack_require__.u(chunkId)); + const webpackRequire = getWebpackRequire(); + return path.join(__dirname, rootOutputDir + webpackRequire.u(chunkId)); }; // Hoisted utility function to get remote entry from cache @@ -185,8 +187,9 @@ export const resolveUrl = ( remoteName: string, chunkName: string, ): URL | null => { + const webpackRequire = getWebpackRequire(); try { - return new URL(chunkName, __webpack_require__.p); + return new URL(chunkName, webpackRequire.p); } catch { const entryUrl = returnFromCache(remoteName) || returnFromGlobalInstances(remoteName); @@ -204,7 +207,7 @@ export const resolveUrl = ( lastSlashIndex >= 0 ? urlPath.substring(0, lastSlashIndex + 1) : '/'; // Get rootDir from webpack configuration - const rootDir = __webpack_require__.federation.rootOutputDir || ''; + const rootDir = webpackRequire.federation.rootOutputDir || ''; // Use path.join to combine the paths properly while handling slashes // Convert Windows-style paths to URL-style paths @@ -240,10 +243,11 @@ export const installChunk = ( chunk: any, installedChunks: { [key: string]: any }, ): void => { + const webpackRequire = getWebpackRequire(); for (const moduleId in chunk.modules) { - __webpack_require__.m[moduleId] = chunk.modules[moduleId]; + webpackRequire.m[moduleId] = chunk.modules[moduleId]; } - if (chunk.runtime) chunk.runtime(__webpack_require__); + if (chunk.runtime) chunk.runtime(webpackRequire); for (const chunkId of chunk.ids) { if (installedChunks[chunkId]) installedChunks[chunkId][0](); installedChunks[chunkId] = 0; @@ -261,7 +265,8 @@ export const deleteChunk = ( // Hoisted function to set up webpack script loader export const setupScriptLoader = (): void => { - __webpack_require__.l = ( + const webpackRequire = getWebpackRequire(); + webpackRequire.l = ( url: string, done: (res: any) => void, key: string, @@ -269,15 +274,11 @@ export const setupScriptLoader = (): void => { ): void => { if (!key || chunkId) throw new Error(`__webpack_require__.l name is required for ${url}`); - __webpack_require__.federation.runtime + webpackRequire.federation.runtime .loadScriptNode(url, { attrs: { globalName: key } }) .then((res) => { const enhancedRemote = - __webpack_require__.federation.instance.initRawContainer( - key, - url, - res, - ); + webpackRequire.federation.instance.initRawContainer(key, url, res); new Function('return globalThis')()[key] = enhancedRemote; done(enhancedRemote); }) @@ -291,13 +292,14 @@ export const setupChunkHandler = ( args: any, ): ((chunkId: string, promises: any[]) => void) => { return (chunkId: string, promises: any[]): void => { + const webpackRequire = getWebpackRequire(); let installedChunkData = installedChunks[chunkId]; if (installedChunkData !== 0) { if (installedChunkData) { promises.push(installedChunkData[2]); } else { - const matcher = __webpack_require__.federation.chunkMatcher - ? __webpack_require__.federation.chunkMatcher(chunkId) + const matcher = webpackRequire.federation.chunkMatcher + ? webpackRequire.federation.chunkMatcher(chunkId) : true; if (matcher) { @@ -310,7 +312,7 @@ export const setupChunkHandler = ( const filename = typeof process !== 'undefined' ? resolveFile( - __webpack_require__.federation.rootOutputDir || '', + webpackRequire.federation.rootOutputDir || '', chunkId, ) : false; @@ -319,7 +321,7 @@ export const setupChunkHandler = ( loadChunk( 'filesystem', chunkId, - __webpack_require__.federation.rootOutputDir || '', + webpackRequire.federation.rootOutputDir || '', (err, chunk) => { if (err) return deleteChunk(chunkId, installedChunks) && reject(err); @@ -329,13 +331,13 @@ export const setupChunkHandler = ( args, ); } else { - const chunkName = __webpack_require__.u(chunkId); + const chunkName = webpackRequire.u(chunkId); const loadingStrategy = typeof process === 'undefined' ? 'http-eval' : 'http-vm'; loadChunk( loadingStrategy, chunkName, - __webpack_require__.federation.initOptions.name, + webpackRequire.federation.initOptions.name, (err, chunk) => { if (err) return deleteChunk(chunkId, installedChunks) && reject(err); @@ -359,17 +361,18 @@ export const setupChunkHandler = ( export const setupWebpackRequirePatching = ( handle: (chunkId: string, promises: any[]) => void, ): void => { - if (__webpack_require__.f) { - if (__webpack_require__.f.require) { + const webpackRequire = getWebpackRequire(); + if (webpackRequire.f) { + if (webpackRequire.f.require) { console.warn( '\x1b[33m%s\x1b[0m', 'CAUTION: build target is not set to "async-node", attempting to patch additional chunk handlers. This may not work', ); - __webpack_require__.f.require = handle; + webpackRequire.f.require = handle; } - if (__webpack_require__.f.readFileVm) { - __webpack_require__.f.readFileVm = handle; + if (webpackRequire.f.readFileVm) { + webpackRequire.f.readFileVm = handle; } } }; diff --git a/packages/node/src/utils/flush-chunks.ts b/packages/node/src/utils/flush-chunks.ts index c767a5a6d59..37ee77e76ad 100644 --- a/packages/node/src/utils/flush-chunks.ts +++ b/packages/node/src/utils/flush-chunks.ts @@ -1,5 +1,7 @@ /* eslint-disable no-undef */ +import { getWebpackShareScopes } from '@module-federation/sdk/bundler'; + // @ts-ignore if (!globalThis.usedChunks) { // @ts-ignore @@ -53,14 +55,12 @@ export const getAllKnownRemotes = function () { * @returns {object} shareMap - An object containing the shareMap data. */ const createShareMap = () => { - // Check if __webpack_share_scopes__ is defined and has a default property - // @ts-ignore - if (__webpack_share_scopes__?.default) { + const webpackShareScopes = getWebpackShareScopes() as any; + // Check if webpack share scopes are defined and have a default property + if (webpackShareScopes?.default) { // Reduce the keys of the default property to create the share map - // @ts-ignore - return Object.keys(__webpack_share_scopes__.default).reduce((acc, key) => { - // @ts-ignore - const shareMap = __webpack_share_scopes__.default[key]; + return Object.keys(webpackShareScopes.default).reduce((acc, key) => { + const shareMap = webpackShareScopes.default[key]; // shareScope may equal undefined or null if it has unexpected value if (!shareMap || typeof shareMap !== 'object') { return acc; @@ -83,7 +83,7 @@ const createShareMap = () => { return acc; }, {}); } - // If __webpack_share_scopes__ is not defined or doesn't have a default property, return an empty object + // If share scopes are not defined or don't have a default property, return an empty object return {}; }; diff --git a/packages/node/src/utils/hot-reload.ts b/packages/node/src/utils/hot-reload.ts index caed2d3bab8..def77f47aef 100644 --- a/packages/node/src/utils/hot-reload.ts +++ b/packages/node/src/utils/hot-reload.ts @@ -2,6 +2,17 @@ import { getAllKnownRemotes } from './flush-chunks'; import crypto from 'crypto'; import helpers from '@module-federation/runtime/helpers'; import path from 'path'; +import { getWebpackRequire } from '@module-federation/sdk/bundler'; + +type HotReloadWebpackRequire = { + federation?: { + instance?: { + moduleCache?: { + clear: () => void; + }; + }; + }; +}; declare global { var mfHashMap: Record | undefined; @@ -156,8 +167,7 @@ export const performReload = async ( delete gs[i.name]; } }); - //@ts-ignore - __webpack_require__?.federation?.instance?.moduleCache?.clear(); + getWebpackRequire()?.federation?.instance?.moduleCache?.clear(); helpers.global.resetFederationGlobalInfo(); globalThis.moduleGraphDirty = false; globalThis.mfHashMap = {}; diff --git a/packages/runtime-core/src/utils/load.ts b/packages/runtime-core/src/utils/load.ts index d3573139aa7..c201f7a8bfe 100644 --- a/packages/runtime-core/src/utils/load.ts +++ b/packages/runtime-core/src/utils/load.ts @@ -4,6 +4,7 @@ import { composeKeyWithSeparator, isBrowserEnv, } from '@module-federation/sdk'; +import { importWithBundlerIgnore } from '@module-federation/sdk/bundler'; import { DEFAULT_REMOTE_TYPE, DEFAULT_SCOPE } from '../constant'; import { ModuleFederation } from '../core'; import { globalLoading, getRemoteEntryExports } from '../global'; @@ -35,7 +36,7 @@ async function loadEsmEntry({ reject, ]); } else { - import(/* webpackIgnore: true */ /* @vite-ignore */ entry) + importWithBundlerIgnore(entry) .then(resolve) .catch(reject); } diff --git a/packages/sdk/__tests__/bundler.spec.ts b/packages/sdk/__tests__/bundler.spec.ts new file mode 100644 index 00000000000..f99f82f5437 --- /dev/null +++ b/packages/sdk/__tests__/bundler.spec.ts @@ -0,0 +1,101 @@ +import { + getWebpackRequire, + getWebpackRequireOrThrow, + getWebpackShareScopes, + getWebpackShareScopesOrThrow, + initWebpackSharing, +} from '../src/bundler'; + +type MutableWebpackGlobals = typeof globalThis & { + __webpack_require__?: unknown; + __webpack_share_scopes__?: unknown; + __webpack_init_sharing__?: unknown; +}; + +const webpackGlobals = globalThis as MutableWebpackGlobals; + +describe('bundler helpers', () => { + afterEach(() => { + delete webpackGlobals.__webpack_require__; + delete webpackGlobals.__webpack_share_scopes__; + delete webpackGlobals.__webpack_init_sharing__; + jest.restoreAllMocks(); + }); + + it('returns undefined when webpack require is unavailable', () => { + expect(getWebpackRequire()).toBeUndefined(); + }); + + it('returns typed webpack require when available', () => { + const webpackRequire = Object.assign(jest.fn(), { marker: 'typed' }); + webpackGlobals.__webpack_require__ = webpackRequire; + + const resolvedRequire = getWebpackRequire(); + + expect(resolvedRequire).toBe(webpackRequire); + expect(resolvedRequire?.marker).toBe('typed'); + }); + + it('returns webpack require in OrThrow helper when available', () => { + const webpackRequire = Object.assign(jest.fn(), { marker: 'or-throw' }); + webpackGlobals.__webpack_require__ = webpackRequire; + + const resolvedRequire = getWebpackRequireOrThrow(); + + expect(resolvedRequire).toBe(webpackRequire); + expect(resolvedRequire.marker).toBe('or-throw'); + }); + + it('throws when webpack require is unavailable in OrThrow helper', () => { + expect(() => getWebpackRequireOrThrow()).toThrow( + 'Unable to access __webpack_require__. Ensure this code runs inside a webpack-compatible runtime.', + ); + }); + + it('returns share scopes when available', () => { + const shareScopes = { default: { react: { loaded: true } } }; + webpackGlobals.__webpack_share_scopes__ = shareScopes; + + const resolvedScopes = getWebpackShareScopes(); + + expect(resolvedScopes).toBe(shareScopes); + expect(resolvedScopes?.default.react.loaded).toBe(true); + }); + + it('returns share scopes in OrThrow helper when available', () => { + const shareScopes = { default: { react: { loaded: true } } }; + webpackGlobals.__webpack_share_scopes__ = shareScopes; + + const resolvedScopes = getWebpackShareScopesOrThrow(); + + expect(resolvedScopes).toBe(shareScopes); + expect(resolvedScopes.default.react.loaded).toBe(true); + }); + + it('returns undefined for missing or invalid share scopes', () => { + expect(getWebpackShareScopes()).toBeUndefined(); + + webpackGlobals.__webpack_share_scopes__ = 'invalid'; + expect(getWebpackShareScopes()).toBeUndefined(); + }); + + it('throws when share scopes are unavailable in OrThrow helper', () => { + expect(() => getWebpackShareScopesOrThrow()).toThrow( + 'Unable to access __webpack_share_scopes__. Ensure this code runs inside a webpack-compatible runtime.', + ); + }); + + it('initializes webpack sharing when runtime helper is available', async () => { + const initSharing = jest.fn().mockResolvedValue(undefined); + webpackGlobals.__webpack_init_sharing__ = initSharing; + + await expect(initWebpackSharing('custom-scope')).resolves.toBeUndefined(); + expect(initSharing).toHaveBeenCalledWith('custom-scope'); + }); + + it('throws when webpack sharing initializer is unavailable', () => { + expect(() => initWebpackSharing()).toThrow( + 'Unable to access __webpack_init_sharing__. Ensure this code runs inside a webpack-compatible runtime.', + ); + }); +}); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index fe84b2d3a0e..0881526851a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -49,6 +49,16 @@ "types": "./dist/normalize-webpack-path.d.ts", "default": "./dist/normalize-webpack-path.cjs" } + }, + "./bundler": { + "import": { + "types": "./dist/bundler.d.ts", + "default": "./dist/bundler.esm.js" + }, + "require": { + "types": "./dist/bundler.d.ts", + "default": "./dist/bundler.cjs.cjs" + } } }, "typesVersions": { @@ -58,6 +68,9 @@ ], "normalize-webpack-path": [ "./dist/normalize-webpack-path.d.ts" + ], + "bundler": [ + "./dist/bundler.d.ts" ] } }, diff --git a/packages/sdk/src/bundler.ts b/packages/sdk/src/bundler.ts new file mode 100644 index 00000000000..3fb3c09607f --- /dev/null +++ b/packages/sdk/src/bundler.ts @@ -0,0 +1,66 @@ +declare const __webpack_require__: unknown; +declare const __webpack_share_scopes__: unknown; +declare const __webpack_init_sharing__: unknown; + +export function getWebpackRequire(): T | undefined { + if (typeof __webpack_require__ !== 'function') { + return undefined; + } + + return __webpack_require__ as T; +} + +export function getWebpackRequireOrThrow(): T { + const webpackRequire = getWebpackRequire(); + + if (!webpackRequire) { + throw new Error( + 'Unable to access __webpack_require__. Ensure this code runs inside a webpack-compatible runtime.', + ); + } + + return webpackRequire; +} + +export function getWebpackShareScopes(): T | undefined { + if ( + typeof __webpack_share_scopes__ !== 'object' || + !__webpack_share_scopes__ + ) { + return undefined; + } + + return __webpack_share_scopes__ as T; +} + +export function getWebpackShareScopesOrThrow(): T { + const webpackShareScopes = getWebpackShareScopes(); + + if (!webpackShareScopes) { + throw new Error( + 'Unable to access __webpack_share_scopes__. Ensure this code runs inside a webpack-compatible runtime.', + ); + } + + return webpackShareScopes; +} + +export function initWebpackSharing(shareScope = 'default'): Promise { + if (typeof __webpack_init_sharing__ !== 'function') { + throw new Error( + 'Unable to access __webpack_init_sharing__. Ensure this code runs inside a webpack-compatible runtime.', + ); + } + + return Promise.resolve(__webpack_init_sharing__(shareScope)) as Promise; +} + +export function importWithBundlerIgnore( + modulePath: string, +): Promise { + return import( + /* webpackIgnore: true */ + /* @vite-ignore */ + modulePath + ) as Promise; +} diff --git a/packages/utilities/src/utils/common.ts b/packages/utilities/src/utils/common.ts index f954f285908..d59491454bb 100644 --- a/packages/utilities/src/utils/common.ts +++ b/packages/utilities/src/utils/common.ts @@ -8,39 +8,49 @@ import type { RuntimeRemote, WebpackRemoteContainer, } from '../types'; +import { + getWebpackShareScopes, + initWebpackSharing, +} from '@module-federation/sdk/bundler'; import { loadScript } from './pure'; const createContainerSharingScope = ( asyncContainer: AsyncContainer | undefined, ) => { + const getDefaultShareScope = () => { + let webpackShareScopes = getWebpackShareScopes>(); + if (!webpackShareScopes?.['default']) { + return Promise.resolve(initWebpackSharing('default')).then(() => { + webpackShareScopes = getWebpackShareScopes>(); + return webpackShareScopes?.['default']; + }); + } + return Promise.resolve(webpackShareScopes?.['default']); + }; + // @ts-ignore return asyncContainer .then(function (container) { - if (!__webpack_share_scopes__['default']) { - // not always a promise, so we wrap it in a resolve - return Promise.resolve(__webpack_init_sharing__('default')).then( - function () { - return container; - }, - ); - } else { + return getDefaultShareScope().then(function () { return container; - } + }); }) .then(function (container) { - try { - // WARNING: here might be a potential BUG. - // `container.init` does not return a Promise, and here we do not call `then` on it. - // But according to [docs](https://webpack.js.org/concepts/module-federation/#dynamic-remote-containers) - // it must be async. - // The problem may be in Proxy in NextFederationPlugin.js. - // or maybe a bug in the webpack itself - instead of returning rejected promise it just throws an error. - // But now everything works properly and we keep this code as is. - container.init(__webpack_share_scopes__['default'] as any); - } catch (e) { - // maybe container already initialized so nothing to throw - } - return container; + return getDefaultShareScope().then(function (defaultShareScope) { + try { + // WARNING: here might be a potential BUG. + // `container.init` does not return a Promise, and here we do not call `then` on it. + // But according to [docs](https://webpack.js.org/concepts/module-federation/#dynamic-remote-containers) + // it must be async. + // The problem may be in Proxy in NextFederationPlugin.js. + // or maybe a bug in the webpack itself - instead of returning rejected promise it just throws an error. + // But now everything works properly and we keep this code as is. + container.init(defaultShareScope as any); + } catch (e) { + // maybe container already initialized so nothing to throw + } + return container; + }); }); }; diff --git a/packages/utilities/src/utils/importRemote.test.ts b/packages/utilities/src/utils/importRemote.test.ts new file mode 100644 index 00000000000..44b759bd6d3 --- /dev/null +++ b/packages/utilities/src/utils/importRemote.test.ts @@ -0,0 +1,134 @@ +import { + getWebpackRequireOrThrow, + getWebpackShareScopes, + initWebpackSharing, + importWithBundlerIgnore, +} from '@module-federation/sdk/bundler'; +import { importRemote } from './importRemote'; + +jest.mock('@module-federation/sdk/bundler', () => ({ + getWebpackRequireOrThrow: jest.fn(), + getWebpackShareScopes: jest.fn(), + initWebpackSharing: jest.fn(), + importWithBundlerIgnore: jest.fn(), +})); + +describe('importRemote (esm)', () => { + const scope = 'esmScope'; + const remoteUrl = 'https://example.com/remote'; + const remoteEntryUrl = `${remoteUrl}/remoteEntry.js`; + + beforeEach(() => { + jest.clearAllMocks(); + (globalThis as any).window = {}; + (globalThis as any).__webpack_share_scopes__ = { default: {} }; + (globalThis as any).__webpack_init_sharing__ = jest + .fn() + .mockResolvedValue(undefined); + (getWebpackShareScopes as jest.Mock).mockImplementation( + () => (globalThis as any).__webpack_share_scopes__, + ); + (initWebpackSharing as jest.Mock).mockImplementation(async () => { + (globalThis as any).__webpack_share_scopes__ ||= {}; + (globalThis as any).__webpack_share_scopes__.default ||= {}; + }); + }); + + afterEach(() => { + delete (globalThis as any).window; + delete (globalThis as any).__webpack_share_scopes__; + delete (globalThis as any).__webpack_init_sharing__; + }); + + it('wraps immutable namespace before attaching runtime flags', async () => { + const get = jest.fn().mockResolvedValue(() => 'esm-module'); + const init = jest.fn().mockResolvedValue(undefined); + const namespace = Object.freeze({ + get, + init, + }); + (importWithBundlerIgnore as jest.Mock).mockResolvedValue(namespace); + + const loaded = await importRemote({ + url: remoteUrl, + scope, + module: './module', + esm: true, + bustRemoteEntryCache: false, + }); + + expect(loaded).toBe('esm-module'); + expect(importWithBundlerIgnore).toHaveBeenCalledWith(remoteEntryUrl); + + const attachedContainer = (globalThis as any).window[scope]; + expect(attachedContainer).toBeDefined(); + expect(attachedContainer).not.toBe(namespace); + expect(attachedContainer.__initialized).toBe(true); + expect((namespace as any).__initialized).toBeUndefined(); + expect(get).toHaveBeenCalledWith('./module'); + expect(init).toHaveBeenCalledWith( + (globalThis as any).__webpack_share_scopes__.default, + ); + expect(getWebpackRequireOrThrow).not.toHaveBeenCalled(); + }); + + it.each([ + ['null namespace', null], + ['non-object namespace', 'invalid-namespace'], + ])('throws when esm loader returns %s', async (_label, namespace) => { + (importWithBundlerIgnore as jest.Mock).mockResolvedValue(namespace); + + await expect( + importRemote({ + url: remoteUrl, + scope, + module: './module', + esm: true, + bustRemoteEntryCache: false, + }), + ).rejects.toThrow( + `Unable to load requested remote from ${remoteEntryUrl} with scope ${scope}`, + ); + expect((globalThis as any).window[scope]).toBeUndefined(); + }); + + it('throws when esm container does not expose get', async () => { + const init = jest.fn().mockResolvedValue(undefined); + (importWithBundlerIgnore as jest.Mock).mockResolvedValue( + Object.freeze({ init }), + ); + + await expect( + importRemote({ + url: remoteUrl, + scope, + module: './module', + esm: true, + bustRemoteEntryCache: false, + }), + ).rejects.toThrow( + `Loaded remote from ${remoteEntryUrl} with scope ${scope} does not expose a valid container API`, + ); + expect((globalThis as any).window[scope]).toBeUndefined(); + }); + + it('throws when esm container does not expose init', async () => { + const get = jest.fn().mockResolvedValue(() => 'esm-module'); + (importWithBundlerIgnore as jest.Mock).mockResolvedValue( + Object.freeze({ get }), + ); + + await expect( + importRemote({ + url: remoteUrl, + scope, + module: './module', + esm: true, + bustRemoteEntryCache: false, + }), + ).rejects.toThrow( + `Loaded remote from ${remoteEntryUrl} with scope ${scope} does not expose a valid container API`, + ); + expect((globalThis as any).window[scope]).toBeUndefined(); + }); +}); diff --git a/packages/utilities/src/utils/importRemote.ts b/packages/utilities/src/utils/importRemote.ts index ed277f84ef6..c0e35aa5cbe 100644 --- a/packages/utilities/src/utils/importRemote.ts +++ b/packages/utilities/src/utils/importRemote.ts @@ -4,6 +4,12 @@ import type { WebpackShareScopes, RemoteData, } from '../types'; +import { + importWithBundlerIgnore, + getWebpackRequireOrThrow, + getWebpackShareScopes, + initWebpackSharing, +} from '@module-federation/sdk/bundler'; /** * Type definition for RemoteUrl @@ -50,7 +56,8 @@ const loadRemote = ( ) => new Promise((resolve, reject) => { const timestamp = bustRemoteEntryCache ? `?t=${new Date().getTime()}` : ''; - const webpackRequire = __webpack_require__ as unknown as WebpackRequire; + const webpackRequire = + getWebpackRequireOrThrow() as unknown as WebpackRequire; webpackRequire.l( `${url}${timestamp}`, (event) => { @@ -72,19 +79,42 @@ const loadEsmRemote = async ( url: RemoteData['url'], scope: ImportRemoteOptions['scope'], ) => { - const module = await import(/* webpackIgnore: true */ url); + const namespace = await importWithBundlerIgnore(url); - if (!module) { + if (!namespace || typeof namespace !== 'object') { throw new Error( `Unable to load requested remote from ${url} with scope ${scope}`, ); } - (window as any)[scope] = { - ...module, - __initializing: false, + const remoteModule = namespace as Partial; + if ( + typeof remoteModule.get !== 'function' || + typeof remoteModule.init !== 'function' + ) { + throw new Error( + `Loaded remote from ${url} with scope ${scope} does not expose a valid container API`, + ); + } + + // ESM namespace objects can be non-extensible/non-writable in strict runtimes. + // Wrap the module API with a mutable container for runtime flags. + const mutableContainer: WebpackRemoteContainer = { __initialized: false, - } satisfies WebpackRemoteContainer; + get: remoteModule.get.bind(remoteModule), + init: remoteModule.init.bind(remoteModule), + }; + (window as any)[scope] = mutableContainer; +}; + +const getDefaultShareScope = async () => { + let webpackShareScopes = getWebpackShareScopes(); + if (!webpackShareScopes?.default) { + await initWebpackSharing('default'); + webpackShareScopes = getWebpackShareScopes(); + } + + return webpackShareScopes?.default; }; /** @@ -93,11 +123,7 @@ const loadEsmRemote = async ( * @function */ const initSharing = async () => { - const webpackShareScopes = - __webpack_share_scopes__ as unknown as WebpackShareScopes; - if (!webpackShareScopes?.default) { - await __webpack_init_sharing__('default'); - } + await getDefaultShareScope(); }; /** @@ -108,11 +134,10 @@ const initSharing = async () => { */ const initContainer = async (containerScope: any) => { try { - const webpackShareScopes = - __webpack_share_scopes__ as unknown as WebpackShareScopes; + const defaultShareScope = await getDefaultShareScope(); if (!containerScope.__initialized && !containerScope.__initializing) { containerScope.__initializing = true; - await containerScope.init(webpackShareScopes.default as any); + await containerScope.init(defaultShareScope as any); containerScope.__initialized = true; delete containerScope.__initializing; } diff --git a/packages/utilities/src/utils/pure.test.ts b/packages/utilities/src/utils/pure.test.ts new file mode 100644 index 00000000000..64771b7a14b --- /dev/null +++ b/packages/utilities/src/utils/pure.test.ts @@ -0,0 +1,51 @@ +import { getWebpackRequireOrThrow } from '@module-federation/sdk/bundler'; +import { loadScript } from './pure'; + +jest.mock('@module-federation/sdk/bundler', () => ({ + getWebpackRequireOrThrow: jest.fn(), +})); + +describe('loadScript', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not resolve webpack require when asyncContainer is already a promise', async () => { + const asyncContainer = Promise.resolve({ + get: jest.fn(), + init: jest.fn(), + }); + + const result = loadScript({ asyncContainer } as any); + + expect(result).toBe(asyncContainer); + await expect(result).resolves.toEqual( + expect.objectContaining({ + get: expect.any(Function), + init: expect.any(Function), + }), + ); + expect(getWebpackRequireOrThrow).not.toHaveBeenCalled(); + }); + + it('does not resolve webpack require when asyncContainer is a factory', async () => { + const asyncContainer = Promise.resolve({ + get: jest.fn(), + init: jest.fn(), + }); + const asyncContainerFactory = jest.fn().mockReturnValue(asyncContainer); + + const result = loadScript({ + asyncContainer: asyncContainerFactory, + } as any); + + expect(asyncContainerFactory).toHaveBeenCalledTimes(1); + await expect(result).resolves.toEqual( + expect.objectContaining({ + get: expect.any(Function), + init: expect.any(Function), + }), + ); + expect(getWebpackRequireOrThrow).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/utilities/src/utils/pure.ts b/packages/utilities/src/utils/pure.ts index ebb9ad36d30..472ad0a6913 100644 --- a/packages/utilities/src/utils/pure.ts +++ b/packages/utilities/src/utils/pure.ts @@ -3,8 +3,10 @@ import { RemoteVars, RuntimeRemote, RuntimeRemotesMap, + WebpackRequire, WebpackRemoteContainer, } from '../types'; +import { getWebpackRequireOrThrow } from '@module-federation/sdk/bundler'; const pure = typeof process !== 'undefined' ? process.env['REMOTES'] || {} : {}; export const remoteVars = pure as RemoteVars; @@ -34,6 +36,9 @@ export const loadScript = (keyOrRuntimeRemoteItem: string | RuntimeRemote) => { : // @ts-ignore reference.asyncContainer(); } else { + const webpackRequire = + getWebpackRequireOrThrow() as unknown as WebpackRequire; + // This casting is just to satisfy typescript, // In reality remoteGlobal will always be a string; const remoteGlobal = reference.global as unknown as string; @@ -83,7 +88,7 @@ export const loadScript = (keyOrRuntimeRemoteItem: string | RuntimeRemote) => { return resolveRemoteGlobal(); } - (__webpack_require__ as any).l( + webpackRequire.l( reference.url, function (event: Event) { //@ts-ignore diff --git a/packages/webpack-bundler-runtime/__tests__/init.spec.ts b/packages/webpack-bundler-runtime/__tests__/init.spec.ts new file mode 100644 index 00000000000..cd84041761b --- /dev/null +++ b/packages/webpack-bundler-runtime/__tests__/init.spec.ts @@ -0,0 +1,108 @@ +const mockGetRemoteEntry = jest.fn(); +const mockGetGlobalSnapshotInfoByModuleInfo = jest.fn(); +const mockGetWebpackRequire = jest.fn(); + +jest.mock('@module-federation/runtime', () => ({ + getRemoteEntry: (...args: unknown[]) => mockGetRemoteEntry(...args), +})); + +jest.mock('@module-federation/runtime/helpers', () => ({ + __esModule: true, + default: { + global: { + getGlobalSnapshotInfoByModuleInfo: (...args: unknown[]) => + mockGetGlobalSnapshotInfoByModuleInfo(...args), + }, + }, +})); + +jest.mock('@module-federation/sdk/bundler', () => ({ + getWebpackRequire: (...args: unknown[]) => mockGetWebpackRequire(...args), +})); + +import type { WebpackRequire } from '../src/types'; +import { init } from '../src/init'; + +describe('init', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('falls back to provided webpackRequire bundlerRuntime when sdk accessor is unavailable', async () => { + const shareEntry = { + init: jest.fn().mockResolvedValue(undefined), + get: jest.fn().mockReturnValue('remote-getter'), + }; + mockGetRemoteEntry.mockResolvedValue(shareEntry); + mockGetWebpackRequire.mockReturnValue(undefined); + mockGetGlobalSnapshotInfoByModuleInfo.mockReturnValue({ + shared: [ + { + sharedName: 'react', + secondarySharedTreeShakingName: 'react-secondary', + secondarySharedTreeShakingEntry: + 'https://example.com/react-secondary.js', + treeShakingStatus: 'loaded', + }, + ], + }); + + const runtimeInit = jest.fn(); + const fallbackBundlerRuntime = { + marker: 'fallback-runtime', + getSharedFallbackGetter: jest.fn( + ({ factory }: { factory: () => unknown }) => factory, + ), + }; + const initOptions = { + plugins: [] as Array<{ beforeInit: (...args: any[]) => any }>, + }; + + const webpackRequire = { + federation: { + initOptions, + runtime: { init: runtimeInit }, + sharedFallback: true, + bundlerRuntime: fallbackBundlerRuntime, + libraryType: 'module', + }, + } as unknown as WebpackRequire; + + init({ webpackRequire }); + + const plugin = initOptions.plugins[0]; + expect(plugin).toBeDefined(); + + const sharedArg = { + version: '18.2.0', + get: jest.fn(), + treeShaking: { status: 'stale' }, + }; + const origin = { name: 'host-app' }; + + plugin.beforeInit({ + origin, + userOptions: { + version: '1.0.0', + shared: { + react: sharedArg, + }, + }, + options: { + version: '1.0.0', + shared: {}, + }, + }); + + const treeShakingGetter = sharedArg.treeShaking + .get as () => Promise; + const getter = await treeShakingGetter(); + + expect(getter).toBe('remote-getter'); + expect(shareEntry.init).toHaveBeenCalledWith( + origin, + fallbackBundlerRuntime, + ); + expect(runtimeInit).toHaveBeenCalledWith(initOptions); + }); +}); diff --git a/packages/webpack-bundler-runtime/src/init.ts b/packages/webpack-bundler-runtime/src/init.ts index fc2e8260a5a..efc1cdf3481 100644 --- a/packages/webpack-bundler-runtime/src/init.ts +++ b/packages/webpack-bundler-runtime/src/init.ts @@ -5,6 +5,7 @@ import { } from '@module-federation/runtime'; import { ShareArgs } from '@module-federation/runtime/types'; import helpers from '@module-federation/runtime/helpers'; +import { getWebpackRequire } from '@module-federation/sdk/bundler'; export function init({ webpackRequire }: { webpackRequire: WebpackRequire }) { const { initOptions, runtime, sharedFallback, bundlerRuntime, libraryType } = @@ -106,8 +107,9 @@ export function init({ webpackRequire }: { webpackRequire: WebpackRequire }) { // @ts-ignore await shareEntry.init( origin, - // @ts-ignore - __webpack_require__.federation.bundlerRuntime, + getWebpackRequire()?.federation + ?.bundlerRuntime ?? + webpackRequire.federation.bundlerRuntime, ); // @ts-ignore const getter = shareEntry.get();