Move module federation enablement in app-next at runtime.
Signed-off-by: David Festal <dfestal@redhat.com> Assisted-by: Cursor
This commit is contained in:
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,11 @@
|
||||
# @backstage/module-federation-common
|
||||
|
||||
This package provides a helper library for module federation, enabling a consistent management of shared dependencies
|
||||
in both the module federation host (frontend application) at runtime, and remote modules at build-time through the CLI.
|
||||
|
||||
It provides:
|
||||
|
||||
- TypeScript types for both host and remote shared dependency definitions,
|
||||
- a default list of shared dependencies (React, React Router, Material-UI, Backstage core packages, etc.) for both the host and remote modules,
|
||||
- utilities used by the CLI to resolve versions of the shared dependencies for the host at build-time,
|
||||
- utilities available for the frontend application to provide the list of resolved shared dependencies at runtime.
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: backstage-module-federation-common
|
||||
title: '@backstage/module-federation-common'
|
||||
description: Helper library for module federation
|
||||
spec:
|
||||
lifecycle: experimental
|
||||
type: backstage-common-library
|
||||
owner: framework-maintainers
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "@backstage/module-federation-common",
|
||||
"version": "0.0.0",
|
||||
"description": "Helper library for module federation",
|
||||
"backstage": {
|
||||
"role": "common-library"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "packages/module-federation-common"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"sideEffects": false,
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
"clean": "backstage-cli package clean",
|
||||
"lint": "backstage-cli package lint",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack",
|
||||
"test": "backstage-cli package test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@module-federation/runtime": "^0.21.6",
|
||||
"serialize-javascript": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/serialize-javascript": "^5.0.4",
|
||||
"react": "^18.0.2",
|
||||
"react-dom": "^18.0.2",
|
||||
"react-router-dom": "^6.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/styles": "^4.10.0",
|
||||
"@mui/material": "^5.12.2",
|
||||
"@types/react": "^17.0.0 || ^18.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0",
|
||||
"react-router": "^6.3.0",
|
||||
"react-router-dom": "^6.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
## API Report File for "@backstage/module-federation-common"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
import type { UserOptions } from '@module-federation/runtime/types';
|
||||
|
||||
// @public
|
||||
export function buildRuntimeSharedUserOption(): Promise<{
|
||||
shared: UserOptions['shared'];
|
||||
errors: ForwardedError[];
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export function defaultHostSharedDependencies(): SharedDependencies<Host>;
|
||||
|
||||
// @public
|
||||
export function defaultRemoteSharedDependencies(): SharedDependencies<Remote>;
|
||||
|
||||
// @public
|
||||
export type Host = {
|
||||
eager?: boolean;
|
||||
requiredVersion: false | string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function prepareRuntimeSharedDependenciesScript(
|
||||
hostSharedDependencies: SharedDependencies<
|
||||
Host & {
|
||||
version: string;
|
||||
}
|
||||
>,
|
||||
): string;
|
||||
|
||||
// @public
|
||||
export type Remote = {
|
||||
import?: false | string;
|
||||
requiredVersion?: false | string;
|
||||
version?: false | string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type SharedDependencies<ContextFields> = {
|
||||
[name: string]: {
|
||||
singleton?: boolean;
|
||||
} & ContextFields;
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
defaultHostSharedDependencies,
|
||||
defaultRemoteSharedDependencies,
|
||||
} from './defaults';
|
||||
|
||||
describe('defaultHostSharedDependencies', () => {
|
||||
it('should return shared dependencies with the host-only eager property', () => {
|
||||
const result = defaultHostSharedDependencies();
|
||||
|
||||
expect(result.react).toEqual({
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
eager: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultRemoteSharedDependencies', () => {
|
||||
it('should return shared dependencies with the remote-only import property', () => {
|
||||
const result = defaultRemoteSharedDependencies();
|
||||
|
||||
expect(result.react).toEqual({
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
import: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Host, Remote, SharedDependencies } from './types';
|
||||
|
||||
/**
|
||||
* The list of default shared dependencies, expected to be the same
|
||||
* for both the module federation host and remotes.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
const defaultSharedDependencies: SharedDependencies<{
|
||||
host: Host;
|
||||
remote: Remote;
|
||||
}> = {
|
||||
// React
|
||||
react: {
|
||||
singleton: true,
|
||||
host: {
|
||||
eager: true,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
remote: {
|
||||
import: false,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
},
|
||||
'react-dom': {
|
||||
singleton: true,
|
||||
host: {
|
||||
eager: true,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
remote: {
|
||||
import: false,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
},
|
||||
// React Router
|
||||
'react-router': {
|
||||
singleton: true,
|
||||
host: {
|
||||
eager: true,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
remote: {
|
||||
import: false,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
},
|
||||
'react-router-dom': {
|
||||
singleton: true,
|
||||
host: {
|
||||
eager: true,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
remote: {
|
||||
import: false,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
},
|
||||
// MUI v4
|
||||
// not setting import: false for MUI packages as this
|
||||
// will break once Backstage moves to BUI
|
||||
'@material-ui/core/styles': {
|
||||
singleton: true,
|
||||
host: {
|
||||
eager: true,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
remote: {
|
||||
requiredVersion: '*',
|
||||
},
|
||||
},
|
||||
'@material-ui/styles': {
|
||||
singleton: true,
|
||||
host: {
|
||||
eager: true,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
remote: {
|
||||
requiredVersion: '*',
|
||||
},
|
||||
},
|
||||
// MUI v5
|
||||
// not setting import: false for MUI packages as this
|
||||
// will break once Backstage moves to BUI
|
||||
'@mui/material/styles/': {
|
||||
singleton: true,
|
||||
host: {
|
||||
eager: true,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
remote: {
|
||||
requiredVersion: '*',
|
||||
},
|
||||
},
|
||||
'@emotion/react': {
|
||||
singleton: true,
|
||||
host: {
|
||||
eager: true,
|
||||
requiredVersion: '*',
|
||||
},
|
||||
remote: {
|
||||
requiredVersion: '*',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the list of default shared dependencies for the host, with host-only properties.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function defaultHostSharedDependencies(): SharedDependencies<Host> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(defaultSharedDependencies).map(([name, p]) => [
|
||||
name,
|
||||
{
|
||||
singleton: p.singleton,
|
||||
...p.host,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of default shared dependencies for the remote, with remote-only properties.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function defaultRemoteSharedDependencies(): SharedDependencies<Remote> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(defaultSharedDependencies).map(([name, p]) => [
|
||||
name,
|
||||
{
|
||||
singleton: p.singleton,
|
||||
...p.remote,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helper library for module federation, enabling a consistent management of shared dependencies
|
||||
* in both the module federation host (frontend application) at runtime,
|
||||
* and remote modules at build time through the CLI.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export {
|
||||
prepareRuntimeSharedDependenciesScript,
|
||||
buildRuntimeSharedUserOption,
|
||||
} from './runtime';
|
||||
export {
|
||||
defaultHostSharedDependencies,
|
||||
defaultRemoteSharedDependencies,
|
||||
} from './defaults';
|
||||
export type { Host, Remote, SharedDependencies } from './types';
|
||||
@@ -0,0 +1,523 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
prepareRuntimeSharedDependenciesScript,
|
||||
buildRuntimeSharedUserOption,
|
||||
} from './runtime';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
|
||||
describe('prepareRuntimeSharedDependenciesScript', () => {
|
||||
it('should generate script with minimal required properties', () => {
|
||||
const sharedDependencies = {
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '*',
|
||||
},
|
||||
};
|
||||
|
||||
const result = prepareRuntimeSharedDependenciesScript(sharedDependencies);
|
||||
|
||||
expect(result)
|
||||
.toBe(`window['__backstage-module-federation-shared-dependencies__'] = {
|
||||
"react": {
|
||||
"version": "18.2.0",
|
||||
"requiredVersion": "*",
|
||||
"module": () => import('react')
|
||||
}
|
||||
};`);
|
||||
});
|
||||
|
||||
it('should handle multiple shared dependencies', () => {
|
||||
const sharedDependencies = {
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '*',
|
||||
},
|
||||
'react-dom': {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '*',
|
||||
},
|
||||
lodash: {
|
||||
version: '4.17.21',
|
||||
requiredVersion: '*',
|
||||
},
|
||||
};
|
||||
|
||||
const result = prepareRuntimeSharedDependenciesScript(sharedDependencies);
|
||||
|
||||
expect(result)
|
||||
.toBe(`window['__backstage-module-federation-shared-dependencies__'] = {
|
||||
"react": {
|
||||
"version": "18.2.0",
|
||||
"requiredVersion": "*",
|
||||
"module": () => import('react')
|
||||
},
|
||||
"react-dom": {
|
||||
"version": "18.2.0",
|
||||
"requiredVersion": "*",
|
||||
"module": () => import('react-dom')
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.21",
|
||||
"requiredVersion": "*",
|
||||
"module": () => import('lodash')
|
||||
}
|
||||
};`);
|
||||
});
|
||||
|
||||
it('should include requiredVersion when provided', () => {
|
||||
const sharedDependencies = {
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '^18.0.0',
|
||||
},
|
||||
};
|
||||
|
||||
const result = prepareRuntimeSharedDependenciesScript(sharedDependencies);
|
||||
|
||||
expect(result)
|
||||
.toBe(`window['__backstage-module-federation-shared-dependencies__'] = {
|
||||
"react": {
|
||||
"version": "18.2.0",
|
||||
"requiredVersion": "^18.0.0",
|
||||
"module": () => import('react')
|
||||
}
|
||||
};`);
|
||||
});
|
||||
|
||||
it('should include requiredVersion when set to false', () => {
|
||||
const sharedDependencies = {
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
requiredVersion: false as const,
|
||||
},
|
||||
};
|
||||
|
||||
const result = prepareRuntimeSharedDependenciesScript(sharedDependencies);
|
||||
|
||||
expect(result)
|
||||
.toBe(`window['__backstage-module-federation-shared-dependencies__'] = {
|
||||
"react": {
|
||||
"version": "18.2.0",
|
||||
"requiredVersion": false,
|
||||
"module": () => import('react')
|
||||
}
|
||||
};`);
|
||||
});
|
||||
|
||||
it('should include singleton when true', () => {
|
||||
const sharedDependencies = {
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = prepareRuntimeSharedDependenciesScript(sharedDependencies);
|
||||
|
||||
expect(result)
|
||||
.toBe(`window['__backstage-module-federation-shared-dependencies__'] = {
|
||||
"react": {
|
||||
"version": "18.2.0",
|
||||
"requiredVersion": "*",
|
||||
"singleton": true,
|
||||
"module": () => import('react')
|
||||
}
|
||||
};`);
|
||||
});
|
||||
|
||||
it('should include singleton when false', () => {
|
||||
const sharedDependencies = {
|
||||
lodash: {
|
||||
version: '4.17.21',
|
||||
requiredVersion: '*',
|
||||
singleton: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = prepareRuntimeSharedDependenciesScript(sharedDependencies);
|
||||
|
||||
expect(result)
|
||||
.toBe(`window['__backstage-module-federation-shared-dependencies__'] = {
|
||||
"lodash": {
|
||||
"version": "4.17.21",
|
||||
"requiredVersion": "*",
|
||||
"singleton": false,
|
||||
"module": () => import('lodash')
|
||||
}
|
||||
};`);
|
||||
});
|
||||
|
||||
it('should include eager when true', () => {
|
||||
const sharedDependencies = {
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '*',
|
||||
eager: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = prepareRuntimeSharedDependenciesScript(sharedDependencies);
|
||||
|
||||
expect(result)
|
||||
.toBe(`window['__backstage-module-federation-shared-dependencies__'] = {
|
||||
"react": {
|
||||
"version": "18.2.0",
|
||||
"requiredVersion": "*",
|
||||
"eager": true,
|
||||
"module": () => import('react')
|
||||
}
|
||||
};`);
|
||||
});
|
||||
|
||||
it('should include eager when false', () => {
|
||||
const sharedDependencies = {
|
||||
lodash: {
|
||||
version: '4.17.21',
|
||||
requiredVersion: '*',
|
||||
eager: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = prepareRuntimeSharedDependenciesScript(sharedDependencies);
|
||||
|
||||
expect(result)
|
||||
.toBe(`window['__backstage-module-federation-shared-dependencies__'] = {
|
||||
"lodash": {
|
||||
"version": "4.17.21",
|
||||
"requiredVersion": "*",
|
||||
"eager": false,
|
||||
"module": () => import('lodash')
|
||||
}
|
||||
};`);
|
||||
});
|
||||
|
||||
it('should handle all properties together', () => {
|
||||
const sharedDependencies = {
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '^18.0.0',
|
||||
singleton: true,
|
||||
eager: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = prepareRuntimeSharedDependenciesScript(sharedDependencies);
|
||||
|
||||
expect(result)
|
||||
.toBe(`window['__backstage-module-federation-shared-dependencies__'] = {
|
||||
"react": {
|
||||
"version": "18.2.0",
|
||||
"requiredVersion": "^18.0.0",
|
||||
"singleton": true,
|
||||
"eager": false,
|
||||
"module": () => import('react')
|
||||
}
|
||||
};`);
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const sharedDependencies = {};
|
||||
|
||||
const result = prepareRuntimeSharedDependenciesScript(sharedDependencies);
|
||||
|
||||
expect(result).toBe(
|
||||
`window['__backstage-module-federation-shared-dependencies__'] = {};`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle scoped package names and special characters', () => {
|
||||
const sharedDependencies = {
|
||||
'@backstage/core-plugin-api': {
|
||||
version: '1.0.0',
|
||||
requiredVersion: '*',
|
||||
},
|
||||
};
|
||||
|
||||
const result = prepareRuntimeSharedDependenciesScript(sharedDependencies);
|
||||
|
||||
expect(result)
|
||||
.toBe(`window['__backstage-module-federation-shared-dependencies__'] = {
|
||||
"@backstage/core-plugin-api": {
|
||||
"version": "1.0.0",
|
||||
"requiredVersion": "*",
|
||||
"module": () => import('@backstage/core-plugin-api')
|
||||
}
|
||||
};`);
|
||||
});
|
||||
});
|
||||
|
||||
const globalSpy = jest.fn();
|
||||
Object.defineProperty(
|
||||
global,
|
||||
'__backstage-module-federation-shared-dependencies__',
|
||||
{
|
||||
get: globalSpy,
|
||||
},
|
||||
);
|
||||
|
||||
describe('getRuntimeSharedDependencies', () => {
|
||||
afterEach(jest.resetAllMocks);
|
||||
|
||||
it('should get runtime shared dependencies with minimal required properties', async () => {
|
||||
const reactMock = { default: { React: 'react' } };
|
||||
const reactDomMock = { default: { ReactDom: 'react-dom' } };
|
||||
|
||||
globalSpy.mockReturnValue({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '*',
|
||||
module: async () => reactMock,
|
||||
},
|
||||
'react-dom': {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '*',
|
||||
module: async () => reactDomMock,
|
||||
},
|
||||
});
|
||||
const result = await buildRuntimeSharedUserOption();
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.shared).toEqual({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
lib: expect.any(Function),
|
||||
shareConfig: {
|
||||
requiredVersion: '*',
|
||||
},
|
||||
},
|
||||
'react-dom': {
|
||||
version: '18.2.0',
|
||||
lib: expect.any(Function),
|
||||
shareConfig: {
|
||||
requiredVersion: '*',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Test that lib functions return the correct modules
|
||||
expect((result?.shared?.react as { lib: () => any }).lib()).toBe(reactMock);
|
||||
expect((result?.shared?.['react-dom'] as { lib: () => any }).lib()).toBe(
|
||||
reactDomMock,
|
||||
);
|
||||
});
|
||||
|
||||
it('should get runtime shared dependencies with custom version and requiredVersion', async () => {
|
||||
globalSpy.mockReturnValue({
|
||||
react: {
|
||||
module: async () => ({ default: {} }),
|
||||
version: '18.2.0',
|
||||
requiredVersion: '^18.0.0',
|
||||
},
|
||||
lodash: {
|
||||
module: async () => ({ default: {} }),
|
||||
version: '4.17.21',
|
||||
requiredVersion: '^4.17.0',
|
||||
},
|
||||
});
|
||||
const result = await buildRuntimeSharedUserOption();
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.shared).toEqual({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
lib: expect.any(Function),
|
||||
shareConfig: {
|
||||
requiredVersion: '^18.0.0',
|
||||
},
|
||||
},
|
||||
lodash: {
|
||||
version: '4.17.21',
|
||||
lib: expect.any(Function),
|
||||
shareConfig: {
|
||||
requiredVersion: '^4.17.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle eager', async () => {
|
||||
globalSpy.mockReturnValue({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '*',
|
||||
eager: false,
|
||||
module: async () => ({ default: {} }),
|
||||
},
|
||||
lodash: {
|
||||
version: '4.17.21',
|
||||
requiredVersion: '*',
|
||||
eager: true,
|
||||
module: async () => ({ default: {} }),
|
||||
},
|
||||
});
|
||||
const result = await buildRuntimeSharedUserOption();
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.shared).toEqual({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
lib: expect.any(Function),
|
||||
shareConfig: {
|
||||
requiredVersion: '*',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
lodash: {
|
||||
version: '4.17.21',
|
||||
lib: expect.any(Function),
|
||||
shareConfig: {
|
||||
requiredVersion: '*',
|
||||
eager: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle singleton', async () => {
|
||||
globalSpy.mockReturnValue({
|
||||
react: {
|
||||
module: async () => ({ default: {} }),
|
||||
version: '18.2.0',
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
},
|
||||
lodash: {
|
||||
module: async () => ({ default: {} }),
|
||||
version: '4.17.21',
|
||||
requiredVersion: '*',
|
||||
singleton: false,
|
||||
},
|
||||
});
|
||||
const result = await buildRuntimeSharedUserOption();
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.shared).toEqual({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
lib: expect.any(Function),
|
||||
shareConfig: {
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
},
|
||||
},
|
||||
lodash: {
|
||||
version: '4.17.21',
|
||||
lib: expect.any(Function),
|
||||
shareConfig: {
|
||||
requiredVersion: '*',
|
||||
singleton: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle an empty object', async () => {
|
||||
globalSpy.mockReturnValue({});
|
||||
const result = await buildRuntimeSharedUserOption();
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.shared).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle module import failures and collect errors', async () => {
|
||||
const mockError = new Error('Module import failed');
|
||||
const reactMock = { default: { React: 'react' } };
|
||||
const lodashMock = { default: { _: 'lodash' } };
|
||||
|
||||
globalSpy.mockReturnValue({
|
||||
react: {
|
||||
module: async () => reactMock,
|
||||
version: '18.2.0',
|
||||
requiredVersion: '^18.0.0',
|
||||
},
|
||||
'failing-module': {
|
||||
module: async () => {
|
||||
throw mockError;
|
||||
},
|
||||
},
|
||||
lodash: {
|
||||
module: async () => lodashMock,
|
||||
version: '4.17.21',
|
||||
requiredVersion: '^4.17.0',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await buildRuntimeSharedUserOption();
|
||||
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toBeInstanceOf(ForwardedError);
|
||||
expect(result.errors[0].message).toContain(
|
||||
'Failed to dynamically import "failing-module" and add it to module federation shared dependencies:',
|
||||
);
|
||||
expect(result.errors[0].cause).toBe(mockError);
|
||||
|
||||
// Should still include successful modules
|
||||
expect(result.shared).toEqual({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
lib: expect.any(Function),
|
||||
shareConfig: {
|
||||
requiredVersion: '^18.0.0',
|
||||
},
|
||||
},
|
||||
lodash: {
|
||||
version: '4.17.21',
|
||||
lib: expect.any(Function),
|
||||
shareConfig: {
|
||||
requiredVersion: '^4.17.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Test that lib functions work correctly
|
||||
expect((result?.shared?.react as { lib: () => any }).lib()).toBe(reactMock);
|
||||
expect((result?.shared?.lodash as { lib: () => any }).lib()).toBe(
|
||||
lodashMock,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple module import failures', async () => {
|
||||
const mockError1 = new Error('First module failed');
|
||||
const mockError2 = new Error('Second module failed');
|
||||
globalSpy.mockReturnValue({
|
||||
'failing-module-1': {
|
||||
module: async () => {
|
||||
throw mockError1;
|
||||
},
|
||||
},
|
||||
'failing-module-2': {
|
||||
module: async () => {
|
||||
throw mockError2;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await buildRuntimeSharedUserOption();
|
||||
|
||||
expect(result.errors).toHaveLength(2);
|
||||
expect(result.errors[0]).toBeInstanceOf(ForwardedError);
|
||||
expect(result.errors[0].message).toContain('failing-module-1');
|
||||
expect(result.errors[0].cause).toBe(mockError1);
|
||||
expect(result.errors[1]).toBeInstanceOf(ForwardedError);
|
||||
expect(result.errors[1].message).toContain('failing-module-2');
|
||||
expect(result.errors[1].cause).toBe(mockError2);
|
||||
|
||||
expect(result.shared).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Host, Runtime, SharedDependencies } from './types';
|
||||
import { default as serialize } from 'serialize-javascript';
|
||||
import type { UserOptions } from '@module-federation/runtime/types';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
|
||||
const BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL =
|
||||
'__backstage-module-federation-shared-dependencies__';
|
||||
|
||||
/**
|
||||
* Prepares the runtime shared dependencies script for the module federation host,
|
||||
* which will be written by the CLI into a Javascript file added as an additional entry point for the frontend bundler.
|
||||
* This script is used in the browser to build the list of shared dependencies provided to the module federation runtime.
|
||||
*
|
||||
* @see {@link buildRuntimeSharedUserOption}
|
||||
*
|
||||
* @param hostSharedDependencies - The shared dependencies for the module federation host.
|
||||
* @returns The runtime shared dependencies script.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function prepareRuntimeSharedDependenciesScript(
|
||||
hostSharedDependencies: SharedDependencies<Host & { version: string }>,
|
||||
) {
|
||||
const runtimeSharedDependencies: SharedDependencies<Host & Runtime> =
|
||||
Object.fromEntries(
|
||||
Object.entries(hostSharedDependencies).map(([name, sharedDep]) => [
|
||||
name,
|
||||
{
|
||||
version: sharedDep.version,
|
||||
requiredVersion: sharedDep.requiredVersion,
|
||||
...(sharedDep.singleton !== undefined
|
||||
? { singleton: sharedDep.singleton }
|
||||
: {}),
|
||||
...(sharedDep.eager !== undefined ? { eager: sharedDep.eager } : {}),
|
||||
// eslint-disable-next-line no-new-func
|
||||
module: new Function(
|
||||
`return () => import('${name}')`,
|
||||
)() as () => Promise<any>,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
return `window['${BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL}'] = ${serialize(
|
||||
runtimeSharedDependencies,
|
||||
{
|
||||
space: 2,
|
||||
unsafe: true,
|
||||
},
|
||||
)};`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the list of shared dependencies provided to the module federation runtime.
|
||||
* It uses the runtime shared dependencies script prepared by {@link prepareRuntimeSharedDependenciesScript}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export async function buildRuntimeSharedUserOption(): Promise<{
|
||||
shared: UserOptions['shared'];
|
||||
errors: ForwardedError[];
|
||||
}> {
|
||||
const runtimeSharedDependencies =
|
||||
(
|
||||
window as {
|
||||
[BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL]?: SharedDependencies<
|
||||
Host & Runtime
|
||||
>;
|
||||
}
|
||||
)[BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL] ?? {};
|
||||
|
||||
const result: UserOptions['shared'] = {};
|
||||
const errors: ForwardedError[] = [];
|
||||
for (const [name, sharedDep] of Object.entries(runtimeSharedDependencies)) {
|
||||
try {
|
||||
const module = await sharedDep.module();
|
||||
result[name] = {
|
||||
version: sharedDep.version,
|
||||
lib: () => module,
|
||||
shareConfig: {
|
||||
singleton: sharedDep.singleton,
|
||||
requiredVersion: sharedDep.requiredVersion,
|
||||
eager: sharedDep.eager,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
errors.push(
|
||||
new ForwardedError(
|
||||
`Failed to dynamically import "${name}" and add it to module federation shared dependencies:`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { shared: result, errors };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generic type for shared dependencies configuration in module federation.
|
||||
*
|
||||
* The ContextFields type parameter is used to provide additional fields
|
||||
* to the shared dependencies configuration according to the context.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SharedDependencies<ContextFields> = {
|
||||
[name: string]: {
|
||||
/** Whether this dependency should be a singleton */
|
||||
singleton?: boolean;
|
||||
} & ContextFields;
|
||||
};
|
||||
|
||||
/**
|
||||
* Specific fields for shared dependencies configured in module federation remote modules.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type Remote = {
|
||||
/** Provided module for fallback. Set to false to not provide a fallback, or a custom import path. */
|
||||
import?: false | string;
|
||||
/** Required version range. Optional for remotes - can be auto-filled from package.json at build time. */
|
||||
requiredVersion?: false | string;
|
||||
/** Version of the shared dependency. Will be resolved at build time by default but can be overridden, but not completely removed. */
|
||||
version?: false | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Specific fields for shared dependencies configured in the module federation host.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type Host = {
|
||||
/** Whether to load this dependency eagerly */
|
||||
eager?: boolean;
|
||||
/** Required version range. Required for host. */
|
||||
requiredVersion: false | string;
|
||||
/** Version of the shared dependency. Will be resolved at build time by default but can be overridden, but not completely removed. */
|
||||
version?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Specific fields for shared dependencies configured when bootstrapping the module federation host at runtime.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export type Runtime = {
|
||||
// version is always expected for runtime host dependencies
|
||||
version: string;
|
||||
module: () => Promise<any>;
|
||||
};
|
||||
Reference in New Issue
Block a user