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');
};
@@ -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",
@@ -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<ModuleFederationRuntimePlugin>;
shared?: UserOptions['shared'] | (() => Promise<UserOptions['shared']>);
plugins?:
| Array<ModuleFederationRuntimePlugin>
| (() => Promise<Array<ModuleFederationRuntimePlugin>>);
instance?: ModuleFederation | (() => Promise<ModuleFederation>);
};
};
```
@@ -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([]);
});
});
@@ -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<ModuleFederationRuntimePlugin>;
shared?: UserOptions['shared'] | (() => Promise<UserOptions['shared']>);
plugins?:
| Array<ModuleFederationRuntimePlugin>
| (() => Promise<Array<ModuleFederationRuntimePlugin>>);
instance?: ModuleFederation | (() => Promise<ModuleFederation>);
};
};
@@ -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<Module>(remoteModuleName);
module = await instance.loadRemote<Module>(remoteModuleName);
} catch (err) {
error(
`Failed loading remote module '${remoteModuleName}' of dynamic plugin '${remote.packageName}'`,
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -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.
@@ -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
@@ -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
}
}
}
@@ -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<Host>;
// @public
export function defaultRemoteSharedDependencies(): SharedDependencies<Remote>;
// @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<ContextFields> = {
[name: string]: {
singleton?: boolean;
} & ContextFields;
};
```
@@ -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,
});
});
});
@@ -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<Host> {
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<Remote> {
return Object.fromEntries(
Object.entries(defaultSharedDependencies).map(([name, p]) => [
name,
{
singleton: p.singleton,
...p.remote,
},
]),
);
}
@@ -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';
@@ -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({});
});
});
@@ -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<Host & { version: string }>,
) {
const runtimeSharedDependencies: SharedDependencies<Host & Runtime> =
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<any>,
},
]),
);
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 };
}
@@ -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<ContextFields> = {
[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<any>;
};
+42 -1
View File
@@ -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: