diff --git a/.changeset/small-ravens-add.md b/.changeset/small-ravens-add.md new file mode 100644 index 0000000000..bec57e94fe --- /dev/null +++ b/.changeset/small-ravens-add.md @@ -0,0 +1,10 @@ +--- +'@backstage/backend-dynamic-feature-service': patch +'@backstage/frontend-dynamic-feature-loader': patch +'@backstage/module-federation-common': patch +'@backstage/cli': patch +--- + +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. diff --git a/docs/frontend-system/building-apps/07-module-federation.md b/docs/frontend-system/building-apps/07-module-federation.md new file mode 100644 index 0000000000..81d2dde7d6 --- /dev/null +++ b/docs/frontend-system/building-apps/07-module-federation.md @@ -0,0 +1,172 @@ +--- +id: module-federation +title: Module Federation +sidebar_label: Module Federation +description: Using Module Federation in Backstage +--- + +## Introduction + +Module Federation is a feature that enables sharing code and dependencies between separately built JavaScript applications at runtime. In Backstage, module federation support allows you to: + +- Build your frontend application as a **module federation host** that can load remote modules at runtime +- Package individual plugins or bundles of several plugins as **module federation remotes** that can be loaded dynamically +- Share dependencies efficiently between the host and remotes to avoid code duplication + +This guide explains how to configure and build both module federation hosts and remotes in Backstage, and how to initialize module federation at runtime using the standard Module Federation Runtime API. + +## Overview + +### Module Federation Host vs Remotes + +In module federation terminology: + +- **Host**: The main frontend application that loads and consumes remote modules. In Backstage, this is your app package (typically `packages/app`). +- **Remote**: A separately built module that can be loaded by the host at runtime. In Backstage, these are typically plugin packages built as module federation remotes. + +### Shared Dependencies + +A critical aspect of module federation is **shared dependencies**. When a host loads remote modules, both need to share common dependencies (like React, React Router, Material-UI) in order to ensure singleton dependencies only have one instance. + +Backstage provides a list of default shared dependencies for common packages like React, React Router, and Material-UI. At build-time the `version` field is automatically resolved from your `package.json` files. + +## Building the Module Federation Host + +The module federation host is your main frontend application. By default, Backstage frontend applications include a default list of module federation shared dependencies. + +When building and bundling the frontend application, the CLI automatically: + +1. Resolves versions of the shared dependencies based on the monorepo dependencies +2. Adds an additional entrypoint to the frontend application bundle with the list of resolved runtime shared dependencies + +## Building Module Federation Remotes + +Plugin packages can be built as module federation remotes, allowing them to be loaded dynamically by a host application. + +### Using the CLI + +To build a plugin as a module federation remote, use the `--module-federation` option with the `package build` command: + +```bash +cd plugins/my-plugin +yarn build --module-federation +``` + +### Build Output + +When building a plugin as a module federation remote, the CLI: + +1. Resolves versions of the shared dependencies based on the monorepo dependencies (done automatically by the Rspack/Webpack module federation plugin) +2. Produces the bundle assets in the `dist` folder, including: + - a `mf-manifest.json` file which contains the module federation manifest + - a `remoteEntry.js` file which is the main entrypoint for the remote module + +## Runtime Usage + +To use module federation in your Backstage app, you need to initialize the Module Federation Runtime with the shared dependencies configuration. + +### Basic Usage + +Here's how to initialize module federation in your app, and load remote modules: + +```typescript title="packages/app/src/moduleFederation.ts" +import { + createInstance, + ModuleFederation, +} from '@module-federation/enhanced/runtime'; +import { buildRuntimeSharedUserOption } from '@backstage/module-federation-common'; + +export async function initializeModuleFederation(): Promise { + // 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', + remotes: [ + { + name: 'my_plugin', + entry: 'http://localhost:3001/mf-manifest.json', + }, + ], + shared, + }); +} + +export async function loadRemote( + instance: ModuleFederation, + name: string, +): Promise { + return await instance.loadRemote(name); +} +``` + +### 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" +import { createInstance } from '@module-federation/enhanced/runtime'; +import { buildRuntimeSharedUserOption } from '@backstage/module-federation-common'; +import { createFrontendFeatureLoader } from '@backstage/frontend-plugin-api'; + +const moduleFederationInstance = createInstance({ + name: 'app-next', + remotes: [], +}); + +const moduleFederationLoader = createFrontendFeatureLoader({ + async loader() { + moduleFederationInstance.registerShared( + (await buildRuntimeSharedUserOption()).shared, + ); + moduleFederationInstance.registerRemotes([ + { + name: 'myFirstRemoteWith2ExposedModules', + entry: + 'https://someCDN.org/myFirstRemoteWith2ExposedModules/mf-manifest.json', + }, + { + name: 'mySecondRemote', + entry: 'https://someCDN.org/mySecondRemote/mf-manifest.json', + }, + ]); + const myFirstRemoteModule1 = await moduleFederationInstance.loadRemote( + 'myFirstRemoteWith2ExposedModules/module1', + ); + const myFirstRemoteModule2 = await moduleFederationInstance.loadRemote( + 'myFirstRemoteWith2ExposedModules/module2', + ); + const mySecondRemoteModule = await moduleFederationInstance.loadRemote( + 'mySecondRemote', + ); + return [ + myFirstRemoteModule1.default, + myFirstRemoteModule2.default, + mySecondRemoteModule.default, + ]; + }, +}); + +const app = createApp({ + features: [moduleFederationLoader], +}); + +export default app.createRoot(); +``` + +Note that, on top of the standard API, we plan to provide a more simplified way to configure module federation remotes. + +Additionally, the [`dynamicFrontendFeaturesLoader`](https://github.com/backstage/backstage/blob/master/packages/frontend-dynamic-feature-loader/src/loader.ts) provided in the [`@backstage/frontend-dynamic-feature-loader`](https://github.com/backstage/backstage/blob/master/packages/frontend-dynamic-feature-loader/README.md) package, which provides an integrated solution to load module federation remotes as dynamic frontend plugins, is a more complete example of a feature loader based on the module federation support. + +## Default Shared Dependencies + +Default shared dependencies are the same for both the host and remotes, and the list can be found in the [`@backstage/module-federation-common`](https://github.com/backstage/backstage/blob/master/packages/module-federation-common/src/defaults.ts) package. diff --git a/docs/tooling/cli/02-build-system.md b/docs/tooling/cli/02-build-system.md index c896aa1399..2c7826a149 100644 --- a/docs/tooling/cli/02-build-system.md +++ b/docs/tooling/cli/02-build-system.md @@ -270,6 +270,12 @@ that are exported from the package, leaving a much cleaner type definition file and making sure that the type definitions are in sync with the generated JavaScript. +### Building Module Federation Remotes + +Frontend plugin packages can be built as module federation remotes, which allows them to be loaded dynamically at runtime by a module federation host (typically your main frontend app). To build a package as a module federation remote, use the `--module-federation` option with the `package build` command. + +More details are given in the [Module Federation](../../frontend-system/building-apps/07-module-federation.md#building-module-federation-remotes) documentation. + ## Bundling The goal of the bundling process is to combine multiple packages together into a diff --git a/docs/tooling/cli/03-commands.md b/docs/tooling/cli/03-commands.md index 0a0931f1db..2a09cb1b11 100644 --- a/docs/tooling/cli/03-commands.md +++ b/docs/tooling/cli/03-commands.md @@ -200,6 +200,7 @@ Options: --skip-build-dependencies Skip the automatic building of local dependencies. Applies to backend packages only. --stats If bundle stats are available, write them to the output directory. Applies to app packages only. --config Config files to load instead of app-config.yaml. Applies to app packages only. (default: []) + --module-federation Build a package as a module federation remote. Applies to frontend plugin packages only. ``` ## package lint diff --git a/microsite/sidebars.ts b/microsite/sidebars.ts index 1e2da6ff2b..b3053e3953 100644 --- a/microsite/sidebars.ts +++ b/microsite/sidebars.ts @@ -586,6 +586,7 @@ export default { 'frontend-system/building-apps/configuring-extensions', 'frontend-system/building-apps/built-in-extensions', 'frontend-system/building-apps/plugin-conversion', + 'frontend-system/building-apps/module-federation', 'frontend-system/building-apps/migrating', ], ), diff --git a/packages/backend-dynamic-feature-service/package.json b/packages/backend-dynamic-feature-service/package.json index 175379b198..4928ee4148 100644 --- a/packages/backend-dynamic-feature-service/package.json +++ b/packages/backend-dynamic-feature-service/package.json @@ -68,7 +68,7 @@ "@backstage/plugin-search-common": "workspace:^", "@backstage/types": "workspace:^", "@manypkg/get-packages": "^1.1.3", - "@module-federation/sdk": "^0.9.0", + "@module-federation/sdk": "^0.21.6", "chokidar": "^3.5.3", "express": "^4.22.0", "express-promise-router": "^4.1.0", diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md index 8979e91a82..0948954ff7 100644 --- a/packages/cli/cli-report.md +++ b/packages/cli/cli-report.md @@ -260,6 +260,7 @@ Usage: program [options] Options: --config --minify + --module-federation --role --skip-build-dependencies --stats diff --git a/packages/cli/package.json b/packages/cli/package.json index 9a34814ead..fbe1b87253 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -55,10 +55,11 @@ "@backstage/errors": "workspace:^", "@backstage/eslint-plugin": "workspace:^", "@backstage/integration": "workspace:^", + "@backstage/module-federation-common": "workspace:^", "@backstage/release-manifests": "workspace:^", "@backstage/types": "workspace:^", "@manypkg/get-packages": "^1.1.3", - "@module-federation/enhanced": "^0.9.0", + "@module-federation/enhanced": "^0.21.6", "@octokit/request": "^8.0.0", "@rollup/plugin-commonjs": "^26.0.0", "@rollup/plugin-json": "^6.0.0", @@ -197,7 +198,7 @@ }, "peerDependencies": { "@jest/environment-jsdom-abstract": "^30.0.0", - "@module-federation/enhanced": "^0.9.0", + "@module-federation/enhanced": "^0.21.6", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.0", "esbuild-loader": "^4.0.0", "eslint-webpack-plugin": "^4.2.0", diff --git a/packages/cli/src/modules/build/commands/package/build/command.ts b/packages/cli/src/modules/build/commands/package/build/command.ts index 690534d724..017024c476 100644 --- a/packages/cli/src/modules/build/commands/package/build/command.ts +++ b/packages/cli/src/modules/build/commands/package/build/command.ts @@ -60,18 +60,26 @@ export async function command(opts: OptionValues): Promise { }); } - // experimental + let isModuleFederationRemote: boolean | undefined = undefined; if ((role as string) === 'frontend-dynamic-container') { console.log( chalk.yellow( `⚠️ WARNING: The 'frontend-dynamic-container' package role is experimental and will receive immediate breaking changes in the future.`, ), ); + isModuleFederationRemote = true; + } + if (opts.moduleFederation) { + isModuleFederationRemote = true; + } + + if (isModuleFederationRemote) { + console.log('Building package as a module federation remote'); return buildFrontend({ targetDir: paths.targetDir, configPaths: [], writeStats: Boolean(opts.stats), - isModuleFederationRemote: true, + isModuleFederationRemote, webpack, }); } diff --git a/packages/cli/src/modules/build/commands/package/start/startFrontend.ts b/packages/cli/src/modules/build/commands/package/start/startFrontend.ts index 1fa439f4ea..027dbf68e3 100644 --- a/packages/cli/src/modules/build/commands/package/start/startFrontend.ts +++ b/packages/cli/src/modules/build/commands/package/start/startFrontend.ts @@ -17,7 +17,7 @@ import { readJson } from 'fs-extra'; import { resolve as resolvePath } from 'node:path'; import { - getModuleFederationOptions, + getModuleFederationRemoteOptions, serveBundle, } from '../../../../build/lib/bundler'; import { paths } from '../../../../../lib/paths'; @@ -55,11 +55,12 @@ export async function startFrontend(options: StartAppOptions) { verifyVersions: options.verifyVersions, skipOpenBrowser: options.skipOpenBrowser, linkedWorkspace: options.linkedWorkspace, - moduleFederation: await getModuleFederationOptions( - packageJson, - resolvePath(paths.targetDir), - options.isModuleFederationRemote, - ), + moduleFederationRemote: options.isModuleFederationRemote + ? await getModuleFederationRemoteOptions( + packageJson, + resolvePath(paths.targetDir), + ) + : undefined, }); await waitForExit(); diff --git a/packages/cli/src/modules/build/index.ts b/packages/cli/src/modules/build/index.ts index 5382adfec9..9b37efaa8b 100644 --- a/packages/cli/src/modules/build/index.ts +++ b/packages/cli/src/modules/build/index.ts @@ -42,6 +42,10 @@ export function registerPackageCommands(command: Command) { (opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]), Array(), ) + .option( + '--module-federation', + 'Build a package as a module federation remote. Applies to frontend plugin packages only.', + ) .action(lazy(() => import('./commands/package/build'), 'command')); } @@ -77,6 +81,10 @@ export const buildPlugin = createCliPlugin({ (opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]), Array(), ) + .option( + '--module-federation', + 'Build a package as a module federation remote. Applies to frontend plugin packages only.', + ) .action(lazy(() => import('./commands/package/build'), 'command')); await defaultCommand.parseAsync(args, { from: 'user' }); }, diff --git a/packages/cli/src/modules/build/lib/buildFrontend.ts b/packages/cli/src/modules/build/lib/buildFrontend.ts index 554b90306d..7b0955a519 100644 --- a/packages/cli/src/modules/build/lib/buildFrontend.ts +++ b/packages/cli/src/modules/build/lib/buildFrontend.ts @@ -16,7 +16,7 @@ import fs from 'fs-extra'; import { resolve as resolvePath } from 'node:path'; -import { buildBundle, getModuleFederationOptions } from './bundler'; +import { buildBundle, getModuleFederationRemoteOptions } from './bundler'; import { getEnvironmentParallelism } from '../../../lib/parallel'; import { loadCliConfig } from '../../config/lib/config'; import { BackstagePackageJson } from '@backstage/cli-node'; @@ -25,7 +25,7 @@ interface BuildAppOptions { targetDir: string; writeStats: boolean; configPaths: string[]; - isModuleFederationRemote?: true; + isModuleFederationRemote?: boolean; webpack?: typeof import('webpack'); } @@ -39,11 +39,12 @@ export async function buildFrontend(options: BuildAppOptions) { entry: 'src/index', parallelism: getEnvironmentParallelism(), statsJsonEnabled: writeStats, - moduleFederation: await getModuleFederationOptions( - packageJson, - resolvePath(targetDir), - options.isModuleFederationRemote, - ), + moduleFederationRemote: options.isModuleFederationRemote + ? await getModuleFederationRemoteOptions( + packageJson, + resolvePath(targetDir), + ) + : undefined, ...(await loadCliConfig({ args: configPaths, fromPackage: packageJson.name, diff --git a/packages/cli/src/modules/build/lib/bundler/bundle.ts b/packages/cli/src/modules/build/lib/bundler/bundle.ts index 6235d9b214..8e500ba808 100644 --- a/packages/cli/src/modules/build/lib/bundler/bundle.ts +++ b/packages/cli/src/modules/build/lib/bundler/bundle.ts @@ -28,6 +28,7 @@ import { BuildOptions } from './types'; import { resolveBundlingPaths, resolveOptionalBundlingPaths } from './paths'; import chalk from 'chalk'; import { createDetectedModulesEntryPoint } from './packageDetection'; +import { createRuntimeSharedDependenciesEntryPoint } from './moduleFederation'; // TODO(Rugvip): Limits from CRA, we might want to tweak these though. const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; @@ -55,8 +56,7 @@ export async function buildBundle(options: BuildOptions) { }; const configs: Configuration[] = []; - - if (options.moduleFederation?.mode === 'remote') { + if (options.moduleFederationRemote) { // Package detection is disabled for remote bundles configs.push(await createConfig(paths, commonConfigOptions)); } else { @@ -65,10 +65,18 @@ export async function buildBundle(options: BuildOptions) { targetPath: paths.targetPath, }); + const moduleFederationSharedDependenciesEntryPoint = + await createRuntimeSharedDependenciesEntryPoint({ + targetPath: paths.targetPath, + }); + configs.push( await createConfig(paths, { ...commonConfigOptions, - additionalEntryPoints: detectedModulesEntryPoint, + additionalEntryPoints: [ + ...detectedModulesEntryPoint, + ...moduleFederationSharedDependenciesEntryPoint, + ], appMode: publicPaths ? 'protected' : 'public', }), ); diff --git a/packages/cli/src/modules/build/lib/bundler/config.ts b/packages/cli/src/modules/build/lib/bundler/config.ts index e68ce9588d..278f6a3b69 100644 --- a/packages/cli/src/modules/build/lib/bundler/config.ts +++ b/packages/cli/src/modules/build/lib/bundler/config.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { BundlingOptions, ModuleFederationOptions } from './types'; import { resolve as resolvePath } from 'node:path'; +import { BundlingOptions, ModuleFederationRemoteOptions } from './types'; import { rspack, Configuration } from '@rspack/core'; import { BundlingPaths } from './paths'; @@ -39,14 +39,13 @@ import { ConfigInjectingHtmlWebpackPlugin } from './ConfigInjectingHtmlWebpackPl export function resolveBaseUrl( config: Config, - moduleFederation?: ModuleFederationOptions, + moduleFederationRemote?: ModuleFederationRemoteOptions, ): URL { const baseUrl = config.getOptionalString('app.baseUrl'); - const defaultBaseUrl = - moduleFederation?.mode === 'remote' - ? `http://localhost:${process.env.PORT ?? '3000'}` - : 'http://localhost:3000'; + const defaultBaseUrl = moduleFederationRemote + ? `http://localhost:${process.env.PORT ?? '3000'}` + : 'http://localhost:3000'; try { return new URL(baseUrl ?? '/', defaultBaseUrl); @@ -57,12 +56,12 @@ export function resolveBaseUrl( export function resolveEndpoint( config: Config, - moduleFederation?: ModuleFederationOptions, + moduleFederationRemote?: ModuleFederationRemoteOptions, ): { host: string; port: number; } { - const url = resolveBaseUrl(config, moduleFederation); + const url = resolveBaseUrl(config, moduleFederationRemote); return { host: config.getOptionalString('app.listen.host') ?? url.hostname, @@ -117,7 +116,7 @@ export async function createConfig( checksEnabled, isDev, frontendConfig, - moduleFederation, + moduleFederationRemote, publicSubPath = '', webpack, } = options; @@ -126,7 +125,7 @@ export async function createConfig( // Any package that is part of the monorepo but outside the monorepo root dir need // separate resolution logic. - const validBaseUrl = resolveBaseUrl(frontendConfig, moduleFederation); + const validBaseUrl = resolveBaseUrl(frontendConfig, moduleFederationRemote); let publicPath = validBaseUrl.pathname.replace(/\/$/, ''); if (publicSubPath) { publicPath = `${publicPath}${publicSubPath}`.replace('//', '/'); @@ -135,7 +134,7 @@ export async function createConfig( if (isDev) { const { host, port } = resolveEndpoint( options.frontendConfig, - options.moduleFederation, + options.moduleFederationRemote, ); const refreshOptions = { @@ -186,7 +185,7 @@ export async function createConfig( }), ); - if (options.moduleFederation?.mode !== 'remote') { + if (!options.moduleFederationRemote) { const templateOptions = { meta: { 'backstage-app-mode': options?.appMode ?? 'public', @@ -231,20 +230,17 @@ export async function createConfig( ); } - if (options.moduleFederation) { - const isRemote = options.moduleFederation?.mode === 'remote'; - + if (options.moduleFederationRemote) { const AdaptedModuleFederationPlugin = webpack ? (require('@module-federation/enhanced/webpack') .ModuleFederationPlugin as unknown as typeof ModuleFederationPlugin) : ModuleFederationPlugin; - const exposes = options.moduleFederation?.exposes + const exposes = options.moduleFederationRemote.exposes ? Object.fromEntries( - Object.entries(options.moduleFederation?.exposes).map(([k, v]) => [ - k, - resolvePath(paths.targetPath, v), - ]), + Object.entries(options.moduleFederationRemote?.exposes).map( + ([k, v]) => [k, resolvePath(paths.targetPath, v)], + ), ) : { '.': paths.targetEntry, @@ -252,66 +248,28 @@ export async function createConfig( plugins.push( new AdaptedModuleFederationPlugin({ - ...(isRemote && { - filename: 'remoteEntry.js', - exposes, - }), - name: options.moduleFederation.name, + filename: 'remoteEntry.js', + exposes, + name: options.moduleFederationRemote.name, runtime: false, - shared: { - // React - react: { - singleton: true, - requiredVersion: '*', - eager: !isRemote, - ...(isRemote && { import: false }), - }, - 'react-dom': { - singleton: true, - requiredVersion: '*', - eager: !isRemote, - ...(isRemote && { import: false }), - }, - // React Router - 'react-router': { - singleton: true, - requiredVersion: '*', - eager: !isRemote, - ...(isRemote && { import: false }), - }, - 'react-router-dom': { - singleton: true, - requiredVersion: '*', - eager: !isRemote, - ...(isRemote && { import: false }), - }, - // MUI v4 - // not setting import: false for MUI packages as this - // will break once Backstage moves to BUI - '@material-ui/core/styles': { - singleton: true, - requiredVersion: '*', - eager: !isRemote, - }, - '@material-ui/styles': { - singleton: true, - requiredVersion: '*', - eager: !isRemote, - }, - // MUI v5 - // not setting import: false for MUI packages as this - // will break once Backstage moves to BUI - '@mui/material/styles/': { - singleton: true, - requiredVersion: '*', - eager: !isRemote, - }, - '@emotion/react': { - singleton: true, - requiredVersion: '*', - eager: !isRemote, - }, - }, + shared: Object.fromEntries( + Object.entries(options.moduleFederationRemote.sharedDependencies).map( + ([name, p]) => [ + name, + { + ...(p.version === undefined ? {} : { version: p.version }), + ...(p.requiredVersion === undefined + ? {} + : { requiredVersion: p.requiredVersion }), + ...(p.singleton === undefined + ? {} + : { singleton: p.singleton }), + ...(p.import === undefined ? {} : { import: p.import }), + eager: false, + }, + ], + ), + ), }), ); } @@ -426,10 +384,9 @@ export async function createConfig( rules: loaders, }, output: { - uniqueName: options.moduleFederation?.name, + uniqueName: options.moduleFederationRemote?.name, path: paths.targetDist, - publicPath: - options.moduleFederation?.mode === 'remote' ? 'auto' : `${publicPath}/`, + publicPath: options.moduleFederationRemote ? 'auto' : `${publicPath}/`, filename: isDev ? '[name].js' : 'static/[name].[contenthash:8].js', chunkFilename: isDev ? '[name].chunk.js' diff --git a/packages/cli/src/modules/build/lib/bundler/index.ts b/packages/cli/src/modules/build/lib/bundler/index.ts index d8dd6ed0d6..7cb05e3cf0 100644 --- a/packages/cli/src/modules/build/lib/bundler/index.ts +++ b/packages/cli/src/modules/build/lib/bundler/index.ts @@ -15,5 +15,5 @@ */ export { buildBundle } from './bundle'; -export { getModuleFederationOptions } from './moduleFederation'; +export { getModuleFederationRemoteOptions } from './moduleFederation'; export { serveBundle } from './server'; diff --git a/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts b/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts index 0bc606f82a..becf438c34 100644 --- a/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts +++ b/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts @@ -14,36 +14,34 @@ * limitations under the License. */ -import chalk from 'chalk'; -import { ModuleFederationOptions } from './types'; +import { ModuleFederationRemoteOptions } from './types'; import { BackstagePackageJson } from '@backstage/cli-node'; import { readEntryPoints } from '../../../../lib/entryPoints'; import { createTypeDistProject, getEntryPointDefaultFeatureType, } from '../../../../lib/typeDistProject'; +import { + SharedDependencies, + Host, + prepareRuntimeSharedDependenciesScript, + defaultRemoteSharedDependencies, + defaultHostSharedDependencies, +} from '@backstage/module-federation-common'; +import { dirname, join as joinPath, resolve as resolvePath } from 'path'; +import fs from 'fs-extra'; +import chokidar from 'chokidar'; +import PQueue from 'p-queue'; -export async function getModuleFederationOptions( +// Remote modules management utilities + +export async function getModuleFederationRemoteOptions( packageJson: BackstagePackageJson, packageDir: string, - isModuleFederationRemote?: boolean, -): Promise { - if ( - !isModuleFederationRemote && - !process.env.EXPERIMENTAL_MODULE_FEDERATION - ) { - return undefined; - } - - console.log( - chalk.yellow( - `⚠️ WARNING: Module federation is experimental and will receive immediate breaking changes in the future.`, - ), - ); - - let exposes: ModuleFederationOptions['exposes']; +): Promise { + let exposes: ModuleFederationRemoteOptions['exposes']; const packageRole = packageJson.backstage?.role; - if (isModuleFederationRemote && packageJson.exports && packageRole) { + if (packageJson.exports && packageRole) { const project = await createTypeDistProject(); exposes = Object.fromEntries( readEntryPoints(packageJson) @@ -70,7 +68,6 @@ export async function getModuleFederationOptions( } return { - mode: isModuleFederationRemote ? 'remote' : 'host', // The default output mode requires the name to be a usable as a code // symbol, there might be better options here but for now we need to // sanitize the name. @@ -79,5 +76,101 @@ export async function getModuleFederationOptions( .replaceAll('/', '__') .replaceAll('-', '_'), exposes, + sharedDependencies: defaultRemoteSharedDependencies(), }; } + +// Module federation host management utilities + +const RUNTIME_SHARED_DEPENDENCIES_MODULE_NAME = + '__backstage-module-federation-runtime-shared-dependencies__'; + +// Make sure we're not issuing multiple writes at the same time, which can cause partial overwrites +const writeQueue = new PQueue({ concurrency: 1 }); + +async function writeRuntimeSharedDependenciesModule( + targetPath: string, + runtimeSharedDependencies: SharedDependencies, +) { + const script = prepareRuntimeSharedDependenciesScript( + runtimeSharedDependencies, + ); + + await writeQueue.add(async () => { + const path = joinPath( + targetPath, + 'node_modules', + `${RUNTIME_SHARED_DEPENDENCIES_MODULE_NAME}.js`, + ); + + await fs.ensureDir(dirname(path)); + await fs.writeFile(path, script); + }); +} + +function resolveSharedDependencyVersions( + targetPath: string, + hostSharedDependencies: SharedDependencies, +): SharedDependencies { + return Object.fromEntries( + Object.entries(hostSharedDependencies) + .filter(([_, sharedDep]) => sharedDep !== undefined) + .map(([name, sharedDep]) => { + // Use require.resolve to find the package + // For scoped modules, keep the scope and the module name, but remove any sub-folder + const nameParts = name.split('/'); + const moduleName = + nameParts[0].startsWith('@') && nameParts.length > 1 + ? `${nameParts[0]}/${nameParts[1]}` + : nameParts[0]; + let packagePath: string; + try { + packagePath = require.resolve(`${moduleName}/package.json`, { + paths: [targetPath], + }); + } catch (e) { + throw new Error( + `Failed to resolve package.json for module federation shared dependency '${name}': ${e}`, + ); + } + const packageJson = require(packagePath); + + if (sharedDep.version && packageJson.version !== sharedDep.version) { + throw new Error( + `Version mismatch for module federation shared dependency '${name}': '${sharedDep.version}' vs '${packageJson.version}' found in '${packagePath}'.`, + ); + } + + return [ + name, + { ...sharedDep, version: sharedDep.version ?? packageJson.version }, + ]; + }), + ); +} + +export async function createRuntimeSharedDependenciesEntryPoint(options: { + targetPath: string; + watch?: () => void; +}): Promise { + const { targetPath, watch } = options; + + const doWriteSharedDependenciesModule = async () => { + const sharedDependencies = defaultHostSharedDependencies(); + await writeRuntimeSharedDependenciesModule( + targetPath, + resolveSharedDependencyVersions(targetPath, sharedDependencies), + ); + }; + + if (watch) { + const watcher = chokidar.watch(resolvePath(targetPath, 'package.json')); + watcher.on('change', async () => { + await doWriteSharedDependenciesModule(); + watch(); + }); + } + await doWriteSharedDependenciesModule(); + + return [RUNTIME_SHARED_DEPENDENCIES_MODULE_NAME]; +} diff --git a/packages/cli/src/modules/build/lib/bundler/server.ts b/packages/cli/src/modules/build/lib/bundler/server.ts index 0f287c0713..b2d480f061 100644 --- a/packages/cli/src/modules/build/lib/bundler/server.ts +++ b/packages/cli/src/modules/build/lib/bundler/server.ts @@ -28,6 +28,7 @@ import { createConfig, resolveBaseUrl, resolveEndpoint } from './config'; import { createDetectedModulesEntryPoint } from './packageDetection'; import { resolveBundlingPaths, resolveOptionalBundlingPaths } from './paths'; import { ServeOptions } from './types'; +import { createRuntimeSharedDependenciesEntryPoint } from './moduleFederation'; export async function serveBundle(options: ServeOptions) { const paths = resolveBundlingPaths(options); @@ -108,10 +109,10 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be } const { frontendConfig, fullConfig } = cliConfig; - const url = resolveBaseUrl(frontendConfig, options.moduleFederation); + const url = resolveBaseUrl(frontendConfig, options.moduleFederationRemote); const { host, port } = resolveEndpoint( frontendConfig, - options.moduleFederation, + options.moduleFederationRemote, ); const detectedModulesEntryPoint = await createDetectedModulesEntryPoint({ @@ -122,6 +123,14 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be }, }); + const moduleFederationSharedDependenciesEntryPoint = + await createRuntimeSharedDependenciesEntryPoint({ + targetPath: paths.targetPath, + watch() { + triggerReload(); + }, + }); + const webpack = process.env.LEGACY_WEBPACK_BUILD ? (require('webpack') as typeof import('webpack')) : undefined; @@ -140,8 +149,11 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be const config = await createConfig(paths, { ...commonConfigOptions, - additionalEntryPoints: detectedModulesEntryPoint, - moduleFederation: options.moduleFederation, + additionalEntryPoints: [ + ...detectedModulesEntryPoint, + ...moduleFederationSharedDependenciesEntryPoint, + ], + moduleFederationRemote: options.moduleFederationRemote, }); const bundler = (webpack ?? rspack) as typeof rspack; @@ -181,17 +193,16 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be directory: paths.targetPublic, } : undefined, - historyApiFallback: - options.moduleFederation?.mode === 'remote' - ? false - : { - // Paths with dots should still use the history fallback. - // See https://github.com/facebookincubator/create-react-app/issues/387. - disableDotRule: true, + historyApiFallback: options.moduleFederationRemote + ? false + : { + // Paths with dots should still use the history fallback. + // See https://github.com/facebookincubator/create-react-app/issues/387. + disableDotRule: true, - // The index needs to be rewritten relative to the new public path, including subroutes. - index: `${config.output?.publicPath}index.html`, - }, + // The index needs to be rewritten relative to the new public path, including subroutes. + index: `${config.output?.publicPath}index.html`, + }, server: url.protocol === 'https:' ? { diff --git a/packages/cli/src/modules/build/lib/bundler/types.ts b/packages/cli/src/modules/build/lib/bundler/types.ts index 59b0dcb70d..835b92e180 100644 --- a/packages/cli/src/modules/build/lib/bundler/types.ts +++ b/packages/cli/src/modules/build/lib/bundler/types.ts @@ -17,18 +17,21 @@ import { AppConfig, Config } from '@backstage/config'; import { BundlingPathsOptions } from './paths'; import { ConfigSchema } from '@backstage/config-loader'; +import { + SharedDependencies, + Remote, +} from '@backstage/module-federation-common'; -export type ModuleFederationOptions = { +export type ModuleFederationRemoteOptions = { // Unique name for this module federation bundle name: string; - // Whether this is a host or remote bundle - mode: 'host' | 'remote'; exposes?: { /** * Modules that should be exposed by this container. */ [k: string]: string; }; + sharedDependencies: SharedDependencies; }; export type BundlingOptions = { @@ -44,7 +47,7 @@ export type BundlingOptions = { appMode?: string; // An external linked workspace to include in the bundling linkedWorkspace?: string; - moduleFederation?: ModuleFederationOptions; + moduleFederationRemote?: ModuleFederationRemoteOptions; webpack?: typeof import('webpack'); }; @@ -54,7 +57,7 @@ export type ServeOptions = BundlingPathsOptions & { configPaths: string[]; verifyVersions?: boolean; skipOpenBrowser?: boolean; - moduleFederation?: ModuleFederationOptions; + moduleFederationRemote?: ModuleFederationRemoteOptions; // An external linked workspace to include in the bundling linkedWorkspace?: string; }; @@ -68,7 +71,7 @@ export type BuildOptions = BundlingPathsOptions & { frontendConfig: Config; frontendAppConfigs: AppConfig[]; fullConfig: Config; - moduleFederation?: ModuleFederationOptions; + moduleFederationRemote?: ModuleFederationRemoteOptions; webpack?: typeof import('webpack'); }; diff --git a/packages/frontend-dynamic-feature-loader/package.json b/packages/frontend-dynamic-feature-loader/package.json index 48ae8d9132..105e3c0884 100644 --- a/packages/frontend-dynamic-feature-loader/package.json +++ b/packages/frontend-dynamic-feature-loader/package.json @@ -34,9 +34,10 @@ "dependencies": { "@backstage/config": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", - "@module-federation/enhanced": "^0.9.0", - "@module-federation/runtime": "^0.9.0", - "@module-federation/sdk": "^0.9.0", + "@backstage/module-federation-common": "workspace:^", + "@module-federation/enhanced": "^0.21.6", + "@module-federation/runtime": "^0.21.6", + "@module-federation/sdk": "^0.21.6", "cross-fetch": "^4.0.0", "uri-template": "^2.0.0" }, diff --git a/packages/frontend-dynamic-feature-loader/report.api.md b/packages/frontend-dynamic-feature-loader/report.api.md index 3188551bad..fb14cb7c57 100644 --- a/packages/frontend-dynamic-feature-loader/report.api.md +++ b/packages/frontend-dynamic-feature-loader/report.api.md @@ -3,8 +3,9 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { FederationRuntimePlugin } from '@module-federation/enhanced/runtime'; import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api'; +import { ModuleFederation } from '@module-federation/enhanced/runtime'; +import { ModuleFederationRuntimePlugin } from '@module-federation/enhanced/runtime'; import { ShareStrategy } from '@module-federation/runtime/types'; import { UserOptions } from '@module-federation/runtime/types'; @@ -18,7 +19,8 @@ export type DynamicFrontendFeaturesLoaderOptions = { moduleFederation: { shared?: UserOptions['shared']; shareStrategy?: ShareStrategy; - plugins?: Array; + plugins?: Array; + instance?: ModuleFederation; }; }; ``` diff --git a/packages/frontend-dynamic-feature-loader/src/loader.test.tsx b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx index c0bf5822cc..b24aea0fdd 100644 --- a/packages/frontend-dynamic-feature-loader/src/loader.test.tsx +++ b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx @@ -15,21 +15,45 @@ */ import { mockApis, registerMswTestHooks } from '@backstage/test-utils'; -import { - DynamicFrontendFeaturesLoaderOptions, - dynamicFrontendFeaturesLoader, -} from './loader'; +import { dynamicFrontendFeaturesLoader } from './loader'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import { ModuleFederationRuntimePlugin } from '@module-federation/enhanced/runtime'; import { RemoteEntryExports } from '@module-federation/runtime/types'; import { Module } from '@module-federation/sdk'; import { createFrontendPlugin } from '@backstage/frontend-plugin-api'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { InternalFrontendFeatureLoader } from '../../frontend-plugin-api/src/wiring/createFrontendFeatureLoader'; import { resetFederationGlobalInfo } from '@module-federation/runtime/core'; +import { Config } from '@backstage/config'; const baseUrl = 'http://localhost:7007'; +function mockDefaultConfig(): Config { + return mockApis.config({ + data: { + app: { + packages: { + include: [], + }, + }, + backend: { + baseUrl, + }, + dynamicPlugins: {}, + }, + }); +} + +const globalSpy = jest.fn(); +Object.defineProperty( + global, + '__backstage-module-federation-shared-dependencies__', + { + get: globalSpy, + }, +); + describe('dynamicFrontendFeaturesLoader', () => { const server = setupServer(); registerMswTestHooks(server); @@ -46,31 +70,27 @@ describe('dynamicFrontendFeaturesLoader', () => { }, }; - const getCommonOptions = (): DynamicFrontendFeaturesLoaderOptions => ({ - moduleFederation: { + const testModuleFederationPlugins: ModuleFederationRuntimePlugin[] = [ + { // We add this module federation plugin to mock the // effective retrieval of the remote content, since it // normally requires a host application built with module federation support, // and won't work by default in Jest tests. - plugins: [ - { - name: 'load-entry-mock', - loadEntry: async args => { - return { - get: (id: string) => async () => { - return await mocks.federation.get({ - name: args.remoteInfo.name, - id, - }); - }, - init: async () => {}, - } as RemoteEntryExports; + name: 'load-entry-mock', + loadEntry: async args => { + return { + get: (id: string) => async () => { + return await mocks.federation.get({ + name: args.remoteInfo.name, + id, + }); }, - onLoad: mocks.federation.onLoad, - }, - ], + init: async () => {}, + } as RemoteEntryExports; + }, + onLoad: mocks.federation.onLoad, }, - }); + ]; const manifestDummyData = { metaData: { @@ -104,9 +124,73 @@ describe('dynamicFrontendFeaturesLoader', () => { mocks.console.debug.mockReset(); mocks.federation.get.mockReset(); mocks.federation.onLoad.mockReset(); + globalSpy.mockReset(); resetFederationGlobalInfo(); }); + const mockedDefaultSharedDependencies = { + react: { + version: '18.3.1', + requiredVersion: '*', + singleton: true, + eager: true, + module: () => ({ default: {} }), + }, + 'react-dom': { + version: '18.3.1', + requiredVersion: '*', + singleton: true, + eager: true, + module: () => ({ default: {} }), + }, + 'react-router': { + version: '6.28.2', + requiredVersion: '*', + singleton: true, + eager: true, + module: () => ({ default: {} }), + }, + 'react-router-dom': { + version: '6.28.2', + requiredVersion: '*', + singleton: true, + eager: true, + module: () => ({ default: {} }), + }, + '@material-ui/core/styles': { + version: '4.12.4', + requiredVersion: '*', + singleton: true, + eager: true, + module: () => ({ default: {} }), + }, + '@material-ui/styles': { + version: '4.11.5', + requiredVersion: '*', + singleton: true, + eager: true, + module: () => ({ default: {} }), + }, + '@mui/material/styles/': { + version: '5.16.14', + requiredVersion: '*', + singleton: true, + eager: true, + module: () => ({ default: {} }), + }, + '@emotion/react': { + version: '11.13.5', + requiredVersion: '*', + singleton: true, + eager: true, + module: () => ({ default: {} }), + }, + }; + + beforeEach(() => { + globalSpy.mockReturnValue(mockedDefaultSharedDependencies); + }); + it('should return immediately if dynamic plugins are not enabled in config', async () => { let manifestsEndpointCalled = false; server.use( @@ -121,7 +205,9 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ config: mockApis.config({ @@ -196,22 +282,12 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); @@ -320,22 +396,12 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); @@ -435,22 +501,12 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); @@ -526,22 +582,12 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); @@ -588,22 +634,12 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); @@ -635,22 +671,12 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); @@ -739,22 +765,12 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); @@ -867,22 +883,12 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); @@ -978,30 +984,21 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); - expect(errorCalls).toEqual([ - "Failed loading remote module 'plugin_1' of dynamic plugin 'plugin-1': Error: [ Federation Runtime ]: [ Federation Runtime ]: [ Federation Runtime ]: http://localhost:7007/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json is not a federation manifest", - ]); const warnCalls = mocks.console.warn.mock.calls.flatMap(e => e[0]); - expect(warnCalls).toEqual([]); + + expect(warnCalls).toEqual(['[ Federation Runtime ]']); + expect(errorCalls).toEqual([ + "Failed loading remote module 'plugin_1' of dynamic plugin 'plugin-1': Error: [ Federation Runtime ]: http://localhost:7007/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json is not a federation manifest", + ]); expect(features).toMatchObject([ { $$type: '@backstage/FrontendPlugin', @@ -1086,30 +1083,20 @@ describe('dynamicFrontendFeaturesLoader', () => { const features = await ( dynamicFrontendFeaturesLoader({ - ...getCommonOptions(), + moduleFederation: { + plugins: testModuleFederationPlugins, + }, }) as InternalFrontendFeatureLoader ).loader({ - config: mockApis.config({ - data: { - app: { - packages: { - include: [], - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), + config: mockDefaultConfig(), }); const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); - expect(errorCalls).toEqual([ - "Failed loading remote module 'plugin_1' of dynamic plugin 'plugin-1': Error: [ Federation Runtime ]: [ Federation Runtime ]: [ Federation Runtime ]: http://localhost:7007/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json is not a federation manifest", - ]); const warnCalls = mocks.console.warn.mock.calls.flatMap(e => e[0]); - expect(warnCalls).toEqual([]); + expect(warnCalls).toEqual(['[ Federation Runtime ]']); + expect(errorCalls).toEqual([ + "Failed loading remote module 'plugin_1' of dynamic plugin 'plugin-1': Error: [ Federation Runtime ]: http://localhost:7007/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json is not a federation manifest", + ]); expect(features).toMatchObject([ { $$type: '@backstage/FrontendPlugin', @@ -1133,4 +1120,187 @@ describe('dynamicFrontendFeaturesLoader', () => { }, ]); }); + + it('should initialize module federation with resolved shared dependencies', async () => { + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes`, + (_, res, ctx) => res(ctx.json([])), + ), + ); + + const spyInit = jest.fn(); + await ( + dynamicFrontendFeaturesLoader({ + moduleFederation: { + plugins: [ + { + name: 'spy-init', + init: args => { + spyInit(args); + return args; + }, + }, + ...testModuleFederationPlugins, + ], + }, + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockDefaultConfig(), + }); + + expect(spyInit).toHaveBeenCalledTimes(1); + expect(spyInit.mock.calls[0][0].options).toMatchObject({ + shared: Object.fromEntries( + Object.entries(mockedDefaultSharedDependencies).map( + ([name, shared]) => [ + name, + [ + { + version: shared.version, + shareConfig: { + singleton: shared.singleton, + requiredVersion: shared.requiredVersion, + eager: shared.eager, + }, + strategy: 'version-first', + }, + ], + ], + ), + ), + }); + }); + + it('should initialize module federation with shareStrategy option', async () => { + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes`, + (_, res, ctx) => res(ctx.json([])), + ), + ); + + const spyInit = jest.fn(); + await ( + dynamicFrontendFeaturesLoader({ + moduleFederation: { + shareStrategy: 'loaded-first', + plugins: [ + { + name: 'spy-init', + init: args => { + spyInit(args); + return args; + }, + }, + ...testModuleFederationPlugins, + ], + }, + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockDefaultConfig(), + }); + + const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); + expect(errorCalls).toEqual([]); + const warnCalls = mocks.console.warn.mock.calls.flatMap(e => e[0]); + expect(warnCalls).toEqual([]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([]); + const debugCalls = mocks.console.debug.mock.calls.flatMap(e => e[0]); + expect(debugCalls).toEqual([]); + expect(spyInit).toHaveBeenCalledTimes(1); + expect(spyInit.mock.calls[0][0].options).toMatchObject({ + shareStrategy: 'loaded-first', + shared: { + react: [ + { + version: '18.3.1', + shareConfig: { + singleton: true, + requiredVersion: '*', + }, + strategy: 'loaded-first', + }, + ], + }, + }); + }); + + it('should return an empty list of features if module federation initialization fails', async () => { + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes`, + (_, res, ctx) => + res( + ctx.json([ + { + packageName: 'plugin-test-dynamic', + exposedModules: ['.'], + remoteInfo: { + name: 'test_plugin', + entry: `${baseUrl}/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json`, + }, + }, + ]), + ), + ), + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json`, + (_, res, ctx) => + res( + ctx.json({ + name: 'test_plugin', + ...manifestDummyData, + exposes: [ + { + id: 'test_plugin:.', + name: '.', + path: '.', + ...manifestExposedRemoteDummyData, + }, + ], + }), + ), + ), + ); + + mocks.federation.get.mockReturnValue({ + default: createFrontendPlugin({ + pluginId: 'test-plugin', + extensions: [], + }), + }); + + const features = await ( + dynamicFrontendFeaturesLoader({ + moduleFederation: { + plugins: [ + ...testModuleFederationPlugins, + { + name: 'fail-init', + init: () => { + throw new Error('An initialization error'); + }, + }, + ], + }, + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockDefaultConfig(), + }); + + const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); + expect(errorCalls).toEqual([ + 'Failed initializing module federation: Error: An initialization error', + ]); + const warnCalls = mocks.console.warn.mock.calls.flatMap(e => e[0]); + expect(warnCalls).toEqual([]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([]); + const debugCalls = mocks.console.debug.mock.calls.flatMap(e => e[0]); + expect(debugCalls).toEqual([]); + expect(mocks.federation.get.mock.calls.flatMap(e => e[0])).toEqual([]); + expect(features).toEqual([]); + }); }); diff --git a/packages/frontend-dynamic-feature-loader/src/loader.ts b/packages/frontend-dynamic-feature-loader/src/loader.ts index 9e2585d1fa..616481ae95 100644 --- a/packages/frontend-dynamic-feature-loader/src/loader.ts +++ b/packages/frontend-dynamic-feature-loader/src/loader.ts @@ -15,9 +15,9 @@ */ import { - FederationRuntimePlugin, - init, - loadRemote, + ModuleFederationRuntimePlugin, + createInstance, + ModuleFederation, } from '@module-federation/enhanced/runtime'; import { Module } from '@module-federation/sdk'; import { DefaultApiClient, Remote } from './schema/openapi'; @@ -27,6 +27,7 @@ import { createFrontendFeatureLoader, } from '@backstage/frontend-plugin-api'; import { ShareStrategy, UserOptions } from '@module-federation/runtime/types'; +import { buildRuntimeSharedUserOption } from '@backstage/module-federation-common'; /** * @@ -39,7 +40,8 @@ export type DynamicFrontendFeaturesLoaderOptions = { moduleFederation: { shared?: UserOptions['shared']; shareStrategy?: ShareStrategy; - plugins?: Array; + plugins?: Array; + instance?: ModuleFederation; }; }; @@ -98,18 +100,50 @@ export function dynamicFrontendFeaturesLoader( return []; } + let instance: ModuleFederation; try { - init({ - ...options?.moduleFederation, - name: appPackageName - .replaceAll('@', '') - .replaceAll('/', '__') - .replaceAll('-', '_'), - remotes: frontendPluginRemotes.map(remote => ({ - alias: remote.packageName, - ...remote.remoteInfo, - })), - }); + if (options?.moduleFederation?.instance) { + instance = options.moduleFederation.instance; + } else { + const { shared, errors } = await buildRuntimeSharedUserOption(); + for (const err of errors) { + error(err.message, err.cause); + } + + const createOptions: UserOptions = { + name: appPackageName + .replaceAll('@', '') + .replaceAll('/', '__') + .replaceAll('-', '_'), + shared, + remotes: [], + }; + if (options?.moduleFederation?.shareStrategy) { + createOptions.shareStrategy = + options.moduleFederation.shareStrategy; + } + instance = createInstance(createOptions); + } + + const userOptions: UserOptions = { + name: instance.name, + remotes: [], + }; + + if (options?.moduleFederation?.plugins) { + userOptions.plugins = options.moduleFederation.plugins; + } + if (options?.moduleFederation?.shareStrategy) { + userOptions.shareStrategy = options.moduleFederation.shareStrategy; + } + if (options?.moduleFederation?.shared) { + userOptions.shared = options.moduleFederation.shared; + } + userOptions.remotes = frontendPluginRemotes.map(remote => ({ + alias: remote.packageName, + ...remote.remoteInfo, + })); + instance.initOptions(userOptions); } catch (err) { error(`Failed initializing module federation`, err); return []; @@ -131,7 +165,7 @@ export function dynamicFrontendFeaturesLoader( : `${remote.remoteInfo.name}/${exposedModuleName}`; let module: Module; try { - module = await loadRemote(remoteModuleName); + module = await instance.loadRemote(remoteModuleName); } catch (err) { error( `Failed loading remote module '${remoteModuleName}' of dynamic plugin '${remote.packageName}'`, diff --git a/packages/module-federation-common/.eslintrc.js b/packages/module-federation-common/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/module-federation-common/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/module-federation-common/README.md b/packages/module-federation-common/README.md new file mode 100644 index 0000000000..fcf1f5726c --- /dev/null +++ b/packages/module-federation-common/README.md @@ -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, 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. diff --git a/packages/module-federation-common/catalog-info.yaml b/packages/module-federation-common/catalog-info.yaml new file mode 100644 index 0000000000..a5843ad116 --- /dev/null +++ b/packages/module-federation-common/catalog-info.yaml @@ -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 diff --git a/packages/module-federation-common/package.json b/packages/module-federation-common/package.json new file mode 100644 index 0000000000..dfed332525 --- /dev/null +++ b/packages/module-federation-common/package.json @@ -0,0 +1,68 @@ +{ + "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" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@types/react": "^18.0.0", + "react": "^18.0.2", + "react-dom": "^18.0.2", + "react-router-dom": "^6.30.2" + }, + "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.30.2", + "react-router-dom": "^6.30.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } +} diff --git a/packages/module-federation-common/report.api.md b/packages/module-federation-common/report.api.md new file mode 100644 index 0000000000..ec0fb25394 --- /dev/null +++ b/packages/module-federation-common/report.api.md @@ -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; + +// @public +export function defaultRemoteSharedDependencies(): SharedDependencies; + +// @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 = { + [name: string]: { + singleton?: boolean; + } & ContextFields; +}; +``` diff --git a/packages/module-federation-common/src/defaults.test.ts b/packages/module-federation-common/src/defaults.test.ts new file mode 100644 index 0000000000..cc84c82e09 --- /dev/null +++ b/packages/module-federation-common/src/defaults.test.ts @@ -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, + }); + }); +}); diff --git a/packages/module-federation-common/src/defaults.ts b/packages/module-federation-common/src/defaults.ts new file mode 100644 index 0000000000..8197f5eb98 --- /dev/null +++ b/packages/module-federation-common/src/defaults.ts @@ -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 { + 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 { + return Object.fromEntries( + Object.entries(defaultSharedDependencies).map(([name, p]) => [ + name, + { + singleton: p.singleton, + ...p.remote, + }, + ]), + ); +} diff --git a/packages/module-federation-common/src/index.ts b/packages/module-federation-common/src/index.ts new file mode 100644 index 0000000000..cc79a20c6e --- /dev/null +++ b/packages/module-federation-common/src/index.ts @@ -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'; diff --git a/packages/module-federation-common/src/runtime.test.ts b/packages/module-federation-common/src/runtime.test.ts new file mode 100644 index 0000000000..fb73b866fa --- /dev/null +++ b/packages/module-federation-common/src/runtime.test.ts @@ -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({}); + }); +}); diff --git a/packages/module-federation-common/src/runtime.ts b/packages/module-federation-common/src/runtime.ts new file mode 100644 index 0000000000..dada9dcf46 --- /dev/null +++ b/packages/module-federation-common/src/runtime.ts @@ -0,0 +1,110 @@ +/* + * 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, +) { + type StringModule = Omit & { module: string }; + const runtimeSharedDependencies: SharedDependencies = + 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 }; +} diff --git a/packages/module-federation-common/src/types.ts b/packages/module-federation-common/src/types.ts new file mode 100644 index 0000000000..e8070990df --- /dev/null +++ b/packages/module-federation-common/src/types.ts @@ -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 = { + [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; +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index b0b739f87d..671be132e8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -25,6 +25,8 @@ "./css/styles.css": "./src/css/styles.css", "./package.json": "./package.json" }, + "main": "src/index.ts", + "types": "src/index.ts", "typesVersions": { "*": { "package.json": [ @@ -32,8 +34,6 @@ ] } }, - "main": "src/index.ts", - "types": "src/index.ts", "files": [ "dist" ], diff --git a/plugins/catalog-backend-module-gitea/package.json b/plugins/catalog-backend-module-gitea/package.json index 80e35d8321..34eb2bad54 100644 --- a/plugins/catalog-backend-module-gitea/package.json +++ b/plugins/catalog-backend-module-gitea/package.json @@ -1,10 +1,12 @@ { "name": "@backstage/plugin-catalog-backend-module-gitea", "version": "0.1.9-next.2", - "license": "Apache-2.0", "description": "The gitea backend module for the catalog plugin.", - "main": "src/index.ts", - "types": "src/index.ts", + "backstage": { + "role": "backend-plugin-module", + "pluginId": "catalog", + "pluginPackage": "@backstage/plugin-catalog-backend" + }, "publishConfig": { "access": "public", "main": "dist/index.cjs.js", @@ -15,19 +17,20 @@ "url": "https://github.com/backstage/backstage", "directory": "plugins/catalog-backend-module-gitea" }, - "backstage": { - "role": "backend-plugin-module", - "pluginId": "catalog", - "pluginPackage": "@backstage/plugin-catalog-backend" - }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist" + ], "scripts": { - "start": "backstage-cli package start", "build": "backstage-cli package build", - "lint": "backstage-cli package lint", - "test": "backstage-cli package test", "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", "prepack": "backstage-cli package prepack", - "postpack": "backstage-cli package postpack" + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test" }, "dependencies": { "@backstage/backend-plugin-api": "workspace:^", @@ -42,8 +45,5 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^" - }, - "files": [ - "dist" - ] + } } diff --git a/plugins/catalog-node/package.json b/plugins/catalog-node/package.json index 992143c83d..50c16d8c4c 100644 --- a/plugins/catalog-node/package.json +++ b/plugins/catalog-node/package.json @@ -71,6 +71,11 @@ "lodash": "^4.17.21", "yaml": "^2.0.0" }, + "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "msw": "^1.0.0" + }, "peerDependencies": { "@backstage/backend-test-utils": "workspace:^" }, @@ -78,10 +83,5 @@ "@backstage/backend-test-utils": { "optional": true } - }, - "devDependencies": { - "@backstage/backend-test-utils": "workspace:^", - "@backstage/cli": "workspace:^", - "msw": "^1.0.0" } } diff --git a/yarn.lock b/yarn.lock index 6393dca68e..bc198d3dbc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3103,7 +3103,7 @@ __metadata: "@backstage/repo-tools": "workspace:^" "@backstage/types": "workspace:^" "@manypkg/get-packages": "npm:^1.1.3" - "@module-federation/sdk": "npm:^0.9.0" + "@module-federation/sdk": "npm:^0.21.6" "@types/express": "npm:^4.17.6" chokidar: "npm:^3.5.3" express: "npm:^4.22.0" @@ -3297,6 +3297,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/eslint-plugin": "workspace:^" "@backstage/integration": "workspace:^" + "@backstage/module-federation-common": "workspace:^" "@backstage/plugin-auth-backend": "workspace:^" "@backstage/plugin-auth-backend-module-guest-provider": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" @@ -3308,7 +3309,7 @@ __metadata: "@backstage/types": "workspace:^" "@jest/environment-jsdom-abstract": "npm:^30.0.0" "@manypkg/get-packages": "npm:^1.1.3" - "@module-federation/enhanced": "npm:^0.9.0" + "@module-federation/enhanced": "npm:^0.21.6" "@octokit/request": "npm:^8.0.0" "@pmmmwh/react-refresh-webpack-plugin": "npm:^0.6.0" "@rollup/plugin-commonjs": "npm:^26.0.0" @@ -3427,7 +3428,7 @@ __metadata: zod-validation-error: "npm:^4.0.2" peerDependencies: "@jest/environment-jsdom-abstract": ^30.0.0 - "@module-federation/enhanced": ^0.9.0 + "@module-federation/enhanced": ^0.21.6 "@pmmmwh/react-refresh-webpack-plugin": ^0.6.0 esbuild-loader: ^4.0.0 eslint-webpack-plugin: ^4.2.0 @@ -3911,10 +3912,11 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/module-federation-common": "workspace:^" "@backstage/test-utils": "workspace:^" - "@module-federation/enhanced": "npm:^0.9.0" - "@module-federation/runtime": "npm:^0.9.0" - "@module-federation/sdk": "npm:^0.9.0" + "@module-federation/enhanced": "npm:^0.21.6" + "@module-federation/runtime": "npm:^0.21.6" + "@module-federation/sdk": "npm:^0.21.6" "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^16.0.0" "@types/react": "npm:^18.0.0" @@ -4080,6 +4082,36 @@ __metadata: languageName: unknown linkType: soft +"@backstage/module-federation-common@workspace:^, @backstage/module-federation-common@workspace:packages/module-federation-common": + version: 0.0.0-use.local + resolution: "@backstage/module-federation-common@workspace:packages/module-federation-common" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/types": "workspace:^" + "@module-federation/runtime": "npm:^0.21.6" + "@types/react": "npm:^18.0.0" + react: "npm:^18.0.2" + react-dom: "npm:^18.0.2" + react-router-dom: "npm:^6.30.2" + 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.30.2 + react-router-dom: ^6.30.2 + peerDependenciesMeta: + "@types/react": + optional: true + languageName: unknown + linkType: soft + "@backstage/plugin-api-docs-module-protoc-gen-doc@workspace:plugins/api-docs-module-protoc-gen-doc": version: 0.0.0-use.local resolution: "@backstage/plugin-api-docs-module-protoc-gen-doc@workspace:plugins/api-docs-module-protoc-gen-doc" @@ -11294,46 +11326,61 @@ __metadata: languageName: node linkType: hard -"@module-federation/bridge-react-webpack-plugin@npm:0.9.1": - version: 0.9.1 - resolution: "@module-federation/bridge-react-webpack-plugin@npm:0.9.1" +"@module-federation/bridge-react-webpack-plugin@npm:0.21.6": + version: 0.21.6 + resolution: "@module-federation/bridge-react-webpack-plugin@npm:0.21.6" dependencies: - "@module-federation/sdk": "npm:0.9.1" + "@module-federation/sdk": "npm:0.21.6" "@types/semver": "npm:7.5.8" semver: "npm:7.6.3" - checksum: 10/4ff197741b1bdccf8f9e2236781e5ce3ef434e4207c5462b4b95b044c4c57a3ab3dd4dce48490d9fde5bd06fb855b9918841c7d04306726ee224d3e288074091 + checksum: 10/35762b352875289703b5bdd7f8ec14da4912400bb8408c6d7afccd5d27f9137f98465f000dac08720dc672122f8223600da3b6d643b702ad36598f69af755e48 languageName: node linkType: hard -"@module-federation/data-prefetch@npm:0.9.1": - version: 0.9.1 - resolution: "@module-federation/data-prefetch@npm:0.9.1" +"@module-federation/cli@npm:0.21.6": + version: 0.21.6 + resolution: "@module-federation/cli@npm:0.21.6" dependencies: - "@module-federation/runtime": "npm:0.9.1" - "@module-federation/sdk": "npm:0.9.1" + "@module-federation/dts-plugin": "npm:0.21.6" + "@module-federation/sdk": "npm:0.21.6" + chalk: "npm:3.0.0" + commander: "npm:11.1.0" + jiti: "npm:2.4.2" + bin: + mf: bin/mf.js + checksum: 10/7b982871e817465f0effe0cf43f1c3642f7f7188a084953a3c7776b8ec8ec6aef641b98e18c703071c17145681c3aba88ab14ffc8cb9e7e17434d1088d8d99dc + languageName: node + linkType: hard + +"@module-federation/data-prefetch@npm:0.21.6": + version: 0.21.6 + resolution: "@module-federation/data-prefetch@npm:0.21.6" + dependencies: + "@module-federation/runtime": "npm:0.21.6" + "@module-federation/sdk": "npm:0.21.6" fs-extra: "npm:9.1.0" peerDependencies: react: ">=16.9.0" react-dom: ">=16.9.0" - checksum: 10/a3c0e8d77f4d06e3851d041175ef3f55d47ff4069a526b9ac773c8aba71a520a0490653c87cdcf3761e96f9eb9ea16d45026f3aafdf0851c34ff4db6d71ec113 + checksum: 10/1ae3961aac317f5c63c21a4fd7c640b1ce0be96a0b787af58fd12dc599010087397552b9a2dedb37742ea7544163cbdb6e27b87064b3cf842cfded084c99dccf languageName: node linkType: hard -"@module-federation/dts-plugin@npm:0.9.1": - version: 0.9.1 - resolution: "@module-federation/dts-plugin@npm:0.9.1" +"@module-federation/dts-plugin@npm:0.21.6": + version: 0.21.6 + resolution: "@module-federation/dts-plugin@npm:0.21.6" dependencies: - "@module-federation/error-codes": "npm:0.9.1" - "@module-federation/managers": "npm:0.9.1" - "@module-federation/sdk": "npm:0.9.1" - "@module-federation/third-party-dts-extractor": "npm:0.9.1" + "@module-federation/error-codes": "npm:0.21.6" + "@module-federation/managers": "npm:0.21.6" + "@module-federation/sdk": "npm:0.21.6" + "@module-federation/third-party-dts-extractor": "npm:0.21.6" adm-zip: "npm:^0.5.10" ansi-colors: "npm:^4.1.3" - axios: "npm:^1.7.4" + axios: "npm:^1.12.0" chalk: "npm:3.0.0" fs-extra: "npm:9.1.0" isomorphic-ws: "npm:5.0.0" - koa: "npm:2.15.4" + koa: "npm:3.0.3" lodash.clonedeepwith: "npm:4.5.0" log4js: "npm:6.9.1" node-schedule: "npm:2.1.1" @@ -11345,25 +11392,27 @@ __metadata: peerDependenciesMeta: vue-tsc: optional: true - checksum: 10/e9fd11b150456f2621636587d181f6456fe5cd60a0720843fe1310e350c3ff8ebef02ea87d1bf40dc04a4b4cd65cfb2e9007ca1c20e4e5664142b7c670c4a57d + checksum: 10/abc35da5cb602e9e05d9485d14a1a7a3eed754e123d8b6a703720041bb890fd2ac5045c8d95f3d1720a86ae523d391cf1559e4d7cc385c6915cb8b022cf0b8c7 languageName: node linkType: hard -"@module-federation/enhanced@npm:^0.9.0": - version: 0.9.1 - resolution: "@module-federation/enhanced@npm:0.9.1" +"@module-federation/enhanced@npm:^0.21.6": + version: 0.21.6 + resolution: "@module-federation/enhanced@npm:0.21.6" dependencies: - "@module-federation/bridge-react-webpack-plugin": "npm:0.9.1" - "@module-federation/data-prefetch": "npm:0.9.1" - "@module-federation/dts-plugin": "npm:0.9.1" - "@module-federation/error-codes": "npm:0.9.1" - "@module-federation/inject-external-runtime-core-plugin": "npm:0.9.1" - "@module-federation/managers": "npm:0.9.1" - "@module-federation/manifest": "npm:0.9.1" - "@module-federation/rspack": "npm:0.9.1" - "@module-federation/runtime-tools": "npm:0.9.1" - "@module-federation/sdk": "npm:0.9.1" + "@module-federation/bridge-react-webpack-plugin": "npm:0.21.6" + "@module-federation/cli": "npm:0.21.6" + "@module-federation/data-prefetch": "npm:0.21.6" + "@module-federation/dts-plugin": "npm:0.21.6" + "@module-federation/error-codes": "npm:0.21.6" + "@module-federation/inject-external-runtime-core-plugin": "npm:0.21.6" + "@module-federation/managers": "npm:0.21.6" + "@module-federation/manifest": "npm:0.21.6" + "@module-federation/rspack": "npm:0.21.6" + "@module-federation/runtime-tools": "npm:0.21.6" + "@module-federation/sdk": "npm:0.21.6" btoa: "npm:^1.2.1" + schema-utils: "npm:^4.3.0" upath: "npm:2.0.1" peerDependencies: typescript: ^4.9.0 || ^5.0.0 @@ -11376,7 +11425,16 @@ __metadata: optional: true webpack: optional: true - checksum: 10/a7955711f37ba02a18f3570289dfd8fbed30511b82fcf593e56808559cfd49d8852028aa67950db5e45617d0917536d067d85847892e62c20b68ba6ab40e17c4 + bin: + mf: bin/mf.js + checksum: 10/ee67837a6bbfade36424088d6e2d18518e33f7cfb76303f03a6758bde051060105417e51eb6c10e823510376e195f7b8c4090e905a9ac53f0faa3e87edbe7a4b + languageName: node + linkType: hard + +"@module-federation/error-codes@npm:0.21.6": + version: 0.21.6 + resolution: "@module-federation/error-codes@npm:0.21.6" + checksum: 10/6ded1ecab780f1f9ec46a59adb200e75cdf11580d70aa79dd75d71fbbf276615690da277ea67aa1ceb5bc88386f5708cc1d2ba5526be5c9ff02397a6123e36bf languageName: node linkType: hard @@ -11387,57 +11445,51 @@ __metadata: languageName: node linkType: hard -"@module-federation/error-codes@npm:0.9.1": - version: 0.9.1 - resolution: "@module-federation/error-codes@npm:0.9.1" - checksum: 10/545aecc606a506ee47f061835e0eaa41b8d1b02f6bf71b36ec9ae85a1b0370af1f7b7cf92a8f52c3c4b35da858653244316de5ab06bea5dac5b92995467631cc - languageName: node - linkType: hard - -"@module-federation/inject-external-runtime-core-plugin@npm:0.9.1": - version: 0.9.1 - resolution: "@module-federation/inject-external-runtime-core-plugin@npm:0.9.1" +"@module-federation/inject-external-runtime-core-plugin@npm:0.21.6": + version: 0.21.6 + resolution: "@module-federation/inject-external-runtime-core-plugin@npm:0.21.6" peerDependencies: - "@module-federation/runtime-tools": 0.9.1 - checksum: 10/931eef6292c278450fc8cdb017073fa0b721796461eee12a254fc60a88a2f17e91395b12371f2c8b3b25b4056fc2dd2b76d1022e679be7353947c3d797e75ea5 + "@module-federation/runtime-tools": 0.21.6 + checksum: 10/579d6d1208ff22f5073b6e96c978fc68f9f130d6e12eed94bd2b37905476fad8daa85adae0d655f77ab3cd07c948cb06a78788d1bfbd7de1c3d5e7f08620f84e languageName: node linkType: hard -"@module-federation/managers@npm:0.9.1": - version: 0.9.1 - resolution: "@module-federation/managers@npm:0.9.1" +"@module-federation/managers@npm:0.21.6": + version: 0.21.6 + resolution: "@module-federation/managers@npm:0.21.6" dependencies: - "@module-federation/sdk": "npm:0.9.1" + "@module-federation/sdk": "npm:0.21.6" find-pkg: "npm:2.0.0" fs-extra: "npm:9.1.0" - checksum: 10/32c1666244ba98644ab6eccdc844415d1aece1c105f6ba2ff17a3836866e7cdb2f61e556cb5a2b506b898ec709951f318d397f4d153efeff377067237968462f + checksum: 10/2267a340ae44e62b0cb6f565bb19745996d4840e47a9d16fdf8d1a27273f7f6cb08487b727d03b223e7bfe7cd11254c5f0ab29def369ce25765ae1b20a592024 languageName: node linkType: hard -"@module-federation/manifest@npm:0.9.1": - version: 0.9.1 - resolution: "@module-federation/manifest@npm:0.9.1" +"@module-federation/manifest@npm:0.21.6": + version: 0.21.6 + resolution: "@module-federation/manifest@npm:0.21.6" dependencies: - "@module-federation/dts-plugin": "npm:0.9.1" - "@module-federation/managers": "npm:0.9.1" - "@module-federation/sdk": "npm:0.9.1" + "@module-federation/dts-plugin": "npm:0.21.6" + "@module-federation/managers": "npm:0.21.6" + "@module-federation/sdk": "npm:0.21.6" chalk: "npm:3.0.0" find-pkg: "npm:2.0.0" - checksum: 10/539b86bd5388296fb35a34c7b732b92788600ab6625d53f11588ad67c3a603ac3c1974541d9cf04db2d24635be1610414c29388e849d96bbb812cad3b1c79425 + checksum: 10/957c4642cd54b328740e3f45e84d6c449fbc39969afed169f34ed2b19da13a2d8c029321276500f1212aa78a7853612d2dc945f9d9679012b753708306392be3 languageName: node linkType: hard -"@module-federation/rspack@npm:0.9.1": - version: 0.9.1 - resolution: "@module-federation/rspack@npm:0.9.1" +"@module-federation/rspack@npm:0.21.6": + version: 0.21.6 + resolution: "@module-federation/rspack@npm:0.21.6" dependencies: - "@module-federation/bridge-react-webpack-plugin": "npm:0.9.1" - "@module-federation/dts-plugin": "npm:0.9.1" - "@module-federation/inject-external-runtime-core-plugin": "npm:0.9.1" - "@module-federation/managers": "npm:0.9.1" - "@module-federation/manifest": "npm:0.9.1" - "@module-federation/runtime-tools": "npm:0.9.1" - "@module-federation/sdk": "npm:0.9.1" + "@module-federation/bridge-react-webpack-plugin": "npm:0.21.6" + "@module-federation/dts-plugin": "npm:0.21.6" + "@module-federation/inject-external-runtime-core-plugin": "npm:0.21.6" + "@module-federation/managers": "npm:0.21.6" + "@module-federation/manifest": "npm:0.21.6" + "@module-federation/runtime-tools": "npm:0.21.6" + "@module-federation/sdk": "npm:0.21.6" + btoa: "npm:1.2.1" peerDependencies: "@rspack/core": ">=0.7" typescript: ^4.9.0 || ^5.0.0 @@ -11447,7 +11499,17 @@ __metadata: optional: true vue-tsc: optional: true - checksum: 10/e6deb7236ccc66d7f0475a09916ec714c9855847bfd3d3ec6cdbee991016a222a22f6ae3fd14d71351d55e22e8949140f37ce9ce79dece5416731c6698726096 + checksum: 10/8dd952ee1499a897e30d2ba363c29c2c1eba80f0b712f008a9f1bd84b49a2204e4657033e3086e31afe0452cbf023f132db27fc61b4e9caa66170bca1d78896a + languageName: node + linkType: hard + +"@module-federation/runtime-core@npm:0.21.6": + version: 0.21.6 + resolution: "@module-federation/runtime-core@npm:0.21.6" + dependencies: + "@module-federation/error-codes": "npm:0.21.6" + "@module-federation/sdk": "npm:0.21.6" + checksum: 10/85efa2042d6f3a7cf0e4971b991472d4339d88f6f15684afb6d451f19ed934e225b2510c86b7bb4d2c5f64253ed7b0175f08c17f95bfc2b9929930a8a03fff1e languageName: node linkType: hard @@ -11461,13 +11523,13 @@ __metadata: languageName: node linkType: hard -"@module-federation/runtime-core@npm:0.9.1": - version: 0.9.1 - resolution: "@module-federation/runtime-core@npm:0.9.1" +"@module-federation/runtime-tools@npm:0.21.6": + version: 0.21.6 + resolution: "@module-federation/runtime-tools@npm:0.21.6" dependencies: - "@module-federation/error-codes": "npm:0.9.1" - "@module-federation/sdk": "npm:0.9.1" - checksum: 10/6f9edbe23013395d7896fc2a24cb4055bc78df5a335f090e079df951835c1cf91c567228f19879eee3fddb0b34128abd0b50feaca1cf3fb2828c7b9bacc22169 + "@module-federation/runtime": "npm:0.21.6" + "@module-federation/webpack-bundler-runtime": "npm:0.21.6" + checksum: 10/36e7ccab948e11f310e87397a1a2185b56064e5691e553b34173686e2bc7372ec710e5ad48c026eb28c85b168765788b743aa2111513f3b57118b47636312dd1 languageName: node linkType: hard @@ -11481,13 +11543,14 @@ __metadata: languageName: node linkType: hard -"@module-federation/runtime-tools@npm:0.9.1": - version: 0.9.1 - resolution: "@module-federation/runtime-tools@npm:0.9.1" +"@module-federation/runtime@npm:0.21.6, @module-federation/runtime@npm:^0.21.6": + version: 0.21.6 + resolution: "@module-federation/runtime@npm:0.21.6" dependencies: - "@module-federation/runtime": "npm:0.9.1" - "@module-federation/webpack-bundler-runtime": "npm:0.9.1" - checksum: 10/9436e814a4ab72839b8aed1aa097f32cf7d4d49728dd863c9ce6dc4fb149fe49da6dc9cdeb97814f0023cc91489c9aca06cbfdf9fb4e43d20498ab6ee78c95dd + "@module-federation/error-codes": "npm:0.21.6" + "@module-federation/runtime-core": "npm:0.21.6" + "@module-federation/sdk": "npm:0.21.6" + checksum: 10/93fd9bb284630933cab7e4bc070d648b56272f3636038c05eec7d1e3eeb189be3ccebe5f8ecc450197ee992d2616ed282d54e673ec0acd63adee4faddf80b144 languageName: node linkType: hard @@ -11502,14 +11565,10 @@ __metadata: languageName: node linkType: hard -"@module-federation/runtime@npm:0.9.1, @module-federation/runtime@npm:^0.9.0": - version: 0.9.1 - resolution: "@module-federation/runtime@npm:0.9.1" - dependencies: - "@module-federation/error-codes": "npm:0.9.1" - "@module-federation/runtime-core": "npm:0.9.1" - "@module-federation/sdk": "npm:0.9.1" - checksum: 10/71eb1c3e81b307ebfe06c43ce70bfa56b217e9dfbed27f0b4235d3d0d05cc0fe2eb1dc57fffb3260ed9e0239257ef117ae13924c642b5ff97d9c65bdf48206fe +"@module-federation/sdk@npm:0.21.6, @module-federation/sdk@npm:^0.21.6": + version: 0.21.6 + resolution: "@module-federation/sdk@npm:0.21.6" + checksum: 10/effc4aa932e2f06742bda8f02aaec84e138f5512b50f18c38b051490020b20d3d8edf7ece853fccffc1f78a0b43dec78e69bf02150e7e2801d5ce03c3ee367b9 languageName: node linkType: hard @@ -11520,21 +11579,24 @@ __metadata: languageName: node linkType: hard -"@module-federation/sdk@npm:0.9.1, @module-federation/sdk@npm:^0.9.0": - version: 0.9.1 - resolution: "@module-federation/sdk@npm:0.9.1" - checksum: 10/ea0320feff328a05405e65503d1df28e46a9ad17ef99b77f1428db5c89efbadd2a76ec82d99a54ac1d21286cee6d9ddbba881a9c46748dfe8abdb11e9afef7da - languageName: node - linkType: hard - -"@module-federation/third-party-dts-extractor@npm:0.9.1": - version: 0.9.1 - resolution: "@module-federation/third-party-dts-extractor@npm:0.9.1" +"@module-federation/third-party-dts-extractor@npm:0.21.6": + version: 0.21.6 + resolution: "@module-federation/third-party-dts-extractor@npm:0.21.6" dependencies: find-pkg: "npm:2.0.0" fs-extra: "npm:9.1.0" resolve: "npm:1.22.8" - checksum: 10/68c70c79573cd927212d95879aa7b35490519d0ec9f58dd264c9d61e22fea8d452e45a52a94155128d8378e5cdcaecb229707b5b0f4e4c0d53ae96e2fbac366a + checksum: 10/d9328575d3b2c64711dacae38c8186a38732c900d5b7c63d84ec3d4b42b199aa484cfe69dad228e1197170c33d1b39a1671467d274046780e6bddbe0c68b1e25 + languageName: node + linkType: hard + +"@module-federation/webpack-bundler-runtime@npm:0.21.6": + version: 0.21.6 + resolution: "@module-federation/webpack-bundler-runtime@npm:0.21.6" + dependencies: + "@module-federation/runtime": "npm:0.21.6" + "@module-federation/sdk": "npm:0.21.6" + checksum: 10/a5ceb72ee3867acad5d7d3c654eb568068b1d5288f60ce9301bdc9f56effa5a4c26a732a2cec7176a81b87139566cd51dd8dfbc6112da05d47b870fa3ad3ba1f languageName: node linkType: hard @@ -11548,16 +11610,6 @@ __metadata: languageName: node linkType: hard -"@module-federation/webpack-bundler-runtime@npm:0.9.1": - version: 0.9.1 - resolution: "@module-federation/webpack-bundler-runtime@npm:0.9.1" - dependencies: - "@module-federation/runtime": "npm:0.9.1" - "@module-federation/sdk": "npm:0.9.1" - checksum: 10/430cac0a770b3c46bc195088eb4c1892e1e29a69238dbe72423d64b2b67050afeca2b5026b1a30659b4fe8d9faa038ff97cceba7e2ddf6193b93763f270d9df6 - languageName: node - linkType: hard - "@motionone/animation@npm:^10.12.0": version: 10.16.3 resolution: "@motionone/animation@npm:10.16.3" @@ -24538,7 +24590,7 @@ __metadata: languageName: node linkType: hard -"accepts@npm:^1.3.5, accepts@npm:^1.3.7, accepts@npm:~1.3.4, accepts@npm:~1.3.8": +"accepts@npm:^1.3.7, accepts@npm:^1.3.8, accepts@npm:~1.3.4, accepts@npm:~1.3.8": version: 1.3.8 resolution: "accepts@npm:1.3.8" dependencies: @@ -25615,7 +25667,7 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.0.0, axios@npm:^1.11.0, axios@npm:^1.12.2, axios@npm:^1.13.0, axios@npm:^1.7.4": +"axios@npm:^1.0.0, axios@npm:^1.11.0, axios@npm:^1.12.0, axios@npm:^1.12.2, axios@npm:^1.13.0, axios@npm:^1.7.4": version: 1.13.5 resolution: "axios@npm:1.13.5" dependencies: @@ -26367,7 +26419,7 @@ __metadata: languageName: node linkType: hard -"btoa@npm:^1.2.1": +"btoa@npm:1.2.1, btoa@npm:^1.2.1": version: 1.2.1 resolution: "btoa@npm:1.2.1" bin: @@ -26609,16 +26661,6 @@ __metadata: languageName: node linkType: hard -"cache-content-type@npm:^1.0.0": - version: 1.0.1 - resolution: "cache-content-type@npm:1.0.1" - dependencies: - mime-types: "npm:^2.1.18" - ylru: "npm:^1.2.0" - checksum: 10/18db4d59452669ccbfd7146a1510a37eb28e9eccf18ca7a4eb603dff2edc5cccdca7498fc3042a2978f76f11151fba486eb9eb69d9afa3fb124957870aef4fd3 - languageName: node - linkType: hard - "cacheable-lookup@npm:^5.0.3": version: 5.0.3 resolution: "cacheable-lookup@npm:5.0.3" @@ -27534,6 +27576,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:11.1.0, commander@npm:^11.0.0": + version: 11.1.0 + resolution: "commander@npm:11.1.0" + checksum: 10/66bd2d8a0547f6cb1d34022efb25f348e433b0e04ad76a65279b1b09da108f59a4d3001ca539c60a7a46ea38bcf399fc17d91adad76a8cf43845d8dcbaf5cda1 + languageName: node + linkType: hard + "commander@npm:7.1.0": version: 7.1.0 resolution: "commander@npm:7.1.0" @@ -27562,13 +27611,6 @@ __metadata: languageName: node linkType: hard -"commander@npm:^11.0.0": - version: 11.1.0 - resolution: "commander@npm:11.1.0" - checksum: 10/66bd2d8a0547f6cb1d34022efb25f348e433b0e04ad76a65279b1b09da108f59a4d3001ca539c60a7a46ea38bcf399fc17d91adad76a8cf43845d8dcbaf5cda1 - languageName: node - linkType: hard - "commander@npm:^12.0.0, commander@npm:^12.1.0": version: 12.1.0 resolution: "commander@npm:12.1.0" @@ -27856,7 +27898,7 @@ __metadata: languageName: node linkType: hard -"content-disposition@npm:^0.5.3, content-disposition@npm:~0.5.2, content-disposition@npm:~0.5.4": +"content-disposition@npm:^0.5.3, content-disposition@npm:~0.5.4": version: 0.5.4 resolution: "content-disposition@npm:0.5.4" dependencies: @@ -27874,7 +27916,7 @@ __metadata: languageName: node linkType: hard -"content-type@npm:^1.0.4, content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5": +"content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5": version: 1.0.5 resolution: "content-type@npm:1.0.5" checksum: 10/585847d98dc7fb8035c02ae2cb76c7a9bd7b25f84c447e5ed55c45c2175e83617c8813871b4ee22f368126af6b2b167df655829007b21aa10302873ea9c62662 @@ -27954,7 +27996,7 @@ __metadata: languageName: node linkType: hard -"cookies@npm:~0.9.0": +"cookies@npm:~0.9.1": version: 0.9.1 resolution: "cookies@npm:0.9.1" dependencies: @@ -29150,7 +29192,7 @@ __metadata: languageName: node linkType: hard -"destroy@npm:1.2.0, destroy@npm:^1.0.4, destroy@npm:~1.2.0": +"destroy@npm:1.2.0, destroy@npm:^1.0.4, destroy@npm:^1.2.0, destroy@npm:~1.2.0": version: 1.2.0 resolution: "destroy@npm:1.2.0" checksum: 10/0acb300b7478a08b92d810ab229d5afe0d2f4399272045ab22affa0d99dbaf12637659411530a6fcd597a9bdac718fc94373a61a95b4651bbc7b83684a565e38 @@ -29181,9 +29223,9 @@ __metadata: linkType: hard "detect-libc@npm:^2.0.0": - version: 2.0.3 - resolution: "detect-libc@npm:2.0.3" - checksum: 10/b4ea018d623e077bd395f168a9e81db77370dde36a5b01d067f2ad7989924a81d31cb547ff764acb2aa25d50bb7fdde0b0a93bec02212b0cb430621623246d39 + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10/b736c8d97d5d46164c0d1bed53eb4e6a3b1d8530d460211e2d52f1c552875e706c58a5376854e4e54f8b828c9cada58c855288c968522eb93ac7696d65970766 languageName: node linkType: hard @@ -29881,13 +29923,6 @@ __metadata: languageName: node linkType: hard -"encodeurl@npm:^1.0.2, encodeurl@npm:~1.0.2": - version: 1.0.2 - resolution: "encodeurl@npm:1.0.2" - checksum: 10/e50e3d508cdd9c4565ba72d2012e65038e5d71bdc9198cb125beb6237b5b1ade6c0d343998da9e170fb2eae52c1bed37d4d6d98a46ea423a0cddbed5ac3f780c - languageName: node - linkType: hard - "encodeurl@npm:^2.0.0, encodeurl@npm:~2.0.0": version: 2.0.0 resolution: "encodeurl@npm:2.0.0" @@ -29895,6 +29930,13 @@ __metadata: languageName: node linkType: hard +"encodeurl@npm:~1.0.2": + version: 1.0.2 + resolution: "encodeurl@npm:1.0.2" + checksum: 10/e50e3d508cdd9c4565ba72d2012e65038e5d71bdc9198cb125beb6237b5b1ade6c0d343998da9e170fb2eae52c1bed37d4d6d98a46ea423a0cddbed5ac3f780c + languageName: node + linkType: hard + "encoding@npm:^0.1.12, encoding@npm:^0.1.13": version: 0.1.13 resolution: "encoding@npm:0.1.13" @@ -33968,7 +34010,7 @@ __metadata: languageName: node linkType: hard -"http-assert@npm:^1.3.0": +"http-assert@npm:^1.5.0": version: 1.5.0 resolution: "http-assert@npm:1.5.0" dependencies: @@ -34027,19 +34069,6 @@ __metadata: languageName: node linkType: hard -"http-errors@npm:^1.6.3, http-errors@npm:~1.8.0": - version: 1.8.1 - resolution: "http-errors@npm:1.8.1" - dependencies: - depd: "npm:~1.1.2" - inherits: "npm:2.0.4" - setprototypeof: "npm:1.2.0" - statuses: "npm:>= 1.5.0 < 2" - toidentifier: "npm:1.0.1" - checksum: 10/76fc491bd8df2251e21978e080d5dae20d9736cfb29bb72b5b76ec1bcebb1c14f0f58a3a128dd89288934379d2173cfb0421c571d54103e93dd65ef6243d64d8 - languageName: node - linkType: hard - "http-errors@npm:^2.0.0, http-errors@npm:~2.0.0, http-errors@npm:~2.0.1": version: 2.0.1 resolution: "http-errors@npm:2.0.1" @@ -34065,6 +34094,19 @@ __metadata: languageName: node linkType: hard +"http-errors@npm:~1.8.0": + version: 1.8.1 + resolution: "http-errors@npm:1.8.1" + dependencies: + depd: "npm:~1.1.2" + inherits: "npm:2.0.4" + setprototypeof: "npm:1.2.0" + statuses: "npm:>= 1.5.0 < 2" + toidentifier: "npm:1.0.1" + checksum: 10/76fc491bd8df2251e21978e080d5dae20d9736cfb29bb72b5b76ec1bcebb1c14f0f58a3a128dd89288934379d2173cfb0421c571d54103e93dd65ef6243d64d8 + languageName: node + linkType: hard + "http-parser-js@npm:>=0.5.1": version: 0.5.3 resolution: "http-parser-js@npm:0.5.3" @@ -36292,6 +36334,15 @@ __metadata: languageName: node linkType: hard +"jiti@npm:2.4.2": + version: 2.4.2 + resolution: "jiti@npm:2.4.2" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10/e2b07eb2e3fbb245e29ad288dddecab31804967fc84d5e01d39858997d2743b5e248946defcecf99272275a00284ecaf7ec88b8c841331324f0c946d8274414b + languageName: node + linkType: hard + "jiti@npm:^2.0.0, jiti@npm:^2.6.0": version: 2.6.1 resolution: "jiti@npm:2.6.1" @@ -37184,44 +37235,29 @@ __metadata: languageName: node linkType: hard -"koa-convert@npm:^2.0.0": - version: 2.0.0 - resolution: "koa-convert@npm:2.0.0" +"koa@npm:3.0.3": + version: 3.0.3 + resolution: "koa@npm:3.0.3" dependencies: - co: "npm:^4.6.0" - koa-compose: "npm:^4.1.0" - checksum: 10/7385b3391995f59c1312142e110d5dff677f9850dbfbcf387cd36a7b0af03b5d26e82b811eb9bb008b4f3e661cdab1f8817596e46b1929da2cf6e97a2f7456ed - languageName: node - linkType: hard - -"koa@npm:2.15.4": - version: 2.15.4 - resolution: "koa@npm:2.15.4" - dependencies: - accepts: "npm:^1.3.5" - cache-content-type: "npm:^1.0.0" - content-disposition: "npm:~0.5.2" - content-type: "npm:^1.0.4" - cookies: "npm:~0.9.0" - debug: "npm:^4.3.2" + accepts: "npm:^1.3.8" + content-disposition: "npm:~0.5.4" + content-type: "npm:^1.0.5" + cookies: "npm:~0.9.1" delegates: "npm:^1.0.0" - depd: "npm:^2.0.0" - destroy: "npm:^1.0.4" - encodeurl: "npm:^1.0.2" + destroy: "npm:^1.2.0" + encodeurl: "npm:^2.0.0" escape-html: "npm:^1.0.3" fresh: "npm:~0.5.2" - http-assert: "npm:^1.3.0" - http-errors: "npm:^1.6.3" - is-generator-function: "npm:^1.0.7" + http-assert: "npm:^1.5.0" + http-errors: "npm:^2.0.0" koa-compose: "npm:^4.1.0" - koa-convert: "npm:^2.0.0" - on-finished: "npm:^2.3.0" - only: "npm:~0.0.2" - parseurl: "npm:^1.3.2" - statuses: "npm:^1.5.0" - type-is: "npm:^1.6.16" + mime-types: "npm:^3.0.1" + on-finished: "npm:^2.4.1" + parseurl: "npm:^1.3.3" + statuses: "npm:^2.0.1" + type-is: "npm:^2.0.1" vary: "npm:^1.1.2" - checksum: 10/98de77173822f0a28c0f5d1ebd261ab02f3f905d40602e51957a0c7202122647a60c5b6c59be03361dd24bf6a5685eac97af84b306914efd057751e71f93cb0f + checksum: 10/15df9a7777ad357851253deaba534403dcc97eb81efce263c8a8b30b5bc3e5db7d61e35b7a89ce359ec3cf043b50244996df0efe6f29785adefbc6f691a1eda6 languageName: node linkType: hard @@ -39160,7 +39196,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:2.1.35, mime-types@npm:^2.1.12, mime-types@npm:^2.1.18, mime-types@npm:^2.1.27, mime-types@npm:^2.1.31, mime-types@npm:^2.1.35, mime-types@npm:~2.1.17, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": +"mime-types@npm:2.1.35, mime-types@npm:^2.1.12, mime-types@npm:^2.1.27, mime-types@npm:^2.1.31, mime-types@npm:^2.1.35, mime-types@npm:~2.1.17, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -40936,7 +40972,7 @@ __metadata: languageName: node linkType: hard -"on-finished@npm:2.4.1, on-finished@npm:^2.3.0, on-finished@npm:^2.4.1, on-finished@npm:~2.4.1": +"on-finished@npm:2.4.1, on-finished@npm:^2.4.1, on-finished@npm:~2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1" dependencies: @@ -41015,13 +41051,6 @@ __metadata: languageName: node linkType: hard -"only@npm:~0.0.2": - version: 0.0.2 - resolution: "only@npm:0.0.2" - checksum: 10/e2ad03e486534dc6bfb983393be83125a4669052b4a19a353eb00475b46971fb238a18223f2b609fe0d1bcb61ff8373964ccac64d05cbf970865299f655ed0ba - languageName: node - linkType: hard - "ono@npm:^7.1.3": version: 7.1.3 resolution: "ono@npm:7.1.3" @@ -41779,7 +41808,7 @@ __metadata: languageName: node linkType: hard -"parseurl@npm:^1.3.2, parseurl@npm:^1.3.3, parseurl@npm:~1.3.2, parseurl@npm:~1.3.3": +"parseurl@npm:^1.3.3, parseurl@npm:~1.3.2, parseurl@npm:~1.3.3": version: 1.3.3 resolution: "parseurl@npm:1.3.3" checksum: 10/407cee8e0a3a4c5cd472559bca8b6a45b82c124e9a4703302326e9ab60fc1081442ada4e02628efef1eb16197ddc7f8822f5a91fd7d7c86b51f530aedb17dfa2 @@ -47296,7 +47325,7 @@ __metadata: languageName: node linkType: hard -"statuses@npm:>= 1.4.0 < 2, statuses@npm:>= 1.5.0 < 2, statuses@npm:^1.5.0, statuses@npm:~1.5.0": +"statuses@npm:>= 1.4.0 < 2, statuses@npm:>= 1.5.0 < 2, statuses@npm:~1.5.0": version: 1.5.0 resolution: "statuses@npm:1.5.0" checksum: 10/c469b9519de16a4bb19600205cffb39ee471a5f17b82589757ca7bd40a8d92ebb6ed9f98b5a540c5d302ccbc78f15dc03cc0280dd6e00df1335568a5d5758a5c @@ -49220,7 +49249,7 @@ __metadata: languageName: node linkType: hard -"type-is@npm:^1.6.16, type-is@npm:^1.6.18, type-is@npm:~1.6.18": +"type-is@npm:^1.6.18, type-is@npm:~1.6.18": version: 1.6.18 resolution: "type-is@npm:1.6.18" dependencies: @@ -51452,7 +51481,7 @@ __metadata: languageName: node linkType: hard -"ylru@npm:^1.2.0, ylru@npm:^1.3.2": +"ylru@npm:^1.3.2": version: 1.4.0 resolution: "ylru@npm:1.4.0" checksum: 10/5437f8eb2fb5dd515845c657dde3cecaa9f6bd4c6386d2a5212d3fafe02189c7d8ebfdfc84940a7811607cb3524eb362ce95d3180d355cd5deb610aa8c82c9bc