module-federation-common: followup to initial implementation

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-13 13:20:03 +01:00
parent 8fc6bf9bfb
commit fdbd40488e
19 changed files with 788 additions and 875 deletions
+48 -36
View File
@@ -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 };
}
+58 -34
View File
@@ -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';
};