module-federation-common: followup to initial implementation
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Removed the `EXPERIMENTAL_MODULE_FEDERATION` environment variable flag, making module federation host support always available during `package start`. The host shared dependencies are now managed through `@backstage/module-federation-common` and injected as a versioned runtime script at build time.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-dynamic-feature-loader': patch
|
||||
---
|
||||
|
||||
Updated module federation integration to use `@module-federation/enhanced/runtime` `createInstance` API and the new `loadModuleFederationHostShared` from `@backstage/module-federation-common` for loading shared dependencies. Also added support for passing a pre-created `ModuleFederation` instance via the `moduleFederation.instance` option.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/backend-dynamic-feature-service': patch
|
||||
'@backstage/frontend-dynamic-feature-loader': patch
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Updated `@module-federation/enhanced`, `@module-federation/runtime`, and `@module-federation/sdk` dependencies from `^0.9.0` to `^0.21.6`.
|
||||
@@ -1,10 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-dynamic-feature-service': patch
|
||||
'@backstage/frontend-dynamic-feature-loader': patch
|
||||
'@backstage/module-federation-common': patch
|
||||
'@backstage/cli': patch
|
||||
'@backstage/module-federation-common': minor
|
||||
---
|
||||
|
||||
Enable Module Federation support in the frontend application (Module Federation host) through API only, without using the ModuleFederationPlugin at build time, nor producing specific generated bundled assets.
|
||||
Module federation remotes still use ModuleFederationPlugin at build time to provide module-federation enabled remote modules, like plugin bundles or dynamic frontend plugins.
|
||||
Default shared dependencies are provided for both the frontend application (Module Federation host), and Module Federation remotes, maintaining consistency between both sides.
|
||||
Added new `@backstage/module-federation-common` package that provides shared types, default configurations, and runtime utilities for module federation. It includes `loadModuleFederationHostShared` for loading shared dependencies in parallel at runtime, `defaultHostSharedDependencies` and `defaultRemoteSharedDependencies` for consistent dependency configuration, and types such as `HostSharedDependencies`, `RemoteSharedDependencies`, and `RuntimeSharedDependenciesGlobal`.
|
||||
|
||||
@@ -74,29 +74,18 @@ import {
|
||||
createInstance,
|
||||
ModuleFederation,
|
||||
} from '@module-federation/enhanced/runtime';
|
||||
import { buildRuntimeSharedUserOption } from '@backstage/module-federation-common';
|
||||
import { loadModuleFederationHostShared } from '@backstage/module-federation-common';
|
||||
|
||||
export async function initializeModuleFederation(): Promise<ModuleFederation> {
|
||||
// Build the shared dependencies configuration
|
||||
const { shared, errors } = await buildRuntimeSharedUserOption();
|
||||
|
||||
// Log any errors loading shared dependencies
|
||||
if (errors.length > 0) {
|
||||
for (const err of errors) {
|
||||
console.error(err.message, err.cause);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Module Federation Runtime
|
||||
return createInstance({
|
||||
name: 'app-next',
|
||||
name: 'app',
|
||||
remotes: [
|
||||
{
|
||||
name: 'my_plugin',
|
||||
entry: 'http://localhost:3001/mf-manifest.json',
|
||||
},
|
||||
],
|
||||
shared,
|
||||
shared: await loadModuleFederationHostShared(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,26 +97,31 @@ export async function loadRemote(
|
||||
}
|
||||
```
|
||||
|
||||
The `loadModuleFederationHostShared` function loads all shared dependencies in parallel and returns them in the format expected by the Module Federation Runtime. By default it will throw if any shared dependency fails to load. You can pass an `onError` callback to handle errors gracefully instead:
|
||||
|
||||
```typescript
|
||||
const shared = await loadModuleFederationHostShared({
|
||||
onError: error => console.error(error.message, error.cause),
|
||||
});
|
||||
```
|
||||
|
||||
### Integration with Feature Loaders
|
||||
|
||||
Standard Module Federation runtime API integrates very well with frontend feature loaders,
|
||||
as shown in the example below:
|
||||
|
||||
```typescript title="packages/app/src/App.tsx"
|
||||
```typescript title="packages/app/src/loader.tsx"
|
||||
import { createInstance } from '@module-federation/enhanced/runtime';
|
||||
import { buildRuntimeSharedUserOption } from '@backstage/module-federation-common';
|
||||
import { loadModuleFederationHostShared } from '@backstage/module-federation-common';
|
||||
import { createFrontendFeatureLoader } from '@backstage/frontend-plugin-api';
|
||||
|
||||
const moduleFederationInstance = createInstance({
|
||||
name: 'app-next',
|
||||
remotes: [],
|
||||
});
|
||||
|
||||
const moduleFederationLoader = createFrontendFeatureLoader({
|
||||
export const moduleFederationLoader = createFrontendFeatureLoader({
|
||||
async loader() {
|
||||
moduleFederationInstance.registerShared(
|
||||
(await buildRuntimeSharedUserOption()).shared,
|
||||
);
|
||||
const moduleFederationInstance = createInstance({
|
||||
name: 'app',
|
||||
remotes: [],
|
||||
shared: await loadModuleFederationHostShared(),
|
||||
});
|
||||
moduleFederationInstance.registerRemotes([
|
||||
{
|
||||
name: 'myFirstRemoteWith2ExposedModules',
|
||||
|
||||
@@ -252,24 +252,7 @@ export async function createConfig(
|
||||
exposes,
|
||||
name: options.moduleFederationRemote.name,
|
||||
runtime: false,
|
||||
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,
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
shared: options.moduleFederationRemote.sharedDependencies,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* 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 } from './moduleFederation';
|
||||
import { BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL } from '@backstage/module-federation-common';
|
||||
|
||||
const GLOBAL = BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL;
|
||||
|
||||
describe('prepareRuntimeSharedDependenciesScript', () => {
|
||||
it('should generate script with a single dependency', () => {
|
||||
const result = prepareRuntimeSharedDependenciesScript({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
eager: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(`window['${GLOBAL}'] = {
|
||||
"items": [
|
||||
{
|
||||
"name": "react",
|
||||
"version": "18.2.0",
|
||||
"lib": () => import("react"),
|
||||
"shareConfig": {
|
||||
"singleton": true,
|
||||
"requiredVersion": "*",
|
||||
"eager": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"version": "v1"
|
||||
};`);
|
||||
});
|
||||
|
||||
it('should generate script with multiple dependencies', () => {
|
||||
const result = prepareRuntimeSharedDependenciesScript({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
eager: true,
|
||||
},
|
||||
'react-dom': {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
eager: true,
|
||||
},
|
||||
lodash: {
|
||||
version: '4.17.21',
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
eager: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(`window['${GLOBAL}'] = {
|
||||
"items": [
|
||||
{
|
||||
"name": "react",
|
||||
"version": "18.2.0",
|
||||
"lib": () => import("react"),
|
||||
"shareConfig": {
|
||||
"singleton": true,
|
||||
"requiredVersion": "*",
|
||||
"eager": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "react-dom",
|
||||
"version": "18.2.0",
|
||||
"lib": () => import("react-dom"),
|
||||
"shareConfig": {
|
||||
"singleton": true,
|
||||
"requiredVersion": "*",
|
||||
"eager": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "lodash",
|
||||
"version": "4.17.21",
|
||||
"lib": () => import("lodash"),
|
||||
"shareConfig": {
|
||||
"singleton": true,
|
||||
"requiredVersion": "*",
|
||||
"eager": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"version": "v1"
|
||||
};`);
|
||||
});
|
||||
|
||||
it('should handle custom requiredVersion', () => {
|
||||
const result = prepareRuntimeSharedDependenciesScript({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '^18.0.0',
|
||||
singleton: true,
|
||||
eager: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toContain('"requiredVersion": "^18.0.0"');
|
||||
});
|
||||
|
||||
it('should handle scoped package names', () => {
|
||||
const result = prepareRuntimeSharedDependenciesScript({
|
||||
'@backstage/core-plugin-api': {
|
||||
version: '1.0.0',
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
eager: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toContain('"name": "@backstage/core-plugin-api"');
|
||||
expect(result).toContain(
|
||||
'"lib": () => import("@backstage/core-plugin-api")',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty dependencies', () => {
|
||||
const result = prepareRuntimeSharedDependenciesScript({});
|
||||
|
||||
expect(result).toBe(`window['${GLOBAL}'] = {
|
||||
"items": [],
|
||||
"version": "v1"
|
||||
};`);
|
||||
});
|
||||
|
||||
it('should throw if version is missing', () => {
|
||||
expect(() =>
|
||||
prepareRuntimeSharedDependenciesScript({
|
||||
react: {
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
eager: false,
|
||||
},
|
||||
}),
|
||||
).toThrow("Version is required for shared dependency 'react'");
|
||||
});
|
||||
});
|
||||
@@ -22,11 +22,11 @@ import {
|
||||
getEntryPointDefaultFeatureType,
|
||||
} from '../../../../lib/typeDistProject';
|
||||
import {
|
||||
SharedDependencies,
|
||||
Host,
|
||||
prepareRuntimeSharedDependenciesScript,
|
||||
BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL,
|
||||
defaultRemoteSharedDependencies,
|
||||
defaultHostSharedDependencies,
|
||||
HostSharedDependencies,
|
||||
RuntimeSharedDependenciesGlobal,
|
||||
} from '@backstage/module-federation-common';
|
||||
import { dirname, join as joinPath, resolve as resolvePath } from 'path';
|
||||
import fs from 'fs-extra';
|
||||
@@ -82,6 +82,44 @@ export async function getModuleFederationRemoteOptions(
|
||||
|
||||
// Module federation host management utilities
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function prepareRuntimeSharedDependenciesScript(
|
||||
hostSharedDependencies: HostSharedDependencies,
|
||||
) {
|
||||
const items = Object.entries(hostSharedDependencies).map(
|
||||
([name, sharedDep]) => {
|
||||
if (!sharedDep.version) {
|
||||
throw new Error(`Version is required for shared dependency '${name}'`);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
version: sharedDep.version,
|
||||
lib: name as unknown as () => Promise<unknown>, // Coverted into import below
|
||||
shareConfig: {
|
||||
singleton: sharedDep.singleton,
|
||||
requiredVersion: sharedDep.requiredVersion,
|
||||
eager: sharedDep.eager,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return `window['${BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL}'] = ${JSON.stringify(
|
||||
{ items, version: 'v1' } satisfies RuntimeSharedDependenciesGlobal,
|
||||
null,
|
||||
2,
|
||||
).replace(
|
||||
/"lib": ("[^"]+")/gm,
|
||||
(_, name) => `"lib": () => import(${name})`,
|
||||
)};`;
|
||||
}
|
||||
|
||||
const RUNTIME_SHARED_DEPENDENCIES_MODULE_NAME =
|
||||
'__backstage-module-federation-runtime-shared-dependencies__';
|
||||
|
||||
@@ -90,7 +128,7 @@ const writeQueue = new PQueue({ concurrency: 1 });
|
||||
|
||||
async function writeRuntimeSharedDependenciesModule(
|
||||
targetPath: string,
|
||||
runtimeSharedDependencies: SharedDependencies<Host & { version: string }>,
|
||||
runtimeSharedDependencies: HostSharedDependencies,
|
||||
) {
|
||||
const script = prepareRuntimeSharedDependenciesScript(
|
||||
runtimeSharedDependencies,
|
||||
@@ -110,41 +148,31 @@ async function writeRuntimeSharedDependenciesModule(
|
||||
|
||||
function resolveSharedDependencyVersions(
|
||||
targetPath: string,
|
||||
hostSharedDependencies: SharedDependencies<Host>,
|
||||
): SharedDependencies<Host & { version: string }> {
|
||||
hostSharedDependencies: HostSharedDependencies,
|
||||
): HostSharedDependencies {
|
||||
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;
|
||||
.flatMap(([importPath, sharedDep]) => {
|
||||
// Remove any sub-path exports from the import path
|
||||
const moduleName = importPath.startsWith('@')
|
||||
? importPath.split('/').slice(0, 2).join('/')
|
||||
: importPath.split('/')[0];
|
||||
|
||||
let version: string;
|
||||
try {
|
||||
packagePath = require.resolve(`${moduleName}/package.json`, {
|
||||
const packagePath = require.resolve(`${moduleName}/package.json`, {
|
||||
paths: [targetPath],
|
||||
});
|
||||
version = require(packagePath).version;
|
||||
} 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}'.`,
|
||||
console.log(
|
||||
`Skipping module federation shared dependency '${importPath}' because it could not be resolved.`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
name,
|
||||
{ ...sharedDep, version: sharedDep.version ?? packageJson.version },
|
||||
];
|
||||
return [[importPath, { ...sharedDep, version }]];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,10 +17,7 @@
|
||||
import { AppConfig, Config } from '@backstage/config';
|
||||
import { BundlingPathsOptions } from './paths';
|
||||
import { ConfigSchema } from '@backstage/config-loader';
|
||||
import {
|
||||
SharedDependencies,
|
||||
Remote,
|
||||
} from '@backstage/module-federation-common';
|
||||
import { RemoteSharedDependencies } from '@backstage/module-federation-common';
|
||||
|
||||
export type ModuleFederationRemoteOptions = {
|
||||
// Unique name for this module federation bundle
|
||||
@@ -31,7 +28,7 @@ export type ModuleFederationRemoteOptions = {
|
||||
*/
|
||||
[k: string]: string;
|
||||
};
|
||||
sharedDependencies: SharedDependencies<Remote>;
|
||||
sharedDependencies: RemoteSharedDependencies;
|
||||
};
|
||||
|
||||
export type BundlingOptions = {
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
createFrontendFeatureLoader,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { ShareStrategy, UserOptions } from '@module-federation/runtime/types';
|
||||
import { buildRuntimeSharedUserOption } from '@backstage/module-federation-common';
|
||||
import { loadModuleFederationHostShared } from '@backstage/module-federation-common';
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -105,10 +105,9 @@ export function dynamicFrontendFeaturesLoader(
|
||||
if (options?.moduleFederation?.instance) {
|
||||
instance = options.moduleFederation.instance;
|
||||
} else {
|
||||
const { shared, errors } = await buildRuntimeSharedUserOption();
|
||||
for (const err of errors) {
|
||||
error(err.message, err.cause);
|
||||
}
|
||||
const shared = await loadModuleFederationHostShared({
|
||||
onError: err => error(err.message, err.cause),
|
||||
});
|
||||
|
||||
const createOptions: UserOptions = {
|
||||
name: appPackageName
|
||||
|
||||
@@ -3,48 +3,60 @@
|
||||
> 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 const BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL =
|
||||
'__@backstage/module_federation_shared_dependencies__';
|
||||
|
||||
// @public
|
||||
export function buildRuntimeSharedUserOption(): Promise<{
|
||||
shared: UserOptions['shared'];
|
||||
errors: ForwardedError[];
|
||||
}>;
|
||||
export function defaultHostSharedDependencies(): HostSharedDependencies;
|
||||
|
||||
// @public
|
||||
export function defaultHostSharedDependencies(): SharedDependencies<Host>;
|
||||
export function defaultRemoteSharedDependencies(): RemoteSharedDependencies;
|
||||
|
||||
// @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> = {
|
||||
export type HostSharedDependencies = {
|
||||
[name: string]: {
|
||||
singleton?: boolean;
|
||||
} & ContextFields;
|
||||
singleton: boolean;
|
||||
eager: boolean;
|
||||
requiredVersion: string;
|
||||
version?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export type LoadedRuntimeSharedDependency = {
|
||||
version: string;
|
||||
lib: () => unknown;
|
||||
shareConfig: HostSharedDependencies[string];
|
||||
};
|
||||
|
||||
// @public
|
||||
export function loadModuleFederationHostShared(options?: {
|
||||
onError?: (error: Error) => void;
|
||||
}): Promise<Record<string, LoadedRuntimeSharedDependency>>;
|
||||
|
||||
// @public
|
||||
export type RemoteSharedDependencies = {
|
||||
[name: string]: {
|
||||
singleton: boolean;
|
||||
eager: boolean;
|
||||
import?: false | string;
|
||||
requiredVersion: string;
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export type RuntimeSharedDependenciesGlobal = {
|
||||
items: Array<{
|
||||
name: string;
|
||||
version: string;
|
||||
lib: () => Promise<unknown>;
|
||||
shareConfig: {
|
||||
singleton: boolean;
|
||||
requiredVersion: string;
|
||||
eager: boolean;
|
||||
};
|
||||
}>;
|
||||
version: 'v1';
|
||||
};
|
||||
```
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('defaultRemoteSharedDependencies', () => {
|
||||
expect(result.react).toEqual({
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
eager: false,
|
||||
import: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Host, Remote, SharedDependencies } from './types';
|
||||
import { RemoteSharedDependencies, HostSharedDependencies } from './types';
|
||||
|
||||
/**
|
||||
* The list of default shared dependencies, expected to be the same
|
||||
@@ -22,115 +22,62 @@ import { Host, Remote, SharedDependencies } from './types';
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
const defaultSharedDependencies: SharedDependencies<{
|
||||
host: Host;
|
||||
remote: Remote;
|
||||
}> = {
|
||||
const defaultSharedDependencies = {
|
||||
// React
|
||||
react: {
|
||||
singleton: true,
|
||||
host: {
|
||||
eager: true,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
remote: {
|
||||
import: false,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
host: {},
|
||||
remote: { import: false },
|
||||
},
|
||||
'react-dom': {
|
||||
singleton: true,
|
||||
host: {
|
||||
eager: true,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
remote: {
|
||||
import: false,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
host: {},
|
||||
remote: { import: false },
|
||||
},
|
||||
// React Router
|
||||
'react-router': {
|
||||
singleton: true,
|
||||
host: {
|
||||
eager: true,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
remote: {
|
||||
import: false,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
host: {},
|
||||
remote: { import: false },
|
||||
},
|
||||
'react-router-dom': {
|
||||
singleton: true,
|
||||
host: {
|
||||
eager: true,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
remote: {
|
||||
import: false,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
host: {},
|
||||
remote: { 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,
|
||||
host: {
|
||||
eager: true,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
remote: {
|
||||
requiredVersion: '*',
|
||||
},
|
||||
host: {},
|
||||
remote: {},
|
||||
},
|
||||
'@material-ui/styles': {
|
||||
singleton: true,
|
||||
host: {
|
||||
eager: true,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
remote: {
|
||||
requiredVersion: '*',
|
||||
},
|
||||
host: {},
|
||||
remote: {},
|
||||
},
|
||||
// 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: '*',
|
||||
},
|
||||
host: {},
|
||||
remote: {},
|
||||
},
|
||||
'@emotion/react': {
|
||||
singleton: true,
|
||||
host: {
|
||||
eager: true,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
remote: {
|
||||
requiredVersion: '*',
|
||||
},
|
||||
host: {},
|
||||
remote: {},
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Returns the list of default shared dependencies for the host, with host-only properties.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function defaultHostSharedDependencies(): SharedDependencies<Host> {
|
||||
export function defaultHostSharedDependencies(): HostSharedDependencies {
|
||||
return Object.fromEntries(
|
||||
Object.entries(defaultSharedDependencies).map(([name, p]) => [
|
||||
name,
|
||||
{
|
||||
singleton: p.singleton,
|
||||
eager: true,
|
||||
singleton: true,
|
||||
requiredVersion: '*',
|
||||
...p.host,
|
||||
},
|
||||
]),
|
||||
@@ -142,12 +89,14 @@ export function defaultHostSharedDependencies(): SharedDependencies<Host> {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function defaultRemoteSharedDependencies(): SharedDependencies<Remote> {
|
||||
export function defaultRemoteSharedDependencies(): RemoteSharedDependencies {
|
||||
return Object.fromEntries(
|
||||
Object.entries(defaultSharedDependencies).map(([name, p]) => [
|
||||
name,
|
||||
{
|
||||
singleton: p.singleton,
|
||||
eager: false,
|
||||
singleton: true,
|
||||
requiredVersion: '*',
|
||||
...p.remote,
|
||||
},
|
||||
]),
|
||||
|
||||
@@ -22,12 +22,15 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export {
|
||||
prepareRuntimeSharedDependenciesScript,
|
||||
buildRuntimeSharedUserOption,
|
||||
} from './runtime';
|
||||
export { loadModuleFederationHostShared } from './loadModuleFederationHostShared';
|
||||
export {
|
||||
defaultHostSharedDependencies,
|
||||
defaultRemoteSharedDependencies,
|
||||
} from './defaults';
|
||||
export type { Host, Remote, SharedDependencies } from './types';
|
||||
export {
|
||||
BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL,
|
||||
type HostSharedDependencies,
|
||||
type RemoteSharedDependencies,
|
||||
type LoadedRuntimeSharedDependency,
|
||||
type RuntimeSharedDependenciesGlobal,
|
||||
} from './types';
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
* 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 { loadModuleFederationHostShared } from './loadModuleFederationHostShared';
|
||||
import {
|
||||
BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL,
|
||||
RuntimeSharedDependenciesGlobal,
|
||||
} from './types';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
|
||||
const globalSpy = jest.fn();
|
||||
Object.defineProperty(global, BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL, {
|
||||
get: globalSpy,
|
||||
});
|
||||
|
||||
function mockGlobal(items: RuntimeSharedDependenciesGlobal['items']) {
|
||||
globalSpy.mockReturnValue({ items, version: 'v1' });
|
||||
}
|
||||
|
||||
describe('loadModuleFederationHostShared', () => {
|
||||
afterEach(jest.resetAllMocks);
|
||||
|
||||
it('should preload and return shared dependencies keyed by name', async () => {
|
||||
const reactMock = { default: { React: 'react' } };
|
||||
const reactDomMock = { default: { ReactDom: 'react-dom' } };
|
||||
|
||||
mockGlobal([
|
||||
{
|
||||
name: 'react',
|
||||
version: '18.2.0',
|
||||
lib: async () => reactMock,
|
||||
shareConfig: {
|
||||
singleton: true,
|
||||
requiredVersion: '*',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'react-dom',
|
||||
version: '18.2.0',
|
||||
lib: async () => reactDomMock,
|
||||
shareConfig: {
|
||||
singleton: true,
|
||||
requiredVersion: '*',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const shared = await loadModuleFederationHostShared();
|
||||
|
||||
expect(shared).toEqual({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
lib: expect.any(Function),
|
||||
shareConfig: {
|
||||
singleton: true,
|
||||
requiredVersion: '*',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
'react-dom': {
|
||||
version: '18.2.0',
|
||||
lib: expect.any(Function),
|
||||
shareConfig: {
|
||||
singleton: true,
|
||||
requiredVersion: '*',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(shared?.react?.lib()).toBe(reactMock);
|
||||
expect(shared?.['react-dom']?.lib()).toBe(reactDomMock);
|
||||
});
|
||||
|
||||
it('should preserve shareConfig properties from each remote', async () => {
|
||||
mockGlobal([
|
||||
{
|
||||
name: 'react',
|
||||
version: '18.2.0',
|
||||
lib: async () => ({ default: {} }),
|
||||
shareConfig: {
|
||||
singleton: true,
|
||||
requiredVersion: '^18.0.0',
|
||||
eager: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'lodash',
|
||||
version: '4.17.21',
|
||||
lib: async () => ({ default: {} }),
|
||||
shareConfig: {
|
||||
singleton: false,
|
||||
requiredVersion: '^4.17.0',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const shared = await loadModuleFederationHostShared();
|
||||
|
||||
expect(shared).toEqual({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
lib: expect.any(Function),
|
||||
shareConfig: {
|
||||
singleton: true,
|
||||
requiredVersion: '^18.0.0',
|
||||
eager: true,
|
||||
},
|
||||
},
|
||||
lodash: {
|
||||
version: '4.17.21',
|
||||
lib: expect.any(Function),
|
||||
shareConfig: {
|
||||
singleton: false,
|
||||
requiredVersion: '^4.17.0',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty items', async () => {
|
||||
mockGlobal([]);
|
||||
expect(await loadModuleFederationHostShared()).toEqual({});
|
||||
});
|
||||
|
||||
it('should throw on unsupported or missing version', async () => {
|
||||
globalSpy.mockReturnValue({ items: [], version: 'v2' });
|
||||
await expect(loadModuleFederationHostShared()).rejects.toThrow(
|
||||
'Unsupported version of the runtime shared dependencies: v2',
|
||||
);
|
||||
|
||||
globalSpy.mockReturnValue({ items: [] });
|
||||
await expect(loadModuleFederationHostShared()).rejects.toThrow(
|
||||
'Unsupported version of the runtime shared dependencies: undefined',
|
||||
);
|
||||
|
||||
globalSpy.mockReturnValue(undefined);
|
||||
await expect(loadModuleFederationHostShared()).rejects.toThrow(
|
||||
'Unsupported version of the runtime shared dependencies: undefined',
|
||||
);
|
||||
});
|
||||
|
||||
it('should report errors via onError and still return successful modules', async () => {
|
||||
const mockError = new Error('Module import failed');
|
||||
const reactMock = { default: { React: 'react' } };
|
||||
const lodashMock = { default: { _: 'lodash' } };
|
||||
|
||||
mockGlobal([
|
||||
{
|
||||
name: 'react',
|
||||
version: '18.2.0',
|
||||
lib: async () => reactMock,
|
||||
shareConfig: {
|
||||
singleton: true,
|
||||
requiredVersion: '^18.0.0',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'failing-module',
|
||||
version: '0.0.0',
|
||||
lib: async () => {
|
||||
throw mockError;
|
||||
},
|
||||
shareConfig: {
|
||||
singleton: true,
|
||||
requiredVersion: '*',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'lodash',
|
||||
version: '4.17.21',
|
||||
lib: async () => lodashMock,
|
||||
shareConfig: {
|
||||
singleton: true,
|
||||
requiredVersion: '^4.17.0',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const errors: Error[] = [];
|
||||
const shared = await loadModuleFederationHostShared({
|
||||
onError: err => errors.push(err),
|
||||
});
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toBeInstanceOf(ForwardedError);
|
||||
expect(errors[0].message).toContain(
|
||||
'Failed to preload module federation shared dependency',
|
||||
);
|
||||
expect(errors[0]).toHaveProperty('cause', mockError);
|
||||
|
||||
expect(shared).toEqual({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
lib: expect.any(Function),
|
||||
shareConfig: {
|
||||
singleton: true,
|
||||
requiredVersion: '^18.0.0',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
lodash: {
|
||||
version: '4.17.21',
|
||||
lib: expect.any(Function),
|
||||
shareConfig: {
|
||||
singleton: true,
|
||||
requiredVersion: '^4.17.0',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(shared?.react?.lib()).toBe(reactMock);
|
||||
expect(shared?.lodash?.lib()).toBe(lodashMock);
|
||||
});
|
||||
|
||||
it('should throw on failure when no onError is provided', async () => {
|
||||
const mockError = new Error('Module import failed');
|
||||
|
||||
mockGlobal([
|
||||
{
|
||||
name: 'react',
|
||||
version: '18.2.0',
|
||||
lib: async () => ({ default: {} }),
|
||||
shareConfig: {
|
||||
singleton: true,
|
||||
requiredVersion: '*',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'failing-module',
|
||||
version: '0.0.0',
|
||||
lib: async () => {
|
||||
throw mockError;
|
||||
},
|
||||
shareConfig: {
|
||||
singleton: true,
|
||||
requiredVersion: '*',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(loadModuleFederationHostShared()).rejects.toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
'Failed to preload module federation shared dependency',
|
||||
),
|
||||
cause: mockError,
|
||||
});
|
||||
});
|
||||
|
||||
it('should report multiple errors via onError', async () => {
|
||||
const mockError1 = new Error('First module failed');
|
||||
const mockError2 = new Error('Second module failed');
|
||||
|
||||
mockGlobal([
|
||||
{
|
||||
name: 'failing-module-1',
|
||||
version: '0.0.0',
|
||||
lib: async () => {
|
||||
throw mockError1;
|
||||
},
|
||||
shareConfig: {
|
||||
singleton: true,
|
||||
requiredVersion: '*',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'failing-module-2',
|
||||
version: '0.0.0',
|
||||
lib: async () => {
|
||||
throw mockError2;
|
||||
},
|
||||
shareConfig: {
|
||||
singleton: true,
|
||||
requiredVersion: '*',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const errors: Error[] = [];
|
||||
const shared = await loadModuleFederationHostShared({
|
||||
onError: err => errors.push(err),
|
||||
});
|
||||
|
||||
expect(errors).toHaveLength(2);
|
||||
expect(errors[0]).toBeInstanceOf(ForwardedError);
|
||||
expect(errors[0]).toHaveProperty('cause', mockError1);
|
||||
expect(errors[1]).toBeInstanceOf(ForwardedError);
|
||||
expect(errors[1]).toHaveProperty('cause', mockError2);
|
||||
|
||||
expect(shared).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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 {
|
||||
BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL,
|
||||
LoadedRuntimeSharedDependency,
|
||||
RuntimeSharedDependenciesGlobal,
|
||||
} from './types';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
|
||||
/**
|
||||
* Preloads and builds the list of shared dependencies provided to the module federation runtime.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export async function loadModuleFederationHostShared(options?: {
|
||||
onError?: (error: Error) => void;
|
||||
}): Promise<Record<string, LoadedRuntimeSharedDependency>> {
|
||||
const { items = [], version } =
|
||||
(
|
||||
window as {
|
||||
[BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL]?: RuntimeSharedDependenciesGlobal;
|
||||
}
|
||||
)[BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL] ?? {};
|
||||
if (version !== 'v1') {
|
||||
throw new Error(
|
||||
`Unsupported version of the runtime shared dependencies: ${version}`,
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(items.map(item => item.lib()));
|
||||
|
||||
const shared: Record<string, LoadedRuntimeSharedDependency> = {};
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i];
|
||||
if (result.status === 'fulfilled') {
|
||||
const mod = result.value;
|
||||
const item = items[i];
|
||||
shared[item.name] = {
|
||||
version: item.version,
|
||||
lib: () => mod,
|
||||
shareConfig: item.shareConfig,
|
||||
};
|
||||
} else {
|
||||
const error = new ForwardedError(
|
||||
'Failed to preload module federation shared dependency',
|
||||
result.reason,
|
||||
);
|
||||
if (options?.onError) {
|
||||
options.onError(error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shared;
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
/*
|
||||
* 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({});
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
/*
|
||||
* 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 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 }>,
|
||||
) {
|
||||
type StringModule = Omit<Runtime, 'module'> & { module: string };
|
||||
const runtimeSharedDependencies: SharedDependencies<Host & StringModule> =
|
||||
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 } : {}),
|
||||
module: `() => import('${name}')`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
return `window['${BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL}'] = ${JSON.stringify(
|
||||
runtimeSharedDependencies,
|
||||
null,
|
||||
2,
|
||||
).replace(
|
||||
/(^\s+"module":\s*)"([^"]+)"$/gm,
|
||||
(_, start, unquoted) => `${start}${unquoted}`,
|
||||
)};`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -15,55 +15,79 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The name of the global object used to pass shared dependencies from the CLI to the module federation runtime.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SharedDependencies<ContextFields> = {
|
||||
export const BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL =
|
||||
'__@backstage/module_federation_shared_dependencies__';
|
||||
|
||||
/**
|
||||
* Shared dependencies configuration in module federation remote modules.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type RemoteSharedDependencies = {
|
||||
[name: string]: {
|
||||
/** Whether this dependency should be a singleton */
|
||||
singleton?: boolean;
|
||||
} & ContextFields;
|
||||
/** Whether to share this dependency as a singleton */
|
||||
singleton: boolean;
|
||||
/** Whether to load this dependency eagerly */
|
||||
eager: boolean;
|
||||
/** Provided module for fallback. Set to false to not provide a fallback, or a custom import path. */
|
||||
import?: false | string;
|
||||
/** Required version range. */
|
||||
requiredVersion: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Specific fields for shared dependencies configured in module federation remote modules.
|
||||
* Shared dependencies configuration in module federation host 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;
|
||||
export type HostSharedDependencies = {
|
||||
[name: string]: {
|
||||
/** Whether to share this dependency as a singleton */
|
||||
singleton: boolean;
|
||||
/** Whether to load this dependency eagerly */
|
||||
eager: boolean;
|
||||
/** Required version range. */
|
||||
requiredVersion: 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 in the module federation host.
|
||||
* A single shared runtime dependency from the CLI that has been loaded and ready to be used in the module federation runtime.
|
||||
*
|
||||
* @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
|
||||
export type LoadedRuntimeSharedDependency = {
|
||||
version: string;
|
||||
module: () => Promise<any>;
|
||||
lib: () => unknown;
|
||||
shareConfig: HostSharedDependencies[string];
|
||||
};
|
||||
|
||||
/**
|
||||
* The global object used to pass shared dependencies from the CLI to the module federation runtime.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Stored in {@link BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type RuntimeSharedDependenciesGlobal = {
|
||||
items: Array<{
|
||||
name: string;
|
||||
version: string;
|
||||
lib: () => Promise<unknown>;
|
||||
shareConfig: {
|
||||
singleton: boolean;
|
||||
requiredVersion: string;
|
||||
eager: boolean;
|
||||
};
|
||||
}>;
|
||||
version: 'v1';
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user