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:
@@ -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>;
|
||||
};
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user