diff --git a/.changeset/large-phones-tie.md b/.changeset/large-phones-tie.md new file mode 100644 index 0000000000..baee28ad0b --- /dev/null +++ b/.changeset/large-phones-tie.md @@ -0,0 +1,10 @@ +--- +'@backstage/backend-dynamic-feature-service': patch +'@backstage/frontend-dynamic-feature-loader': minor +'@backstage/frontend-defaults': patch +--- + +The new package `frontend-dynamic-features-loader` provides a frontend feature loader that dynamically +loads frontend features based on the new frontend system and exposed as module federation remotes. +This new frontend feature loader works hand-in-hand with a new server of frontend plugin module federation +remotes, which is added as part of backend dynamic feature service in package `@backstage/backend-dynamic-feature-service`. diff --git a/packages/backend-dynamic-feature-service/package.json b/packages/backend-dynamic-feature-service/package.json index 90964cc004..1e62f8d296 100644 --- a/packages/backend-dynamic-feature-service/package.json +++ b/packages/backend-dynamic-feature-service/package.json @@ -38,6 +38,9 @@ "scripts": { "build": "backstage-cli package build", "clean": "backstage-cli package clean", + "diff": "backstage-repo-tools package schema openapi diff", + "fuzz": "backstage-repo-tools package schema openapi fuzz --exclude-checks response_schema_conformance", + "generate": "backstage-repo-tools package schema openapi generate --server --client-package packages/frontend-dynamic-feature-loader", "lint": "backstage-cli package lint", "prepack": "backstage-cli package prepack", "postpack": "backstage-cli package postpack", @@ -46,6 +49,7 @@ }, "dependencies": { "@backstage/backend-defaults": "workspace:^", + "@backstage/backend-openapi-utils": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/cli-common": "workspace:^", "@backstage/cli-node": "workspace:^", @@ -64,9 +68,11 @@ "@backstage/plugin-search-common": "workspace:^", "@backstage/types": "workspace:^", "@manypkg/get-packages": "^1.1.3", + "@module-federation/sdk": "^0.9.0", "@types/express": "^4.17.6", "chokidar": "^3.5.3", "express": "^4.17.1", + "express-promise-router": "^4.1.0", "fs-extra": "^11.2.0", "lodash": "^4.17.21", "winston": "^3.2.1" @@ -76,7 +82,9 @@ "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@backstage/plugin-app-backend": "workspace:^", + "@backstage/repo-tools": "workspace:^", "triple-beam": "^1.4.1", "wait-for-expect": "^3.0.2" - } + }, + "configSchema": "config.d.ts" } diff --git a/packages/backend-dynamic-feature-service/report.api.md b/packages/backend-dynamic-feature-service/report.api.md index 10c8258ad2..37701abd18 100644 --- a/packages/backend-dynamic-feature-service/report.api.md +++ b/packages/backend-dynamic-feature-service/report.api.md @@ -15,6 +15,7 @@ import { EventsService } from '@backstage/plugin-events-node'; import { HttpPostIngressOptions } from '@backstage/plugin-events-node'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { IndexBuilder } from '@backstage/plugin-search-backend-node'; +import { JsonObject } from '@backstage/types'; import { Logger } from 'winston'; import { LoggerService } from '@backstage/backend-plugin-api'; import { PackagePlatform } from '@backstage/cli-node'; @@ -31,6 +32,9 @@ import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { UrlReaderService } from '@backstage/backend-plugin-api'; import { WinstonLoggerOptions } from '@backstage/backend-defaults/rootLogger'; +// @public (undocumented) +export type AdditionalRemoteInfo = Omit; + // @public (undocumented) export interface BackendDynamicPlugin extends BaseDynamicPlugin { // (undocumented) @@ -158,9 +162,22 @@ export type DynamicPluginsFeatureLoaderOptions = DynamicPluginsFactoryOptions & logger?: (config?: Config) => DynamicPluginsRootLoggerFactoryOptions; }; +// @public (undocumented) +export interface DynamicPluginsFrontendRemotesService { + // (undocumented) + setResolverProvider(provider: FrontendRemoteResolverProvider): void; +} + // @public @deprecated (undocumented) export const dynamicPluginsFrontendSchemas: BackendFeature; +// @public +export const dynamicPluginsFrontendServiceRef: ServiceRef< + DynamicPluginsFrontendRemotesService, + 'root', + 'singleton' +>; + // @public (undocumented) export type DynamicPluginsRootLoggerFactoryOptions = Omit< WinstonLoggerOptions, @@ -224,6 +241,31 @@ export interface FrontendPluginProvider { }): FrontendDynamicPlugin[]; } +// @public (undocumented) +export type FrontendRemoteResolver = { + assetsPathFromPackage?: string; + manifestFileName?: string; + getRemoteEntryType?: ( + manifestContent: JsonObject, + ) => 'manifest' | 'javascript'; + getAdditionaRemoteInfo?: ( + manifestContent: JsonObject, + ) => AdditionalRemoteInfo; + overrideExposedModules?: ( + exposedModules: string[], + manifestContent: JsonObject, + ) => string[]; + customizeManifest?: (content: JsonObject) => JsonObject; +}; + +// @public (undocumented) +export type FrontendRemoteResolverProvider = { + for( + pluginName: string, + pluginPackagePath: string, + ): Partial | undefined; +}; + // @public (undocumented) export function isBackendDynamicPluginInstaller( obj: any, @@ -291,6 +333,39 @@ export interface NewBackendPluginInstaller { kind: 'new'; } +// @public +export interface RemoteInfo { + entry: string; + // (undocumented) + entryGlobalName?: string; + name: string; + // (undocumented) + shareScope?: string; + // (undocumented) + type?: RemoteInfoTypeEnum; +} + +// @public (undocumented) +export type RemoteInfoTypeEnum = + | 'var' + | 'module' + | 'assign' + | 'assign-properties' + | 'this' + | 'window' + | 'self' + | 'global' + | 'commonjs' + | 'commonjs2' + | 'commonjs-module' + | 'commonjs-static' + | 'amd' + | 'amd-require' + | 'umd' + | 'umd2' + | 'jsonp' + | 'system'; + // @public (undocumented) export type ScannedPluginManifest = BackstagePackageJson & Required> & diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/configSchema.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/.config-schema.json similarity index 100% rename from packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/configSchema.json rename to packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/.config-schema.json diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/index.cjs.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/index.cjs.js index 77c2e32bef..5ce6f4e48f 100644 --- a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/index.cjs.js +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/index.cjs.js @@ -31,27 +31,8 @@ const testPlugin = backendPluginApi.createBackendPlugin({ metadata, }) { logger.info("This secret value should be hidden by the dynamic-plugin-aware logger: AVerySecretValue"); - const externalBaseUrl = await discovery.getExternalBaseUrl(metadata.getId()); - const router = express.Router(); - const frontendPluginsIndexPath = privateDep.frontendPluginsIndexPath; - const frontendPluginManifests = Object.fromEntries(dynamicPlugins.frontendPlugins().map(fp => { - const pluginScannedPackage = dynamicPlugins.getScannedPackage(fp); - const pkgDistLocation = path.resolve( - url.fileURLToPath(pluginScannedPackage.location), - 'dist', - ); - router.use(`/${frontendPluginsIndexPath}/${fp.name}`, express.static(pkgDistLocation)) - return [fp.name, `${externalBaseUrl}/${frontendPluginsIndexPath}/${fp.name}/mf-manifest.json`] - })); - router.get(`/${frontendPluginsIndexPath}`, (req, res) => { - res.status(200).json(frontendPluginManifests); - }); - http.use(router); - http.addAuthPolicy({ - path: `/`, - allow: 'unauthenticated', - }); - + const messageFromPrivateDep = privateDep.message; + logger.info(messageFromPrivateDep); } }); } diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/index.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/index.js index e0fb6a55de..279a8baa6c 100644 --- a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/index.js +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/index.js @@ -2,6 +2,6 @@ Object.defineProperty(exports, '__esModule', { value: true }); -const frontendPluginsIndexPath = 'frontend-plugins'; +const message = 'This message comes from a plugin private dependency'; -exports.frontendPluginsIndexPath = frontendPluginsIndexPath; +exports.message = message; diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist-alternate/.config-schema.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist-alternate/.config-schema.json new file mode 100644 index 0000000000..f85adb3334 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist-alternate/.config-schema.json @@ -0,0 +1,25 @@ +{ + "schemas": [ + { + "packageName": "plugin-test-dynamic", + "path": "./test-dynamic", + "value": { + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "test-frontend": { + "type": "object", + "required": ["frontendValue"], + "properties": { + "frontendValue": { + "type": "string", + "visibility": "frontend" + } + } + } + } + } + } + ], + "backstageConfigSchemaVersion": 1 +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist-alternate/mf-manifest.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist-alternate/mf-manifest.json new file mode 100644 index 0000000000..8ba1953558 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist-alternate/mf-manifest.json @@ -0,0 +1,36 @@ +{ + "id": "backstage__plugin_test", + "name": "backstage__plugin_test", + "metaData": { + "name": "backstage__plugin_test", + "type": "app", + "buildInfo": { + "buildVersion": "0.0.0", + "buildName": "@backstage/plugin-test" + }, + "remoteEntry": { + "name": "remoteEntry.js", + "path": "", + "type": "global" + }, + "types": { + "path": "", + "name": "", + "zip": "", + "api": "" + }, + "globalName": "backstage__plugin_test", + "pluginVersion": "0.0.0", + "publicPath": "auto" + }, + "shared": [], + "remotes": [], + "exposes": [ + { + "name": "." + }, + { + "name": "alpha" + } + ] +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist-alternate/remoteEntry.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist-alternate/remoteEntry.js new file mode 100644 index 0000000000..c6c215aae4 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist-alternate/remoteEntry.js @@ -0,0 +1,17 @@ +/* + * Copyright 2023 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. + */ + +(function doNothing() {})(); diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/.config-schema.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/.config-schema.json new file mode 100644 index 0000000000..d93ef25f0e --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/.config-schema.json @@ -0,0 +1,27 @@ +{ + "schemas": [ + { + "packageName": "plugin-test-dynamic", + "path": "./test-dynamic", + "value": { + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "test-frontend": { + "type": "object", + "required": [ + "frontendValue" + ], + "properties": { + "frontendValue": { + "type": "string", + "visibility": "frontend" + } + } + } + } + } + } + ], + "backstageConfigSchemaVersion": 1 +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/configSchema.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/configSchema.json deleted file mode 100644 index a1f22b0374..0000000000 --- a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/configSchema.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "type": "object", - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "test-frontend": { - "type": "object", - "required": [ - "frontendValue" - ], - "properties": { - "frontendValue": { - "type": "string", - "visibility": "frontend" - } - } - } - } -} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest-with-wrong-remote-entry.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest-with-wrong-remote-entry.json new file mode 100644 index 0000000000..fd7d745886 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest-with-wrong-remote-entry.json @@ -0,0 +1,33 @@ +{ + "id": "backstage__plugin_test", + "name": "backstage__plugin_test", + "metaData": { + "name": "backstage__plugin_test", + "type": "app", + "buildInfo": { + "buildVersion": "0.0.0", + "buildName": "@backstage/plugin-test" + }, + "remoteEntry": { + "name": "remoteEntry-not-found.js", + "path": "", + "type": "global" + }, + "types": { + "path": "", + "name": "", + "zip": "", + "api": "" + }, + "globalName": "backstage__plugin_test", + "pluginVersion": "0.0.0", + "publicPath": "auto" + }, + "shared": [], + "remotes": [], + "exposes": [{ + "name": "." + },{ + "name": "alpha" + }] +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest-without-name.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest-without-name.json new file mode 100644 index 0000000000..93f83c2d93 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest-without-name.json @@ -0,0 +1,32 @@ +{ + "id": "backstage__plugin_test", + "metaData": { + "name": "backstage__plugin_test", + "type": "app", + "buildInfo": { + "buildVersion": "0.0.0", + "buildName": "@backstage/plugin-test" + }, + "remoteEntry": { + "name": "remoteEntry.js", + "path": "", + "type": "global" + }, + "types": { + "path": "", + "name": "", + "zip": "", + "api": "" + }, + "globalName": "backstage__plugin_test", + "pluginVersion": "0.0.0", + "publicPath": "auto" + }, + "shared": [], + "remotes": [], + "exposes": [{ + "name": "." + },{ + "name": "alpha" + }] +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest.json index 99a2949671..a86ee4eea6 100644 --- a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest.json +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest.json @@ -25,5 +25,9 @@ }, "shared": [], "remotes": [], - "exposes": [] + "exposes": [{ + "name": "." + },{ + "name": "alpha" + }] } diff --git a/packages/backend-dynamic-feature-service/src/features/features.test.ts b/packages/backend-dynamic-feature-service/src/features/features.test.ts index fb6aa5e48a..eb2901340c 100644 --- a/packages/backend-dynamic-feature-service/src/features/features.test.ts +++ b/packages/backend-dynamic-feature-service/src/features/features.test.ts @@ -35,6 +35,10 @@ import * as url from 'url'; import { MESSAGE } from 'triple-beam'; import { overridePackagePathResolution } from '@backstage/backend-plugin-api/testUtils'; import { ScannedPluginPackage } from '../scanner'; +import { + dynamicPluginsFrontendServiceRef, + FrontendRemoteResolverProvider, +} from '../server/frontendRemotesServer'; // these can get a bit slow in CI jest.setTimeout(60_000); @@ -117,6 +121,9 @@ describe('dynamicPluginsFeatureLoader', () => { dynamicPlugins: { rootDirectory: dynamicPluginsRootDirectory, }, + backend: { + baseUrl: `http://localhost:0`, + }, }, }), dynamicPluginsFeatureLoader({ @@ -161,6 +168,9 @@ describe('dynamicPluginsFeatureLoader', () => { dynamicPlugins: { rootDirectory: dynamicPluginsRootDirectory, }, + backend: { + baseUrl: `http://localhost:0`, + }, }, }), dynamicPluginsFeatureLoader({ @@ -211,6 +221,9 @@ Require stack: dynamicPlugins: { rootDirectory: dynamicPluginsRootDirectory, }, + backend: { + baseUrl: `http://localhost:0`, + }, }, }), dynamicPluginsFeatureLoader({ @@ -250,6 +263,9 @@ Require stack: rootDirectory: dynamicPluginsRootDirectory, }, customLogLabel: 'a very nice label', + backend: { + baseUrl: `http://localhost:0`, + }, }, }), dynamicPluginsFeatureLoader({ @@ -289,6 +305,9 @@ Require stack: 'test-backend': { secretValue: 'AVerySecretValue', }, + backend: { + baseUrl: `http://localhost:0`, + }, }, }), dynamicPluginsFeatureLoader({ @@ -342,6 +361,9 @@ Require stack: 'test-frontend': { frontendValue: 'AFrontendValue', }, + backend: { + baseUrl: `http://localhost:0`, + }, }, }), dynamicPluginsFeatureLoader({ @@ -371,52 +393,6 @@ Require stack: `); }); - it('should access the module federation assets of the frontend plugin through the backend plugin', async () => { - const { server } = await startTestBackend({ - features: [ - mockServices.rootConfig.factory({ - data: { - dynamicPlugins: { - rootDirectory: dynamicPluginsRootDirectory, - }, - }, - }), - dynamicPluginsFeatureLoader({ - moduleLoader: logger => - jestFreeTypescriptAwareModuleLoader({ logger }), - }), - ], - }); - - const list = await fetch( - `http://localhost:${server.port()}/api/test/frontend-plugins`, - ); - expect(list.ok).toBe(true); - expect(await list.json()).toEqual({ - 'plugin-test-dynamic': `http://localhost:${server.port()}/api/test/frontend-plugins/plugin-test-dynamic/mf-manifest.json`, - }); - - const manifest = await fetch( - `http://localhost:${server.port()}/api/test/frontend-plugins/plugin-test-dynamic/mf-manifest.json`, - ); - expect(manifest.ok).toBe(true); - expect(await manifest.json()).toMatchObject({ - exposes: [], - id: 'backstage__plugin_test', - name: 'backstage__plugin_test', - metaData: { - buildInfo: { - buildName: '@backstage/plugin-test', - buildVersion: '0.0.0', - }, - globalName: 'backstage__plugin_test', - name: 'backstage__plugin_test', - pluginVersion: '0.0.0', - publicPath: 'auto', - }, - }); - }); - it('should load a backend plugin from the alpha package first', async () => { const dynamicPLuginsLister = new DynamicPluginLister(); const mockedTransport = new MockedTransport(); @@ -431,6 +407,9 @@ Require stack: dynamicPlugins: { rootDirectory: dynamicPluginsRootForAlpha, }, + backend: { + baseUrl: `http://localhost:0`, + }, }, }), dynamicPluginsFeatureLoader({ @@ -485,4 +464,385 @@ Require stack: }, }); }); + + describe('module federation support', () => { + const createRemoteProviderPlugin = ( + provider: FrontendRemoteResolverProvider, + ) => + createBackendPlugin({ + pluginId: 'test-remote-provider', + register(reg) { + reg.registerInit({ + deps: { + frontendRemotes: dynamicPluginsFrontendServiceRef, + }, + async init({ frontendRemotes }) { + frontendRemotes.setResolverProvider(provider); + }, + }); + }, + }); + + it('should access the module federation assets of the frontend plugin through the backend plugin', async () => { + const { server } = await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + backend: { + baseUrl: `http://localhost:0`, + }, + }, + }), + dynamicPluginsFeatureLoader({ + moduleLoader: logger => + jestFreeTypescriptAwareModuleLoader({ logger }), + }), + ], + }); + + const list = await fetch( + `http://localhost:${server.port()}/.backstage/dynamic-features/remotes`, + ); + expect(list.ok).toBe(true); + expect(await list.json()).toEqual([ + { + packageName: 'plugin-test-dynamic', + exposedModules: ['.', 'alpha'], + remoteInfo: { + name: 'backstage__plugin_test', + entry: + 'http://localhost:0/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json', + }, + }, + ]); + + const manifest = await fetch( + `http://localhost:${server.port()}/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json`, + ); + expect(manifest.ok).toBe(true); + expect(await manifest.json()).toMatchObject({ + exposes: [{ name: '.' }, { name: 'alpha' }], + id: 'backstage__plugin_test', + name: 'backstage__plugin_test', + metaData: { + buildInfo: { + buildName: '@backstage/plugin-test', + buildVersion: '0.0.0', + }, + globalName: 'backstage__plugin_test', + name: 'backstage__plugin_test', + pluginVersion: '0.0.0', + publicPath: 'auto', + }, + }); + }); + + it('should allow overriding the module federation assets folder, return the Javascript remote entry, the exposed modules and additional remote intry info fields', async () => { + const mockedTransport = new MockedTransport(); + const { server } = await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + backend: { + baseUrl: `http://localhost:0`, + }, + }, + }), + createRemoteProviderPlugin({ + for: () => ({ + assetsPathFromPackage: 'dist-alternate', + getRemoteEntryType: () => 'javascript', + getAdditionaRemoteInfo: manifest => ({ + type: (manifest as any).metaData.remoteEntry.type, + }), + overrideExposedModules: exposedModules => + exposedModules.filter(name => name !== 'alpha'), + }), + }), + dynamicPluginsFeatureLoader({ + moduleLoader: logger => + jestFreeTypescriptAwareModuleLoader({ logger }), + logger: () => ({ + transports: [mockedTransport], + format: winston.format.simple(), + }), + }), + ], + }); + + const list = await fetch( + `http://localhost:${server.port()}/.backstage/dynamic-features/remotes`, + ); + expect(list.ok).toBe(true); + expect(await list.json()).toEqual([ + { + exposedModules: ['.'], + packageName: 'plugin-test-dynamic', + remoteInfo: { + entry: + 'http://localhost:0/.backstage/dynamic-features/remotes/plugin-test-dynamic/remoteEntry.js', + name: 'backstage__plugin_test', + type: 'global', + }, + }, + ]); + expect(mockedTransport.logs).toContainEqual( + expect.stringMatching( + `Exposed dynamic frontend plugin 'plugin-test-dynamic' from '.*/dist-alternate' `, + ), + ); + }); + + it('should allow customizing the module federation manifest when returning it as the remote entry', async () => { + const mockedTransport = new MockedTransport(); + const { server } = await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + backend: { + baseUrl: `http://localhost:0`, + }, + }, + }), + createRemoteProviderPlugin({ + for: () => ({ + customizeManifest(manifest) { + (manifest as any).metaData.publicPath = + 'https://some-cdn-url-where-module-federation-assets-are-mirrored'; + return manifest; + }, + }), + }), + dynamicPluginsFeatureLoader({ + moduleLoader: logger => + jestFreeTypescriptAwareModuleLoader({ logger }), + logger: () => ({ + transports: [mockedTransport], + format: winston.format.simple(), + }), + }), + ], + }); + + const list = await fetch( + `http://localhost:${server.port()}/.backstage/dynamic-features/remotes`, + ); + expect(list.ok).toBe(true); + expect(await list.json()).toEqual([ + { + packageName: 'plugin-test-dynamic', + exposedModules: ['.', 'alpha'], + remoteInfo: { + name: 'backstage__plugin_test', + entry: + 'http://localhost:0/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json', + }, + }, + ]); + const manifest = await fetch( + `http://localhost:${server.port()}/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json`, + ); + expect(manifest.ok).toBe(true); + expect(await manifest.json()).toMatchObject({ + exposes: [{ name: '.' }, { name: 'alpha' }], + id: 'backstage__plugin_test', + name: 'backstage__plugin_test', + metaData: { + buildInfo: { + buildName: '@backstage/plugin-test', + buildVersion: '0.0.0', + }, + globalName: 'backstage__plugin_test', + name: 'backstage__plugin_test', + pluginVersion: '0.0.0', + publicPath: + 'https://some-cdn-url-where-module-federation-assets-are-mirrored', + }, + }); + + expect(mockedTransport.logs).toContainEqual( + expect.stringMatching( + `Exposed dynamic frontend plugin 'plugin-test-dynamic' from '.*/dist' `, + ), + ); + }); + + it('should log an error and skip the plugin if the module federation manifest does not contain a valid name', async () => { + const mockedTransport = new MockedTransport(); + const { server } = await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + backend: { + baseUrl: `http://localhost:0`, + }, + }, + }), + createRemoteProviderPlugin({ + for: () => ({ + manifestFileName: 'mf-manifest-without-name.json', + }), + }), + dynamicPluginsFeatureLoader({ + moduleLoader: logger => + jestFreeTypescriptAwareModuleLoader({ logger }), + logger: () => ({ + transports: [mockedTransport], + format: winston.format.simple(), + }), + }), + ], + }); + + const list = await fetch( + `http://localhost:${server.port()}/.backstage/dynamic-features/remotes`, + ); + expect(list.ok).toBe(true); + expect(await list.json()).toEqual([]); + expect(mockedTransport.logs).toContainEqual( + expect.stringMatching( + "error: Error in manifest '.*' for plugin .*@.*: module name not found", + ), + ); + }); + + it('should log an error and skip the plugin if a resolver provider triggers an error', async () => { + const mockedTransport = new MockedTransport(); + const { server } = await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + backend: { + baseUrl: `http://localhost:0`, + }, + }, + }), + createRemoteProviderPlugin({ + for() { + throw new Error('Resolver provider error'); + }, + }), + dynamicPluginsFeatureLoader({ + moduleLoader: logger => + jestFreeTypescriptAwareModuleLoader({ logger }), + logger: () => ({ + transports: [mockedTransport], + format: winston.format.simple(), + }), + }), + ], + }); + + const list = await fetch( + `http://localhost:${server.port()}/.backstage/dynamic-features/remotes`, + ); + expect(list.ok).toBe(true); + expect(await list.json()).toEqual([]); + expect(mockedTransport.logs).toContainEqual( + expect.stringMatching( + "error: Unexpected error when exposing dynamic frontend plugin 'plugin-test-dynamic@0.0.0' Resolver provider error", + ), + ); + }); + + it('should log an error and skip the plugin if the module federation manifest is not found', async () => { + const mockedTransport = new MockedTransport(); + const { server } = await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + backend: { + baseUrl: `http://localhost:0`, + }, + }, + }), + createRemoteProviderPlugin({ + for: () => ({ + manifestFileName: 'mf-manifest-not-found.json', + }), + }), + dynamicPluginsFeatureLoader({ + moduleLoader: logger => + jestFreeTypescriptAwareModuleLoader({ logger }), + logger: () => ({ + transports: [mockedTransport], + format: winston.format.simple(), + }), + }), + ], + }); + + const list = await fetch( + `http://localhost:${server.port()}/.backstage/dynamic-features/remotes`, + ); + expect(list.ok).toBe(true); + expect(await list.json()).toEqual([]); + expect(mockedTransport.logs).toContainEqual( + expect.stringMatching( + "error: Could not find manifest '.*/dist/mf-manifest-not-found.json' for frontend plugin plugin-test-dynamic@0.0.0", + ), + ); + }); + + it('should log an error and skip the plugin if the module federation remote entry asset is not found', async () => { + const mockedTransport = new MockedTransport(); + const { server } = await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + backend: { + baseUrl: `http://localhost:0`, + }, + }, + }), + createRemoteProviderPlugin({ + for: () => ({ + manifestFileName: 'mf-manifest-with-wrong-remote-entry.json', + getRemoteEntryType: () => 'javascript', + }), + }), + dynamicPluginsFeatureLoader({ + moduleLoader: logger => + jestFreeTypescriptAwareModuleLoader({ logger }), + logger: () => ({ + transports: [mockedTransport], + format: winston.format.simple(), + }), + }), + ], + }); + + const list = await fetch( + `http://localhost:${server.port()}/.backstage/dynamic-features/remotes`, + ); + expect(list.ok).toBe(true); + expect(await list.json()).toEqual([]); + expect(mockedTransport.logs).toContainEqual( + expect.stringMatching( + "error: Could not find remote entry asset '.*/dist/remoteEntry-not-found.js' for frontend plugin plugin-test-dynamic@0.0.0", + ), + ); + }); + }); }); diff --git a/packages/backend-dynamic-feature-service/src/features/features.ts b/packages/backend-dynamic-feature-service/src/features/features.ts index 94711816d7..05a5d55ca7 100644 --- a/packages/backend-dynamic-feature-service/src/features/features.ts +++ b/packages/backend-dynamic-feature-service/src/features/features.ts @@ -32,6 +32,7 @@ import { dynamicPluginsRootLoggerServiceFactory, dynamicPluginsSchemasServiceFactory, } from '../schemas'; +import { frontendRemotesServerService } from '../server/frontendRemotesServer'; /** * @public @@ -64,6 +65,7 @@ const dynamicPluginsFeatureLoaderWithOptions = ( yield* [ dynamicPluginsRootLoggerServiceFactory(rootLoggerOptions), dynamicPluginsFrontendSchemas, + frontendRemotesServerService, dynamicPluginsFeatureDiscoveryLoader, ]; } diff --git a/packages/backend-dynamic-feature-service/src/index.ts b/packages/backend-dynamic-feature-service/src/index.ts index abdbff677b..a69a1f4eb6 100644 --- a/packages/backend-dynamic-feature-service/src/index.ts +++ b/packages/backend-dynamic-feature-service/src/index.ts @@ -19,3 +19,8 @@ export * from './scanner'; export * from './manager'; export * from './schemas'; export * from './features'; +export * from './server'; +export { + type RemoteInfo, + type RemoteInfoTypeEnum, +} from './schema/openapi/generated/models'; diff --git a/packages/backend-dynamic-feature-service/src/schema/openapi.yaml b/packages/backend-dynamic-feature-service/src/schema/openapi.yaml new file mode 100644 index 0000000000..9b1871c366 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi.yaml @@ -0,0 +1,159 @@ +openapi: 3.0.3 +info: + title: .backstage/dynamic-features + version: '1' + description: The Backstage backend plugin that serves the frontend plugins module federation manifests and assets + license: + name: Apache-2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + contact: {} +servers: + - url: / +components: + examples: {} + headers: {} + parameters: {} + requestBodies: {} + responses: + RemotesResponse: + description: List of Module Federation Remotes exposed by the backend + content: + application/json: + schema: + $ref: '#/components/schemas/Remotes' + + ErrorResponse: + description: An error response from the backend. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + schemas: + Remotes: + type: array + items: + $ref: '#/components/schemas/Remote' + + Remote: + description: Definition of a frontend plugin Module Federation remote served by the backend + type: object + properties: + packageName: + description: Name of the package exposed through this Module Federation remote + type: string + remoteInfo: + $ref: '#/components/schemas/RemoteInfo' + + exposedModules: + description: Names of modules exposed by this module federation remote + type: array + items: + type: string + required: + - packageName + - remoteInfo + - exposedModules + + RemoteInfo: + description: Definition of a frontend plugin Module Federation remote served by the backend + externalDocs: + url: https://module-federation.io/guide/basic/runtime.html#init + type: object + properties: + name: + description: Name of the module federation remote + type: string + entry: + description: Remote entry, either the remote manifest file, or the remote entry Javascript file. + type: string + entryGlobalName: + type: string + shareScope: + type: string + type: + type: string + enum: + [ + 'var', + 'module', + 'assign', + 'assign-properties', + 'this', + 'window', + 'self', + 'global', + 'commonjs', + 'commonjs2', + 'commonjs-module', + 'commonjs-static', + 'amd', + 'amd-require', + 'umd', + 'umd2', + 'jsonp', + 'system', + ] + required: + - 'name' + - 'entry' + + Error: + type: object + properties: + error: + type: object + properties: + name: + type: string + message: + type: string + stack: + type: string + code: + type: string + required: + - name + - message + request: + type: object + properties: + method: + type: string + url: + type: string + required: + - method + - url + response: + type: object + properties: + statusCode: + type: number + required: + - statusCode + required: + - error + - response + additionalProperties: {} + securitySchemes: + JWT: + type: http + scheme: bearer + bearerFormat: JWT +paths: + /remotes: + get: + operationId: GetRemotes + description: Get the Module Federation remote definitions. + responses: + '200': + $ref: '#/components/responses/RemotesResponse' + '400': + $ref: '#/components/responses/ErrorResponse' + default: + $ref: '#/components/responses/ErrorResponse' + security: + - {} + - JWT: [] + parameters: [] diff --git a/packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/Api.server.ts b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/Api.server.ts new file mode 100644 index 0000000000..285345d44c --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/Api.server.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. + */ + +// + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { Remote } from '../models/Remote.model'; + +/** + * @public + */ +export type GetRemotes = { + response: Array | Error | Error; +}; + +export type EndpointMap = { + '#get|/remotes': GetRemotes; +}; diff --git a/packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/index.ts b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/index.ts new file mode 100644 index 0000000000..8d81cbaf39 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/index.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +// + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +export * from './Api.server'; diff --git a/packages/backend-dynamic-feature-service/src/schema/openapi/generated/index.ts b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/index.ts new file mode 100644 index 0000000000..dec4b8804e --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export * from './apis'; +export * from './router'; diff --git a/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ErrorError.model.ts b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ErrorError.model.ts new file mode 100644 index 0000000000..e0265e95d7 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ErrorError.model.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface ErrorError { + name: string; + message: string; + stack?: string; + code?: string; +} diff --git a/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ErrorRequest.model.ts b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ErrorRequest.model.ts new file mode 100644 index 0000000000..3eb5e15740 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ErrorRequest.model.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface ErrorRequest { + method: string; + url: string; +} diff --git a/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ErrorResponse.model.ts b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ErrorResponse.model.ts new file mode 100644 index 0000000000..edbcc32df7 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ErrorResponse.model.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface ErrorResponse { + statusCode: number; +} diff --git a/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ModelError.model.ts b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ModelError.model.ts new file mode 100644 index 0000000000..958fde7d0b --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ModelError.model.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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { ErrorError } from '../models/ErrorError.model'; +import { ErrorRequest } from '../models/ErrorRequest.model'; +import { ErrorResponse } from '../models/ErrorResponse.model'; + +/** + * @public + */ +export interface ModelError { + [key: string]: any; + + error: ErrorError; + request?: ErrorRequest; + response: ErrorResponse; +} diff --git a/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/Remote.model.ts b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/Remote.model.ts new file mode 100644 index 0000000000..7440110ad8 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/Remote.model.ts @@ -0,0 +1,36 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { RemoteInfo } from '../models/RemoteInfo.model'; + +/** + * Definition of a frontend plugin Module Federation remote served by the backend + * @public + */ +export interface Remote { + /** + * Name of the package exposed through this Module Federation remote + */ + packageName: string; + remoteInfo: RemoteInfo; + /** + * Names of modules exposed by this module federation remote + */ + exposedModules: Array; +} diff --git a/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/RemoteInfo.model.ts b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/RemoteInfo.model.ts new file mode 100644 index 0000000000..72edba90d4 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/RemoteInfo.model.ts @@ -0,0 +1,60 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * Definition of a frontend plugin Module Federation remote served by the backend + * @public + */ +export interface RemoteInfo { + /** + * Name of the module federation remote + */ + name: string; + /** + * Remote entry, either the remote manifest file, or the remote entry Javascript file. + */ + entry: string; + entryGlobalName?: string; + shareScope?: string; + type?: RemoteInfoTypeEnum; +} + +/** + * @public + */ +export type RemoteInfoTypeEnum = + | 'var' + | 'module' + | 'assign' + | 'assign-properties' + | 'this' + | 'window' + | 'self' + | 'global' + | 'commonjs' + | 'commonjs2' + | 'commonjs-module' + | 'commonjs-static' + | 'amd' + | 'amd-require' + | 'umd' + | 'umd2' + | 'jsonp' + | 'system'; diff --git a/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/index.ts b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/index.ts new file mode 100644 index 0000000000..0c54a6585b --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/index.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export * from '../models/ErrorError.model'; +export * from '../models/ErrorRequest.model'; +export * from '../models/ErrorResponse.model'; +export * from '../models/ModelError.model'; +export * from '../models/Remote.model'; +export * from '../models/RemoteInfo.model'; diff --git a/packages/backend-dynamic-feature-service/src/schema/openapi/generated/router.ts b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/router.ts new file mode 100644 index 0000000000..9f1ef68a77 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/router.ts @@ -0,0 +1,238 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { createValidatedOpenApiRouterFromGeneratedEndpointMap } from '@backstage/backend-openapi-utils'; +import { EndpointMap } from './'; + +export const spec = { + openapi: '3.0.3', + info: { + title: '.backstage/dynamic-features', + version: '1', + description: + 'The Backstage backend plugin that serves the frontend plugins module federation manifests and assets', + license: { + name: 'Apache-2.0', + url: 'http://www.apache.org/licenses/LICENSE-2.0.html', + }, + contact: {}, + }, + servers: [ + { + url: '/', + }, + ], + components: { + examples: {}, + headers: {}, + parameters: {}, + requestBodies: {}, + responses: { + RemotesResponse: { + description: 'List of Module Federation Remotes exposed by the backend', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Remotes', + }, + }, + }, + }, + ErrorResponse: { + description: 'An error response from the backend.', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + }, + }, + }, + }, + schemas: { + Remotes: { + type: 'array', + items: { + $ref: '#/components/schemas/Remote', + }, + }, + Remote: { + description: + 'Definition of a frontend plugin Module Federation remote served by the backend', + type: 'object', + properties: { + packageName: { + description: + 'Name of the package exposed through this Module Federation remote', + type: 'string', + }, + remoteInfo: { + $ref: '#/components/schemas/RemoteInfo', + }, + exposedModules: { + description: + 'Names of modules exposed by this module federation remote', + type: 'array', + items: { + type: 'string', + }, + }, + }, + required: ['packageName', 'remoteInfo', 'exposedModules'], + }, + RemoteInfo: { + description: + 'Definition of a frontend plugin Module Federation remote served by the backend', + externalDocs: { + url: 'https://module-federation.io/guide/basic/runtime.html#init', + }, + type: 'object', + properties: { + name: { + description: 'Name of the module federation remote', + type: 'string', + }, + entry: { + description: + 'Remote entry, either the remote manifest file, or the remote entry Javascript file.', + type: 'string', + }, + entryGlobalName: { + type: 'string', + }, + shareScope: { + type: 'string', + }, + type: { + type: 'string', + enum: [ + 'var', + 'module', + 'assign', + 'assign-properties', + 'this', + 'window', + 'self', + 'global', + 'commonjs', + 'commonjs2', + 'commonjs-module', + 'commonjs-static', + 'amd', + 'amd-require', + 'umd', + 'umd2', + 'jsonp', + 'system', + ], + }, + }, + required: ['name', 'entry'], + }, + Error: { + type: 'object', + properties: { + error: { + type: 'object', + properties: { + name: { + type: 'string', + }, + message: { + type: 'string', + }, + stack: { + type: 'string', + }, + code: { + type: 'string', + }, + }, + required: ['name', 'message'], + }, + request: { + type: 'object', + properties: { + method: { + type: 'string', + }, + url: { + type: 'string', + }, + }, + required: ['method', 'url'], + }, + response: { + type: 'object', + properties: { + statusCode: { + type: 'number', + }, + }, + required: ['statusCode'], + }, + }, + required: ['error', 'response'], + additionalProperties: {}, + }, + }, + securitySchemes: { + JWT: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + }, + paths: { + '/remotes': { + get: { + operationId: 'GetRemotes', + description: 'Get the Module Federation remote definitions.', + responses: { + '200': { + $ref: '#/components/responses/RemotesResponse', + }, + '400': { + $ref: '#/components/responses/ErrorResponse', + }, + default: { + $ref: '#/components/responses/ErrorResponse', + }, + }, + security: [ + {}, + { + JWT: [], + }, + ], + parameters: [], + }, + }, + }, +} as const; +export const createOpenApiRouter = async ( + options?: Parameters< + typeof createValidatedOpenApiRouterFromGeneratedEndpointMap + >['1'], +) => + createValidatedOpenApiRouterFromGeneratedEndpointMap( + spec, + options, + ); diff --git a/packages/backend-dynamic-feature-service/src/schema/openapi/index.ts b/packages/backend-dynamic-feature-service/src/schema/openapi/index.ts new file mode 100644 index 0000000000..196aad553a --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export * from './generated'; diff --git a/packages/backend-dynamic-feature-service/src/schemas/schemas.ts b/packages/backend-dynamic-feature-service/src/schemas/schemas.ts index 0e873e986c..7fd53af799 100644 --- a/packages/backend-dynamic-feature-service/src/schemas/schemas.ts +++ b/packages/backend-dynamic-feature-service/src/schemas/schemas.ts @@ -29,7 +29,11 @@ import { isEmpty } from 'lodash'; import { LoggerService } from '@backstage/backend-plugin-api'; import { JsonObject } from '@backstage/types'; import { PluginScanner } from '../scanner/plugin-scanner'; -import { ConfigSchema, loadConfigSchema } from '@backstage/config-loader'; +import { + ConfigSchema, + loadConfigSchema, + mergeConfigSchemas, +} from '@backstage/config-loader'; import { dynamicPluginsFeatureLoader } from '../features'; /** @@ -153,7 +157,7 @@ async function gatherDynamicPluginsSchemas( packages: ScannedPluginPackage[], logger: LoggerService, schemaLocator: (pluginPackage: ScannedPluginPackage) => string = () => - path.join('dist', 'configSchema.json'), + path.join('dist', '.config-schema.json'), ): Promise<{ [context: string]: JsonObject }> { const allSchemas: { [context: string]: JsonObject } = {}; @@ -169,7 +173,7 @@ async function gatherDynamicPluginsSchemas( continue; } - const serialized = await fs.readJson(schemaLocation); + let serialized = await fs.readJson(schemaLocation); if (!serialized) { continue; } @@ -178,6 +182,12 @@ async function gatherDynamicPluginsSchemas( continue; } + if (serialized?.backstageConfigSchemaVersion === 1) { + serialized = mergeConfigSchemas( + (serialized?.schemas as JsonObject[]).map(_ => _.value as any), + ); + } + if (!serialized?.$schema || serialized?.type !== 'object') { logger.error( `Serialized configuration schema is invalid for plugin ${pluginPackage.manifest.name}`, diff --git a/packages/backend-dynamic-feature-service/src/server/frontendRemotesServer.ts b/packages/backend-dynamic-feature-service/src/server/frontendRemotesServer.ts new file mode 100644 index 0000000000..fa7e574bf3 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/server/frontendRemotesServer.ts @@ -0,0 +1,166 @@ +/* + * Copyright 2024 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 { + coreServices, + createServiceFactory, + createServiceRef, +} from '@backstage/backend-plugin-api'; +import { createRouter } from './router'; +import { dynamicPluginsServiceRef } from '@backstage/backend-dynamic-feature-service'; +import { spec } from '../schema/openapi'; +import { ManifestFileName } from '@module-federation/sdk'; +import { RemoteInfo } from '../schema/openapi/generated/models'; +import { JsonObject } from '@backstage/types'; + +/** + * + * @public + * */ +export type AdditionalRemoteInfo = Omit; + +/** + * + * @public + * */ +export type FrontendRemoteResolver = { + /** + * Relative path to the module federation assets folder from thr root folder of the plugin package. + * Default value is `dist`. + */ + assetsPathFromPackage?: string; + + /** + * File name of the module federation manifest inside the module federation assets folder. + * Default value is `mf-manifest.json`. + */ + manifestFileName?: string; + + /** + * Type of the remote entry returned in the RemoteInfo for this remote. + * Default value is `manifest`. + */ + getRemoteEntryType?: ( + manifestContent: JsonObject, + ) => 'manifest' | 'javascript'; + + /** + * Additional module federation fields, which might be required if the remote entry type is 'javascript'. + */ + getAdditionaRemoteInfo?: ( + manifestContent: JsonObject, + ) => AdditionalRemoteInfo; + + /** + * Overrides the list of exposed modules. By default the exposed modules are read from the manifest file. + */ + overrideExposedModules?: ( + exposedModules: string[], + manifestContent: JsonObject, + ) => string[]; + + /** + * Customizes the manifest before returning it as the remote entry. + */ + customizeManifest?: (content: JsonObject) => JsonObject; +}; + +/** + * + * @public + * */ +export type FrontendRemoteResolverProvider = { + for( + pluginName: string, + pluginPackagePath: string, + ): Partial | undefined; +}; + +/** + * + * @public + * */ +export interface DynamicPluginsFrontendRemotesService { + setResolverProvider(provider: FrontendRemoteResolverProvider): void; +} + +/** + * A service that serves the frontend module federation remotes, + * and allows a plugin to customize the way remotes are served, + * by setting a ResolverProvider. + * + * @public + */ +export const dynamicPluginsFrontendServiceRef = + createServiceRef({ + id: 'core.dynamicplugins.frontendRemotes', + scope: 'root', + }); + +export type FrontendRemoteResolvers = { + default: FrontendRemoteResolver & + Required< + Pick< + FrontendRemoteResolver, + 'assetsPathFromPackage' | 'manifestFileName' | 'getRemoteEntryType' + > + >; + provider?: FrontendRemoteResolverProvider; +}; + +export const frontendRemotesServerService = createServiceFactory({ + service: dynamicPluginsFrontendServiceRef, + deps: { + logger: coreServices.rootLogger, + rootHttpRouter: coreServices.rootHttpRouter, + config: coreServices.rootConfig, + dynamicPlugins: dynamicPluginsServiceRef, + lifecycle: coreServices.rootLifecycle, + }, + async factory({ logger, rootHttpRouter, config, dynamicPlugins, lifecycle }) { + const resolvers: FrontendRemoteResolvers = { + default: { + assetsPathFromPackage: 'dist', + manifestFileName: ManifestFileName, + getRemoteEntryType: () => 'manifest', + }, + provider: undefined, + }; + + lifecycle.addStartupHook(async () => { + rootHttpRouter.use( + `/${spec.info.title}`, + await createRouter({ + logger, + config, + dynamicPlugins, + resolvers, + }), + ); + }); + + return { + setResolverProvider(resolver) { + logger.info('Setting resolver provider'); + if (resolvers.provider) { + throw new Error( + 'Attempted to install a frontend remote resolver provider twice', + ); + } + resolvers.provider = resolver; + }, + }; + }, +}); diff --git a/packages/backend-dynamic-feature-service/src/server/index.ts b/packages/backend-dynamic-feature-service/src/server/index.ts new file mode 100644 index 0000000000..e6df117f10 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/server/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2024 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. + */ +export { dynamicPluginsFrontendServiceRef } from './frontendRemotesServer'; + +export type { + DynamicPluginsFrontendRemotesService, + FrontendRemoteResolver, + FrontendRemoteResolverProvider, + AdditionalRemoteInfo, +} from './frontendRemotesServer'; diff --git a/packages/backend-dynamic-feature-service/src/server/router.ts b/packages/backend-dynamic-feature-service/src/server/router.ts new file mode 100644 index 0000000000..4233147069 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/server/router.ts @@ -0,0 +1,195 @@ +/* + * Copyright 2024 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 { + LoggerService, + RootConfigService, +} from '@backstage/backend-plugin-api'; +import express from 'express'; +import { createOpenApiRouter, spec } from '../schema/openapi'; +import { DynamicPluginProvider } from '@backstage/backend-dynamic-feature-service'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as url from 'url'; +import { FrontendRemoteResolvers } from './frontendRemotesServer'; +import { Remote } from '../schema/openapi/generated/models'; +import { JsonObject } from '@backstage/types'; + +export async function createRouter({ + logger, + config, + dynamicPlugins, + resolvers, +}: { + logger: LoggerService; + config: RootConfigService; + dynamicPlugins: DynamicPluginProvider; + resolvers: FrontendRemoteResolvers; +}): Promise { + const externalBaseUrl = `${config.getString('backend.baseUrl')}/${ + spec.info.title + }`; + + const typedRouter = await createOpenApiRouter(); + + const frontendPluginRemotes: Remote[] = []; + + const { default: defaultResolver, provider: resolverProvider } = resolvers; + for (const plugin of dynamicPlugins.frontendPlugins()) { + try { + const pluginScannedPackage = dynamicPlugins.getScannedPackage(plugin); + const pluginScannedPackagePath = path.resolve( + url.fileURLToPath(pluginScannedPackage.location), + ); + const providedResolver = resolverProvider?.for( + plugin.name, + pluginScannedPackagePath, + ); + + const assetsPath = path.resolve( + pluginScannedPackagePath, + providedResolver?.assetsPathFromPackage ?? + defaultResolver.assetsPathFromPackage, + ); + + const manifestFileName = + providedResolver?.manifestFileName ?? defaultResolver.manifestFileName; + const manifestLocation = path.resolve(assetsPath, manifestFileName); + if (!fs.existsSync(manifestLocation)) { + logger.error( + `Could not find manifest '${manifestLocation}' for frontend plugin ${plugin.name}@${plugin.version}`, + ); + continue; + } + + let manifest: JsonObject; + try { + manifest = JSON.parse(fs.readFileSync(manifestLocation).toString()); + } catch (error) { + logger.error( + `Dynamic frontend plugin manifest '${manifestLocation}' could not be parsed for plugin ${plugin.name}@${plugin.version}`, + ); + continue; + } + + if (!manifest.name || typeof manifest.name !== 'string') { + logger.error( + `Error in manifest '${manifestLocation}' for plugin ${plugin.name}@${plugin.version}: module name not found`, + ); + continue; + } + if ( + !manifest.metaData || + typeof manifest.metaData !== 'object' || + !('remoteEntry' in manifest.metaData) || + !manifest.metaData.remoteEntry || + typeof manifest.metaData.remoteEntry !== 'object' || + !('name' in manifest.metaData.remoteEntry) || + typeof manifest.metaData.remoteEntry.name !== 'string' + ) { + logger.error( + `Could not find remote entry asset in the manifest '${manifestLocation}' for plugin ${plugin.name}@${plugin.version}`, + ); + continue; + } + + if ( + !manifest.exposes || + !Array.isArray(manifest.exposes) || + !manifest.exposes.every<{ name: string }>( + (i): i is { name: string } => + i !== null && typeof i === 'object' && 'name' in i, + ) + ) { + logger.error( + `Could not find the exposes field in the manifest '${manifestLocation}' for plugin ${plugin.name}@${plugin.version}`, + ); + continue; + } + + const getAdditionalRemoteInfo = + providedResolver?.getAdditionaRemoteInfo ?? + defaultResolver.getAdditionaRemoteInfo; + const getRemoteEntryType = + providedResolver?.getRemoteEntryType ?? + defaultResolver.getRemoteEntryType; + const remoteEntryType = getRemoteEntryType(manifest); + + let remoteEntryAsset = manifestFileName; + if (remoteEntryType === 'javascript') { + remoteEntryAsset = manifest.metaData.remoteEntry.name; + } + + const remoteEntryAssetLocation = path.resolve( + assetsPath, + remoteEntryAsset, + ); + if (!fs.existsSync(remoteEntryAssetLocation)) { + logger.error( + `Could not find remote entry asset '${remoteEntryAssetLocation}' for frontend plugin ${plugin.name}@${plugin.version}`, + ); + continue; + } + + const remoteAssetsPrefix = `/remotes/${plugin.name}`; + const remoteEntryPath = `${remoteAssetsPrefix}/${remoteEntryAsset}`; + + const overrideExposedModules = + providedResolver?.overrideExposedModules ?? + defaultResolver.overrideExposedModules; + + const exposedModules = manifest.exposes.map(e => e.name); + + frontendPluginRemotes.push({ + packageName: plugin.name, + remoteInfo: { + name: manifest.name, + entry: `${externalBaseUrl}${remoteEntryPath}`, + ...getAdditionalRemoteInfo?.(manifest), + }, + exposedModules: + overrideExposedModules?.(exposedModules, manifest) ?? exposedModules, + }); + + const customizeManifest = + providedResolver?.customizeManifest ?? + defaultResolver.customizeManifest; + if (remoteEntryType === 'manifest' && customizeManifest) { + const customizedContent = customizeManifest(manifest); + typedRouter.use(`${remoteEntryPath}`, (_, res) => { + res.json(customizedContent); + }); + } + typedRouter.use(remoteAssetsPrefix, express.static(assetsPath)); + logger.info( + `Exposed dynamic frontend plugin '${plugin.name}' from '${assetsPath}' `, + ); + } catch (error) { + logger.error( + `Unexpected error when exposing dynamic frontend plugin '${plugin.name}@${plugin.version}'`, + error, + ); + continue; + } + } + + logger.info(`/remotes => ${JSON.stringify(frontendPluginRemotes)}`); + typedRouter.get('/remotes', (_, res) => { + res.status(200).json(frontendPluginRemotes); + }); + + return typedRouter; +} diff --git a/packages/frontend-dynamic-feature-loader/.eslintrc.js b/packages/frontend-dynamic-feature-loader/.eslintrc.js new file mode 100644 index 0000000000..e487f765b2 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, { + rules: { + '@backstage/no-top-level-material-ui-4-imports': 'error', + }, +}); diff --git a/packages/frontend-dynamic-feature-loader/README.md b/packages/frontend-dynamic-feature-loader/README.md new file mode 100644 index 0000000000..a1ab46d7b0 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/README.md @@ -0,0 +1,32 @@ +# @backstage/frontend-dynamic-feature-loader + +Backstage frontend feature loader to load new frontend system plugins exposed as module federation remotes. +The frontend feature loader provided in this package works hand-in-hand with the server of frontend plugin module federation remotes server which is part of backend dynamic feature service in package `@backstage/backend-dynamic-feature-service`. + +**NOTE: The [new frontend system](https://backstage.io/docs/frontend-system/) that this package is relaying upon is in alpha, and we do not yet recommend using it for production deployments** + +## Usage + +- To enable this loader, you should: + + - Enable the backend dynamic features in your backend application, as explained in the [`backend-dynamic-feature-service` README.md file](../backend-dynamic-feature-service/README.md#how-it-works) + - Add the frontend feature loader to the list of features when creating the frontend application: + + ```typescript + const app = createApp({ + features: [...someOtherFeatures, dynamicFrontendFeaturesLoader()], + }); + ``` + +## How to add a frontend plugin for dynamic loading + +Adding a frontend plugin (with new frontend system support, possibly in alpha support), is straightforward and consists in: + +- building the frontend plugin with the `frontend-dynamic-container` role, which enables the module federation support, and packages the plugin as a module remote +- copying the frontend package folder, with the `dist` folder generated during the build, to the dynamic plugins root folder of the Backstage installation (defined by the `dynamicPlugins.rootDirectory` configuration value, which is usually set as `dynamic-plugins-root`). + +So from a frontend plugin package folder, you would use the following command: + +```bash +yarn build --role frontend-dynamic-container && cp -R $(pwd) /dynamic-plugins-root/ +``` diff --git a/packages/frontend-dynamic-feature-loader/catalog-info.yaml b/packages/frontend-dynamic-feature-loader/catalog-info.yaml new file mode 100644 index 0000000000..3f9ef427ed --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-frontend-dynamic-feature-loader + title: '@backstage/frontend-dynamic-feature-loader' + description: Backstage frontend feature loader to load new frontend system plugins exposed as module federation remotes. +spec: + lifecycle: experimental + type: backstage-web-library + owner: maintainers diff --git a/packages/frontend-dynamic-feature-loader/config.d.ts b/packages/frontend-dynamic-feature-loader/config.d.ts new file mode 100644 index 0000000000..c541782683 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/config.d.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2024 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. + */ + +export interface Config { + /** + * @visibility frontend + */ + dynamicPlugins?: {}; +} diff --git a/packages/frontend-dynamic-feature-loader/knip-report.md b/packages/frontend-dynamic-feature-loader/knip-report.md new file mode 100644 index 0000000000..2661c35327 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/knip-report.md @@ -0,0 +1,2 @@ +# Knip report + diff --git a/packages/frontend-dynamic-feature-loader/package.json b/packages/frontend-dynamic-feature-loader/package.json new file mode 100644 index 0000000000..9865f6b37b --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/package.json @@ -0,0 +1,66 @@ +{ + "name": "@backstage/frontend-dynamic-feature-loader", + "version": "0.0.0", + "backstage": { + "role": "web-library" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/frontend-dynamic-feature-loader" + }, + "license": "Apache-2.0", + "sideEffects": false, + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "config.d.ts", + "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", + "start": "backstage-cli package start", + "test": "backstage-cli package test" + }, + "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", + "cross-fetch": "^4.0.0", + "uri-template": "^2.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^16.0.0", + "@types/react": "^18.0.0", + "msw": "^1.0.0", + "react": "^18.0.2", + "react-dom": "^18.0.2", + "react-router-dom": "^6.3.0" + }, + "peerDependencies": { + "@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-dom": "^6.3.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + }, + "configSchema": "config.d.ts" +} diff --git a/packages/frontend-dynamic-feature-loader/report.api.md b/packages/frontend-dynamic-feature-loader/report.api.md new file mode 100644 index 0000000000..3188551bad --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/report.api.md @@ -0,0 +1,24 @@ +## API Report File for "@backstage/frontend-dynamic-feature-loader" + +> 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 { ShareStrategy } from '@module-federation/runtime/types'; +import { UserOptions } from '@module-federation/runtime/types'; + +// @public +export function dynamicFrontendFeaturesLoader( + options?: DynamicFrontendFeaturesLoaderOptions, +): FrontendFeatureLoader; + +// @public (undocumented) +export type DynamicFrontendFeaturesLoaderOptions = { + moduleFederation: { + shared?: UserOptions['shared']; + shareStrategy?: ShareStrategy; + plugins?: Array; + }; +}; +``` diff --git a/packages/frontend-dynamic-feature-loader/src/index.ts b/packages/frontend-dynamic-feature-loader/src/index.ts new file mode 100644 index 0000000000..2b3c4616f5 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2024 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. + */ + +/** + * Core API used by Backstage frontend apps. + * + * @packageDocumentation + */ + +export * from './loader'; diff --git a/packages/frontend-dynamic-feature-loader/src/loader.test.tsx b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx new file mode 100644 index 0000000000..87ddc7770f --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx @@ -0,0 +1,1158 @@ +/* + * Copyright 2023 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 { mockApis, registerMswTestHooks } from '@backstage/test-utils'; +import { + DynamicFrontendFeaturesLoaderOptions, + dynamicFrontendFeaturesLoader, +} from './loader'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +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'; + +const baseUrl = 'http://localhost:7007'; + +describe('dynamicFrontendFeaturesLoader', () => { + const server = setupServer(); + registerMswTestHooks(server); + const mocks = { + console: { + error: jest.spyOn(console, 'error').mockImplementation(() => {}), + warn: jest.spyOn(console, 'warn').mockImplementation(() => {}), + info: jest.spyOn(console, 'info').mockImplementation(() => {}), + debug: jest.spyOn(console, 'debug').mockImplementation(() => {}), + }, + federation: { + get: jest.fn((_: { name: string; id: string }): Module => ({})), + onLoad: jest.fn(() => {}), + }, + }; + + const getCommonOptions = (): DynamicFrontendFeaturesLoaderOptions => ({ + moduleFederation: { + // 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; + }, + onLoad: mocks.federation.onLoad, + }, + ], + }, + }); + + const manifestDummyData = { + metaData: { + buildInfo: {}, + remoteEntry: { + name: 'remoteEntry.js', + }, + types: {}, + publicPath: 'auto', + }, + shared: [], + }; + + const manifestExposedRemoteDummyData = { + assets: { + js: { + sync: [], + async: [], + }, + css: { + sync: [], + async: [], + }, + }, + }; + + afterEach(() => { + mocks.console.error.mockReset(); + mocks.console.warn.mockReset(); + mocks.console.info.mockReset(); + mocks.console.debug.mockReset(); + mocks.federation.get.mockReset(); + mocks.federation.onLoad.mockReset(); + resetFederationGlobalInfo(); + }); + + it('should return immediately if dynamic plugins are not enabled in config', async () => { + let manifestsEndpointCalled = false; + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-plugins/remotes`, + (_, res, ctx) => { + manifestsEndpointCalled = true; + return res(ctx.json({})); + }, + ), + ); + + const features = await ( + dynamicFrontendFeaturesLoader({ + ...getCommonOptions(), + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockApis.config({ + data: { + app: { + experimental: { + packages: { + include: [], + }, + }, + }, + backend: { + baseUrl, + }, + }, + }), + }); + + 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([]); + expect(features).toMatchObject([]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([]); + expect(mocks.federation.get.mock.calls.flatMap(e => e[0])).toEqual([]); + expect(manifestsEndpointCalled).toBe(false); + }); + + it('should load a dynamic frontend plugin with the default exposed remote module', 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({ + id: 'test-plugin', + extensions: [], + }), + }); + + const features = await ( + dynamicFrontendFeaturesLoader({ + ...getCommonOptions(), + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockApis.config({ + data: { + app: { + experimental: { + packages: { + include: [], + }, + }, + }, + backend: { + baseUrl, + }, + dynamicPlugins: {}, + }, + }), + }); + + 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([]); + expect(features).toMatchObject([ + { + $$type: '@backstage/FrontendPlugin', + id: 'test-plugin', + version: 'v1', + }, + ]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([ + "Remote module 'test_plugin' of dynamic plugin 'plugin-test-dynamic' loaded from http://localhost:7007/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json", + ]); + const debugCalls = mocks.console.debug.mock.calls.flatMap(e => e[0]); + expect(debugCalls).toEqual([ + "Loading dynamic plugin 'plugin-test-dynamic' from 'http://localhost:7007/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json'", + ]); + expect(mocks.federation.get.mock.calls.flatMap(e => e[0])).toEqual([ + { + id: '.', + name: 'test_plugin', + }, + ]); + }); + + it('should load several dynamic frontend plugins', async () => { + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes`, + (_, res, ctx) => + res( + ctx.json([ + { + packageName: 'plugin-1', + exposedModules: ['.'], + remoteInfo: { + name: 'plugin_1', + entry: `${baseUrl}/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json`, + }, + }, + { + packageName: 'plugin-2', + exposedModules: ['.'], + remoteInfo: { + name: 'plugin_2', + entry: `${baseUrl}/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json`, + }, + }, + ]), + ), + ), + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json`, + (_, res, ctx) => + res( + ctx.json({ + name: 'plugin_1', + ...manifestDummyData, + exposes: [ + { + id: 'plugin_1:.', + name: '.', + path: '.', + ...manifestExposedRemoteDummyData, + }, + ], + }), + ), + ), + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json`, + (_, res, ctx) => + res( + ctx.json({ + name: 'plugin_2', + ...manifestDummyData, + exposes: [ + { + id: 'plugin_2:.', + name: '.', + path: '.', + ...manifestExposedRemoteDummyData, + }, + ], + }), + ), + ), + ); + + mocks.federation.get.mockReturnValueOnce({ + default: createFrontendPlugin({ + id: 'plugin-1', + extensions: [], + }), + }); + mocks.federation.get.mockReturnValueOnce({ + default: createFrontendPlugin({ + id: 'plugin-2', + extensions: [], + }), + }); + + const features = await ( + dynamicFrontendFeaturesLoader({ + ...getCommonOptions(), + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockApis.config({ + data: { + app: { + experimental: { + packages: { + include: [], + }, + }, + }, + backend: { + baseUrl, + }, + dynamicPlugins: {}, + }, + }), + }); + + 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([]); + expect(features).toMatchObject([ + { + $$type: '@backstage/FrontendPlugin', + id: 'plugin-1', + version: 'v1', + }, + { + $$type: '@backstage/FrontendPlugin', + id: 'plugin-2', + version: 'v1', + }, + ]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([ + "Remote module 'plugin_1' of dynamic plugin 'plugin-1' loaded from http://localhost:7007/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json", + "Remote module 'plugin_2' of dynamic plugin 'plugin-2' loaded from http://localhost:7007/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json", + ]); + const debugCalls = mocks.console.debug.mock.calls.flatMap(e => e[0]); + expect(debugCalls).toEqual([ + "Loading dynamic plugin 'plugin-1' from 'http://localhost:7007/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json'", + "Loading dynamic plugin 'plugin-2' from 'http://localhost:7007/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json'", + ]); + expect(mocks.federation.get.mock.calls.flatMap(e => e[0])).toEqual([ + { + id: '.', + name: 'plugin_1', + }, + { + id: '.', + name: 'plugin_2', + }, + ]); + }); + + it('should load a dynamic frontend plugin with several exposed remote modules', async () => { + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes`, + (_, res, ctx) => + res( + ctx.json([ + { + packageName: 'plugin-test-dynamic', + exposedModules: ['.', 'alpha'], + 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, + }, + { + id: 'test_plugin:alpha', + name: 'alpha', + path: './alpha', + ...manifestExposedRemoteDummyData, + }, + ], + }), + ), + ), + ); + + mocks.federation.get.mockReturnValueOnce({ + default: createFrontendPlugin({ + id: 'test-plugin', + extensions: [], + }), + }); + mocks.federation.get.mockReturnValueOnce({ + default: createFrontendPlugin({ + id: 'test-plugin-alpha', + extensions: [], + }), + }); + + const features = await ( + dynamicFrontendFeaturesLoader({ + ...getCommonOptions(), + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockApis.config({ + data: { + app: { + experimental: { + packages: { + include: [], + }, + }, + }, + backend: { + baseUrl, + }, + dynamicPlugins: {}, + }, + }), + }); + + 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([]); + expect(features).toMatchObject([ + { + $$type: '@backstage/FrontendPlugin', + id: 'test-plugin', + version: 'v1', + }, + { + $$type: '@backstage/FrontendPlugin', + id: 'test-plugin-alpha', + version: 'v1', + }, + ]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([ + "Remote module 'test_plugin' of dynamic plugin 'plugin-test-dynamic' loaded from http://localhost:7007/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json", + "Remote module 'test_plugin/alpha' of dynamic plugin 'plugin-test-dynamic' loaded from http://localhost:7007/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json", + ]); + const debugCalls = mocks.console.debug.mock.calls.flatMap(e => e[0]); + expect(debugCalls).toEqual([ + "Loading dynamic plugin 'plugin-test-dynamic' from 'http://localhost:7007/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json'", + ]); + expect(mocks.federation.get.mock.calls.flatMap(e => e[0])).toEqual([ + { + id: '.', + name: 'test_plugin', + }, + { + id: './alpha', + name: 'test_plugin', + }, + ]); + }); + + it('should load a dynamic frontend plugin from Javascript remote entry', async () => { + mocks.federation.get.mockRestore(); + mocks.federation.onLoad.mockRestore(); + 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/remoteEntry.js`, + type: 'jsonp', + }, + }, + ]), + ), + ), + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-test-dynamic/remoteEntry.js`, + (_, res, ctx) => res(ctx.text('coucou :-)')), + ), + ); + + mocks.federation.get.mockReturnValueOnce({ + default: createFrontendPlugin({ + id: 'test-plugin', + extensions: [], + }), + }); + + const features = await ( + dynamicFrontendFeaturesLoader({ + ...getCommonOptions(), + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockApis.config({ + data: { + app: { + experimental: { + packages: { + include: [], + }, + }, + }, + backend: { + baseUrl, + }, + dynamicPlugins: {}, + }, + }), + }); + + 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([]); + expect(features).toMatchObject([ + { + $$type: '@backstage/FrontendPlugin', + id: 'test-plugin', + version: 'v1', + }, + ]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([ + "Remote module 'test_plugin' of dynamic plugin 'plugin-test-dynamic' loaded from http://localhost:7007/.backstage/dynamic-features/remotes/plugin-test-dynamic/remoteEntry.js", + ]); + const debugCalls = mocks.console.debug.mock.calls.flatMap(e => e[0]); + expect(debugCalls).toEqual([ + "Loading dynamic plugin 'plugin-test-dynamic' from 'http://localhost:7007/.backstage/dynamic-features/remotes/plugin-test-dynamic/remoteEntry.js'", + ]); + expect(mocks.federation.get.mock.calls.flatMap(e => e[0])).toEqual([ + { + id: '.', + name: 'test_plugin', + }, + ]); + }); + + it('should warn and recover from a 404 error fetching module feredation configuration', async () => { + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes`, + (_, res, ctx) => res(ctx.status(404, 'NOT FOUND')), + ), + ); + + mocks.federation.get.mockReturnValue({ + default: createFrontendPlugin({ + id: 'test-plugin', + extensions: [], + }), + }); + + const features = await ( + dynamicFrontendFeaturesLoader({ + ...getCommonOptions(), + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockApis.config({ + data: { + app: { + experimental: { + packages: { + include: [], + }, + }, + }, + backend: { + baseUrl, + }, + dynamicPlugins: {}, + }, + }), + }); + + const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); + expect(errorCalls).toEqual([ + `Failed fetching module federation configuration of dynamic frontend plugins: Error: 404 - NOT FOUND`, + ]); + const warnCalls = mocks.console.warn.mock.calls.flatMap(e => e[0]); + expect(warnCalls).toEqual([]); + expect(features).toMatchObject([]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([]); + expect(mocks.federation.get.mock.calls.flatMap(e => e[0])).toEqual([]); + }); + + it('should warn and recover from empty response while fetching module feredation configuration', async () => { + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes`, + (_, res, ctx) => res(ctx.status(200)), + ), + ); + + mocks.federation.get.mockReturnValue({ + default: createFrontendPlugin({ + id: 'test-plugin', + extensions: [], + }), + }); + + const features = await ( + dynamicFrontendFeaturesLoader({ + ...getCommonOptions(), + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockApis.config({ + data: { + app: { + experimental: { + packages: { + include: [], + }, + }, + }, + backend: { + baseUrl, + }, + dynamicPlugins: {}, + }, + }), + }); + + const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); + expect(errorCalls).toEqual([ + `Failed fetching module federation configuration of dynamic frontend plugins: FetchError: invalid json response body at http://localhost:7007/.backstage/dynamic-features/remotes reason: Unexpected end of JSON input`, + ]); + const warnCalls = mocks.console.warn.mock.calls.flatMap(e => e[0]); + expect(warnCalls).toEqual([]); + expect(features).toMatchObject([]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([]); + expect(mocks.federation.get.mock.calls.flatMap(e => e[0])).toEqual([]); + }); + + it('should warn on empty module, but still load other remotes', async () => { + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes`, + (_, res, ctx) => + res( + ctx.json([ + { + packageName: 'plugin-1', + exposedModules: ['.'], + remoteInfo: { + name: 'plugin_1', + entry: `${baseUrl}/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json`, + }, + }, + { + packageName: 'plugin-2', + exposedModules: ['.'], + remoteInfo: { + name: 'plugin_2', + entry: `${baseUrl}/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json`, + }, + }, + ]), + ), + ), + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json`, + (_, res, ctx) => + res( + ctx.json({ + name: 'plugin_1', + ...manifestDummyData, + exposes: [ + { + id: 'plugin_1:.', + name: '.', + path: '.', + ...manifestExposedRemoteDummyData, + }, + ], + }), + ), + ), + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json`, + (_, res, ctx) => + res( + ctx.json({ + name: 'plugin_2', + ...manifestDummyData, + exposes: [ + { + id: 'plugin_2:.', + name: '.', + path: '.', + ...manifestExposedRemoteDummyData, + }, + ], + }), + ), + ), + ); + + mocks.federation.get.mockReturnValueOnce(undefined); + mocks.federation.get.mockReturnValueOnce({ + default: createFrontendPlugin({ + id: 'plugin-2', + extensions: [], + }), + }); + + const features = await ( + dynamicFrontendFeaturesLoader({ + ...getCommonOptions(), + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockApis.config({ + data: { + app: { + experimental: { + packages: { + include: [], + }, + }, + }, + backend: { + baseUrl, + }, + dynamicPlugins: {}, + }, + }), + }); + + 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([ + "Skipping empty dynamic plugin remote module 'plugin_1'.", + ]); + expect(features).toMatchObject([ + { + $$type: '@backstage/FrontendPlugin', + id: 'plugin-2', + version: 'v1', + }, + ]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([ + "Remote module 'plugin_2' of dynamic plugin 'plugin-2' loaded from http://localhost:7007/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json", + ]); + const debugCalls = mocks.console.debug.mock.calls.flatMap(e => e[0]); + expect(debugCalls).toEqual([ + "Loading dynamic plugin 'plugin-1' from 'http://localhost:7007/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json'", + "Loading dynamic plugin 'plugin-2' from 'http://localhost:7007/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json'", + ]); + expect(mocks.federation.get.mock.calls.flatMap(e => e[0])).toEqual([ + { + id: '.', + name: 'plugin_1', + }, + { + id: '.', + name: 'plugin_2', + }, + ]); + }); + + it('should skip module without default export, but still load other remotes', async () => { + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes`, + (_, res, ctx) => + res( + ctx.json([ + { + packageName: 'plugin-1', + exposedModules: ['.'], + remoteInfo: { + name: 'plugin_1', + entry: `${baseUrl}/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json`, + }, + }, + { + packageName: 'plugin-2', + exposedModules: ['.'], + remoteInfo: { + name: 'plugin_2', + entry: `${baseUrl}/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json`, + }, + }, + ]), + ), + ), + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json`, + (_, res, ctx) => + res( + ctx.json({ + name: 'plugin_1', + ...manifestDummyData, + exposes: [ + { + id: 'plugin_1:.', + name: '.', + path: '.', + ...manifestExposedRemoteDummyData, + }, + ], + }), + ), + ), + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json`, + (_, res, ctx) => + res( + ctx.json({ + name: 'plugin_2', + ...manifestDummyData, + exposes: [ + { + id: 'plugin_2:.', + name: '.', + path: '.', + ...manifestExposedRemoteDummyData, + }, + ], + }), + ), + ), + ); + + mocks.federation.get.mockReturnValueOnce({ + anExport: 'anExportValue', + }); + mocks.federation.get.mockReturnValueOnce({ + default: createFrontendPlugin({ + id: 'plugin-2', + extensions: [], + }), + }); + + const features = await ( + dynamicFrontendFeaturesLoader({ + ...getCommonOptions(), + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockApis.config({ + data: { + app: { + experimental: { + packages: { + include: [], + }, + }, + }, + backend: { + baseUrl, + }, + dynamicPlugins: {}, + }, + }), + }); + + 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([]); + expect(features).toMatchObject([ + { + $$type: '@backstage/FrontendPlugin', + id: 'plugin-2', + version: 'v1', + }, + ]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([ + "Remote module 'plugin_1' of dynamic plugin 'plugin-1' loaded from http://localhost:7007/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json", + "Remote module 'plugin_2' of dynamic plugin 'plugin-2' loaded from http://localhost:7007/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json", + ]); + const debugCalls = mocks.console.debug.mock.calls.flatMap(e => e[0]); + expect(debugCalls).toEqual([ + "Loading dynamic plugin 'plugin-1' from 'http://localhost:7007/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json'", + "Loading dynamic plugin 'plugin-2' from 'http://localhost:7007/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json'", + "Skipping dynamic plugin remote module '[object Object]' since it doesn't export a new 'FrontendFeature' as default export.", + ]); + expect(mocks.federation.get.mock.calls.flatMap(e => e[0])).toEqual([ + { + id: '.', + name: 'plugin_1', + }, + { + id: '.', + name: 'plugin_2', + }, + ]); + }); + + it('should warn on 404 error fetching module feredation manifest, but still load other remotes', async () => { + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes`, + (_, res, ctx) => + res( + ctx.json([ + { + packageName: 'plugin-1', + exposedModules: ['.'], + remoteInfo: { + name: 'plugin_1', + entry: `${baseUrl}/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json`, + }, + }, + { + packageName: 'plugin-2', + exposedModules: ['.'], + remoteInfo: { + name: 'plugin_2', + entry: `${baseUrl}/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json`, + }, + }, + ]), + ), + ), + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json`, + (_, res, ctx) => res(ctx.json({}), ctx.status(404, 'NOT FOUND')), + ), + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json`, + (_, res, ctx) => + res( + ctx.json({ + name: 'plugin_2', + ...manifestDummyData, + exposes: [ + { + id: 'plugin_2:.', + name: '.', + path: '.', + ...manifestExposedRemoteDummyData, + }, + ], + }), + ), + ), + ); + + mocks.federation.get.mockReturnValueOnce({ + default: createFrontendPlugin({ + id: 'plugin-2', + extensions: [], + }), + }); + + const features = await ( + dynamicFrontendFeaturesLoader({ + ...getCommonOptions(), + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockApis.config({ + data: { + app: { + experimental: { + packages: { + include: [], + }, + }, + }, + backend: { + baseUrl, + }, + dynamicPlugins: {}, + }, + }), + }); + + 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(features).toMatchObject([ + { + $$type: '@backstage/FrontendPlugin', + id: 'plugin-2', + version: 'v1', + }, + ]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([ + "Remote module 'plugin_2' of dynamic plugin 'plugin-2' loaded from http://localhost:7007/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json", + ]); + const debugCalls = mocks.console.debug.mock.calls.flatMap(e => e[0]); + expect(debugCalls).toEqual([ + "Loading dynamic plugin 'plugin-1' from 'http://localhost:7007/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json'", + "Loading dynamic plugin 'plugin-2' from 'http://localhost:7007/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json'", + ]); + + expect(mocks.federation.get.mock.calls.flatMap(e => e[0])).toEqual([ + { + id: '.', + name: 'plugin_2', + }, + ]); + }); + + it('should warn on unexpected Json content while fetching module feredation manifest, but still load other remotes', async () => { + server.use( + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes`, + (_, res, ctx) => + res( + ctx.json([ + { + packageName: 'plugin-1', + exposedModules: ['.'], + remoteInfo: { + name: 'plugin_1', + entry: `${baseUrl}/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json`, + }, + }, + { + packageName: 'plugin-2', + exposedModules: ['.'], + remoteInfo: { + name: 'plugin_2', + entry: `${baseUrl}/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json`, + }, + }, + ]), + ), + ), + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json`, + (_, res, ctx) => res(ctx.json('A Json String')), + ), + rest.get( + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json`, + (_, res, ctx) => + res( + ctx.json({ + name: 'plugin-2', + ...manifestDummyData, + exposes: [ + { + id: 'plugin-2:.', + name: '.', + path: '.', + ...manifestExposedRemoteDummyData, + }, + ], + }), + ), + ), + ); + + mocks.federation.get.mockReturnValueOnce({ + default: createFrontendPlugin({ + id: 'plugin-2', + extensions: [], + }), + }); + + const features = await ( + dynamicFrontendFeaturesLoader({ + ...getCommonOptions(), + }) as InternalFrontendFeatureLoader + ).loader({ + config: mockApis.config({ + data: { + app: { + experimental: { + packages: { + include: [], + }, + }, + }, + backend: { + baseUrl, + }, + dynamicPlugins: {}, + }, + }), + }); + + 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(features).toMatchObject([ + { + $$type: '@backstage/FrontendPlugin', + id: 'plugin-2', + version: 'v1', + }, + ]); + const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); + expect(infoCalls).toEqual([ + "Remote module 'plugin_2' of dynamic plugin 'plugin-2' loaded from http://localhost:7007/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json", + ]); + const debugCalls = mocks.console.debug.mock.calls.flatMap(e => e[0]); + expect(debugCalls).toEqual([ + "Loading dynamic plugin 'plugin-1' from 'http://localhost:7007/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json'", + "Loading dynamic plugin 'plugin-2' from 'http://localhost:7007/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json'", + ]); + expect(mocks.federation.get.mock.calls.flatMap(e => e[0])).toEqual([ + { + id: '.', + name: 'plugin_2', + }, + ]); + }); +}); diff --git a/packages/frontend-dynamic-feature-loader/src/loader.ts b/packages/frontend-dynamic-feature-loader/src/loader.ts new file mode 100644 index 0000000000..9e2585d1fa --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/loader.ts @@ -0,0 +1,184 @@ +/* + * Copyright 2024 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 { + FederationRuntimePlugin, + init, + loadRemote, +} from '@module-federation/enhanced/runtime'; +import { Module } from '@module-federation/sdk'; +import { DefaultApiClient, Remote } from './schema/openapi'; +import { + FrontendFeature, + FrontendFeatureLoader, + createFrontendFeatureLoader, +} from '@backstage/frontend-plugin-api'; +import { ShareStrategy, UserOptions } from '@module-federation/runtime/types'; + +/** + * + * @public + */ +export type DynamicFrontendFeaturesLoaderOptions = { + /** + * Additional module federation arguments for the Module Federation runtime initialization. + */ + moduleFederation: { + shared?: UserOptions['shared']; + shareStrategy?: ShareStrategy; + plugins?: Array; + }; +}; + +/** + * A function providing a loader of frontend features exposed as module federation remotes + * from the backend dynamic features service. + * + * @public + */ +export function dynamicFrontendFeaturesLoader( + options?: DynamicFrontendFeaturesLoaderOptions, +): FrontendFeatureLoader { + return createFrontendFeatureLoader({ + async loader({ config }) { + const dynamicPLuginsConfig = config.getOptionalConfig('dynamicPlugins'); + if (!dynamicPLuginsConfig) { + return []; + } + + function error(message: string, err: unknown) { + // eslint-disable-next-line no-console + console.error( + `${message}: ${ + err instanceof Error ? err.toString() : JSON.stringify(err) + }`, + ); + } + + const backendBaseUrl = config.getString('backend.baseUrl'); + + const appPackageName = + config.getOptionalString('app.packageName') ?? 'app'; + let frontendPluginRemotes: Array; + try { + const apiClient = new DefaultApiClient({ + discoveryApi: { + getBaseUrl: async rootPath => `${backendBaseUrl}/${rootPath}`, + }, + fetchApi: { + fetch(input) { + return global.fetch(input); + }, + }, + }); + + const response = await apiClient.getRemotes({}); + if (!response.ok) { + throw new Error(`${response.status} - ${response.statusText}`); + } + frontendPluginRemotes = await response.json(); + } catch (err) { + error( + `Failed fetching module federation configuration of dynamic frontend plugins`, + err, + ); + return []; + } + + try { + init({ + ...options?.moduleFederation, + name: appPackageName + .replaceAll('@', '') + .replaceAll('/', '__') + .replaceAll('-', '_'), + remotes: frontendPluginRemotes.map(remote => ({ + alias: remote.packageName, + ...remote.remoteInfo, + })), + }); + } catch (err) { + error(`Failed initializing module federation`, err); + return []; + } + + const features = ( + await Promise.all( + frontendPluginRemotes.map(async remote => { + // eslint-disable-next-line no-console + console.debug( + `Loading dynamic plugin '${remote.packageName}' from '${remote.remoteInfo.entry}'`, + ); + + const moduleFeatures = await Promise.all( + remote.exposedModules.map(async exposedModuleName => { + const remoteModuleName = + exposedModuleName === '.' + ? remote.remoteInfo.name + : `${remote.remoteInfo.name}/${exposedModuleName}`; + let module: Module; + try { + module = await loadRemote(remoteModuleName); + } catch (err) { + error( + `Failed loading remote module '${remoteModuleName}' of dynamic plugin '${remote.packageName}'`, + err, + ); + return undefined; + } + if (!module) { + // eslint-disable-next-line no-console + console.warn( + `Skipping empty dynamic plugin remote module '${remoteModuleName}'.`, + ); + return undefined; + } + // eslint-disable-next-line no-console + console.info( + `Remote module '${remoteModuleName}' of dynamic plugin '${remote.packageName}' loaded from ${remote.remoteInfo.entry}`, + ); + const defaultEntry = module.default; + if (!isLoadable(defaultEntry)) { + // eslint-disable-next-line no-console + console.debug( + `Skipping dynamic plugin remote module '${remote}' since it doesn't export a new 'FrontendFeature' as default export.`, + ); + return undefined; + } + return defaultEntry; + }), + ); + return moduleFeatures; + }), + ) + ) + .flat() + .filter((feature): feature is FrontendFeature => feature !== undefined); + + return [...features]; + }, + }); +} + +function isLoadable(obj: unknown): obj is FrontendFeature { + if (obj !== null && typeof obj === 'object' && '$$type' in obj) { + return ( + obj.$$type === '@backstage/FrontendPlugin' || + obj.$$type === '@backstage/FrontendModule' + ); + } + return false; +} diff --git a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/Api.client.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/Api.client.ts new file mode 100644 index 0000000000..8623dc3c33 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/Api.client.ts @@ -0,0 +1,86 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { DiscoveryApi } from '../types/discovery'; +import { FetchApi } from '../types/fetch'; +import crossFetch from 'cross-fetch'; +import { pluginId } from '../pluginId'; +import * as parser from 'uri-template'; +import { Remote } from '../models/Remote.model'; + +/** + * Wraps the Response type to convey a type on the json call. + * + * @public + */ +export type TypedResponse = Omit & { + json: () => Promise; +}; + +/** + * Options you can pass into a request for additional information. + * + * @public + */ +export interface RequestOptions { + token?: string; +} +/** + * @public + */ +export type GetRemotes = {}; + +/** + * @public + */ +export class DefaultApiClient { + private readonly discoveryApi: DiscoveryApi; + private readonly fetchApi: FetchApi; + + constructor(options: { + discoveryApi: { getBaseUrl(pluginId: string): Promise }; + fetchApi?: { fetch: typeof fetch }; + }) { + this.discoveryApi = options.discoveryApi; + this.fetchApi = options.fetchApi || { fetch: crossFetch }; + } + + /** + * Get the Module Federation remote definitions. + */ + public async getRemotes( + // @ts-ignore + request: GetRemotes, + options?: RequestOptions, + ): Promise>> { + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + + const uriTemplate = `/remotes`; + + const uri = parser.parse(uriTemplate).expand({}); + + return await this.fetchApi.fetch(`${baseUrl}${uri}`, { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'GET', + }); + } +} diff --git a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/index.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/index.ts new file mode 100644 index 0000000000..fc7c83b736 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export * from './Api.client'; diff --git a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/index.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/index.ts new file mode 100644 index 0000000000..dc3055033d --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export * from './apis'; +export * from './models'; diff --git a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ErrorError.model.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ErrorError.model.ts new file mode 100644 index 0000000000..e0265e95d7 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ErrorError.model.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface ErrorError { + name: string; + message: string; + stack?: string; + code?: string; +} diff --git a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ErrorRequest.model.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ErrorRequest.model.ts new file mode 100644 index 0000000000..3eb5e15740 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ErrorRequest.model.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface ErrorRequest { + method: string; + url: string; +} diff --git a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ErrorResponse.model.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ErrorResponse.model.ts new file mode 100644 index 0000000000..edbcc32df7 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ErrorResponse.model.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface ErrorResponse { + statusCode: number; +} diff --git a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ModelError.model.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ModelError.model.ts new file mode 100644 index 0000000000..958fde7d0b --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ModelError.model.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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { ErrorError } from '../models/ErrorError.model'; +import { ErrorRequest } from '../models/ErrorRequest.model'; +import { ErrorResponse } from '../models/ErrorResponse.model'; + +/** + * @public + */ +export interface ModelError { + [key: string]: any; + + error: ErrorError; + request?: ErrorRequest; + response: ErrorResponse; +} diff --git a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/Remote.model.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/Remote.model.ts new file mode 100644 index 0000000000..7440110ad8 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/Remote.model.ts @@ -0,0 +1,36 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { RemoteInfo } from '../models/RemoteInfo.model'; + +/** + * Definition of a frontend plugin Module Federation remote served by the backend + * @public + */ +export interface Remote { + /** + * Name of the package exposed through this Module Federation remote + */ + packageName: string; + remoteInfo: RemoteInfo; + /** + * Names of modules exposed by this module federation remote + */ + exposedModules: Array; +} diff --git a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/RemoteInfo.model.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/RemoteInfo.model.ts new file mode 100644 index 0000000000..72edba90d4 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/RemoteInfo.model.ts @@ -0,0 +1,60 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * Definition of a frontend plugin Module Federation remote served by the backend + * @public + */ +export interface RemoteInfo { + /** + * Name of the module federation remote + */ + name: string; + /** + * Remote entry, either the remote manifest file, or the remote entry Javascript file. + */ + entry: string; + entryGlobalName?: string; + shareScope?: string; + type?: RemoteInfoTypeEnum; +} + +/** + * @public + */ +export type RemoteInfoTypeEnum = + | 'var' + | 'module' + | 'assign' + | 'assign-properties' + | 'this' + | 'window' + | 'self' + | 'global' + | 'commonjs' + | 'commonjs2' + | 'commonjs-module' + | 'commonjs-static' + | 'amd' + | 'amd-require' + | 'umd' + | 'umd2' + | 'jsonp' + | 'system'; diff --git a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/index.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/index.ts new file mode 100644 index 0000000000..0c54a6585b --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/index.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export * from '../models/ErrorError.model'; +export * from '../models/ErrorRequest.model'; +export * from '../models/ErrorResponse.model'; +export * from '../models/ModelError.model'; +export * from '../models/Remote.model'; +export * from '../models/RemoteInfo.model'; diff --git a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/pluginId.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/pluginId.ts new file mode 100644 index 0000000000..eaea330f99 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/pluginId.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export const pluginId = '.backstage/dynamic-features'; diff --git a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/types/discovery.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/types/discovery.ts new file mode 100644 index 0000000000..a7f87d3780 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/types/discovery.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2023 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. + */ + +/** + * This is a copy of the DiscoveryApi, to avoid importing core-plugin-api. + */ +export type DiscoveryApi = { + getBaseUrl(pluginId: string): Promise; +}; diff --git a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/types/fetch.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/types/fetch.ts new file mode 100644 index 0000000000..3de56c028e --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/types/fetch.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2023 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. + */ + +/** + * This is a copy of FetchApi, to avoid importing core-plugin-api. + */ +export type FetchApi = { + fetch: typeof fetch; +}; diff --git a/packages/frontend-dynamic-feature-loader/src/schema/openapi/index.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/index.ts new file mode 100644 index 0000000000..196aad553a --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export * from './generated'; diff --git a/packages/frontend-dynamic-feature-loader/src/setupTests.ts b/packages/frontend-dynamic-feature-loader/src/setupTests.ts new file mode 100644 index 0000000000..91af6695ac --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 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 '@testing-library/jest-dom'; diff --git a/scripts/verify-local-dependencies.js b/scripts/verify-local-dependencies.js index 045abcff57..a09571b7fb 100755 --- a/scripts/verify-local-dependencies.js +++ b/scripts/verify-local-dependencies.js @@ -77,6 +77,7 @@ const roleRules = [ '@backstage/core-compat-api', '@backstage/dev-utils', '@backstage/frontend-defaults', + '@backstage/frontend-dynamic-feature-loader', '@backstage/frontend-app-api', '@backstage/frontend-test-utils', '@backstage/test-utils', diff --git a/yarn.lock b/yarn.lock index adf18bef85..94758f0eca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3656,6 +3656,7 @@ __metadata: dependencies: "@backstage/backend-app-api": "workspace:^" "@backstage/backend-defaults": "workspace:^" + "@backstage/backend-openapi-utils": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" @@ -3675,11 +3676,14 @@ __metadata: "@backstage/plugin-scaffolder-node": "workspace:^" "@backstage/plugin-search-backend-node": "workspace:^" "@backstage/plugin-search-common": "workspace:^" + "@backstage/repo-tools": "workspace:^" "@backstage/types": "workspace:^" "@manypkg/get-packages": "npm:^1.1.3" + "@module-federation/sdk": "npm:^0.9.0" "@types/express": "npm:^4.17.6" chokidar: "npm:^3.5.3" express: "npm:^4.17.1" + express-promise-router: "npm:^4.1.0" fs-extra: "npm:^11.2.0" lodash: "npm:^4.17.21" triple-beam: "npm:^1.4.1" @@ -4097,7 +4101,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/core-app-api@npm:^1.16.0": +"@backstage/core-app-api@npm:^1.15.5, @backstage/core-app-api@npm:^1.16.0": version: 1.16.0 resolution: "@backstage/core-app-api@npm:1.16.0" dependencies: @@ -4169,13 +4173,12 @@ __metadata: languageName: unknown linkType: soft -"@backstage/core-compat-api@npm:^0.4.0": - version: 0.4.0 - resolution: "@backstage/core-compat-api@npm:0.4.0" +"@backstage/core-compat-api@npm:^0.3.6": + version: 0.3.6 + resolution: "@backstage/core-compat-api@npm:0.3.6" dependencies: - "@backstage/core-plugin-api": "npm:^1.10.5" - "@backstage/frontend-plugin-api": "npm:^0.10.0" - "@backstage/plugin-catalog-react": "npm:^1.16.0" + "@backstage/core-plugin-api": "npm:^1.10.4" + "@backstage/frontend-plugin-api": "npm:^0.9.5" "@backstage/version-bridge": "npm:^1.0.11" lodash: "npm:^4.17.21" peerDependencies: @@ -4186,7 +4189,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/6b29f14dadc5dd5c055d063cb12ff901e2ff4458eb606489fc917e029afc3bb81b5292cf701227cedf35c3df25dc85ad1c60ca20ef3b8f75aa41d40a7f5580ee + checksum: 10/befd4cb7108672ff0c3f4433e39bf5557e43a755fdf658de7b4a161977efb69b9b8a129930b141b02ea7bca5f71d70353d415d7d89f9a440afd6b4fa28c9a8cf languageName: node linkType: hard @@ -4330,12 +4333,12 @@ __metadata: languageName: node linkType: hard -"@backstage/core-components@npm:^0.17.0": - version: 0.17.0 - resolution: "@backstage/core-components@npm:0.17.0" +"@backstage/core-components@npm:^0.16.4": + version: 0.16.4 + resolution: "@backstage/core-components@npm:0.16.4" dependencies: "@backstage/config": "npm:^1.3.2" - "@backstage/core-plugin-api": "npm:^1.10.5" + "@backstage/core-plugin-api": "npm:^1.10.4" "@backstage/errors": "npm:^1.2.7" "@backstage/theme": "npm:^0.6.4" "@backstage/version-bridge": "npm:^1.0.11" @@ -4380,7 +4383,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/b48f7a3a6df469f26eb033f2f59da36dcf56bb52ba7298b7dba05d9132c90f8ab5b58dd247a8f9c1b652b91138358fbae482654465550c74780a32fe04bf6e7e + checksum: 10/472f4a17edc740cec15041612068320114b0128d6917d6968db8b435c3f4c2f002be939978b29efb12ce32c3d660b28e9de051ac1104c14f4eae2b6129c5ab49 languageName: node linkType: hard @@ -4462,7 +4465,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/core-plugin-api@npm:^1.10.0, @backstage/core-plugin-api@npm:^1.10.5, @backstage/core-plugin-api@npm:^1.8.2": +"@backstage/core-plugin-api@npm:^1.10.0, @backstage/core-plugin-api@npm:^1.10.4, @backstage/core-plugin-api@npm:^1.10.5, @backstage/core-plugin-api@npm:^1.8.2": version: 1.10.5 resolution: "@backstage/core-plugin-api@npm:1.10.5" dependencies: @@ -4616,16 +4619,16 @@ __metadata: languageName: unknown linkType: soft -"@backstage/frontend-app-api@npm:^0.11.0": - version: 0.11.0 - resolution: "@backstage/frontend-app-api@npm:0.11.0" +"@backstage/frontend-app-api@npm:^0.10.5": + version: 0.10.5 + resolution: "@backstage/frontend-app-api@npm:0.10.5" dependencies: "@backstage/config": "npm:^1.3.2" - "@backstage/core-app-api": "npm:^1.16.0" - "@backstage/core-plugin-api": "npm:^1.10.5" + "@backstage/core-app-api": "npm:^1.15.5" + "@backstage/core-plugin-api": "npm:^1.10.4" "@backstage/errors": "npm:^1.2.7" - "@backstage/frontend-defaults": "npm:^0.2.0" - "@backstage/frontend-plugin-api": "npm:^0.10.0" + "@backstage/frontend-defaults": "npm:^0.1.6" + "@backstage/frontend-plugin-api": "npm:^0.9.5" "@backstage/types": "npm:^1.2.1" "@backstage/version-bridge": "npm:^1.0.11" lodash: "npm:^4.17.21" @@ -4638,7 +4641,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/5522ddd487766ea44288d5626f269e42ad7d84902a5a257fd3962d2b2409743cf1b5de135e15915ba568bf2b12ec25c814ece077c87e1436c67e63998b1b9652 + checksum: 10/55837eeacbc3bac8b9e684c63d8784f56d5adc76d20e415f1e1a30c7fb2014b8407c7e30bdd9823a5f2c9c63ce793271b5f0997c5057359a4c5e73b7ba346afb languageName: node linkType: hard @@ -4676,15 +4679,15 @@ __metadata: languageName: unknown linkType: soft -"@backstage/frontend-defaults@npm:^0.2.0": - version: 0.2.0 - resolution: "@backstage/frontend-defaults@npm:0.2.0" +"@backstage/frontend-defaults@npm:^0.1.6": + version: 0.1.6 + resolution: "@backstage/frontend-defaults@npm:0.1.6" dependencies: "@backstage/config": "npm:^1.3.2" "@backstage/errors": "npm:^1.2.7" - "@backstage/frontend-app-api": "npm:^0.11.0" - "@backstage/frontend-plugin-api": "npm:^0.10.0" - "@backstage/plugin-app": "npm:^0.1.7" + "@backstage/frontend-app-api": "npm:^0.10.5" + "@backstage/frontend-plugin-api": "npm:^0.9.5" + "@backstage/plugin-app": "npm:^0.1.6" "@react-hookz/web": "npm:^24.0.0" peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 @@ -4694,7 +4697,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/65decef0c603ededc36791db771f82c35f6b8c283a65d6b81ad098175290ce8d125d22127968820d29dcbc69c4241b7e703013c5091a1c433bfaf86eaad482ea + checksum: 10/2e387815586c436c5c24a116f0294dddcf1a5bc0a84b8dd73bdb9fc797fd5aca2aeaa77b4da3908f48e4b18a356751d9241473bfbe4951edf56138e0a62994e1 languageName: node linkType: hard @@ -4728,12 +4731,43 @@ __metadata: languageName: unknown linkType: soft -"@backstage/frontend-plugin-api@npm:^0.10.0": - version: 0.10.0 - resolution: "@backstage/frontend-plugin-api@npm:0.10.0" +"@backstage/frontend-dynamic-feature-loader@workspace:packages/frontend-dynamic-feature-loader": + version: 0.0.0-use.local + resolution: "@backstage/frontend-dynamic-feature-loader@workspace:packages/frontend-dynamic-feature-loader" dependencies: - "@backstage/core-components": "npm:^0.17.0" - "@backstage/core-plugin-api": "npm:^1.10.5" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/frontend-plugin-api": "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" + "@testing-library/jest-dom": "npm:^6.0.0" + "@testing-library/react": "npm:^16.0.0" + "@types/react": "npm:^18.0.0" + cross-fetch: "npm:^4.0.0" + msw: "npm:^1.0.0" + react: "npm:^18.0.2" + react-dom: "npm:^18.0.2" + react-router-dom: "npm:^6.3.0" + uri-template: "npm:^2.0.0" + peerDependencies: + "@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-dom: ^6.3.0 + peerDependenciesMeta: + "@types/react": + optional: true + languageName: unknown + linkType: soft + +"@backstage/frontend-plugin-api@npm:^0.9.5": + version: 0.9.5 + resolution: "@backstage/frontend-plugin-api@npm:0.9.5" + dependencies: + "@backstage/core-components": "npm:^0.16.4" + "@backstage/core-plugin-api": "npm:^1.10.4" "@backstage/types": "npm:^1.2.1" "@backstage/version-bridge": "npm:^1.0.11" "@material-ui/core": "npm:^4.12.4" @@ -4748,7 +4782,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/9f52031e65d38087da1ad8d3117fdb2c54dbde20326f32c7cfebd755af6d28d881c9e57b5f9c17f4920fb40eea90b3b5b48b4cfc95f7dd2a3359866544ad5411 + checksum: 10/91a55d7d12545aa8041b64c2443e566775c9c6cca62e6b8bd5d3ac5eb4c8f2d28a8c7bb602c1389537be4ce61d0bf93e2ddeddd4e2fa3ae691817a3bb55fabab languageName: node linkType: hard @@ -4786,15 +4820,15 @@ __metadata: languageName: unknown linkType: soft -"@backstage/frontend-test-utils@npm:^0.3.0": - version: 0.3.0 - resolution: "@backstage/frontend-test-utils@npm:0.3.0" +"@backstage/frontend-test-utils@npm:^0.2.6": + version: 0.2.6 + resolution: "@backstage/frontend-test-utils@npm:0.2.6" dependencies: "@backstage/config": "npm:^1.3.2" - "@backstage/frontend-app-api": "npm:^0.11.0" - "@backstage/frontend-plugin-api": "npm:^0.10.0" - "@backstage/plugin-app": "npm:^0.1.7" - "@backstage/test-utils": "npm:^1.7.6" + "@backstage/frontend-app-api": "npm:^0.10.5" + "@backstage/frontend-plugin-api": "npm:^0.9.5" + "@backstage/plugin-app": "npm:^0.1.6" + "@backstage/test-utils": "npm:^1.7.5" "@backstage/types": "npm:^1.2.1" "@backstage/version-bridge": "npm:^1.0.11" zod: "npm:^3.22.4" @@ -4807,7 +4841,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/d141510fd32dc585677e1e1d7e0962b7656fd3ba7c214ef6341da39771e6fc41f5a1ca593ccfe660017fab15c90e7f7166c501618cb4c21d076ea98f101039dc + checksum: 10/0fd0c43c85f6a28c23840ac44064d9294340e892bb6e316d66afc683b05eaadebc9415a32a3f98fb81d4e8090b9496255e10ed519bb74757e089ab1d70794174 languageName: node linkType: hard @@ -4860,7 +4894,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/integration-react@npm:^1.1.24, @backstage/integration-react@npm:^1.2.5": +"@backstage/integration-react@npm:^1.1.24, @backstage/integration-react@npm:^1.2.4": version: 1.2.5 resolution: "@backstage/integration-react@npm:1.2.5" dependencies: @@ -5091,17 +5125,16 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-app@npm:^0.1.7": - version: 0.1.7 - resolution: "@backstage/plugin-app@npm:0.1.7" +"@backstage/plugin-app@npm:^0.1.6": + version: 0.1.6 + resolution: "@backstage/plugin-app@npm:0.1.6" dependencies: - "@backstage/core-components": "npm:^0.17.0" - "@backstage/core-plugin-api": "npm:^1.10.5" - "@backstage/frontend-plugin-api": "npm:^0.10.0" - "@backstage/integration-react": "npm:^1.2.5" - "@backstage/plugin-permission-react": "npm:^0.4.32" + "@backstage/core-components": "npm:^0.16.4" + "@backstage/core-plugin-api": "npm:^1.10.4" + "@backstage/frontend-plugin-api": "npm:^0.9.5" + "@backstage/integration-react": "npm:^1.2.4" + "@backstage/plugin-permission-react": "npm:^0.4.31" "@backstage/theme": "npm:^0.6.4" - "@backstage/types": "npm:^1.2.1" "@material-ui/core": "npm:^4.9.13" "@material-ui/icons": "npm:^4.9.1" "@material-ui/lab": "npm:^4.0.0-alpha.61" @@ -5114,7 +5147,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/29819de2c81dbeb787e485d43b8ccb475d42fb4e071ce2eab6e0a814e35f5d6f14deb1266082326dd3b07005136a34b780a14af3aed7efae6f0fff4ae28bc9cb + checksum: 10/6ba4de2ba60b95366f71b1b301c234a3d7ff34356db006696e8cae15b8c1ba9e3f31e17acf205cc74f2faa068b55ecdc535b1c087ae3a97c90915841192efee6 languageName: node linkType: hard @@ -6276,22 +6309,22 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-catalog-react@npm:^1.14.0, @backstage/plugin-catalog-react@npm:^1.16.0, @backstage/plugin-catalog-react@npm:^1.9.3": - version: 1.16.0 - resolution: "@backstage/plugin-catalog-react@npm:1.16.0" +"@backstage/plugin-catalog-react@npm:^1.14.0, @backstage/plugin-catalog-react@npm:^1.9.3": + version: 1.15.2 + resolution: "@backstage/plugin-catalog-react@npm:1.15.2" dependencies: "@backstage/catalog-client": "npm:^1.9.1" "@backstage/catalog-model": "npm:^1.7.3" - "@backstage/core-compat-api": "npm:^0.4.0" - "@backstage/core-components": "npm:^0.17.0" - "@backstage/core-plugin-api": "npm:^1.10.5" + "@backstage/core-compat-api": "npm:^0.3.6" + "@backstage/core-components": "npm:^0.16.4" + "@backstage/core-plugin-api": "npm:^1.10.4" "@backstage/errors": "npm:^1.2.7" - "@backstage/frontend-plugin-api": "npm:^0.10.0" - "@backstage/frontend-test-utils": "npm:^0.3.0" - "@backstage/integration-react": "npm:^1.2.5" + "@backstage/frontend-plugin-api": "npm:^0.9.5" + "@backstage/frontend-test-utils": "npm:^0.2.6" + "@backstage/integration-react": "npm:^1.2.4" "@backstage/plugin-catalog-common": "npm:^1.1.3" "@backstage/plugin-permission-common": "npm:^0.8.4" - "@backstage/plugin-permission-react": "npm:^0.4.32" + "@backstage/plugin-permission-react": "npm:^0.4.31" "@backstage/types": "npm:^1.2.1" "@backstage/version-bridge": "npm:^1.0.11" "@material-ui/core": "npm:^4.12.2" @@ -6313,7 +6346,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/0149b99ede5d563cd81dfa1d657cf332af2cbb3e6ff2e88244c4b3c59e2109264c62e3a6903ee471596a3c709294b81fe2da83513d79690704fb37f6cf67d305 + checksum: 10/379f934cc871a101714675bbe6aa51a4b0a831731dfac807004b74c804b107f2def033b53bb56c10c7bda1b16d07a014c00b374fccad7677e3f9b29be51fc354 languageName: node linkType: hard @@ -7381,7 +7414,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-permission-react@npm:^0.4.32": +"@backstage/plugin-permission-react@npm:^0.4.31, @backstage/plugin-permission-react@npm:^0.4.32": version: 0.4.32 resolution: "@backstage/plugin-permission-react@npm:0.4.32" dependencies: @@ -8804,7 +8837,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/test-utils@npm:^1.7.6": +"@backstage/test-utils@npm:^1.7.5": version: 1.7.6 resolution: "@backstage/test-utils@npm:1.7.6" dependencies: @@ -12032,39 +12065,39 @@ __metadata: languageName: node linkType: hard -"@module-federation/bridge-react-webpack-plugin@npm:0.9.0": - version: 0.9.0 - resolution: "@module-federation/bridge-react-webpack-plugin@npm:0.9.0" +"@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" dependencies: - "@module-federation/sdk": "npm:0.9.0" + "@module-federation/sdk": "npm:0.9.1" "@types/semver": "npm:7.5.8" semver: "npm:7.6.3" - checksum: 10/234b022407b1d84790aecc32f2b825ad22c7ebfa541914367270f307b0962194511460d6ace0b7c76bcf9aaf6dc4d09c3021f4cb73ec6568412d31023bf9c93f + checksum: 10/4ff197741b1bdccf8f9e2236781e5ce3ef434e4207c5462b4b95b044c4c57a3ab3dd4dce48490d9fde5bd06fb855b9918841c7d04306726ee224d3e288074091 languageName: node linkType: hard -"@module-federation/data-prefetch@npm:0.9.0": - version: 0.9.0 - resolution: "@module-federation/data-prefetch@npm:0.9.0" +"@module-federation/data-prefetch@npm:0.9.1": + version: 0.9.1 + resolution: "@module-federation/data-prefetch@npm:0.9.1" dependencies: - "@module-federation/runtime": "npm:0.9.0" - "@module-federation/sdk": "npm:0.9.0" + "@module-federation/runtime": "npm:0.9.1" + "@module-federation/sdk": "npm:0.9.1" fs-extra: "npm:9.1.0" peerDependencies: react: ">=16.9.0" react-dom: ">=16.9.0" - checksum: 10/d8cfef2e3427326048e17a686e3d2df8df7f6913170ddd03fff93af29bd03e68ab26f8b2587bec99357e2fb3fbed457f81e3ba6e8af98deacd744850869c1516 + checksum: 10/a3c0e8d77f4d06e3851d041175ef3f55d47ff4069a526b9ac773c8aba71a520a0490653c87cdcf3761e96f9eb9ea16d45026f3aafdf0851c34ff4db6d71ec113 languageName: node linkType: hard -"@module-federation/dts-plugin@npm:0.9.0": - version: 0.9.0 - resolution: "@module-federation/dts-plugin@npm:0.9.0" +"@module-federation/dts-plugin@npm:0.9.1": + version: 0.9.1 + resolution: "@module-federation/dts-plugin@npm:0.9.1" dependencies: - "@module-federation/error-codes": "npm:0.9.0" - "@module-federation/managers": "npm:0.9.0" - "@module-federation/sdk": "npm:0.9.0" - "@module-federation/third-party-dts-extractor": "npm:0.9.0" + "@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" adm-zip: "npm:^0.5.10" ansi-colors: "npm:^4.1.3" axios: "npm:^1.7.4" @@ -12083,24 +12116,24 @@ __metadata: peerDependenciesMeta: vue-tsc: optional: true - checksum: 10/539d277dd7c769ec9edde394fe3e9dddb38533c6fde0ac0fef0346d1229795a1b2199c4cc73e457b0ac2480c3548814e01d9fa47e12327fe97e3f6486300002d + checksum: 10/e9fd11b150456f2621636587d181f6456fe5cd60a0720843fe1310e350c3ff8ebef02ea87d1bf40dc04a4b4cd65cfb2e9007ca1c20e4e5664142b7c670c4a57d languageName: node linkType: hard "@module-federation/enhanced@npm:^0.9.0": - version: 0.9.0 - resolution: "@module-federation/enhanced@npm:0.9.0" + version: 0.9.1 + resolution: "@module-federation/enhanced@npm:0.9.1" dependencies: - "@module-federation/bridge-react-webpack-plugin": "npm:0.9.0" - "@module-federation/data-prefetch": "npm:0.9.0" - "@module-federation/dts-plugin": "npm:0.9.0" - "@module-federation/error-codes": "npm:0.9.0" - "@module-federation/inject-external-runtime-core-plugin": "npm:0.9.0" - "@module-federation/managers": "npm:0.9.0" - "@module-federation/manifest": "npm:0.9.0" - "@module-federation/rspack": "npm:0.9.0" - "@module-federation/runtime-tools": "npm:0.9.0" - "@module-federation/sdk": "npm:0.9.0" + "@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" btoa: "npm:^1.2.1" upath: "npm:2.0.1" peerDependencies: @@ -12114,7 +12147,7 @@ __metadata: optional: true webpack: optional: true - checksum: 10/9cca13bf8118ed3e11a54adb1006dc5a55437c0140d1edff3a8969c0ed90c07a268f42360e47f90070a32cdfedc340129b3b4e50143ca846c3b8e4ee5880523e + checksum: 10/a7955711f37ba02a18f3570289dfd8fbed30511b82fcf593e56808559cfd49d8852028aa67950db5e45617d0917536d067d85847892e62c20b68ba6ab40e17c4 languageName: node linkType: hard @@ -12125,57 +12158,57 @@ __metadata: languageName: node linkType: hard -"@module-federation/error-codes@npm:0.9.0": - version: 0.9.0 - resolution: "@module-federation/error-codes@npm:0.9.0" - checksum: 10/9ebe1bbf3ed61b1d9ec3643d6743884ab5b44290fe5f1cb080ef3f514f1bf9c4cd39309fa3733697030f2468577c6f5159ec0a12d1cb69c7842aaee11159a647 +"@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.0": - version: 0.9.0 - resolution: "@module-federation/inject-external-runtime-core-plugin@npm:0.9.0" +"@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" peerDependencies: - "@module-federation/runtime-tools": 0.9.0 - checksum: 10/7821cc6bc2cd865ec4b0496c1c53e009611e1bf7571ca6be7e852e79de418df3952ce02b02f7a6773d833638bccbc2b3b853ebd22869a6abdaec717dc5996a8a + "@module-federation/runtime-tools": 0.9.1 + checksum: 10/931eef6292c278450fc8cdb017073fa0b721796461eee12a254fc60a88a2f17e91395b12371f2c8b3b25b4056fc2dd2b76d1022e679be7353947c3d797e75ea5 languageName: node linkType: hard -"@module-federation/managers@npm:0.9.0": - version: 0.9.0 - resolution: "@module-federation/managers@npm:0.9.0" +"@module-federation/managers@npm:0.9.1": + version: 0.9.1 + resolution: "@module-federation/managers@npm:0.9.1" dependencies: - "@module-federation/sdk": "npm:0.9.0" + "@module-federation/sdk": "npm:0.9.1" find-pkg: "npm:2.0.0" fs-extra: "npm:9.1.0" - checksum: 10/64f8430d6beff7b76e0fe1eaead080bd42a8565bcd3c80b7dcbfba69578c8407ee725785aee847c11e3c3a28c5defb30a39780cb319dca3140c8296c5af0fcfe + checksum: 10/32c1666244ba98644ab6eccdc844415d1aece1c105f6ba2ff17a3836866e7cdb2f61e556cb5a2b506b898ec709951f318d397f4d153efeff377067237968462f languageName: node linkType: hard -"@module-federation/manifest@npm:0.9.0": - version: 0.9.0 - resolution: "@module-federation/manifest@npm:0.9.0" +"@module-federation/manifest@npm:0.9.1": + version: 0.9.1 + resolution: "@module-federation/manifest@npm:0.9.1" dependencies: - "@module-federation/dts-plugin": "npm:0.9.0" - "@module-federation/managers": "npm:0.9.0" - "@module-federation/sdk": "npm:0.9.0" + "@module-federation/dts-plugin": "npm:0.9.1" + "@module-federation/managers": "npm:0.9.1" + "@module-federation/sdk": "npm:0.9.1" chalk: "npm:3.0.0" find-pkg: "npm:2.0.0" - checksum: 10/4902b945845fc3a0d1cffb33227fadc235ed02f62e436bb90dfb4c45b32f508e3692ccc4799733d8439314b8588ef14e597ef11927eea74b6ecd3878924e6ad6 + checksum: 10/539b86bd5388296fb35a34c7b732b92788600ab6625d53f11588ad67c3a603ac3c1974541d9cf04db2d24635be1610414c29388e849d96bbb812cad3b1c79425 languageName: node linkType: hard -"@module-federation/rspack@npm:0.9.0": - version: 0.9.0 - resolution: "@module-federation/rspack@npm:0.9.0" +"@module-federation/rspack@npm:0.9.1": + version: 0.9.1 + resolution: "@module-federation/rspack@npm:0.9.1" dependencies: - "@module-federation/bridge-react-webpack-plugin": "npm:0.9.0" - "@module-federation/dts-plugin": "npm:0.9.0" - "@module-federation/inject-external-runtime-core-plugin": "npm:0.9.0" - "@module-federation/managers": "npm:0.9.0" - "@module-federation/manifest": "npm:0.9.0" - "@module-federation/runtime-tools": "npm:0.9.0" - "@module-federation/sdk": "npm:0.9.0" + "@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" peerDependencies: "@rspack/core": ">=0.7" typescript: ^4.9.0 || ^5.0.0 @@ -12185,7 +12218,7 @@ __metadata: optional: true vue-tsc: optional: true - checksum: 10/3c67889f1f4114ea2f0180f3ba5f2e9cbbc687590d6ec07a20638655b4041a1874bafbe685a93fcb8132c9ad67ace7fe6cde474c70562f8c4fb9af9d9de5aa62 + checksum: 10/e6deb7236ccc66d7f0475a09916ec714c9855847bfd3d3ec6cdbee991016a222a22f6ae3fd14d71351d55e22e8949140f37ce9ce79dece5416731c6698726096 languageName: node linkType: hard @@ -12199,13 +12232,13 @@ __metadata: languageName: node linkType: hard -"@module-federation/runtime-core@npm:0.6.21": - version: 0.6.21 - resolution: "@module-federation/runtime-core@npm:0.6.21" +"@module-federation/runtime-core@npm:0.9.1": + version: 0.9.1 + resolution: "@module-federation/runtime-core@npm:0.9.1" dependencies: - "@module-federation/error-codes": "npm:0.9.0" - "@module-federation/sdk": "npm:0.9.0" - checksum: 10/6cff7180b7fd36f37188bc8605861f709ffdb89a1d4600c6d60c6fa4fb44672927f855fbae973e7d0b73f4d1df3408e96af6f616890225d5b4463e48afc2d1f1 + "@module-federation/error-codes": "npm:0.9.1" + "@module-federation/sdk": "npm:0.9.1" + checksum: 10/6f9edbe23013395d7896fc2a24cb4055bc78df5a335f090e079df951835c1cf91c567228f19879eee3fddb0b34128abd0b50feaca1cf3fb2828c7b9bacc22169 languageName: node linkType: hard @@ -12219,13 +12252,13 @@ __metadata: languageName: node linkType: hard -"@module-federation/runtime-tools@npm:0.9.0": - version: 0.9.0 - resolution: "@module-federation/runtime-tools@npm:0.9.0" +"@module-federation/runtime-tools@npm:0.9.1": + version: 0.9.1 + resolution: "@module-federation/runtime-tools@npm:0.9.1" dependencies: - "@module-federation/runtime": "npm:0.9.0" - "@module-federation/webpack-bundler-runtime": "npm:0.9.0" - checksum: 10/c92c4614745ded2bbc43edcadb6d661fc392efdc8b0dc72e1f8e34763d3dd326f4b986d4db7ae8e911d6422e5f2cc212d052bd57067e9c4d52a7a572cbdf914c + "@module-federation/runtime": "npm:0.9.1" + "@module-federation/webpack-bundler-runtime": "npm:0.9.1" + checksum: 10/9436e814a4ab72839b8aed1aa097f32cf7d4d49728dd863c9ce6dc4fb149fe49da6dc9cdeb97814f0023cc91489c9aca06cbfdf9fb4e43d20498ab6ee78c95dd languageName: node linkType: hard @@ -12240,14 +12273,14 @@ __metadata: languageName: node linkType: hard -"@module-federation/runtime@npm:0.9.0": - version: 0.9.0 - resolution: "@module-federation/runtime@npm:0.9.0" +"@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.0" - "@module-federation/runtime-core": "npm:0.6.21" - "@module-federation/sdk": "npm:0.9.0" - checksum: 10/4bed92de413925fa66ffac9f176a72a83b47266830b9a7fe6bbc2e3eea226110455a43d737b1476b314c2a7b38564debe7e1dd6d066a0c6724f8c3be07b2eeef + "@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 languageName: node linkType: hard @@ -12258,23 +12291,21 @@ __metadata: languageName: node linkType: hard -"@module-federation/sdk@npm:0.9.0": - version: 0.9.0 - resolution: "@module-federation/sdk@npm:0.9.0" - dependencies: - isomorphic-rslog: "npm:0.0.7" - checksum: 10/031db27afc3ee552dd052f12c8b3d6d967b6590768ebd9b5052c37047453261b9d3bb68e325bdf018f9db88e0a7276bdfeaf82b5cef332f4dd1d5e4a91add0f4 +"@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.0": - version: 0.9.0 - resolution: "@module-federation/third-party-dts-extractor@npm:0.9.0" +"@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" dependencies: find-pkg: "npm:2.0.0" fs-extra: "npm:9.1.0" resolve: "npm:1.22.8" - checksum: 10/accece159cded982499f239b8e7cc7410249ee91b28584652aebb7cc13d07b540939fa9af7ac2609ca20c4b7d2bd113d7120ad535722a3178c404bf19b8ed048 + checksum: 10/68c70c79573cd927212d95879aa7b35490519d0ec9f58dd264c9d61e22fea8d452e45a52a94155128d8378e5cdcaecb229707b5b0f4e4c0d53ae96e2fbac366a languageName: node linkType: hard @@ -12288,13 +12319,13 @@ __metadata: languageName: node linkType: hard -"@module-federation/webpack-bundler-runtime@npm:0.9.0": - version: 0.9.0 - resolution: "@module-federation/webpack-bundler-runtime@npm:0.9.0" +"@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.0" - "@module-federation/sdk": "npm:0.9.0" - checksum: 10/8199fa4b5b088e717ac6cd43bd4d2038099d3660568cf79b9e376a395906c40dfe02095f1e1c2c083e3195326a7648dc05ea495701c91eee1fac0e64246cf783 + "@module-federation/runtime": "npm:0.9.1" + "@module-federation/sdk": "npm:0.9.1" + checksum: 10/430cac0a770b3c46bc195088eb4c1892e1e29a69238dbe72423d64b2b67050afeca2b5026b1a30659b4fe8d9faa038ff97cceba7e2ddf6193b93763f270d9df6 languageName: node linkType: hard @@ -33563,13 +33594,6 @@ __metadata: languageName: node linkType: hard -"isomorphic-rslog@npm:0.0.7": - version: 0.0.7 - resolution: "isomorphic-rslog@npm:0.0.7" - checksum: 10/cb6f03a64bdbb3972c22429a4fcdff25505ceca2bf09ca2cebc1c8086b2580f67d4dd04daba79d7dd642af099deea1eb884667ddf183bd3192b67faaf86438cc - languageName: node - linkType: hard - "isomorphic-textencoder@npm:^1.0.1": version: 1.0.1 resolution: "isomorphic-textencoder@npm:1.0.1"