Move module federation enablement in app-next at runtime.

Signed-off-by: David Festal <dfestal@redhat.com>
Assisted-by: Cursor
This commit is contained in:
David Festal
2025-09-16 21:32:03 +02:00
parent 0a67ed0dde
commit c324bf71be
25 changed files with 1992 additions and 331 deletions
+1
View File
@@ -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",
@@ -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();
@@ -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,
@@ -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',
}),
);
@@ -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'
@@ -15,5 +15,5 @@
*/
export { buildBundle } from './bundle';
export { getModuleFederationOptions } from './moduleFederation';
export { getModuleFederationRemoteOptions } from './moduleFederation';
export { serveBundle } from './server';
@@ -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<ModuleFederationOptions | undefined> {
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<ModuleFederationRemoteOptions | undefined> {
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<Host & { version: string }>,
) {
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<Host>,
): SharedDependencies<Host & { version: string }> {
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<string[]> {
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];
}
@@ -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:'
? {
@@ -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<Remote>;
};
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');
};