diff --git a/packages/cli/package.json b/packages/cli/package.json index c5b75e31d7..6e1fc0b10f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -55,6 +55,7 @@ "@backstage/errors": "workspace:^", "@backstage/eslint-plugin": "workspace:^", "@backstage/integration": "workspace:^", + "@backstage/module-federation-common": "workspace:^", "@backstage/release-manifests": "workspace:^", "@backstage/types": "workspace:^", "@manypkg/get-packages": "^1.1.3", diff --git a/packages/cli/src/modules/build/commands/package/start/startFrontend.ts b/packages/cli/src/modules/build/commands/package/start/startFrontend.ts index 1fa439f4ea..027dbf68e3 100644 --- a/packages/cli/src/modules/build/commands/package/start/startFrontend.ts +++ b/packages/cli/src/modules/build/commands/package/start/startFrontend.ts @@ -17,7 +17,7 @@ import { readJson } from 'fs-extra'; import { resolve as resolvePath } from 'node:path'; import { - getModuleFederationOptions, + getModuleFederationRemoteOptions, serveBundle, } from '../../../../build/lib/bundler'; import { paths } from '../../../../../lib/paths'; @@ -55,11 +55,12 @@ export async function startFrontend(options: StartAppOptions) { verifyVersions: options.verifyVersions, skipOpenBrowser: options.skipOpenBrowser, linkedWorkspace: options.linkedWorkspace, - moduleFederation: await getModuleFederationOptions( - packageJson, - resolvePath(paths.targetDir), - options.isModuleFederationRemote, - ), + moduleFederationRemote: options.isModuleFederationRemote + ? await getModuleFederationRemoteOptions( + packageJson, + resolvePath(paths.targetDir), + ) + : undefined, }); await waitForExit(); diff --git a/packages/cli/src/modules/build/lib/buildFrontend.ts b/packages/cli/src/modules/build/lib/buildFrontend.ts index 554b90306d..7b0955a519 100644 --- a/packages/cli/src/modules/build/lib/buildFrontend.ts +++ b/packages/cli/src/modules/build/lib/buildFrontend.ts @@ -16,7 +16,7 @@ import fs from 'fs-extra'; import { resolve as resolvePath } from 'node:path'; -import { buildBundle, getModuleFederationOptions } from './bundler'; +import { buildBundle, getModuleFederationRemoteOptions } from './bundler'; import { getEnvironmentParallelism } from '../../../lib/parallel'; import { loadCliConfig } from '../../config/lib/config'; import { BackstagePackageJson } from '@backstage/cli-node'; @@ -25,7 +25,7 @@ interface BuildAppOptions { targetDir: string; writeStats: boolean; configPaths: string[]; - isModuleFederationRemote?: true; + isModuleFederationRemote?: boolean; webpack?: typeof import('webpack'); } @@ -39,11 +39,12 @@ export async function buildFrontend(options: BuildAppOptions) { entry: 'src/index', parallelism: getEnvironmentParallelism(), statsJsonEnabled: writeStats, - moduleFederation: await getModuleFederationOptions( - packageJson, - resolvePath(targetDir), - options.isModuleFederationRemote, - ), + moduleFederationRemote: options.isModuleFederationRemote + ? await getModuleFederationRemoteOptions( + packageJson, + resolvePath(targetDir), + ) + : undefined, ...(await loadCliConfig({ args: configPaths, fromPackage: packageJson.name, diff --git a/packages/cli/src/modules/build/lib/bundler/bundle.ts b/packages/cli/src/modules/build/lib/bundler/bundle.ts index 6235d9b214..17ee660735 100644 --- a/packages/cli/src/modules/build/lib/bundler/bundle.ts +++ b/packages/cli/src/modules/build/lib/bundler/bundle.ts @@ -28,6 +28,7 @@ import { BuildOptions } from './types'; import { resolveBundlingPaths, resolveOptionalBundlingPaths } from './paths'; import chalk from 'chalk'; import { createDetectedModulesEntryPoint } from './packageDetection'; +import { createRuntimeSharedDependeciesEntryPoint } from './moduleFederation'; // TODO(Rugvip): Limits from CRA, we might want to tweak these though. const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; @@ -55,8 +56,7 @@ export async function buildBundle(options: BuildOptions) { }; const configs: Configuration[] = []; - - if (options.moduleFederation?.mode === 'remote') { + if (options.moduleFederationRemote) { // Package detection is disabled for remote bundles configs.push(await createConfig(paths, commonConfigOptions)); } else { @@ -65,10 +65,18 @@ export async function buildBundle(options: BuildOptions) { targetPath: paths.targetPath, }); + const moduleFederationSharedDependenciesEntryPoint = + await createRuntimeSharedDependeciesEntryPoint({ + targetPath: paths.targetPath, + }); + configs.push( await createConfig(paths, { ...commonConfigOptions, - additionalEntryPoints: detectedModulesEntryPoint, + additionalEntryPoints: [ + ...detectedModulesEntryPoint, + ...moduleFederationSharedDependenciesEntryPoint, + ], appMode: publicPaths ? 'protected' : 'public', }), ); diff --git a/packages/cli/src/modules/build/lib/bundler/config.ts b/packages/cli/src/modules/build/lib/bundler/config.ts index e68ce9588d..278f6a3b69 100644 --- a/packages/cli/src/modules/build/lib/bundler/config.ts +++ b/packages/cli/src/modules/build/lib/bundler/config.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { BundlingOptions, ModuleFederationOptions } from './types'; import { resolve as resolvePath } from 'node:path'; +import { BundlingOptions, ModuleFederationRemoteOptions } from './types'; import { rspack, Configuration } from '@rspack/core'; import { BundlingPaths } from './paths'; @@ -39,14 +39,13 @@ import { ConfigInjectingHtmlWebpackPlugin } from './ConfigInjectingHtmlWebpackPl export function resolveBaseUrl( config: Config, - moduleFederation?: ModuleFederationOptions, + moduleFederationRemote?: ModuleFederationRemoteOptions, ): URL { const baseUrl = config.getOptionalString('app.baseUrl'); - const defaultBaseUrl = - moduleFederation?.mode === 'remote' - ? `http://localhost:${process.env.PORT ?? '3000'}` - : 'http://localhost:3000'; + const defaultBaseUrl = moduleFederationRemote + ? `http://localhost:${process.env.PORT ?? '3000'}` + : 'http://localhost:3000'; try { return new URL(baseUrl ?? '/', defaultBaseUrl); @@ -57,12 +56,12 @@ export function resolveBaseUrl( export function resolveEndpoint( config: Config, - moduleFederation?: ModuleFederationOptions, + moduleFederationRemote?: ModuleFederationRemoteOptions, ): { host: string; port: number; } { - const url = resolveBaseUrl(config, moduleFederation); + const url = resolveBaseUrl(config, moduleFederationRemote); return { host: config.getOptionalString('app.listen.host') ?? url.hostname, @@ -117,7 +116,7 @@ export async function createConfig( checksEnabled, isDev, frontendConfig, - moduleFederation, + moduleFederationRemote, publicSubPath = '', webpack, } = options; @@ -126,7 +125,7 @@ export async function createConfig( // Any package that is part of the monorepo but outside the monorepo root dir need // separate resolution logic. - const validBaseUrl = resolveBaseUrl(frontendConfig, moduleFederation); + const validBaseUrl = resolveBaseUrl(frontendConfig, moduleFederationRemote); let publicPath = validBaseUrl.pathname.replace(/\/$/, ''); if (publicSubPath) { publicPath = `${publicPath}${publicSubPath}`.replace('//', '/'); @@ -135,7 +134,7 @@ export async function createConfig( if (isDev) { const { host, port } = resolveEndpoint( options.frontendConfig, - options.moduleFederation, + options.moduleFederationRemote, ); const refreshOptions = { @@ -186,7 +185,7 @@ export async function createConfig( }), ); - if (options.moduleFederation?.mode !== 'remote') { + if (!options.moduleFederationRemote) { const templateOptions = { meta: { 'backstage-app-mode': options?.appMode ?? 'public', @@ -231,20 +230,17 @@ export async function createConfig( ); } - if (options.moduleFederation) { - const isRemote = options.moduleFederation?.mode === 'remote'; - + if (options.moduleFederationRemote) { const AdaptedModuleFederationPlugin = webpack ? (require('@module-federation/enhanced/webpack') .ModuleFederationPlugin as unknown as typeof ModuleFederationPlugin) : ModuleFederationPlugin; - const exposes = options.moduleFederation?.exposes + const exposes = options.moduleFederationRemote.exposes ? Object.fromEntries( - Object.entries(options.moduleFederation?.exposes).map(([k, v]) => [ - k, - resolvePath(paths.targetPath, v), - ]), + Object.entries(options.moduleFederationRemote?.exposes).map( + ([k, v]) => [k, resolvePath(paths.targetPath, v)], + ), ) : { '.': paths.targetEntry, @@ -252,66 +248,28 @@ export async function createConfig( plugins.push( new AdaptedModuleFederationPlugin({ - ...(isRemote && { - filename: 'remoteEntry.js', - exposes, - }), - name: options.moduleFederation.name, + filename: 'remoteEntry.js', + exposes, + name: options.moduleFederationRemote.name, runtime: false, - shared: { - // React - react: { - singleton: true, - requiredVersion: '*', - eager: !isRemote, - ...(isRemote && { import: false }), - }, - 'react-dom': { - singleton: true, - requiredVersion: '*', - eager: !isRemote, - ...(isRemote && { import: false }), - }, - // React Router - 'react-router': { - singleton: true, - requiredVersion: '*', - eager: !isRemote, - ...(isRemote && { import: false }), - }, - 'react-router-dom': { - singleton: true, - requiredVersion: '*', - eager: !isRemote, - ...(isRemote && { import: false }), - }, - // MUI v4 - // not setting import: false for MUI packages as this - // will break once Backstage moves to BUI - '@material-ui/core/styles': { - singleton: true, - requiredVersion: '*', - eager: !isRemote, - }, - '@material-ui/styles': { - singleton: true, - requiredVersion: '*', - eager: !isRemote, - }, - // MUI v5 - // not setting import: false for MUI packages as this - // will break once Backstage moves to BUI - '@mui/material/styles/': { - singleton: true, - requiredVersion: '*', - eager: !isRemote, - }, - '@emotion/react': { - singleton: true, - requiredVersion: '*', - eager: !isRemote, - }, - }, + shared: Object.fromEntries( + Object.entries(options.moduleFederationRemote.sharedDependencies).map( + ([name, p]) => [ + name, + { + ...(p.version === undefined ? {} : { version: p.version }), + ...(p.requiredVersion === undefined + ? {} + : { requiredVersion: p.requiredVersion }), + ...(p.singleton === undefined + ? {} + : { singleton: p.singleton }), + ...(p.import === undefined ? {} : { import: p.import }), + eager: false, + }, + ], + ), + ), }), ); } @@ -426,10 +384,9 @@ export async function createConfig( rules: loaders, }, output: { - uniqueName: options.moduleFederation?.name, + uniqueName: options.moduleFederationRemote?.name, path: paths.targetDist, - publicPath: - options.moduleFederation?.mode === 'remote' ? 'auto' : `${publicPath}/`, + publicPath: options.moduleFederationRemote ? 'auto' : `${publicPath}/`, filename: isDev ? '[name].js' : 'static/[name].[contenthash:8].js', chunkFilename: isDev ? '[name].chunk.js' diff --git a/packages/cli/src/modules/build/lib/bundler/index.ts b/packages/cli/src/modules/build/lib/bundler/index.ts index d8dd6ed0d6..7cb05e3cf0 100644 --- a/packages/cli/src/modules/build/lib/bundler/index.ts +++ b/packages/cli/src/modules/build/lib/bundler/index.ts @@ -15,5 +15,5 @@ */ export { buildBundle } from './bundle'; -export { getModuleFederationOptions } from './moduleFederation'; +export { getModuleFederationRemoteOptions } from './moduleFederation'; export { serveBundle } from './server'; diff --git a/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts b/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts index 0bc606f82a..a92192f50f 100644 --- a/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts +++ b/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts @@ -14,36 +14,34 @@ * limitations under the License. */ -import chalk from 'chalk'; -import { ModuleFederationOptions } from './types'; +import { ModuleFederationRemoteOptions } from './types'; import { BackstagePackageJson } from '@backstage/cli-node'; import { readEntryPoints } from '../../../../lib/entryPoints'; import { createTypeDistProject, getEntryPointDefaultFeatureType, } from '../../../../lib/typeDistProject'; +import { + SharedDependencies, + Host, + prepareRuntimeSharedDependenciesScript, + defaultRemoteSharedDependencies, + defaultHostSharedDependencies, +} from '@backstage/module-federation-common'; +import { dirname, join as joinPath, resolve as resolvePath } from 'path'; +import fs from 'fs-extra'; +import chokidar from 'chokidar'; +import PQueue from 'p-queue'; -export async function getModuleFederationOptions( +// Remote modules management utilities + +export async function getModuleFederationRemoteOptions( packageJson: BackstagePackageJson, packageDir: string, - isModuleFederationRemote?: boolean, -): Promise { - if ( - !isModuleFederationRemote && - !process.env.EXPERIMENTAL_MODULE_FEDERATION - ) { - return undefined; - } - - console.log( - chalk.yellow( - `⚠️ WARNING: Module federation is experimental and will receive immediate breaking changes in the future.`, - ), - ); - - let exposes: ModuleFederationOptions['exposes']; +): Promise { + let exposes: ModuleFederationRemoteOptions['exposes']; const packageRole = packageJson.backstage?.role; - if (isModuleFederationRemote && packageJson.exports && packageRole) { + if (packageJson.exports && packageRole) { const project = await createTypeDistProject(); exposes = Object.fromEntries( readEntryPoints(packageJson) @@ -70,7 +68,6 @@ export async function getModuleFederationOptions( } return { - mode: isModuleFederationRemote ? 'remote' : 'host', // The default output mode requires the name to be a usable as a code // symbol, there might be better options here but for now we need to // sanitize the name. @@ -79,5 +76,98 @@ export async function getModuleFederationOptions( .replaceAll('/', '__') .replaceAll('-', '_'), exposes, + sharedDependencies: defaultRemoteSharedDependencies(), }; } + +// Module federation host management utilities + +const RUNTIME_SHARED_DEPENDENCIES_MODULE_NAME = + '__backstage-module-federation-runtime-shared-dependencies__'; + +// Make sure we're not issuing multiple writes at the same time, which can cause partial overwrites +const writeQueue = new PQueue({ concurrency: 1 }); + +async function writeRuntimeSharedDependenciesModule( + targetPath: string, + runtimeSharedDependencies: SharedDependencies, +) { + const script = prepareRuntimeSharedDependenciesScript( + runtimeSharedDependencies, + ); + + await writeQueue.add(async () => { + const path = joinPath( + targetPath, + 'node_modules', + `${RUNTIME_SHARED_DEPENDENCIES_MODULE_NAME}.js`, + ); + + await fs.ensureDir(dirname(path)); + await fs.writeFile(path, script); + }); +} + +function resolveSharedDependencyVersions( + targetPath: string, + hostSharedDependencies: SharedDependencies, +): SharedDependencies { + return Object.fromEntries( + Object.entries(hostSharedDependencies) + .filter(([_, sharedDep]) => sharedDep !== undefined) + .map(([name, sharedDep]) => { + // Use require.resolve to find the package + // For scoped modules, keep the scope and the module name, but remove any sub-folder + const nameParts = name.split('/'); + const moduleName = + nameParts[0].startsWith('@') && nameParts.length > 1 + ? `${nameParts[0]}/${nameParts[1]}` + : nameParts[0]; + let packagePath: string; + try { + packagePath = require.resolve(`${moduleName}/package.json`, { + paths: [targetPath], + }); + } catch (e) { + throw new Error( + `Failed to resolve package.json for module federation shared dependency '${name}': ${e}`, + ); + } + const packageJson = require(packagePath); + + if (sharedDep.version && packageJson.version !== sharedDep.version) { + throw new Error( + `Version mismatch for module federation shared dependency '${name}': '${sharedDep.version}' vs '${packageJson.version}' found in '${packagePath}'.`, + ); + } + + return [ + name, + { ...sharedDep, version: sharedDep.version ?? packageJson.version }, + ]; + }), + ); +} + +export async function createRuntimeSharedDependeciesEntryPoint(options: { + targetPath: string; + watch?: () => void; +}): Promise { + const { targetPath, watch } = options; + + const doWriteSharedDependenciesModule = async () => { + const sharedDependencies = defaultHostSharedDependencies(); + await writeRuntimeSharedDependenciesModule( + targetPath, + resolveSharedDependencyVersions(targetPath, sharedDependencies), + ); + }; + + if (watch) { + const watcher = chokidar.watch(resolvePath(targetPath, 'package.json')); + watcher.on('change', doWriteSharedDependenciesModule); + } + await doWriteSharedDependenciesModule(); + + return [RUNTIME_SHARED_DEPENDENCIES_MODULE_NAME]; +} diff --git a/packages/cli/src/modules/build/lib/bundler/server.ts b/packages/cli/src/modules/build/lib/bundler/server.ts index 0f287c0713..a7cc0be8c5 100644 --- a/packages/cli/src/modules/build/lib/bundler/server.ts +++ b/packages/cli/src/modules/build/lib/bundler/server.ts @@ -28,6 +28,7 @@ import { createConfig, resolveBaseUrl, resolveEndpoint } from './config'; import { createDetectedModulesEntryPoint } from './packageDetection'; import { resolveBundlingPaths, resolveOptionalBundlingPaths } from './paths'; import { ServeOptions } from './types'; +import { createRuntimeSharedDependeciesEntryPoint } from './moduleFederation'; export async function serveBundle(options: ServeOptions) { const paths = resolveBundlingPaths(options); @@ -108,10 +109,10 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be } const { frontendConfig, fullConfig } = cliConfig; - const url = resolveBaseUrl(frontendConfig, options.moduleFederation); + const url = resolveBaseUrl(frontendConfig, options.moduleFederationRemote); const { host, port } = resolveEndpoint( frontendConfig, - options.moduleFederation, + options.moduleFederationRemote, ); const detectedModulesEntryPoint = await createDetectedModulesEntryPoint({ @@ -122,6 +123,14 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be }, }); + const moduleFederationSharedDependenciesEntryPoint = + await createRuntimeSharedDependeciesEntryPoint({ + targetPath: paths.targetPath, + watch() { + triggerReload(); + }, + }); + const webpack = process.env.LEGACY_WEBPACK_BUILD ? (require('webpack') as typeof import('webpack')) : undefined; @@ -140,8 +149,11 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be const config = await createConfig(paths, { ...commonConfigOptions, - additionalEntryPoints: detectedModulesEntryPoint, - moduleFederation: options.moduleFederation, + additionalEntryPoints: [ + ...detectedModulesEntryPoint, + ...moduleFederationSharedDependenciesEntryPoint, + ], + moduleFederationRemote: options.moduleFederationRemote, }); const bundler = (webpack ?? rspack) as typeof rspack; @@ -181,17 +193,16 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be directory: paths.targetPublic, } : undefined, - historyApiFallback: - options.moduleFederation?.mode === 'remote' - ? false - : { - // Paths with dots should still use the history fallback. - // See https://github.com/facebookincubator/create-react-app/issues/387. - disableDotRule: true, + historyApiFallback: options.moduleFederationRemote + ? false + : { + // Paths with dots should still use the history fallback. + // See https://github.com/facebookincubator/create-react-app/issues/387. + disableDotRule: true, - // The index needs to be rewritten relative to the new public path, including subroutes. - index: `${config.output?.publicPath}index.html`, - }, + // The index needs to be rewritten relative to the new public path, including subroutes. + index: `${config.output?.publicPath}index.html`, + }, server: url.protocol === 'https:' ? { diff --git a/packages/cli/src/modules/build/lib/bundler/types.ts b/packages/cli/src/modules/build/lib/bundler/types.ts index 59b0dcb70d..835b92e180 100644 --- a/packages/cli/src/modules/build/lib/bundler/types.ts +++ b/packages/cli/src/modules/build/lib/bundler/types.ts @@ -17,18 +17,21 @@ import { AppConfig, Config } from '@backstage/config'; import { BundlingPathsOptions } from './paths'; import { ConfigSchema } from '@backstage/config-loader'; +import { + SharedDependencies, + Remote, +} from '@backstage/module-federation-common'; -export type ModuleFederationOptions = { +export type ModuleFederationRemoteOptions = { // Unique name for this module federation bundle name: string; - // Whether this is a host or remote bundle - mode: 'host' | 'remote'; exposes?: { /** * Modules that should be exposed by this container. */ [k: string]: string; }; + sharedDependencies: SharedDependencies; }; export type BundlingOptions = { @@ -44,7 +47,7 @@ export type BundlingOptions = { appMode?: string; // An external linked workspace to include in the bundling linkedWorkspace?: string; - moduleFederation?: ModuleFederationOptions; + moduleFederationRemote?: ModuleFederationRemoteOptions; webpack?: typeof import('webpack'); }; @@ -54,7 +57,7 @@ export type ServeOptions = BundlingPathsOptions & { configPaths: string[]; verifyVersions?: boolean; skipOpenBrowser?: boolean; - moduleFederation?: ModuleFederationOptions; + moduleFederationRemote?: ModuleFederationRemoteOptions; // An external linked workspace to include in the bundling linkedWorkspace?: string; }; @@ -68,7 +71,7 @@ export type BuildOptions = BundlingPathsOptions & { frontendConfig: Config; frontendAppConfigs: AppConfig[]; fullConfig: Config; - moduleFederation?: ModuleFederationOptions; + moduleFederationRemote?: ModuleFederationRemoteOptions; webpack?: typeof import('webpack'); }; diff --git a/packages/frontend-dynamic-feature-loader/package.json b/packages/frontend-dynamic-feature-loader/package.json index a323e57840..f2a807757a 100644 --- a/packages/frontend-dynamic-feature-loader/package.json +++ b/packages/frontend-dynamic-feature-loader/package.json @@ -34,6 +34,7 @@ "dependencies": { "@backstage/config": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/module-federation-common": "workspace:^", "@module-federation/enhanced": "^0.21.6", "@module-federation/runtime": "^0.21.6", "@module-federation/sdk": "^0.21.6", diff --git a/packages/frontend-dynamic-feature-loader/report.api.md b/packages/frontend-dynamic-feature-loader/report.api.md index a199d0330d..53934e3de8 100644 --- a/packages/frontend-dynamic-feature-loader/report.api.md +++ b/packages/frontend-dynamic-feature-loader/report.api.md @@ -4,6 +4,7 @@ ```ts import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api'; +import { ModuleFederation } from '@module-federation/enhanced/runtime'; import { ModuleFederationRuntimePlugin } from '@module-federation/enhanced/runtime'; import { ShareStrategy } from '@module-federation/runtime/types'; import { UserOptions } from '@module-federation/runtime/types'; @@ -16,9 +17,12 @@ export function dynamicFrontendFeaturesLoader( // @public (undocumented) export type DynamicFrontendFeaturesLoaderOptions = { moduleFederation: { - shared?: UserOptions['shared']; shareStrategy?: ShareStrategy; - plugins?: Array; + shared?: UserOptions['shared'] | (() => Promise); + plugins?: + | Array + | (() => Promise>); + instance?: ModuleFederation | (() => Promise); }; }; ``` diff --git a/packages/frontend-dynamic-feature-loader/src/loader.test.tsx b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx index c0bf5822cc..c496890361 100644 --- a/packages/frontend-dynamic-feature-loader/src/loader.test.tsx +++ b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx @@ -15,21 +15,48 @@ */ import { mockApis, registerMswTestHooks } from '@backstage/test-utils'; -import { - DynamicFrontendFeaturesLoaderOptions, - dynamicFrontendFeaturesLoader, -} from './loader'; +import { dynamicFrontendFeaturesLoader } from './loader'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import { + createInstance, + ModuleFederationRuntimePlugin, +} from '@module-federation/enhanced/runtime'; import { RemoteEntryExports } from '@module-federation/runtime/types'; import { Module } from '@module-federation/sdk'; import { createFrontendPlugin } from '@backstage/frontend-plugin-api'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { InternalFrontendFeatureLoader } from '../../frontend-plugin-api/src/wiring/createFrontendFeatureLoader'; import { resetFederationGlobalInfo } from '@module-federation/runtime/core'; +import { Config } from '@backstage/config'; const baseUrl = 'http://localhost:7007'; +function mockDefaultConfig(): Config { + return mockApis.config({ + data: { + app: { + packages: { + include: [], + }, + }, + backend: { + baseUrl, + }, + dynamicPlugins: {}, + }, + }); +} + +const globalSpy = jest.fn(); +Object.defineProperty( + global, + '__backstage-module-federation-shared-dependencies__', + { + get: globalSpy, + }, +); + describe('dynamicFrontendFeaturesLoader', () => { const server = setupServer(); registerMswTestHooks(server); @@ -46,31 +73,27 @@ describe('dynamicFrontendFeaturesLoader', () => { }, }; - const getCommonOptions = (): DynamicFrontendFeaturesLoaderOptions => ({ - moduleFederation: { + const testModuleFederationPlugins: ModuleFederationRuntimePlugin[] = [ + { // We add this module federation plugin to mock the // effective retrieval of the remote content, since it // normally requires a host application built with module federation support, // and won't work by default in Jest tests. - plugins: [ - { - name: 'load-entry-mock', - loadEntry: async args => { - return { - get: (id: string) => async () => { - return await mocks.federation.get({ - name: args.remoteInfo.name, - id, - }); - }, - init: async () => {}, - } as RemoteEntryExports; + name: 'load-entry-mock', + loadEntry: async args => { + return { + get: (id: string) => async () => { + return await mocks.federation.get({ + name: args.remoteInfo.name, + id, + }); }, - onLoad: mocks.federation.onLoad, - }, - ], + init: async () => {}, + } as RemoteEntryExports; + }, + onLoad: mocks.federation.onLoad, }, - }); + ]; const manifestDummyData = { metaData: { @@ -104,9 +127,73 @@ describe('dynamicFrontendFeaturesLoader', () => { mocks.console.debug.mockReset(); mocks.federation.get.mockReset(); mocks.federation.onLoad.mockReset(); + globalSpy.mockReset(); resetFederationGlobalInfo(); }); + const mockedDefaultSharedDependencies = { + react: { + version: '18.3.1', + requiredVersion: '*', + singleton: true, + eager: true, + module: () => ({ default: {} }), + }, + 'react-dom': { + version: '18.3.1', + requiredVersion: '*', + singleton: true, + eager: true, + module: () => ({ default: {} }), + }, + 'react-router': { + version: '6.28.2', + requiredVersion: '*', + singleton: true, + eager: true, + module: () => ({ default: {} }), + }, + 'react-router-dom': { + version: '6.28.2', + requiredVersion: '*', + singleton: true, + eager: true, + module: () => ({ default: {} }), + }, + '@material-ui/core/styles': { + version: '4.12.4', + requiredVersion: '*', + singleton: true, + eager: true, + module: () => ({ default: {} }), + }, + '@material-ui/styles': { + version: '4.11.5', + requiredVersion: '*', + singleton: true, + eager: true, + module: () => ({ default: {} }), + }, + '@mui/material/styles/': { + version: '5.16.14', + requiredVersion: '*', + singleton: true, + eager: true, + module: () => ({ default: {} }), + }, + '@emotion/react': { + version: '11.13.5', + requiredVersion: '*', + singleton: true, + eager: true, + module: () => ({ default: {} }), + }, + }; + + beforeEach(() => { + globalSpy.mockReturnValue(mockedDefaultSharedDependencies); + }); + it('should return immediately if dynamic plugins are not enabled in config', async () => { let manifestsEndpointCalled = false; server.use( @@ -121,7 +208,9 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ config: mockApis.config({ @@ -196,22 +285,12 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); @@ -320,22 +399,12 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); @@ -435,22 +504,12 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); @@ -526,22 +585,12 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); @@ -588,22 +637,12 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); @@ -635,22 +674,12 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); @@ -739,22 +768,12 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); @@ -867,22 +886,12 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); @@ -978,30 +987,26 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); - expect(errorCalls).toEqual([ - "Failed loading remote module 'plugin_1' of dynamic plugin 'plugin-1': Error: [ Federation Runtime ]: [ Federation Runtime ]: [ Federation Runtime ]: http://localhost:7007/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json is not a federation manifest", - ]); const warnCalls = mocks.console.warn.mock.calls.flatMap(e => e[0]); - expect(warnCalls).toEqual([]); + + // eslint-disable-next-line no-console + console.log(warnCalls); + // eslint-disable-next-line no-console + console.log(errorCalls); + + expect(warnCalls).toEqual(['[ Federation Runtime ]']); + expect(errorCalls).toEqual([ + "Failed loading remote module 'plugin_1' of dynamic plugin 'plugin-1': Error: [ Federation Runtime ]: http://localhost:7007/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json is not a federation manifest", + ]); expect(features).toMatchObject([ { $$type: '@backstage/FrontendPlugin', @@ -1086,30 +1091,20 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); - expect(errorCalls).toEqual([ - "Failed loading remote module 'plugin_1' of dynamic plugin 'plugin-1': Error: [ Federation Runtime ]: [ Federation Runtime ]: [ Federation Runtime ]: http://localhost:7007/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json is not a federation manifest", - ]); const warnCalls = mocks.console.warn.mock.calls.flatMap(e => e[0]); - expect(warnCalls).toEqual([]); + expect(warnCalls).toEqual(['[ Federation Runtime ]']); + expect(errorCalls).toEqual([ + "Failed loading remote module 'plugin_1' of dynamic plugin 'plugin-1': Error: [ Federation Runtime ]: http://localhost:7007/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json is not a federation manifest", + ]); expect(features).toMatchObject([ { $$type: '@backstage/FrontendPlugin', @@ -1133,4 +1128,426 @@ describe('dynamicFrontendFeaturesLoader', () => { }, ]); }); + + it('should initialize module federation with resolved shared dependencies', async () => { + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes`, + (_, res, ctx) => res(ctx.json([])), + ), + ); + + const spyInit = jest.fn(); + await ( + dynamicFrontendFeaturesLoader({ + moduleFederation: { + plugins: [ + { + name: 'spy-init', + init: args => { + spyInit(args); + return args; + }, + }, + ...testModuleFederationPlugins, + ], + }, + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockDefaultConfig(), + }); + + expect(spyInit).toHaveBeenCalledTimes(1); + expect(spyInit.mock.calls[0][0].options).toMatchObject({ + shared: Object.fromEntries( + Object.entries(mockedDefaultSharedDependencies).map( + ([name, shared]) => [ + name, + [ + { + version: shared.version, + shareConfig: { + singleton: shared.singleton, + requiredVersion: shared.requiredVersion, + eager: shared.eager, + }, + strategy: 'version-first', + }, + ], + ], + ), + ), + }); + }); + + it('should initialize module federation with shareStrategy option', async () => { + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes`, + (_, res, ctx) => res(ctx.json([])), + ), + ); + + const spyInit = jest.fn(); + await ( + dynamicFrontendFeaturesLoader({ + moduleFederation: { + shareStrategy: 'loaded-first', + plugins: [ + { + name: 'spy-init', + init: args => { + spyInit(args); + return args; + }, + }, + ...testModuleFederationPlugins, + ], + }, + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockDefaultConfig(), + }); + + const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); + expect(errorCalls).toEqual([]); + const warnCalls = mocks.console.warn.mock.calls.flatMap(e => e[0]); + expect(warnCalls).toEqual([]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([]); + const debugCalls = mocks.console.debug.mock.calls.flatMap(e => e[0]); + expect(debugCalls).toEqual([]); + expect(spyInit).toHaveBeenCalledTimes(1); + expect(spyInit.mock.calls[0][0].options).toMatchObject({ + shareStrategy: 'loaded-first', + shared: { + react: [ + { + version: '18.3.1', + shareConfig: { + singleton: true, + requiredVersion: '*', + }, + strategy: 'loaded-first', + }, + ], + }, + }); + }); + + it('should initialize module federation with shared as function', async () => { + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes`, + (_, res, ctx) => res(ctx.json([])), + ), + ); + + const spyInit = jest.fn(); + await ( + dynamicFrontendFeaturesLoader({ + moduleFederation: { + shared: async () => ({ + react: { + version: '19.0.0', + lib: () => ({ default: {} }), + shareConfig: { + singleton: true, + requiredVersion: '*', + }, + strategy: 'loaded-first', + }, + newDep: { + version: '1.2.3', + lib: () => ({ default: {} }), + shareConfig: { + singleton: true, + requiredVersion: '*', + }, + }, + }), + plugins: [ + { + name: 'spy-init', + init: args => { + spyInit(args); + return args; + }, + }, + ...testModuleFederationPlugins, + ], + }, + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockDefaultConfig(), + }); + + const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); + expect(errorCalls).toEqual([]); + const warnCalls = mocks.console.warn.mock.calls.flatMap(e => e[0]); + expect(warnCalls).toEqual([]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([]); + const debugCalls = mocks.console.debug.mock.calls.flatMap(e => e[0]); + expect(debugCalls).toEqual([]); + expect(spyInit.mock.calls[0][0].options.shared).toMatchObject({ + react: [ + { + version: '18.3.1', + shareConfig: { + singleton: true, + requiredVersion: '*', + }, + strategy: 'version-first', + }, + { + version: '19.0.0', + shareConfig: { + singleton: true, + requiredVersion: '*', + }, + strategy: 'loaded-first', + }, + ], + newDep: [ + { + version: '1.2.3', + shareConfig: { + singleton: true, + requiredVersion: '*', + }, + strategy: 'version-first', + }, + ], + }); + }); + + it('should use a provided module federation instance provided as an async function, and complete it', async () => { + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes`, + (_, res, ctx) => + res( + ctx.json([ + { + packageName: 'plugin-test-dynamic', + exposedModules: ['.'], + remoteInfo: { + name: 'test_plugin', + entry: `${baseUrl}/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json`, + }, + }, + ]), + ), + ), + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json`, + (_, res, ctx) => + res( + ctx.json({ + name: 'test_plugin', + ...manifestDummyData, + exposes: [ + { + id: 'test_plugin:.', + name: '.', + path: '.', + ...manifestExposedRemoteDummyData, + }, + ], + }), + ), + ), + ); + + mocks.federation.get.mockReturnValue({ + default: createFrontendPlugin({ + pluginId: 'test-plugin', + extensions: [], + }), + }); + + const spyInit = jest.fn(); + await ( + dynamicFrontendFeaturesLoader({ + moduleFederation: { + instance: async () => + Promise.resolve( + createInstance({ + name: 'MyExternalInstance', + remotes: [ + { + name: 'myApplicationRemote', + entry: 'myApplicationRemote.js', + }, + ], + }), + ), + shared: async () => ({ + react: { + version: '19.0.0', + lib: () => ({ default: {} }), + shareConfig: { + singleton: true, + requiredVersion: '*', + }, + strategy: 'loaded-first', + }, + newDep: { + version: '1.2.3', + lib: () => ({ default: {} }), + shareConfig: { + singleton: true, + requiredVersion: '*', + }, + }, + }), + plugins: [ + { + name: 'spy-init', + init: args => { + spyInit(args); + return args; + }, + }, + ...testModuleFederationPlugins, + ], + }, + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockDefaultConfig(), + }); + + const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); + expect(errorCalls).toEqual([]); + const warnCalls = mocks.console.warn.mock.calls.flatMap(e => e[0]); + expect(warnCalls).toEqual([]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([ + "Remote module 'test_plugin' of dynamic plugin 'plugin-test-dynamic' loaded from http://localhost:7007/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json", + ]); + const debugCalls = mocks.console.debug.mock.calls.flatMap(e => e[0]); + expect(debugCalls).toEqual([ + "Loading dynamic plugin 'plugin-test-dynamic' from 'http://localhost:7007/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json'", + ]); + expect(mocks.federation.get.mock.calls.flatMap(e => e[0])).toEqual([ + { + id: '.', + name: 'test_plugin', + }, + ]); + expect(spyInit.mock.calls[0][0].options).toMatchObject({ + name: 'MyExternalInstance', + remotes: [ + { + entry: 'http://localhost/myApplicationRemote.js', + name: 'myApplicationRemote', + shareScope: 'default', + type: 'global', + }, + { + alias: 'plugin-test-dynamic', + entry: + 'http://localhost:7007/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json', + name: 'test_plugin', + shareScope: 'default', + type: 'global', + }, + ], + shared: { + react: [ + { + version: '19.0.0', + shareConfig: { + singleton: true, + requiredVersion: '*', + }, + strategy: 'loaded-first', + }, + ], + newDep: [ + { + version: '1.2.3', + shareConfig: { + singleton: true, + requiredVersion: '*', + }, + strategy: 'version-first', + }, + ], + }, + }); + }); + + it('should return an empty list of features if module federation initialization fails', async () => { + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes`, + (_, res, ctx) => + res( + ctx.json([ + { + packageName: 'plugin-test-dynamic', + exposedModules: ['.'], + remoteInfo: { + name: 'test_plugin', + entry: `${baseUrl}/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json`, + }, + }, + ]), + ), + ), + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json`, + (_, res, ctx) => + res( + ctx.json({ + name: 'test_plugin', + ...manifestDummyData, + exposes: [ + { + id: 'test_plugin:.', + name: '.', + path: '.', + ...manifestExposedRemoteDummyData, + }, + ], + }), + ), + ), + ); + + mocks.federation.get.mockReturnValue({ + default: createFrontendPlugin({ + pluginId: 'test-plugin', + extensions: [], + }), + }); + + const features = await ( + dynamicFrontendFeaturesLoader({ + moduleFederation: { + shared: async () => { + throw new Error('An initialization error'); + }, + plugins: testModuleFederationPlugins, + }, + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockDefaultConfig(), + }); + + const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); + expect(errorCalls).toEqual([ + 'Failed initializing module federation: Error: An initialization error', + ]); + const warnCalls = mocks.console.warn.mock.calls.flatMap(e => e[0]); + expect(warnCalls).toEqual([]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([]); + const debugCalls = mocks.console.debug.mock.calls.flatMap(e => e[0]); + expect(debugCalls).toEqual([]); + expect(mocks.federation.get.mock.calls.flatMap(e => e[0])).toEqual([]); + expect(features).toEqual([]); + }); }); diff --git a/packages/frontend-dynamic-feature-loader/src/loader.ts b/packages/frontend-dynamic-feature-loader/src/loader.ts index 8a9597891f..b5f6c65069 100644 --- a/packages/frontend-dynamic-feature-loader/src/loader.ts +++ b/packages/frontend-dynamic-feature-loader/src/loader.ts @@ -16,8 +16,8 @@ import { ModuleFederationRuntimePlugin, - init, - loadRemote, + createInstance, + ModuleFederation, } from '@module-federation/enhanced/runtime'; import { Module } from '@module-federation/sdk'; import { DefaultApiClient, Remote } from './schema/openapi'; @@ -27,6 +27,7 @@ import { createFrontendFeatureLoader, } from '@backstage/frontend-plugin-api'; import { ShareStrategy, UserOptions } from '@module-federation/runtime/types'; +import { buildRuntimeSharedUserOption } from '@backstage/module-federation-common'; /** * @@ -37,9 +38,12 @@ export type DynamicFrontendFeaturesLoaderOptions = { * Additional module federation arguments for the Module Federation runtime initialization. */ moduleFederation: { - shared?: UserOptions['shared']; shareStrategy?: ShareStrategy; - plugins?: Array; + shared?: UserOptions['shared'] | (() => Promise); + plugins?: + | Array + | (() => Promise>); + instance?: ModuleFederation | (() => Promise); }; }; @@ -98,18 +102,62 @@ export function dynamicFrontendFeaturesLoader( return []; } + let instance: ModuleFederation; try { - init({ - ...options?.moduleFederation, - name: appPackageName - .replaceAll('@', '') - .replaceAll('/', '__') - .replaceAll('-', '_'), - remotes: frontendPluginRemotes.map(remote => ({ - alias: remote.packageName, - ...remote.remoteInfo, - })), - }); + if (options?.moduleFederation?.instance) { + if (typeof options.moduleFederation.instance === 'function') { + instance = await options.moduleFederation.instance(); + } else { + instance = options.moduleFederation.instance; + } + } else { + const { shared, errors } = await buildRuntimeSharedUserOption(); + for (const err of errors) { + error(err.message, err.cause); + } + + const createOptions: UserOptions = { + name: appPackageName + .replaceAll('@', '') + .replaceAll('/', '__') + .replaceAll('-', '_'), + shared, + remotes: [], + }; + if (options?.moduleFederation?.shareStrategy) { + createOptions.shareStrategy = + options.moduleFederation.shareStrategy; + } + instance = createInstance(createOptions); + } + + const userOptions: UserOptions = { + name: instance.name, + remotes: [], + }; + + if (options?.moduleFederation?.plugins) { + if (typeof options.moduleFederation.plugins === 'function') { + userOptions.plugins = await options.moduleFederation.plugins(); + } else { + userOptions.plugins = options.moduleFederation.plugins; + } + } + if (options?.moduleFederation?.shareStrategy) { + userOptions.shareStrategy = options.moduleFederation.shareStrategy; + } + if (options?.moduleFederation?.shared) { + if (typeof options.moduleFederation.shared === 'function') { + userOptions.shared = await options.moduleFederation.shared(); + } else { + userOptions.shared = options.moduleFederation.shared; + } + } + userOptions.remotes = frontendPluginRemotes.map(remote => ({ + alias: remote.packageName, + ...remote.remoteInfo, + })); + instance.initOptions(userOptions); } catch (err) { error(`Failed initializing module federation`, err); return []; @@ -131,7 +179,7 @@ export function dynamicFrontendFeaturesLoader( : `${remote.remoteInfo.name}/${exposedModuleName}`; let module: Module; try { - module = await loadRemote(remoteModuleName); + module = await instance.loadRemote(remoteModuleName); } catch (err) { error( `Failed loading remote module '${remoteModuleName}' of dynamic plugin '${remote.packageName}'`, diff --git a/packages/module-federation-common/.eslintrc.js b/packages/module-federation-common/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/module-federation-common/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/module-federation-common/README.md b/packages/module-federation-common/README.md new file mode 100644 index 0000000000..512c3dc5b0 --- /dev/null +++ b/packages/module-federation-common/README.md @@ -0,0 +1,11 @@ +# @backstage/module-federation-common + +This package provides a helper library for module federation, enabling a consistent management of shared dependencies +in both the module federation host (frontend application) at runtime, and remote modules at build-time through the CLI. + +It provides: + +- TypeScript types for both host and remote shared dependency definitions, +- a default list of shared dependencies (React, React Router, Material-UI, Backstage core packages, etc.) for both the host and remote modules, +- utilities used by the CLI to resolve versions of the shared dependencies for the host at build-time, +- utilities available for the frontend application to provide the list of resolved shared dependencies at runtime. diff --git a/packages/module-federation-common/catalog-info.yaml b/packages/module-federation-common/catalog-info.yaml new file mode 100644 index 0000000000..a5843ad116 --- /dev/null +++ b/packages/module-federation-common/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-module-federation-common + title: '@backstage/module-federation-common' + description: Helper library for module federation +spec: + lifecycle: experimental + type: backstage-common-library + owner: framework-maintainers diff --git a/packages/module-federation-common/package.json b/packages/module-federation-common/package.json new file mode 100644 index 0000000000..15acc57fbd --- /dev/null +++ b/packages/module-federation-common/package.json @@ -0,0 +1,70 @@ +{ + "name": "@backstage/module-federation-common", + "version": "0.0.0", + "description": "Helper library for module federation", + "backstage": { + "role": "common-library" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "keywords": [ + "backstage" + ], + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/module-federation-common" + }, + "license": "Apache-2.0", + "sideEffects": false, + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/config": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/types": "workspace:^", + "@module-federation/runtime": "^0.21.6", + "serialize-javascript": "^6.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@types/react": "^18.0.0", + "@types/serialize-javascript": "^5.0.4", + "react": "^18.0.2", + "react-dom": "^18.0.2", + "react-router-dom": "^6.3.0" + }, + "peerDependencies": { + "@emotion/react": "^11.10.5", + "@material-ui/core": "^4.12.2", + "@material-ui/styles": "^4.10.0", + "@mui/material": "^5.12.2", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0", + "react-router": "^6.3.0", + "react-router-dom": "^6.3.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } +} diff --git a/packages/module-federation-common/report.api.md b/packages/module-federation-common/report.api.md new file mode 100644 index 0000000000..ec0fb25394 --- /dev/null +++ b/packages/module-federation-common/report.api.md @@ -0,0 +1,50 @@ +## API Report File for "@backstage/module-federation-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ForwardedError } from '@backstage/errors'; +import type { UserOptions } from '@module-federation/runtime/types'; + +// @public +export function buildRuntimeSharedUserOption(): Promise<{ + shared: UserOptions['shared']; + errors: ForwardedError[]; +}>; + +// @public +export function defaultHostSharedDependencies(): SharedDependencies; + +// @public +export function defaultRemoteSharedDependencies(): SharedDependencies; + +// @public +export type Host = { + eager?: boolean; + requiredVersion: false | string; + version?: string; +}; + +// @public +export function prepareRuntimeSharedDependenciesScript( + hostSharedDependencies: SharedDependencies< + Host & { + version: string; + } + >, +): string; + +// @public +export type Remote = { + import?: false | string; + requiredVersion?: false | string; + version?: false | string; +}; + +// @public +export type SharedDependencies = { + [name: string]: { + singleton?: boolean; + } & ContextFields; +}; +``` diff --git a/packages/module-federation-common/src/defaults.test.ts b/packages/module-federation-common/src/defaults.test.ts new file mode 100644 index 0000000000..cc84c82e09 --- /dev/null +++ b/packages/module-federation-common/src/defaults.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + defaultHostSharedDependencies, + defaultRemoteSharedDependencies, +} from './defaults'; + +describe('defaultHostSharedDependencies', () => { + it('should return shared dependencies with the host-only eager property', () => { + const result = defaultHostSharedDependencies(); + + expect(result.react).toEqual({ + requiredVersion: '*', + singleton: true, + eager: true, + }); + }); +}); + +describe('defaultRemoteSharedDependencies', () => { + it('should return shared dependencies with the remote-only import property', () => { + const result = defaultRemoteSharedDependencies(); + + expect(result.react).toEqual({ + requiredVersion: '*', + singleton: true, + import: false, + }); + }); +}); diff --git a/packages/module-federation-common/src/defaults.ts b/packages/module-federation-common/src/defaults.ts new file mode 100644 index 0000000000..8197f5eb98 --- /dev/null +++ b/packages/module-federation-common/src/defaults.ts @@ -0,0 +1,155 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Host, Remote, SharedDependencies } from './types'; + +/** + * The list of default shared dependencies, expected to be the same + * for both the module federation host and remotes. + * + * @internal + */ +const defaultSharedDependencies: SharedDependencies<{ + host: Host; + remote: Remote; +}> = { + // React + react: { + singleton: true, + host: { + eager: true, + requiredVersion: '*', + }, + remote: { + import: false, + requiredVersion: '*', + }, + }, + 'react-dom': { + singleton: true, + host: { + eager: true, + requiredVersion: '*', + }, + remote: { + import: false, + requiredVersion: '*', + }, + }, + // React Router + 'react-router': { + singleton: true, + host: { + eager: true, + requiredVersion: '*', + }, + remote: { + import: false, + requiredVersion: '*', + }, + }, + 'react-router-dom': { + singleton: true, + host: { + eager: true, + requiredVersion: '*', + }, + remote: { + import: false, + requiredVersion: '*', + }, + }, + // MUI v4 + // not setting import: false for MUI packages as this + // will break once Backstage moves to BUI + '@material-ui/core/styles': { + singleton: true, + host: { + eager: true, + requiredVersion: '*', + }, + remote: { + requiredVersion: '*', + }, + }, + '@material-ui/styles': { + singleton: true, + host: { + eager: true, + requiredVersion: '*', + }, + remote: { + requiredVersion: '*', + }, + }, + // MUI v5 + // not setting import: false for MUI packages as this + // will break once Backstage moves to BUI + '@mui/material/styles/': { + singleton: true, + host: { + eager: true, + requiredVersion: '*', + }, + remote: { + requiredVersion: '*', + }, + }, + '@emotion/react': { + singleton: true, + host: { + eager: true, + requiredVersion: '*', + }, + remote: { + requiredVersion: '*', + }, + }, +}; + +/** + * Returns the list of default shared dependencies for the host, with host-only properties. + * + * @public + */ +export function defaultHostSharedDependencies(): SharedDependencies { + return Object.fromEntries( + Object.entries(defaultSharedDependencies).map(([name, p]) => [ + name, + { + singleton: p.singleton, + ...p.host, + }, + ]), + ); +} + +/** + * Returns the list of default shared dependencies for the remote, with remote-only properties. + * + * @public + */ +export function defaultRemoteSharedDependencies(): SharedDependencies { + return Object.fromEntries( + Object.entries(defaultSharedDependencies).map(([name, p]) => [ + name, + { + singleton: p.singleton, + ...p.remote, + }, + ]), + ); +} diff --git a/packages/module-federation-common/src/index.ts b/packages/module-federation-common/src/index.ts new file mode 100644 index 0000000000..cc79a20c6e --- /dev/null +++ b/packages/module-federation-common/src/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Helper library for module federation, enabling a consistent management of shared dependencies + * in both the module federation host (frontend application) at runtime, + * and remote modules at build time through the CLI. + * + * @packageDocumentation + */ + +export { + prepareRuntimeSharedDependenciesScript, + buildRuntimeSharedUserOption, +} from './runtime'; +export { + defaultHostSharedDependencies, + defaultRemoteSharedDependencies, +} from './defaults'; +export type { Host, Remote, SharedDependencies } from './types'; diff --git a/packages/module-federation-common/src/runtime.test.ts b/packages/module-federation-common/src/runtime.test.ts new file mode 100644 index 0000000000..fb73b866fa --- /dev/null +++ b/packages/module-federation-common/src/runtime.test.ts @@ -0,0 +1,523 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + prepareRuntimeSharedDependenciesScript, + buildRuntimeSharedUserOption, +} from './runtime'; +import { ForwardedError } from '@backstage/errors'; + +describe('prepareRuntimeSharedDependenciesScript', () => { + it('should generate script with minimal required properties', () => { + const sharedDependencies = { + react: { + version: '18.2.0', + requiredVersion: '*', + }, + }; + + const result = prepareRuntimeSharedDependenciesScript(sharedDependencies); + + expect(result) + .toBe(`window['__backstage-module-federation-shared-dependencies__'] = { + "react": { + "version": "18.2.0", + "requiredVersion": "*", + "module": () => import('react') + } +};`); + }); + + it('should handle multiple shared dependencies', () => { + const sharedDependencies = { + react: { + version: '18.2.0', + requiredVersion: '*', + }, + 'react-dom': { + version: '18.2.0', + requiredVersion: '*', + }, + lodash: { + version: '4.17.21', + requiredVersion: '*', + }, + }; + + const result = prepareRuntimeSharedDependenciesScript(sharedDependencies); + + expect(result) + .toBe(`window['__backstage-module-federation-shared-dependencies__'] = { + "react": { + "version": "18.2.0", + "requiredVersion": "*", + "module": () => import('react') + }, + "react-dom": { + "version": "18.2.0", + "requiredVersion": "*", + "module": () => import('react-dom') + }, + "lodash": { + "version": "4.17.21", + "requiredVersion": "*", + "module": () => import('lodash') + } +};`); + }); + + it('should include requiredVersion when provided', () => { + const sharedDependencies = { + react: { + version: '18.2.0', + requiredVersion: '^18.0.0', + }, + }; + + const result = prepareRuntimeSharedDependenciesScript(sharedDependencies); + + expect(result) + .toBe(`window['__backstage-module-federation-shared-dependencies__'] = { + "react": { + "version": "18.2.0", + "requiredVersion": "^18.0.0", + "module": () => import('react') + } +};`); + }); + + it('should include requiredVersion when set to false', () => { + const sharedDependencies = { + react: { + version: '18.2.0', + requiredVersion: false as const, + }, + }; + + const result = prepareRuntimeSharedDependenciesScript(sharedDependencies); + + expect(result) + .toBe(`window['__backstage-module-federation-shared-dependencies__'] = { + "react": { + "version": "18.2.0", + "requiredVersion": false, + "module": () => import('react') + } +};`); + }); + + it('should include singleton when true', () => { + const sharedDependencies = { + react: { + version: '18.2.0', + requiredVersion: '*', + singleton: true, + }, + }; + + const result = prepareRuntimeSharedDependenciesScript(sharedDependencies); + + expect(result) + .toBe(`window['__backstage-module-federation-shared-dependencies__'] = { + "react": { + "version": "18.2.0", + "requiredVersion": "*", + "singleton": true, + "module": () => import('react') + } +};`); + }); + + it('should include singleton when false', () => { + const sharedDependencies = { + lodash: { + version: '4.17.21', + requiredVersion: '*', + singleton: false, + }, + }; + + const result = prepareRuntimeSharedDependenciesScript(sharedDependencies); + + expect(result) + .toBe(`window['__backstage-module-federation-shared-dependencies__'] = { + "lodash": { + "version": "4.17.21", + "requiredVersion": "*", + "singleton": false, + "module": () => import('lodash') + } +};`); + }); + + it('should include eager when true', () => { + const sharedDependencies = { + react: { + version: '18.2.0', + requiredVersion: '*', + eager: true, + }, + }; + + const result = prepareRuntimeSharedDependenciesScript(sharedDependencies); + + expect(result) + .toBe(`window['__backstage-module-federation-shared-dependencies__'] = { + "react": { + "version": "18.2.0", + "requiredVersion": "*", + "eager": true, + "module": () => import('react') + } +};`); + }); + + it('should include eager when false', () => { + const sharedDependencies = { + lodash: { + version: '4.17.21', + requiredVersion: '*', + eager: false, + }, + }; + + const result = prepareRuntimeSharedDependenciesScript(sharedDependencies); + + expect(result) + .toBe(`window['__backstage-module-federation-shared-dependencies__'] = { + "lodash": { + "version": "4.17.21", + "requiredVersion": "*", + "eager": false, + "module": () => import('lodash') + } +};`); + }); + + it('should handle all properties together', () => { + const sharedDependencies = { + react: { + version: '18.2.0', + requiredVersion: '^18.0.0', + singleton: true, + eager: false, + }, + }; + + const result = prepareRuntimeSharedDependenciesScript(sharedDependencies); + + expect(result) + .toBe(`window['__backstage-module-federation-shared-dependencies__'] = { + "react": { + "version": "18.2.0", + "requiredVersion": "^18.0.0", + "singleton": true, + "eager": false, + "module": () => import('react') + } +};`); + }); + + it('should handle empty object', () => { + const sharedDependencies = {}; + + const result = prepareRuntimeSharedDependenciesScript(sharedDependencies); + + expect(result).toBe( + `window['__backstage-module-federation-shared-dependencies__'] = {};`, + ); + }); + + it('should handle scoped package names and special characters', () => { + const sharedDependencies = { + '@backstage/core-plugin-api': { + version: '1.0.0', + requiredVersion: '*', + }, + }; + + const result = prepareRuntimeSharedDependenciesScript(sharedDependencies); + + expect(result) + .toBe(`window['__backstage-module-federation-shared-dependencies__'] = { + "@backstage/core-plugin-api": { + "version": "1.0.0", + "requiredVersion": "*", + "module": () => import('@backstage/core-plugin-api') + } +};`); + }); +}); + +const globalSpy = jest.fn(); +Object.defineProperty( + global, + '__backstage-module-federation-shared-dependencies__', + { + get: globalSpy, + }, +); + +describe('getRuntimeSharedDependencies', () => { + afterEach(jest.resetAllMocks); + + it('should get runtime shared dependencies with minimal required properties', async () => { + const reactMock = { default: { React: 'react' } }; + const reactDomMock = { default: { ReactDom: 'react-dom' } }; + + globalSpy.mockReturnValue({ + react: { + version: '18.2.0', + requiredVersion: '*', + module: async () => reactMock, + }, + 'react-dom': { + version: '18.2.0', + requiredVersion: '*', + module: async () => reactDomMock, + }, + }); + const result = await buildRuntimeSharedUserOption(); + + expect(result.errors).toEqual([]); + expect(result.shared).toEqual({ + react: { + version: '18.2.0', + lib: expect.any(Function), + shareConfig: { + requiredVersion: '*', + }, + }, + 'react-dom': { + version: '18.2.0', + lib: expect.any(Function), + shareConfig: { + requiredVersion: '*', + }, + }, + }); + + // Test that lib functions return the correct modules + expect((result?.shared?.react as { lib: () => any }).lib()).toBe(reactMock); + expect((result?.shared?.['react-dom'] as { lib: () => any }).lib()).toBe( + reactDomMock, + ); + }); + + it('should get runtime shared dependencies with custom version and requiredVersion', async () => { + globalSpy.mockReturnValue({ + react: { + module: async () => ({ default: {} }), + version: '18.2.0', + requiredVersion: '^18.0.0', + }, + lodash: { + module: async () => ({ default: {} }), + version: '4.17.21', + requiredVersion: '^4.17.0', + }, + }); + const result = await buildRuntimeSharedUserOption(); + + expect(result.errors).toEqual([]); + expect(result.shared).toEqual({ + react: { + version: '18.2.0', + lib: expect.any(Function), + shareConfig: { + requiredVersion: '^18.0.0', + }, + }, + lodash: { + version: '4.17.21', + lib: expect.any(Function), + shareConfig: { + requiredVersion: '^4.17.0', + }, + }, + }); + }); + + it('should handle eager', async () => { + globalSpy.mockReturnValue({ + react: { + version: '18.2.0', + requiredVersion: '*', + eager: false, + module: async () => ({ default: {} }), + }, + lodash: { + version: '4.17.21', + requiredVersion: '*', + eager: true, + module: async () => ({ default: {} }), + }, + }); + const result = await buildRuntimeSharedUserOption(); + + expect(result.errors).toEqual([]); + expect(result.shared).toEqual({ + react: { + version: '18.2.0', + lib: expect.any(Function), + shareConfig: { + requiredVersion: '*', + eager: false, + }, + }, + lodash: { + version: '4.17.21', + lib: expect.any(Function), + shareConfig: { + requiredVersion: '*', + eager: true, + }, + }, + }); + }); + + it('should handle singleton', async () => { + globalSpy.mockReturnValue({ + react: { + module: async () => ({ default: {} }), + version: '18.2.0', + requiredVersion: '*', + singleton: true, + }, + lodash: { + module: async () => ({ default: {} }), + version: '4.17.21', + requiredVersion: '*', + singleton: false, + }, + }); + const result = await buildRuntimeSharedUserOption(); + + expect(result.errors).toEqual([]); + expect(result.shared).toEqual({ + react: { + version: '18.2.0', + lib: expect.any(Function), + shareConfig: { + requiredVersion: '*', + singleton: true, + }, + }, + lodash: { + version: '4.17.21', + lib: expect.any(Function), + shareConfig: { + requiredVersion: '*', + singleton: false, + }, + }, + }); + }); + + it('should handle an empty object', async () => { + globalSpy.mockReturnValue({}); + const result = await buildRuntimeSharedUserOption(); + + expect(result.errors).toEqual([]); + expect(result.shared).toEqual({}); + }); + + it('should handle module import failures and collect errors', async () => { + const mockError = new Error('Module import failed'); + const reactMock = { default: { React: 'react' } }; + const lodashMock = { default: { _: 'lodash' } }; + + globalSpy.mockReturnValue({ + react: { + module: async () => reactMock, + version: '18.2.0', + requiredVersion: '^18.0.0', + }, + 'failing-module': { + module: async () => { + throw mockError; + }, + }, + lodash: { + module: async () => lodashMock, + version: '4.17.21', + requiredVersion: '^4.17.0', + }, + }); + + const result = await buildRuntimeSharedUserOption(); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toBeInstanceOf(ForwardedError); + expect(result.errors[0].message).toContain( + 'Failed to dynamically import "failing-module" and add it to module federation shared dependencies:', + ); + expect(result.errors[0].cause).toBe(mockError); + + // Should still include successful modules + expect(result.shared).toEqual({ + react: { + version: '18.2.0', + lib: expect.any(Function), + shareConfig: { + requiredVersion: '^18.0.0', + }, + }, + lodash: { + version: '4.17.21', + lib: expect.any(Function), + shareConfig: { + requiredVersion: '^4.17.0', + }, + }, + }); + + // Test that lib functions work correctly + expect((result?.shared?.react as { lib: () => any }).lib()).toBe(reactMock); + expect((result?.shared?.lodash as { lib: () => any }).lib()).toBe( + lodashMock, + ); + }); + + it('should handle multiple module import failures', async () => { + const mockError1 = new Error('First module failed'); + const mockError2 = new Error('Second module failed'); + globalSpy.mockReturnValue({ + 'failing-module-1': { + module: async () => { + throw mockError1; + }, + }, + 'failing-module-2': { + module: async () => { + throw mockError2; + }, + }, + }); + + const result = await buildRuntimeSharedUserOption(); + + expect(result.errors).toHaveLength(2); + expect(result.errors[0]).toBeInstanceOf(ForwardedError); + expect(result.errors[0].message).toContain('failing-module-1'); + expect(result.errors[0].cause).toBe(mockError1); + expect(result.errors[1]).toBeInstanceOf(ForwardedError); + expect(result.errors[1].message).toContain('failing-module-2'); + expect(result.errors[1].cause).toBe(mockError2); + + expect(result.shared).toEqual({}); + }); +}); diff --git a/packages/module-federation-common/src/runtime.ts b/packages/module-federation-common/src/runtime.ts new file mode 100644 index 0000000000..1670d8c941 --- /dev/null +++ b/packages/module-federation-common/src/runtime.ts @@ -0,0 +1,112 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Host, Runtime, SharedDependencies } from './types'; +import { default as serialize } from 'serialize-javascript'; +import type { UserOptions } from '@module-federation/runtime/types'; +import { ForwardedError } from '@backstage/errors'; + +const BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL = + '__backstage-module-federation-shared-dependencies__'; + +/** + * Prepares the runtime shared dependencies script for the module federation host, + * which will be written by the CLI into a Javascript file added as an additional entry point for the frontend bundler. + * This script is used in the browser to build the list of shared dependencies provided to the module federation runtime. + * + * @see {@link buildRuntimeSharedUserOption} + * + * @param hostSharedDependencies - The shared dependencies for the module federation host. + * @returns The runtime shared dependencies script. + * + * @public + */ +export function prepareRuntimeSharedDependenciesScript( + hostSharedDependencies: SharedDependencies, +) { + const runtimeSharedDependencies: SharedDependencies = + Object.fromEntries( + Object.entries(hostSharedDependencies).map(([name, sharedDep]) => [ + name, + { + version: sharedDep.version, + requiredVersion: sharedDep.requiredVersion, + ...(sharedDep.singleton !== undefined + ? { singleton: sharedDep.singleton } + : {}), + ...(sharedDep.eager !== undefined ? { eager: sharedDep.eager } : {}), + // eslint-disable-next-line no-new-func + module: new Function( + `return () => import('${name}')`, + )() as () => Promise, + }, + ]), + ); + + return `window['${BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL}'] = ${serialize( + runtimeSharedDependencies, + { + space: 2, + unsafe: true, + }, + )};`; +} + +/** + * Builds the list of shared dependencies provided to the module federation runtime. + * It uses the runtime shared dependencies script prepared by {@link prepareRuntimeSharedDependenciesScript}. + * + * @public + */ +export async function buildRuntimeSharedUserOption(): Promise<{ + shared: UserOptions['shared']; + errors: ForwardedError[]; +}> { + const runtimeSharedDependencies = + ( + window as { + [BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL]?: SharedDependencies< + Host & Runtime + >; + } + )[BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL] ?? {}; + + const result: UserOptions['shared'] = {}; + const errors: ForwardedError[] = []; + for (const [name, sharedDep] of Object.entries(runtimeSharedDependencies)) { + try { + const module = await sharedDep.module(); + result[name] = { + version: sharedDep.version, + lib: () => module, + shareConfig: { + singleton: sharedDep.singleton, + requiredVersion: sharedDep.requiredVersion, + eager: sharedDep.eager, + }, + }; + } catch (e) { + errors.push( + new ForwardedError( + `Failed to dynamically import "${name}" and add it to module federation shared dependencies:`, + e, + ), + ); + } + } + + return { shared: result, errors }; +} diff --git a/packages/module-federation-common/src/types.ts b/packages/module-federation-common/src/types.ts new file mode 100644 index 0000000000..e8070990df --- /dev/null +++ b/packages/module-federation-common/src/types.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Generic type for shared dependencies configuration in module federation. + * + * The ContextFields type parameter is used to provide additional fields + * to the shared dependencies configuration according to the context. + * + * @public + */ +export type SharedDependencies = { + [name: string]: { + /** Whether this dependency should be a singleton */ + singleton?: boolean; + } & ContextFields; +}; + +/** + * Specific fields for shared dependencies configured in module federation remote modules. + * + * @public + */ +export type Remote = { + /** Provided module for fallback. Set to false to not provide a fallback, or a custom import path. */ + import?: false | string; + /** Required version range. Optional for remotes - can be auto-filled from package.json at build time. */ + requiredVersion?: false | string; + /** Version of the shared dependency. Will be resolved at build time by default but can be overridden, but not completely removed. */ + version?: false | string; +}; + +/** + * Specific fields for shared dependencies configured in the module federation host. + * + * @public + */ +export type Host = { + /** Whether to load this dependency eagerly */ + eager?: boolean; + /** Required version range. Required for host. */ + requiredVersion: false | string; + /** Version of the shared dependency. Will be resolved at build time by default but can be overridden, but not completely removed. */ + version?: string; +}; + +/** + * Specific fields for shared dependencies configured when bootstrapping the module federation host at runtime. + * + * @internal + */ +export type Runtime = { + // version is always expected for runtime host dependencies + version: string; + module: () => Promise; +}; diff --git a/yarn.lock b/yarn.lock index c1924eb08b..7dcfbd2512 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3289,6 +3289,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/eslint-plugin": "workspace:^" "@backstage/integration": "workspace:^" + "@backstage/module-federation-common": "workspace:^" "@backstage/plugin-auth-backend": "workspace:^" "@backstage/plugin-auth-backend-module-guest-provider": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" @@ -3889,6 +3890,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/module-federation-common": "workspace:^" "@backstage/test-utils": "workspace:^" "@module-federation/enhanced": "npm:^0.21.6" "@module-federation/runtime": "npm:^0.21.6" @@ -4046,6 +4048,38 @@ __metadata: languageName: unknown linkType: soft +"@backstage/module-federation-common@workspace:^, @backstage/module-federation-common@workspace:packages/module-federation-common": + version: 0.0.0-use.local + resolution: "@backstage/module-federation-common@workspace:packages/module-federation-common" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/types": "workspace:^" + "@module-federation/runtime": "npm:^0.21.6" + "@types/react": "npm:^18.0.0" + "@types/serialize-javascript": "npm:^5.0.4" + react: "npm:^18.0.2" + react-dom: "npm:^18.0.2" + react-router-dom: "npm:^6.3.0" + serialize-javascript: "npm:^6.0.0" + peerDependencies: + "@emotion/react": ^11.10.5 + "@material-ui/core": ^4.12.2 + "@material-ui/styles": ^4.10.0 + "@mui/material": ^5.12.2 + "@types/react": ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + react-router: ^6.3.0 + react-router-dom: ^6.3.0 + peerDependenciesMeta: + "@types/react": + optional: true + languageName: unknown + linkType: soft + "@backstage/plugin-api-docs-module-protoc-gen-doc@workspace:plugins/api-docs-module-protoc-gen-doc": version: 0.0.0-use.local resolution: "@backstage/plugin-api-docs-module-protoc-gen-doc@workspace:plugins/api-docs-module-protoc-gen-doc" @@ -22016,6 +22050,13 @@ __metadata: languageName: node linkType: hard +"@types/serialize-javascript@npm:^5.0.4": + version: 5.0.4 + resolution: "@types/serialize-javascript@npm:5.0.4" + checksum: 10/c342a4d151736c1ba4913e6d9b00537bcf62401a06e3489dcf96b7d51a4ab147efcf3fe20ca9491b1349ee7e55c6d69606682e1630f2e076e2828eaee3764f94 + languageName: node + linkType: hard + "@types/serve-handler@npm:^6.1.0": version: 6.1.4 resolution: "@types/serve-handler@npm:6.1.4" @@ -46020,7 +46061,7 @@ __metadata: languageName: node linkType: hard -"serialize-javascript@npm:^6.0.2": +"serialize-javascript@npm:^6.0.0, serialize-javascript@npm:^6.0.2": version: 6.0.2 resolution: "serialize-javascript@npm:6.0.2" dependencies: