From 182efca350a7b9b2f8868b98d1ad1907f2bc1d95 Mon Sep 17 00:00:00 2001 From: David Festal Date: Thu, 5 Dec 2024 22:09:30 +0100 Subject: [PATCH 1/9] Add the frontend-dynamic-feature-loader Signed-off-by: David Festal --- .../.eslintrc.js | 5 + .../frontend-dynamic-feature-loader/README.md | 32 + .../catalog-info.yaml | 10 + .../config.d.ts | 22 + .../knip-report.md | 2 + .../package.json | 66 ++ .../report.api.md | 18 + .../src/index.ts | 23 + .../src/loader.test.tsx | 1024 +++++++++++++++++ .../src/loader.ts | 200 ++++ .../generated/apis/DefaultApi.client.ts | 87 ++ .../schema/openapi/generated/apis/index.ts | 17 + .../src/schema/openapi/generated/index.ts | 18 + .../generated/models/ErrorError.model.ts | 29 + .../generated/models/ErrorRequest.model.ts | 27 + .../generated/models/ErrorResponse.model.ts | 26 + .../generated/models/ModelError.model.ts | 33 + .../schema/openapi/generated/models/index.ts | 20 + .../src/schema/openapi/generated/pluginId.ts | 17 + .../openapi/generated/types/discovery.ts | 22 + .../schema/openapi/generated/types/fetch.ts | 22 + .../src/schema/openapi/index.ts | 17 + .../src/setupTests.ts | 17 + scripts/verify-local-dependencies.js | 1 + yarn.lock | 293 +++-- 25 files changed, 1974 insertions(+), 74 deletions(-) create mode 100644 packages/frontend-dynamic-feature-loader/.eslintrc.js create mode 100644 packages/frontend-dynamic-feature-loader/README.md create mode 100644 packages/frontend-dynamic-feature-loader/catalog-info.yaml create mode 100644 packages/frontend-dynamic-feature-loader/config.d.ts create mode 100644 packages/frontend-dynamic-feature-loader/knip-report.md create mode 100644 packages/frontend-dynamic-feature-loader/package.json create mode 100644 packages/frontend-dynamic-feature-loader/report.api.md create mode 100644 packages/frontend-dynamic-feature-loader/src/index.ts create mode 100644 packages/frontend-dynamic-feature-loader/src/loader.test.tsx create mode 100644 packages/frontend-dynamic-feature-loader/src/loader.ts create mode 100644 packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/DefaultApi.client.ts create mode 100644 packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/index.ts create mode 100644 packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/index.ts create mode 100644 packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ErrorError.model.ts create mode 100644 packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ErrorRequest.model.ts create mode 100644 packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ErrorResponse.model.ts create mode 100644 packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ModelError.model.ts create mode 100644 packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/index.ts create mode 100644 packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/pluginId.ts create mode 100644 packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/types/discovery.ts create mode 100644 packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/types/fetch.ts create mode 100644 packages/frontend-dynamic-feature-loader/src/schema/openapi/index.ts create mode 100644 packages/frontend-dynamic-feature-loader/src/setupTests.ts 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..cd5ad44fa9 --- /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 `packages/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..9d8fbbdfd2 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/package.json @@ -0,0 +1,66 @@ +{ + "name": "@backstage/frontend-dynamic-feature-loader", + "version": "0.0.1-next.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/core-app-api": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", + "@module-federation/enhanced": "^0.9.0", + "@module-federation/sdk": "^0.9.0", + "cross-fetch": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@module-federation/runtime": "^0.9.0", + "@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": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-router-dom": "6.0.0-beta.0 || ^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..532dddfc43 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/report.api.md @@ -0,0 +1,18 @@ +## 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 { CreateAppFeatureLoader } from '@backstage/frontend-defaults'; +import { init } from '@module-federation/enhanced/runtime'; + +// @public +export function dynamicFrontendFeaturesLoader( + options?: DynamicFrontendFeaturesLoaderOptions, +): CreateAppFeatureLoader; + +// @public (undocumented) +export type DynamicFrontendFeaturesLoaderOptions = { + moduleFederation: Omit[0], 'name' | 'remotes'>; +}; +``` 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..c958688e20 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx @@ -0,0 +1,1024 @@ +/* + * 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'; + +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(() => {}), + }, + 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', + errorLoadRemote: args => { + // eslint-disable-next-line no-console + console.error(args); + }, + 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.federation.get.mockReset(); + }); + + it('should return immediately if dynamic plugins are not enabled in config', async () => { + let manifestsEndpointCalled = false; + server.use( + rest.get( + `${baseUrl}/api/core.dynamicplugins.frontendRemotes/manifests`, + (_, 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}/api/core.dynamicplugins.frontendRemotes/manifests`, + (_, res, ctx) => + res( + ctx.json({ + 'test-plugin': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/mf-manifest.json`, + }), + ), + ), + rest.get( + `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/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([ + "Loading dynamic plugin 'test-plugin' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/mf-manifest.json'", + "Dynamic plugin remote module 'test-plugin' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/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}/api/core.dynamicplugins.frontendRemotes/manifests`, + (_, res, ctx) => + res( + ctx.json({ + 'plugin-1': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, + 'plugin-2': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json`, + }), + ), + ), + rest.get( + `${baseUrl}/api/core.dynamicplugins.frontendRemotes/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}/api/core.dynamicplugins.frontendRemotes/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: { + packageName: 'app-2', + 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([ + "Loading dynamic plugin 'plugin-1' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json'", + "Loading dynamic plugin 'plugin-2' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json'", + "Dynamic plugin remote module 'plugin-1' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json", + "Dynamic plugin remote module 'plugin-2' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/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}/api/core.dynamicplugins.frontendRemotes/manifests`, + (_, res, ctx) => + res( + ctx.json({ + 'test-plugin': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/mf-manifest.json`, + }), + ), + ), + rest.get( + `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/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: [], + }, + }, + packageName: 'app-3', + }, + 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([ + "Loading dynamic plugin 'test-plugin' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/mf-manifest.json'", + "Dynamic plugin remote module 'test-plugin' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/mf-manifest.json", + "Dynamic plugin remote module 'test-plugin/alpha' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/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 warn and recover from a 404 error fetching module feredation configuration', async () => { + server.use( + rest.get( + `${baseUrl}/api/core.dynamicplugins.frontendRemotes/manifests`, + (_, 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 unexpected Json while fetching module feredation configuration', async () => { + server.use( + rest.get( + `${baseUrl}/api/core.dynamicplugins.frontendRemotes/manifests`, + (_, res, ctx) => res(ctx.json('A Json String')), + ), + ); + + 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: Invalid Json content: should be a Json object`, + ]); + 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}/api/core.dynamicplugins.frontendRemotes/manifests`, + (_, 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/api/core.dynamicplugins.frontendRemotes/manifests 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 404 error fetching module feredation manifest, but still load other remotes', async () => { + server.use( + rest.get( + `${baseUrl}/api/core.dynamicplugins.frontendRemotes/manifests`, + (_, res, ctx) => + res( + ctx.json({ + 'plugin-1': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, + 'plugin-2': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json`, + }), + ), + ), + rest.get( + `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, + (_, res, ctx) => res(ctx.json({}), ctx.status(404, 'NOT FOUND')), + ), + rest.get( + `${baseUrl}/api/core.dynamicplugins.frontendRemotes/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: { + packageName: 'app-4', + experimental: { + packages: { + include: [], + }, + }, + }, + backend: { + baseUrl, + }, + dynamicPlugins: {}, + }, + }), + }); + + const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); + expect(errorCalls).toEqual([ + "Failed fetching module federation manifest from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json': Error: 404 - NOT FOUND", + ]); + 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([ + "Loading dynamic plugin 'plugin-1' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json'", + "Loading dynamic plugin 'plugin-2' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json'", + "Dynamic plugin remote module 'plugin-2' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/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}/api/core.dynamicplugins.frontendRemotes/manifests`, + (_, res, ctx) => + res( + ctx.json({ + 'plugin-1': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, + 'plugin-2': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json`, + }), + ), + ), + rest.get( + `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, + (_, res, ctx) => res(ctx.json('A Json String')), + ), + rest.get( + `${baseUrl}/api/core.dynamicplugins.frontendRemotes/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: { + packageName: 'app-5', + experimental: { + packages: { + include: [], + }, + }, + }, + backend: { + baseUrl, + }, + dynamicPlugins: {}, + }, + }), + }); + + const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); + expect(errorCalls).toEqual([ + "Failed fetching module federation manifest from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json': Error: Invalid Json content: should be a Json object", + ]); + 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([ + "Loading dynamic plugin 'plugin-1' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json'", + "Loading dynamic plugin 'plugin-2' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json'", + "Dynamic plugin remote module 'plugin-2' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/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 empty module, but still load other remotes', async () => { + server.use( + rest.get( + `${baseUrl}/api/core.dynamicplugins.frontendRemotes/manifests`, + (_, res, ctx) => + res( + ctx.json({ + 'plugin-1': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, + 'plugin-2': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json`, + }), + ), + ), + rest.get( + `${baseUrl}/api/core.dynamicplugins.frontendRemotes/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}/api/core.dynamicplugins.frontendRemotes/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: { + packageName: 'app-6', + 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([ + "Loading dynamic plugin 'plugin-1' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json'", + "Loading dynamic plugin 'plugin-2' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json'", + "Dynamic plugin remote module 'plugin-2' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/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 warn on module without default export, but still load other remotes', async () => { + server.use( + rest.get( + `${baseUrl}/api/core.dynamicplugins.frontendRemotes/manifests`, + (_, res, ctx) => + res( + ctx.json({ + 'plugin-1': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, + 'plugin-2': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json`, + }), + ), + ), + rest.get( + `${baseUrl}/api/core.dynamicplugins.frontendRemotes/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}/api/core.dynamicplugins.frontendRemotes/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: { + packageName: 'app-7', + 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 dynamic plugin remote module 'plugin-1' since it doesn't export a new 'FrontendFeature' as default export.", + ]); + 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([ + "Loading dynamic plugin 'plugin-1' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json'", + "Loading dynamic plugin 'plugin-2' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json'", + "Dynamic plugin remote module 'plugin-1' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json", + "Dynamic plugin remote module 'plugin-2' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/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', + }, + ]); + }); +}); 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..c15dcb9f3c --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/loader.ts @@ -0,0 +1,200 @@ +/* + * 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 { init, loadRemote } from '@module-federation/enhanced/runtime'; +import { Manifest, Module } from '@module-federation/sdk'; +import { DefaultApiClient } from './schema/openapi'; +import { FrontendHostDiscovery } from '@backstage/core-app-api'; +import { + FrontendFeature, + FrontendFeatureLoader, + createFrontendFeatureLoader, +} from '@backstage/frontend-plugin-api'; + +/** + * + * @public + */ +export type DynamicFrontendFeaturesLoaderOptions = { + /** + * Additional module federation arguments for the Module Federation runtime initialization. + */ + moduleFederation: Omit[0], 'name' | 'remotes'>; +}; + +/** + * 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 appPackageName = + config.getOptionalString('app.packageName') ?? 'app'; + let frontendPluginManifests: { + [key: string]: string; + }; + try { + const apiClient = new DefaultApiClient({ + discoveryApi: FrontendHostDiscovery.fromConfig(config), + fetchApi: { + fetch(input) { + return global.fetch(input); + }, + }, + }); + + const response = await apiClient.getManifests({}); + if (!response.ok) { + throw new Error(`${response.status} - ${response.statusText}`); + } + frontendPluginManifests = await response.json(); + if (typeof frontendPluginManifests !== 'object') { + throw new Error(`Invalid Json content: should be a Json object`); + } + } catch (err) { + error( + `Failed fetching module federation configuration of dynamic frontend plugins`, + err, + ); + return []; + } + + try { + init({ + ...options?.moduleFederation, + name: appPackageName + .replaceAll('@', '') + .replaceAll('/', '__') + .replaceAll('-', '_'), + remotes: Object.entries(frontendPluginManifests).map( + ([name, manifestLocation]) => ({ + name: name, + entry: manifestLocation, + }), + ), + }); + } catch (err) { + error(`Failed initializing module federation`, err); + return []; + } + + const features = ( + await Promise.all( + Object.entries(frontendPluginManifests).map( + async ([name, manifestLocation]) => { + // eslint-disable-next-line no-console + console.info( + `Loading dynamic plugin '${name}' from '${manifestLocation}'`, + ); + let manifest: Manifest; + try { + const response = await fetch(manifestLocation); + if (!response.ok) { + throw new Error( + `${response.status} - ${response.statusText}`, + ); + } + manifest = await response.json(); + if (typeof manifest !== 'object') { + throw new Error( + `Invalid Json content: should be a Json object`, + ); + } + } catch (err) { + error( + `Failed fetching module federation manifest from '${manifestLocation}'`, + err, + ); + return undefined; + } + + const moduleFeatures = await Promise.all( + manifest.exposes.map(async expose => { + const remote = + expose.name === '.' ? name : `${name}/${expose.name}`; + let module: Module; + try { + module = await loadRemote(remote); + } catch (err) { + error( + `Failed loading dynamic plugin remote module '${remote}'`, + err, + ); + return undefined; + } + if (!module) { + // eslint-disable-next-line no-console + console.warn( + `Skipping empty dynamic plugin remote module '${remote}'.`, + ); + return undefined; + } + // eslint-disable-next-line no-console + console.info( + `Dynamic plugin remote module '${remote}' loaded from ${manifestLocation}`, + ); + const defaultEntry = module.default; + if (!isFrontendPluginOrModule(defaultEntry)) { + // eslint-disable-next-line no-console + console.warn( + `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 isFrontendPluginOrModule(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/DefaultApi.client.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/DefaultApi.client.ts new file mode 100644 index 0000000000..1ce952d36d --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/DefaultApi.client.ts @@ -0,0 +1,87 @@ +/* + * 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. + */ + +// ****************************************************************** +// * 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'; + +/** + * 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 GetManifests = {}; + +/** + * no description + * @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 manifest files of dynamic frontend plugins. + */ + public async getManifests( + // @ts-ignore + request: GetManifests, + options?: RequestOptions, + ): Promise> { + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + + const uriTemplate = `/manifests`; + + 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..51dcca33fe --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/index.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. + */ + +export * from './DefaultApi.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..bb399e97a0 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/index.ts @@ -0,0 +1,18 @@ +/* + * 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 * 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..fe5811628d --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ErrorError.model.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +// ****************************************************************** +// * 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..d44dcb66d9 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ErrorRequest.model.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +// ****************************************************************** +// * 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..91c120483d --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ErrorResponse.model.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ + +// ****************************************************************** +// * 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..5526d703e6 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/ModelError.model.ts @@ -0,0 +1,33 @@ +/* + * 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. + */ + +// ****************************************************************** +// * 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/index.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/index.ts new file mode 100644 index 0000000000..4d19a3b2e9 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/index.ts @@ -0,0 +1,20 @@ +/* + * 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 * from '../models/ErrorError.model'; +export * from '../models/ErrorRequest.model'; +export * from '../models/ErrorResponse.model'; +export * from '../models/ModelError.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..21cdf38c03 --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/pluginId.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. + */ + +export const pluginId = 'core.dynamicplugins.frontendRemotes'; 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..db98243cbf --- /dev/null +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/index.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. + */ + +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 97a44d708d..457e63dad3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3335,7 +3335,16 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": + version: 7.26.7 + resolution: "@babel/runtime@npm:7.26.7" + dependencies: + regenerator-runtime: "npm:^0.14.0" + checksum: 10/c7a661a6836b332d9d2e047cba77ba1862c1e4f78cec7146db45808182ef7636d8a7170be9797e5d8fd513180bffb9fa16f6ca1c69341891efec56113cf22bfc + languageName: node + linkType: hard + +"@babel/runtime@npm:^7.26.10": version: 7.27.0 resolution: "@babel/runtime@npm:7.27.0" dependencies: @@ -4096,7 +4105,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: @@ -4168,13 +4177,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: @@ -4185,7 +4193,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/6b29f14dadc5dd5c055d063cb12ff901e2ff4458eb606489fc917e029afc3bb81b5292cf701227cedf35c3df25dc85ad1c60ca20ef3b8f75aa41d40a7f5580ee + checksum: 10/befd4cb7108672ff0c3f4433e39bf5557e43a755fdf658de7b4a161977efb69b9b8a129930b141b02ea7bca5f71d70353d415d7d89f9a440afd6b4fa28c9a8cf languageName: node linkType: hard @@ -4329,12 +4337,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" @@ -4379,7 +4387,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/b48f7a3a6df469f26eb033f2f59da36dcf56bb52ba7298b7dba05d9132c90f8ab5b58dd247a8f9c1b652b91138358fbae482654465550c74780a32fe04bf6e7e + checksum: 10/472f4a17edc740cec15041612068320114b0128d6917d6968db8b435c3f4c2f002be939978b29efb12ce32c3d660b28e9de051ac1104c14f4eae2b6129c5ab49 languageName: node linkType: hard @@ -4461,7 +4469,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: @@ -4615,16 +4623,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" @@ -4637,7 +4645,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/5522ddd487766ea44288d5626f269e42ad7d84902a5a257fd3962d2b2409743cf1b5de135e15915ba568bf2b12ec25c814ece077c87e1436c67e63998b1b9652 + checksum: 10/55837eeacbc3bac8b9e684c63d8784f56d5adc76d20e415f1e1a30c7fb2014b8407c7e30bdd9823a5f2c9c63ce793271b5f0997c5057359a4c5e73b7ba346afb languageName: node linkType: hard @@ -4675,15 +4683,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 @@ -4693,7 +4701,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/65decef0c603ededc36791db771f82c35f6b8c283a65d6b81ad098175290ce8d125d22127968820d29dcbc69c4241b7e703013c5091a1c433bfaf86eaad482ea + checksum: 10/2e387815586c436c5c24a116f0294dddcf1a5bc0a84b8dd73bdb9fc797fd5aca2aeaa77b4da3908f48e4b18a356751d9241473bfbe4951edf56138e0a62994e1 languageName: node linkType: hard @@ -4727,12 +4735,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/core-app-api": "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" + peerDependencies: + "@types/react": ^16.13.1 || ^17.0.0 || ^18.0.0 + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-router-dom: 6.0.0-beta.0 || ^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" @@ -4747,7 +4786,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/9f52031e65d38087da1ad8d3117fdb2c54dbde20326f32c7cfebd755af6d28d881c9e57b5f9c17f4920fb40eea90b3b5b48b4cfc95f7dd2a3359866544ad5411 + checksum: 10/91a55d7d12545aa8041b64c2443e566775c9c6cca62e6b8bd5d3ac5eb4c8f2d28a8c7bb602c1389537be4ce61d0bf93e2ddeddd4e2fa3ae691817a3bb55fabab languageName: node linkType: hard @@ -4785,15 +4824,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" @@ -4806,7 +4845,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/d141510fd32dc585677e1e1d7e0962b7656fd3ba7c214ef6341da39771e6fc41f5a1ca593ccfe660017fab15c90e7f7166c501618cb4c21d076ea98f101039dc + checksum: 10/0fd0c43c85f6a28c23840ac44064d9294340e892bb6e316d66afc683b05eaadebc9415a32a3f98fb81d4e8090b9496255e10ed519bb74757e089ab1d70794174 languageName: node linkType: hard @@ -4859,7 +4898,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: @@ -5090,17 +5129,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" @@ -5113,7 +5151,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/29819de2c81dbeb787e485d43b8ccb475d42fb4e071ce2eab6e0a814e35f5d6f14deb1266082326dd3b07005136a34b780a14af3aed7efae6f0fff4ae28bc9cb + checksum: 10/6ba4de2ba60b95366f71b1b301c234a3d7ff34356db006696e8cae15b8c1ba9e3f31e17acf205cc74f2faa068b55ecdc535b1c087ae3a97c90915841192efee6 languageName: node linkType: hard @@ -6275,22 +6313,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" @@ -6312,7 +6350,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10/0149b99ede5d563cd81dfa1d657cf332af2cbb3e6ff2e88244c4b3c59e2109264c62e3a6903ee471596a3c709294b81fe2da83513d79690704fb37f6cf67d305 + checksum: 10/379f934cc871a101714675bbe6aa51a4b0a831731dfac807004b74c804b107f2def033b53bb56c10c7bda1b16d07a014c00b374fccad7677e3f9b29be51fc354 languageName: node linkType: hard @@ -7361,7 +7399,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: @@ -8784,7 +8822,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: @@ -12112,6 +12150,13 @@ __metadata: languageName: node linkType: hard +"@module-federation/error-codes@npm:0.9.1": + version: 0.9.1 + resolution: "@module-federation/error-codes@npm:0.9.1" + checksum: 10/545aecc606a506ee47f061835e0eaa41b8d1b02f6bf71b36ec9ae85a1b0370af1f7b7cf92a8f52c3c4b35da858653244316de5ab06bea5dac5b92995467631cc + languageName: node + linkType: hard + "@module-federation/inject-external-runtime-core-plugin@npm:0.9.0": version: 0.9.0 resolution: "@module-federation/inject-external-runtime-core-plugin@npm:0.9.0" @@ -12189,6 +12234,16 @@ __metadata: languageName: node linkType: hard +"@module-federation/runtime-core@npm:0.9.1": + version: 0.9.1 + resolution: "@module-federation/runtime-core@npm:0.9.1" + dependencies: + "@module-federation/error-codes": "npm:0.9.1" + "@module-federation/sdk": "npm:0.9.1" + checksum: 10/6f9edbe23013395d7896fc2a24cb4055bc78df5a335f090e079df951835c1cf91c567228f19879eee3fddb0b34128abd0b50feaca1cf3fb2828c7b9bacc22169 + languageName: node + linkType: hard + "@module-federation/runtime-tools@npm:0.11.1": version: 0.11.1 resolution: "@module-federation/runtime-tools@npm:0.11.1" @@ -12231,6 +12286,17 @@ __metadata: languageName: node linkType: hard +"@module-federation/runtime@npm:^0.9.0": + version: 0.9.1 + resolution: "@module-federation/runtime@npm:0.9.1" + dependencies: + "@module-federation/error-codes": "npm:0.9.1" + "@module-federation/runtime-core": "npm:0.9.1" + "@module-federation/sdk": "npm:0.9.1" + checksum: 10/71eb1c3e81b307ebfe06c43ce70bfa56b217e9dfbed27f0b4235d3d0d05cc0fe2eb1dc57fffb3260ed9e0239257ef117ae13924c642b5ff97d9c65bdf48206fe + languageName: node + linkType: hard + "@module-federation/sdk@npm:0.11.1": version: 0.11.1 resolution: "@module-federation/sdk@npm:0.11.1" @@ -12247,6 +12313,13 @@ __metadata: languageName: node linkType: hard +"@module-federation/sdk@npm:0.9.1, @module-federation/sdk@npm:^0.9.0": + version: 0.9.1 + resolution: "@module-federation/sdk@npm:0.9.1" + checksum: 10/ea0320feff328a05405e65503d1df28e46a9ad17ef99b77f1428db5c89efbadd2a76ec82d99a54ac1d21286cee6d9ddbba881a9c46748dfe8abdb11e9afef7da + languageName: node + linkType: hard + "@module-federation/third-party-dts-extractor@npm:0.9.0": version: 0.9.0 resolution: "@module-federation/third-party-dts-extractor@npm:0.9.0" @@ -20485,7 +20558,16 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=12, @types/node@npm:>=12.0.0, @types/node@npm:>=13.7.0, @types/node@npm:>=18.0.0, @types/node@npm:^22.0.0": +"@types/node@npm:*, @types/node@npm:>=13.7.0, @types/node@npm:^22.0.0": + version: 22.13.9 + resolution: "@types/node@npm:22.13.9" + dependencies: + undici-types: "npm:~6.20.0" + checksum: 10/23560df3ee99c907179c688754486b969a72144f2e2bdefe974d320dddc5ca8f93365842966ecbd5c5bba34e919fc1a5a6627712beb8e7f71d71347dcf414a35 + languageName: node + linkType: hard + +"@types/node@npm:>=12, @types/node@npm:>=12.0.0, @types/node@npm:>=18.0.0": version: 22.13.10 resolution: "@types/node@npm:22.13.10" dependencies: @@ -30760,7 +30842,25 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6": +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6": + version: 1.2.6 + resolution: "get-intrinsic@npm:1.2.6" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + dunder-proto: "npm:^1.0.0" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.0.0" + function-bind: "npm:^1.1.2" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.0.0" + checksum: 10/a1ffae6d7893a6fa0f4d1472adbc85095edd6b3b0943ead97c3738539cecb19d422ff4d48009eed8c3c27ad678c2b1e38a83b1a1e96b691d13ed8ecefca1068d + languageName: node + linkType: hard + +"get-intrinsic@npm:^1.2.1": version: 1.3.0 resolution: "get-intrinsic@npm:1.3.0" dependencies: @@ -31824,7 +31924,7 @@ __metadata: languageName: node linkType: hard -"html-entities@npm:^2.1.0, html-entities@npm:^2.5.2": +"html-entities@npm:^2.1.0, html-entities@npm:^2.4.0, html-entities@npm:^2.5.2": version: 2.5.2 resolution: "html-entities@npm:2.5.2" checksum: 10/4ec12ebdf2d5ba8192c68e1aef3c1e4a4f36b29246a0a88464fe278a54517d0196d3489af46a3145c7ecacb4fc5fd50497be19eb713b810acab3f0efcf36fdc2 @@ -32025,7 +32125,7 @@ __metadata: languageName: node linkType: hard -"http-proxy-middleware@npm:^2.0.0, http-proxy-middleware@npm:^2.0.6, http-proxy-middleware@npm:^2.0.7": +"http-proxy-middleware@npm:^2.0.0, http-proxy-middleware@npm:^2.0.3, http-proxy-middleware@npm:^2.0.6, http-proxy-middleware@npm:^2.0.7": version: 2.0.7 resolution: "http-proxy-middleware@npm:2.0.7" dependencies: @@ -36289,7 +36389,7 @@ __metadata: languageName: node linkType: hard -"math-intrinsics@npm:^1.1.0": +"math-intrinsics@npm:^1.0.0, math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" checksum: 10/11df2eda46d092a6035479632e1ec865b8134bdfc4bd9e571a656f4191525404f13a283a515938c3a8de934dbfd9c09674d9da9fa831e6eb7e22b50b197d2edd @@ -47669,7 +47769,7 @@ __metadata: languageName: node linkType: hard -"webpack-dev-server@npm:5.2.0, webpack-dev-server@npm:^5.0.0": +"webpack-dev-server@npm:5.2.0": version: 5.2.0 resolution: "webpack-dev-server@npm:5.2.0" dependencies: @@ -47713,6 +47813,51 @@ __metadata: languageName: node linkType: hard +"webpack-dev-server@npm:^5.0.0": + version: 5.1.0 + resolution: "webpack-dev-server@npm:5.1.0" + dependencies: + "@types/bonjour": "npm:^3.5.13" + "@types/connect-history-api-fallback": "npm:^1.5.4" + "@types/express": "npm:^4.17.21" + "@types/serve-index": "npm:^1.9.4" + "@types/serve-static": "npm:^1.15.5" + "@types/sockjs": "npm:^0.3.36" + "@types/ws": "npm:^8.5.10" + ansi-html-community: "npm:^0.0.8" + bonjour-service: "npm:^1.2.1" + chokidar: "npm:^3.6.0" + colorette: "npm:^2.0.10" + compression: "npm:^1.7.4" + connect-history-api-fallback: "npm:^2.0.0" + express: "npm:^4.19.2" + graceful-fs: "npm:^4.2.6" + html-entities: "npm:^2.4.0" + http-proxy-middleware: "npm:^2.0.3" + ipaddr.js: "npm:^2.1.0" + launch-editor: "npm:^2.6.1" + open: "npm:^10.0.3" + p-retry: "npm:^6.2.0" + schema-utils: "npm:^4.2.0" + selfsigned: "npm:^2.4.1" + serve-index: "npm:^1.9.1" + sockjs: "npm:^0.3.24" + spdy: "npm:^4.0.2" + webpack-dev-middleware: "npm:^7.4.2" + ws: "npm:^8.18.0" + peerDependencies: + webpack: ^5.0.0 + peerDependenciesMeta: + webpack: + optional: true + webpack-cli: + optional: true + bin: + webpack-dev-server: bin/webpack-dev-server.js + checksum: 10/f23255681cc5e2c2709b23ca7b2185aeed83b1c9912657d4512eda8685625a46d7a103a92446494a55fe2afdfab936f9bd4f037d20b52f7fdfff303e7e7199c7 + languageName: node + linkType: hard + "webpack-hot-middleware@npm:^2.25.1": version: 2.26.1 resolution: "webpack-hot-middleware@npm:2.26.1" From 9c4f72b2db5c5532988e39b145a83033b8c5e2b0 Mon Sep 17 00:00:00 2001 From: David Festal Date: Thu, 5 Dec 2024 22:08:21 +0100 Subject: [PATCH 2/9] Update Backend Dynamic Feature Service to provide the router for frontend plugins Signed-off-by: David Festal --- .../package.json | 10 +- .../test-backend-dynamic/dist/index.cjs.js | 23 +-- .../index.js | 4 +- .../src/features/features.test.ts | 6 +- .../src/features/features.ts | 2 + .../src/schema/openapi.yaml | 90 ++++++++++ .../generated/apis/DefaultApi.server.ts | 34 ++++ .../schema/openapi/generated/apis/index.ts | 17 ++ .../src/schema/openapi/generated/index.ts | 18 ++ .../generated/models/ErrorError.model.ts | 29 ++++ .../generated/models/ErrorRequest.model.ts | 27 +++ .../generated/models/ErrorResponse.model.ts | 26 +++ .../generated/models/ModelError.model.ts | 33 ++++ .../schema/openapi/generated/models/index.ts | 20 +++ .../src/schema/openapi/generated/router.ts | 161 ++++++++++++++++++ .../src/schema/openapi/index.ts | 17 ++ .../src/server/index.ts | 16 ++ .../src/server/plugin.ts | 57 +++++++ .../src/server/router.ts | 100 +++++++++++ yarn.lock | 99 ++--------- 20 files changed, 674 insertions(+), 115 deletions(-) create mode 100644 packages/backend-dynamic-feature-service/src/schema/openapi.yaml create mode 100644 packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/DefaultApi.server.ts create mode 100644 packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/index.ts create mode 100644 packages/backend-dynamic-feature-service/src/schema/openapi/generated/index.ts create mode 100644 packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ErrorError.model.ts create mode 100644 packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ErrorRequest.model.ts create mode 100644 packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ErrorResponse.model.ts create mode 100644 packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ModelError.model.ts create mode 100644 packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/index.ts create mode 100644 packages/backend-dynamic-feature-service/src/schema/openapi/generated/router.ts create mode 100644 packages/backend-dynamic-feature-service/src/schema/openapi/index.ts create mode 100644 packages/backend-dynamic-feature-service/src/server/index.ts create mode 100644 packages/backend-dynamic-feature-service/src/server/plugin.ts create mode 100644 packages/backend-dynamic-feature-service/src/server/router.ts 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/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/features.test.ts b/packages/backend-dynamic-feature-service/src/features/features.test.ts index fb6aa5e48a..d1ef658d47 100644 --- a/packages/backend-dynamic-feature-service/src/features/features.test.ts +++ b/packages/backend-dynamic-feature-service/src/features/features.test.ts @@ -389,15 +389,15 @@ Require stack: }); const list = await fetch( - `http://localhost:${server.port()}/api/test/frontend-plugins`, + `http://localhost:${server.port()}/api/core.dynamicplugins.frontendRemotes/manifests`, ); 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`, + 'plugin-test-dynamic': `http://localhost:${server.port()}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-test-dynamic/mf-manifest.json`, }); const manifest = await fetch( - `http://localhost:${server.port()}/api/test/frontend-plugins/plugin-test-dynamic/mf-manifest.json`, + `http://localhost:${server.port()}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-test-dynamic/mf-manifest.json`, ); expect(manifest.ok).toBe(true); expect(await manifest.json()).toMatchObject({ diff --git a/packages/backend-dynamic-feature-service/src/features/features.ts b/packages/backend-dynamic-feature-service/src/features/features.ts index 94711816d7..e300af36c4 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 frontendRemotesServerPlugin from '../server'; /** * @public @@ -64,6 +65,7 @@ const dynamicPluginsFeatureLoaderWithOptions = ( yield* [ dynamicPluginsRootLoggerServiceFactory(rootLoggerOptions), dynamicPluginsFrontendSchemas, + frontendRemotesServerPlugin, dynamicPluginsFeatureDiscoveryLoader, ]; } 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..c546a57ea4 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi.yaml @@ -0,0 +1,90 @@ +openapi: 3.0.3 +info: + title: core.dynamicplugins.frontendRemotes + 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: + ErrorResponse: + description: An error response from the backend. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + schemas: + 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: + /manifests: + get: + operationId: GetManifests + description: Get the Module Federation manifest files of dynamic frontend plugins. + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + properties: {} + additionalProperties: + type: string + '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/DefaultApi.server.ts b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/DefaultApi.server.ts new file mode 100644 index 0000000000..c2ca54a21d --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/DefaultApi.server.ts @@ -0,0 +1,34 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export type GetManifests = { + response: { [key: string]: string } | Error | Error; +}; + +/** + * no description + */ + +export type EndpointMap = { + '#get|/manifests': GetManifests; +}; 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..79855a6fc8 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/index.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. + */ + +export * from './DefaultApi.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..69c39313c6 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/index.ts @@ -0,0 +1,18 @@ +/* + * 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 * 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..fe5811628d --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ErrorError.model.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +// ****************************************************************** +// * 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..d44dcb66d9 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ErrorRequest.model.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +// ****************************************************************** +// * 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..91c120483d --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ErrorResponse.model.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ + +// ****************************************************************** +// * 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..5526d703e6 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/ModelError.model.ts @@ -0,0 +1,33 @@ +/* + * 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. + */ + +// ****************************************************************** +// * 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/index.ts b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/index.ts new file mode 100644 index 0000000000..4d19a3b2e9 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/index.ts @@ -0,0 +1,20 @@ +/* + * 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 * from '../models/ErrorError.model'; +export * from '../models/ErrorRequest.model'; +export * from '../models/ErrorResponse.model'; +export * from '../models/ModelError.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..af61500fea --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/router.ts @@ -0,0 +1,161 @@ +/* + * 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. + */ + +// ****************************************************************** +// * 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: 'core.dynamicplugins.frontendRemotes', + 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: { + ErrorResponse: { + description: 'An error response from the backend.', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + }, + }, + }, + }, + schemas: { + 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: { + '/manifests': { + get: { + operationId: 'GetManifests', + description: + 'Get the Module Federation manifest files of dynamic frontend plugins.', + responses: { + '200': { + description: '', + content: { + 'application/json': { + schema: { + type: 'object', + properties: {}, + additionalProperties: { + type: 'string', + }, + }, + }, + }, + }, + '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..db98243cbf --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/index.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. + */ + +export * from './generated'; 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..ee39fcb84c --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/server/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { frontendRemotesServerPlugin as default } from './plugin'; diff --git a/packages/backend-dynamic-feature-service/src/server/plugin.ts b/packages/backend-dynamic-feature-service/src/server/plugin.ts new file mode 100644 index 0000000000..1e021444ea --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/server/plugin.ts @@ -0,0 +1,57 @@ +/* + * 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, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { createRouter } from './router'; +import { dynamicPluginsServiceRef } from '@backstage/backend-dynamic-feature-service'; + +/** + * frontendRemotesServerPlugin backend plugin + * + * @internal + */ +export const frontendRemotesServerPlugin = createBackendPlugin({ + pluginId: 'core.dynamicplugins.frontendRemotes', + register(env) { + env.registerInit({ + deps: { + logger: coreServices.logger, + auth: coreServices.auth, + httpAuth: coreServices.httpAuth, + httpRouter: coreServices.httpRouter, + discovery: coreServices.discovery, + metadata: coreServices.pluginMetadata, + dynamicPlugins: dynamicPluginsServiceRef, + }, + async init({ logger, httpRouter, discovery, metadata, dynamicPlugins }) { + httpRouter.use( + await createRouter({ + logger, + discovery, + metadata, + dynamicPlugins, + }), + ); + httpRouter.addAuthPolicy({ + path: `/`, + allow: 'unauthenticated', + }); + }, + }); + }, +}); 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..5b9e8a0297 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/server/router.ts @@ -0,0 +1,100 @@ +/* + * 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 { + DiscoveryService, + LoggerService, + PluginMetadataService, +} from '@backstage/backend-plugin-api'; +import express from 'express'; +import { createOpenApiRouter } from '../schema/openapi'; +import { DynamicPluginProvider } from '@backstage/backend-dynamic-feature-service'; +import { ManifestFileName } from '@module-federation/sdk'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as url from 'url'; + +export async function createRouter({ + logger, + discovery, + metadata, + dynamicPlugins, +}: { + logger: LoggerService; + discovery: DiscoveryService; + metadata: PluginMetadataService; + dynamicPlugins: DynamicPluginProvider; +}): Promise { + const typedRouter = await createOpenApiRouter(); + + const externalBaseUrl = await discovery.getExternalBaseUrl(metadata.getId()); + + const frontendPluginManifests: { + [key: string]: string; + } = {}; + + for (const plugin of dynamicPlugins.frontendPlugins()) { + const pluginScannedPackage = dynamicPlugins.getScannedPackage(plugin); + const pkgDistLocation = path.resolve( + url.fileURLToPath(pluginScannedPackage.location), + 'dist', + ); + + const pkgManifestLocation = path.resolve(pkgDistLocation, ManifestFileName); + if (!fs.existsSync(pkgManifestLocation)) { + logger.warn( + `Could not find '${pkgManifestLocation}' for frontend plugin ${plugin.name}@${plugin.version}`, + ); + continue; + } + + let moduleName: string | undefined; + try { + const pkgManifest = JSON.parse( + fs.readFileSync(pkgManifestLocation).toString(), + ); + moduleName = pkgManifest.name; + if (!moduleName) { + logger.error( + `Dynamic frontend plugin module name not found in manifest for plugin ${plugin.name}@${plugin.version}`, + ); + continue; + } + } catch (error) { + logger.error( + `Dynamic frontend plugin manifest could not be loaded for plugin ${plugin.name}@${plugin.version}`, + ); + continue; + } + + const remoteAssetsPrefix = `/remotes/${plugin.name}`; + typedRouter.use(remoteAssetsPrefix, express.static(pkgDistLocation)); + + logger.info( + `Exposed dynamic frontend plugin '${plugin.name}' from '${pluginScannedPackage.location}' `, + ); + + frontendPluginManifests[ + plugin.name + ] = `${externalBaseUrl}/remotes/${plugin.name}/${ManifestFileName}`; + } + + typedRouter.get('/manifests', (_, res) => { + res.status(200).json(frontendPluginManifests); + }); + + return typedRouter; +} diff --git a/yarn.lock b/yarn.lock index 457e63dad3..c30b98094c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3335,16 +3335,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": - version: 7.26.7 - resolution: "@babel/runtime@npm:7.26.7" - dependencies: - regenerator-runtime: "npm:^0.14.0" - checksum: 10/c7a661a6836b332d9d2e047cba77ba1862c1e4f78cec7146db45808182ef7636d8a7170be9797e5d8fd513180bffb9fa16f6ca1c69341891efec56113cf22bfc - languageName: node - linkType: hard - -"@babel/runtime@npm:^7.26.10": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": version: 7.27.0 resolution: "@babel/runtime@npm:7.27.0" dependencies: @@ -3665,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:^" @@ -3684,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" @@ -20558,16 +20553,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=13.7.0, @types/node@npm:^22.0.0": - version: 22.13.9 - resolution: "@types/node@npm:22.13.9" - dependencies: - undici-types: "npm:~6.20.0" - checksum: 10/23560df3ee99c907179c688754486b969a72144f2e2bdefe974d320dddc5ca8f93365842966ecbd5c5bba34e919fc1a5a6627712beb8e7f71d71347dcf414a35 - languageName: node - linkType: hard - -"@types/node@npm:>=12, @types/node@npm:>=12.0.0, @types/node@npm:>=18.0.0": +"@types/node@npm:*, @types/node@npm:>=12, @types/node@npm:>=12.0.0, @types/node@npm:>=13.7.0, @types/node@npm:>=18.0.0, @types/node@npm:^22.0.0": version: 22.13.10 resolution: "@types/node@npm:22.13.10" dependencies: @@ -30842,25 +30828,7 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6": - version: 1.2.6 - resolution: "get-intrinsic@npm:1.2.6" - dependencies: - call-bind-apply-helpers: "npm:^1.0.1" - dunder-proto: "npm:^1.0.0" - es-define-property: "npm:^1.0.1" - es-errors: "npm:^1.3.0" - es-object-atoms: "npm:^1.0.0" - function-bind: "npm:^1.1.2" - gopd: "npm:^1.2.0" - has-symbols: "npm:^1.1.0" - hasown: "npm:^2.0.2" - math-intrinsics: "npm:^1.0.0" - checksum: 10/a1ffae6d7893a6fa0f4d1472adbc85095edd6b3b0943ead97c3738539cecb19d422ff4d48009eed8c3c27ad678c2b1e38a83b1a1e96b691d13ed8ecefca1068d - languageName: node - linkType: hard - -"get-intrinsic@npm:^1.2.1": +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6": version: 1.3.0 resolution: "get-intrinsic@npm:1.3.0" dependencies: @@ -31924,7 +31892,7 @@ __metadata: languageName: node linkType: hard -"html-entities@npm:^2.1.0, html-entities@npm:^2.4.0, html-entities@npm:^2.5.2": +"html-entities@npm:^2.1.0, html-entities@npm:^2.5.2": version: 2.5.2 resolution: "html-entities@npm:2.5.2" checksum: 10/4ec12ebdf2d5ba8192c68e1aef3c1e4a4f36b29246a0a88464fe278a54517d0196d3489af46a3145c7ecacb4fc5fd50497be19eb713b810acab3f0efcf36fdc2 @@ -32125,7 +32093,7 @@ __metadata: languageName: node linkType: hard -"http-proxy-middleware@npm:^2.0.0, http-proxy-middleware@npm:^2.0.3, http-proxy-middleware@npm:^2.0.6, http-proxy-middleware@npm:^2.0.7": +"http-proxy-middleware@npm:^2.0.0, http-proxy-middleware@npm:^2.0.6, http-proxy-middleware@npm:^2.0.7": version: 2.0.7 resolution: "http-proxy-middleware@npm:2.0.7" dependencies: @@ -36389,7 +36357,7 @@ __metadata: languageName: node linkType: hard -"math-intrinsics@npm:^1.0.0, math-intrinsics@npm:^1.1.0": +"math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" checksum: 10/11df2eda46d092a6035479632e1ec865b8134bdfc4bd9e571a656f4191525404f13a283a515938c3a8de934dbfd9c09674d9da9fa831e6eb7e22b50b197d2edd @@ -47769,7 +47737,7 @@ __metadata: languageName: node linkType: hard -"webpack-dev-server@npm:5.2.0": +"webpack-dev-server@npm:5.2.0, webpack-dev-server@npm:^5.0.0": version: 5.2.0 resolution: "webpack-dev-server@npm:5.2.0" dependencies: @@ -47813,51 +47781,6 @@ __metadata: languageName: node linkType: hard -"webpack-dev-server@npm:^5.0.0": - version: 5.1.0 - resolution: "webpack-dev-server@npm:5.1.0" - dependencies: - "@types/bonjour": "npm:^3.5.13" - "@types/connect-history-api-fallback": "npm:^1.5.4" - "@types/express": "npm:^4.17.21" - "@types/serve-index": "npm:^1.9.4" - "@types/serve-static": "npm:^1.15.5" - "@types/sockjs": "npm:^0.3.36" - "@types/ws": "npm:^8.5.10" - ansi-html-community: "npm:^0.0.8" - bonjour-service: "npm:^1.2.1" - chokidar: "npm:^3.6.0" - colorette: "npm:^2.0.10" - compression: "npm:^1.7.4" - connect-history-api-fallback: "npm:^2.0.0" - express: "npm:^4.19.2" - graceful-fs: "npm:^4.2.6" - html-entities: "npm:^2.4.0" - http-proxy-middleware: "npm:^2.0.3" - ipaddr.js: "npm:^2.1.0" - launch-editor: "npm:^2.6.1" - open: "npm:^10.0.3" - p-retry: "npm:^6.2.0" - schema-utils: "npm:^4.2.0" - selfsigned: "npm:^2.4.1" - serve-index: "npm:^1.9.1" - sockjs: "npm:^0.3.24" - spdy: "npm:^4.0.2" - webpack-dev-middleware: "npm:^7.4.2" - ws: "npm:^8.18.0" - peerDependencies: - webpack: ^5.0.0 - peerDependenciesMeta: - webpack: - optional: true - webpack-cli: - optional: true - bin: - webpack-dev-server: bin/webpack-dev-server.js - checksum: 10/f23255681cc5e2c2709b23ca7b2185aeed83b1c9912657d4512eda8685625a46d7a103a92446494a55fe2afdfab936f9bd4f037d20b52f7fdfff303e7e7199c7 - languageName: node - linkType: hard - "webpack-hot-middleware@npm:^2.25.1": version: 2.26.1 resolution: "webpack-hot-middleware@npm:2.26.1" From b07bff44eb58c946d2918ad7e975486965373830 Mon Sep 17 00:00:00 2001 From: David Festal Date: Wed, 22 Jan 2025 19:58:09 +0100 Subject: [PATCH 3/9] Update default schema locator for frontend plugins... ... to match the frontend location of plugins built as module federation remotes. Signed-off-by: David Festal --- .../test-dynamic/dist/.config-schema.json | 27 +++++++++++++++++++ .../test-dynamic/dist/configSchema.json | 18 ------------- .../src/schemas/schemas.ts | 27 ++++++++++++++++--- 3 files changed, 50 insertions(+), 22 deletions(-) create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/.config-schema.json delete mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/configSchema.json 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/schemas/schemas.ts b/packages/backend-dynamic-feature-service/src/schemas/schemas.ts index 0e873e986c..ab4f39a13a 100644 --- a/packages/backend-dynamic-feature-service/src/schemas/schemas.ts +++ b/packages/backend-dynamic-feature-service/src/schemas/schemas.ts @@ -29,8 +29,13 @@ 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'; +import { PackageRoles } from '@backstage/cli-node'; /** * @@ -152,8 +157,16 @@ export const dynamicPluginsSchemasServiceFactory = Object.assign( async function gatherDynamicPluginsSchemas( packages: ScannedPluginPackage[], logger: LoggerService, - schemaLocator: (pluginPackage: ScannedPluginPackage) => string = () => - path.join('dist', 'configSchema.json'), + schemaLocator: ( + pluginPackage: ScannedPluginPackage, + ) => string = pluginPackage => + path.join( + 'dist', + PackageRoles.getRoleInfo(pluginPackage.manifest.backstage.role) + .platform === 'node' + ? 'configSchema.json' + : '.config-schema.json', + ), ): Promise<{ [context: string]: JsonObject }> { const allSchemas: { [context: string]: JsonObject } = {}; @@ -169,7 +182,7 @@ async function gatherDynamicPluginsSchemas( continue; } - const serialized = await fs.readJson(schemaLocation); + let serialized = await fs.readJson(schemaLocation); if (!serialized) { continue; } @@ -178,6 +191,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}`, From 3bee3c3bcf47618ea03c6a327747b316767b260c Mon Sep 17 00:00:00 2001 From: David Festal Date: Mon, 16 Dec 2024 22:18:40 +0100 Subject: [PATCH 4/9] Add Changeset Signed-off-by: David Festal --- .changeset/large-phones-tie.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/large-phones-tie.md diff --git a/.changeset/large-phones-tie.md b/.changeset/large-phones-tie.md new file mode 100644 index 0000000000..771a593a7c --- /dev/null +++ b/.changeset/large-phones-tie.md @@ -0,0 +1,10 @@ +--- +'@backstage/backend-dynamic-feature-service': patch +'@backstage/frontend-dynamic-feature-loader': patch +'@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`. From 7452c175b70c1df851251dfa7adc582d529bb5de Mon Sep 17 00:00:00 2001 From: David Festal Date: Tue, 8 Apr 2025 21:36:46 +0200 Subject: [PATCH 5/9] Fix simple review comments Signed-off-by: David Festal --- .changeset/large-phones-tie.md | 2 +- .../frontend-dynamic-feature-loader/README.md | 2 +- .../package.json | 15 +- yarn.lock | 207 +++++++----------- 4 files changed, 92 insertions(+), 134 deletions(-) diff --git a/.changeset/large-phones-tie.md b/.changeset/large-phones-tie.md index 771a593a7c..baee28ad0b 100644 --- a/.changeset/large-phones-tie.md +++ b/.changeset/large-phones-tie.md @@ -1,6 +1,6 @@ --- '@backstage/backend-dynamic-feature-service': patch -'@backstage/frontend-dynamic-feature-loader': patch +'@backstage/frontend-dynamic-feature-loader': minor '@backstage/frontend-defaults': patch --- diff --git a/packages/frontend-dynamic-feature-loader/README.md b/packages/frontend-dynamic-feature-loader/README.md index cd5ad44fa9..a1ab46d7b0 100644 --- a/packages/frontend-dynamic-feature-loader/README.md +++ b/packages/frontend-dynamic-feature-loader/README.md @@ -9,7 +9,7 @@ The frontend feature loader provided in this package works hand-in-hand with the - To enable this loader, you should: - - Enable the backend dynamic features in your backend application, as explained in `packages/backend-dynamic-feature-service/README.md#how-it-works` + - 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 diff --git a/packages/frontend-dynamic-feature-loader/package.json b/packages/frontend-dynamic-feature-loader/package.json index 9d8fbbdfd2..7b32278058 100644 --- a/packages/frontend-dynamic-feature-loader/package.json +++ b/packages/frontend-dynamic-feature-loader/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/frontend-dynamic-feature-loader", - "version": "0.0.1-next.0", + "version": "0.0.0", "backstage": { "role": "web-library" }, @@ -36,13 +36,14 @@ "@backstage/core-app-api": "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" + "cross-fetch": "^4.0.0", + "uri-template": "^2.0.0" }, "devDependencies": { "@backstage/cli": "workspace:^", "@backstage/test-utils": "workspace:^", - "@module-federation/runtime": "^0.9.0", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^16.0.0", "@types/react": "^18.0.0", @@ -52,10 +53,10 @@ "react-router-dom": "^6.3.0" }, "peerDependencies": { - "@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0", - "react": "^16.13.1 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0", - "react-router-dom": "6.0.0-beta.0 || ^6.3.0" + "@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": { diff --git a/yarn.lock b/yarn.lock index c30b98094c..59f3b1674c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4750,11 +4750,12 @@ __metadata: 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": ^16.13.1 || ^17.0.0 || ^18.0.0 - react: ^16.13.1 || ^17.0.0 || ^18.0.0 - react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 - react-router-dom: 6.0.0-beta.0 || ^6.3.0 + "@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 @@ -12045,39 +12046,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" @@ -12096,24 +12097,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: @@ -12127,7 +12128,7 @@ __metadata: optional: true webpack: optional: true - checksum: 10/9cca13bf8118ed3e11a54adb1006dc5a55437c0140d1edff3a8969c0ed90c07a268f42360e47f90070a32cdfedc340129b3b4e50143ca846c3b8e4ee5880523e + checksum: 10/a7955711f37ba02a18f3570289dfd8fbed30511b82fcf593e56808559cfd49d8852028aa67950db5e45617d0917536d067d85847892e62c20b68ba6ab40e17c4 languageName: node linkType: hard @@ -12138,13 +12139,6 @@ __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 - languageName: node - linkType: hard - "@module-federation/error-codes@npm:0.9.1": version: 0.9.1 resolution: "@module-federation/error-codes@npm:0.9.1" @@ -12152,50 +12146,50 @@ __metadata: 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 @@ -12205,7 +12199,7 @@ __metadata: optional: true vue-tsc: optional: true - checksum: 10/3c67889f1f4114ea2f0180f3ba5f2e9cbbc687590d6ec07a20638655b4041a1874bafbe685a93fcb8132c9ad67ace7fe6cde474c70562f8c4fb9af9d9de5aa62 + checksum: 10/e6deb7236ccc66d7f0475a09916ec714c9855847bfd3d3ec6cdbee991016a222a22f6ae3fd14d71351d55e22e8949140f37ce9ce79dece5416731c6698726096 languageName: node linkType: hard @@ -12219,16 +12213,6 @@ __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" - dependencies: - "@module-federation/error-codes": "npm:0.9.0" - "@module-federation/sdk": "npm:0.9.0" - checksum: 10/6cff7180b7fd36f37188bc8605861f709ffdb89a1d4600c6d60c6fa4fb44672927f855fbae973e7d0b73f4d1df3408e96af6f616890225d5b4463e48afc2d1f1 - languageName: node - linkType: hard - "@module-federation/runtime-core@npm:0.9.1": version: 0.9.1 resolution: "@module-federation/runtime-core@npm:0.9.1" @@ -12249,13 +12233,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 @@ -12270,18 +12254,7 @@ __metadata: languageName: node linkType: hard -"@module-federation/runtime@npm:0.9.0": - version: 0.9.0 - resolution: "@module-federation/runtime@npm:0.9.0" - 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 - languageName: node - linkType: hard - -"@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: @@ -12299,15 +12272,6 @@ __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 - languageName: node - linkType: hard - "@module-federation/sdk@npm:0.9.1, @module-federation/sdk@npm:^0.9.0": version: 0.9.1 resolution: "@module-federation/sdk@npm:0.9.1" @@ -12315,14 +12279,14 @@ __metadata: 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 @@ -12336,13 +12300,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 @@ -33521,13 +33485,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" From f7886f62fdaee092c85d068ceecde51e4bfb99cd Mon Sep 17 00:00:00 2001 From: David Festal Date: Tue, 8 Apr 2025 21:48:44 +0200 Subject: [PATCH 6/9] Change the module federation asset server from a backend plugin to a backend service... ... that returns more module federation options, can be customized through a plugin, and also serves the list of exposed modules. Signed-off-by: David Festal --- .../report.api.md | 75 +++ .../dist-alternate/.config-schema.json | 25 + .../dist-alternate/mf-manifest.json | 36 ++ .../dist-alternate/remoteEntry.js | 17 + .../mf-manifest-with-wrong-remote-entry.json | 33 ++ .../dist/mf-manifest-without-name.json | 32 ++ .../test-dynamic/dist/mf-manifest.json | 6 +- .../src/features/features.test.ts | 452 ++++++++++++++++-- .../src/features/features.ts | 4 +- .../src/index.ts | 5 + .../src/schema/openapi.yaml | 93 +++- .../{DefaultApi.server.ts => Api.server.ts} | 15 +- .../schema/openapi/generated/apis/index.ts | 10 +- .../src/schema/openapi/generated/index.ts | 2 +- .../generated/models/ErrorError.model.ts | 2 +- .../generated/models/ErrorRequest.model.ts | 2 +- .../generated/models/ErrorResponse.model.ts | 2 +- .../generated/models/ModelError.model.ts | 2 +- .../openapi/generated/models/Remote.model.ts | 36 ++ .../generated/models/RemoteInfo.model.ts | 60 +++ .../schema/openapi/generated/models/index.ts | 4 +- .../src/schema/openapi/generated/router.ts | 113 ++++- .../src/schema/openapi/index.ts | 2 +- .../src/schemas/schemas.ts | 13 +- .../src/server/frontendRemotesServer.ts | 166 +++++++ .../src/server/index.ts | 9 +- .../src/server/plugin.ts | 57 --- .../src/server/router.ts | 189 ++++++-- 28 files changed, 1250 insertions(+), 212 deletions(-) create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist-alternate/.config-schema.json create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist-alternate/mf-manifest.json create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist-alternate/remoteEntry.js create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest-with-wrong-remote-entry.json create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest-without-name.json rename packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/{DefaultApi.server.ts => Api.server.ts} (81%) create mode 100644 packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/Remote.model.ts create mode 100644 packages/backend-dynamic-feature-service/src/schema/openapi/generated/models/RemoteInfo.model.ts create mode 100644 packages/backend-dynamic-feature-service/src/server/frontendRemotesServer.ts delete mode 100644 packages/backend-dynamic-feature-service/src/server/plugin.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-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/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 d1ef658d47..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/core.dynamicplugins.frontendRemotes/manifests`, - ); - expect(list.ok).toBe(true); - expect(await list.json()).toEqual({ - 'plugin-test-dynamic': `http://localhost:${server.port()}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-test-dynamic/mf-manifest.json`, - }); - - const manifest = await fetch( - `http://localhost:${server.port()}/api/core.dynamicplugins.frontendRemotes/remotes/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 e300af36c4..05a5d55ca7 100644 --- a/packages/backend-dynamic-feature-service/src/features/features.ts +++ b/packages/backend-dynamic-feature-service/src/features/features.ts @@ -32,7 +32,7 @@ import { dynamicPluginsRootLoggerServiceFactory, dynamicPluginsSchemasServiceFactory, } from '../schemas'; -import frontendRemotesServerPlugin from '../server'; +import { frontendRemotesServerService } from '../server/frontendRemotesServer'; /** * @public @@ -65,7 +65,7 @@ const dynamicPluginsFeatureLoaderWithOptions = ( yield* [ dynamicPluginsRootLoggerServiceFactory(rootLoggerOptions), dynamicPluginsFrontendSchemas, - frontendRemotesServerPlugin, + 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 index c546a57ea4..9b1871c366 100644 --- a/packages/backend-dynamic-feature-service/src/schema/openapi.yaml +++ b/packages/backend-dynamic-feature-service/src/schema/openapi.yaml @@ -1,6 +1,6 @@ openapi: 3.0.3 info: - title: core.dynamicplugins.frontendRemotes + title: .backstage/dynamic-features version: '1' description: The Backstage backend plugin that serves the frontend plugins module federation manifests and assets license: @@ -15,13 +15,89 @@ components: 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: @@ -66,20 +142,13 @@ components: scheme: bearer bearerFormat: JWT paths: - /manifests: + /remotes: get: - operationId: GetManifests - description: Get the Module Federation manifest files of dynamic frontend plugins. + operationId: GetRemotes + description: Get the Module Federation remote definitions. responses: '200': - description: '' - content: - application/json: - schema: - type: object - properties: {} - additionalProperties: - type: string + $ref: '#/components/responses/RemotesResponse' '400': $ref: '#/components/responses/ErrorResponse' default: diff --git a/packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/DefaultApi.server.ts b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/Api.server.ts similarity index 81% rename from packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/DefaultApi.server.ts rename to packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/Api.server.ts index c2ca54a21d..285345d44c 100644 --- a/packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/DefaultApi.server.ts +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/apis/Api.server.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. @@ -14,21 +14,20 @@ * limitations under the License. */ +// + // ****************************************************************** // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** +import { Remote } from '../models/Remote.model'; /** * @public */ -export type GetManifests = { - response: { [key: string]: string } | Error | Error; +export type GetRemotes = { + response: Array | Error | Error; }; -/** - * no description - */ - export type EndpointMap = { - '#get|/manifests': GetManifests; + '#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 index 79855a6fc8..8d81cbaf39 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. @@ -14,4 +14,10 @@ * limitations under the License. */ -export * from './DefaultApi.server'; +// + +// ****************************************************************** +// * 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 index 69c39313c6..dec4b8804e 100644 --- a/packages/backend-dynamic-feature-service/src/schema/openapi/generated/index.ts +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. 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 index fe5811628d..e0265e95d7 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. 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 index d44dcb66d9..3eb5e15740 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. 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 index 91c120483d..edbcc32df7 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. 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 index 5526d703e6..958fde7d0b 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. 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 index 4d19a3b2e9..0c54a6585b 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. @@ -18,3 +18,5 @@ 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 index af61500fea..9f1ef68a77 100644 --- a/packages/backend-dynamic-feature-service/src/schema/openapi/generated/router.ts +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/generated/router.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. @@ -23,7 +23,7 @@ import { EndpointMap } from './'; export const spec = { openapi: '3.0.3', info: { - title: 'core.dynamicplugins.frontendRemotes', + title: '.backstage/dynamic-features', version: '1', description: 'The Backstage backend plugin that serves the frontend plugins module federation manifests and assets', @@ -44,6 +44,16 @@ export const spec = { 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: { @@ -56,6 +66,85 @@ export const spec = { }, }, 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: { @@ -112,25 +201,13 @@ export const spec = { }, }, paths: { - '/manifests': { + '/remotes': { get: { - operationId: 'GetManifests', - description: - 'Get the Module Federation manifest files of dynamic frontend plugins.', + operationId: 'GetRemotes', + description: 'Get the Module Federation remote definitions.', responses: { '200': { - description: '', - content: { - 'application/json': { - schema: { - type: 'object', - properties: {}, - additionalProperties: { - type: 'string', - }, - }, - }, - }, + $ref: '#/components/responses/RemotesResponse', }, '400': { $ref: '#/components/responses/ErrorResponse', diff --git a/packages/backend-dynamic-feature-service/src/schema/openapi/index.ts b/packages/backend-dynamic-feature-service/src/schema/openapi/index.ts index db98243cbf..196aad553a 100644 --- a/packages/backend-dynamic-feature-service/src/schema/openapi/index.ts +++ b/packages/backend-dynamic-feature-service/src/schema/openapi/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. diff --git a/packages/backend-dynamic-feature-service/src/schemas/schemas.ts b/packages/backend-dynamic-feature-service/src/schemas/schemas.ts index ab4f39a13a..7fd53af799 100644 --- a/packages/backend-dynamic-feature-service/src/schemas/schemas.ts +++ b/packages/backend-dynamic-feature-service/src/schemas/schemas.ts @@ -35,7 +35,6 @@ import { mergeConfigSchemas, } from '@backstage/config-loader'; import { dynamicPluginsFeatureLoader } from '../features'; -import { PackageRoles } from '@backstage/cli-node'; /** * @@ -157,16 +156,8 @@ export const dynamicPluginsSchemasServiceFactory = Object.assign( async function gatherDynamicPluginsSchemas( packages: ScannedPluginPackage[], logger: LoggerService, - schemaLocator: ( - pluginPackage: ScannedPluginPackage, - ) => string = pluginPackage => - path.join( - 'dist', - PackageRoles.getRoleInfo(pluginPackage.manifest.backstage.role) - .platform === 'node' - ? 'configSchema.json' - : '.config-schema.json', - ), + schemaLocator: (pluginPackage: ScannedPluginPackage) => string = () => + path.join('dist', '.config-schema.json'), ): Promise<{ [context: string]: JsonObject }> { const allSchemas: { [context: string]: JsonObject } = {}; 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 index ee39fcb84c..e6df117f10 100644 --- a/packages/backend-dynamic-feature-service/src/server/index.ts +++ b/packages/backend-dynamic-feature-service/src/server/index.ts @@ -13,4 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { frontendRemotesServerPlugin as default } from './plugin'; +export { dynamicPluginsFrontendServiceRef } from './frontendRemotesServer'; + +export type { + DynamicPluginsFrontendRemotesService, + FrontendRemoteResolver, + FrontendRemoteResolverProvider, + AdditionalRemoteInfo, +} from './frontendRemotesServer'; diff --git a/packages/backend-dynamic-feature-service/src/server/plugin.ts b/packages/backend-dynamic-feature-service/src/server/plugin.ts deleted file mode 100644 index 1e021444ea..0000000000 --- a/packages/backend-dynamic-feature-service/src/server/plugin.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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, - createBackendPlugin, -} from '@backstage/backend-plugin-api'; -import { createRouter } from './router'; -import { dynamicPluginsServiceRef } from '@backstage/backend-dynamic-feature-service'; - -/** - * frontendRemotesServerPlugin backend plugin - * - * @internal - */ -export const frontendRemotesServerPlugin = createBackendPlugin({ - pluginId: 'core.dynamicplugins.frontendRemotes', - register(env) { - env.registerInit({ - deps: { - logger: coreServices.logger, - auth: coreServices.auth, - httpAuth: coreServices.httpAuth, - httpRouter: coreServices.httpRouter, - discovery: coreServices.discovery, - metadata: coreServices.pluginMetadata, - dynamicPlugins: dynamicPluginsServiceRef, - }, - async init({ logger, httpRouter, discovery, metadata, dynamicPlugins }) { - httpRouter.use( - await createRouter({ - logger, - discovery, - metadata, - dynamicPlugins, - }), - ); - httpRouter.addAuthPolicy({ - path: `/`, - allow: 'unauthenticated', - }); - }, - }); - }, -}); diff --git a/packages/backend-dynamic-feature-service/src/server/router.ts b/packages/backend-dynamic-feature-service/src/server/router.ts index 5b9e8a0297..4233147069 100644 --- a/packages/backend-dynamic-feature-service/src/server/router.ts +++ b/packages/backend-dynamic-feature-service/src/server/router.ts @@ -15,85 +15,180 @@ */ import { - DiscoveryService, LoggerService, - PluginMetadataService, + RootConfigService, } from '@backstage/backend-plugin-api'; import express from 'express'; -import { createOpenApiRouter } from '../schema/openapi'; +import { createOpenApiRouter, spec } from '../schema/openapi'; import { DynamicPluginProvider } from '@backstage/backend-dynamic-feature-service'; -import { ManifestFileName } from '@module-federation/sdk'; 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, - discovery, - metadata, + config, dynamicPlugins, + resolvers, }: { logger: LoggerService; - discovery: DiscoveryService; - metadata: PluginMetadataService; + config: RootConfigService; dynamicPlugins: DynamicPluginProvider; + resolvers: FrontendRemoteResolvers; }): Promise { + const externalBaseUrl = `${config.getString('backend.baseUrl')}/${ + spec.info.title + }`; + const typedRouter = await createOpenApiRouter(); - const externalBaseUrl = await discovery.getExternalBaseUrl(metadata.getId()); - - const frontendPluginManifests: { - [key: string]: string; - } = {}; + const frontendPluginRemotes: Remote[] = []; + const { default: defaultResolver, provider: resolverProvider } = resolvers; for (const plugin of dynamicPlugins.frontendPlugins()) { - const pluginScannedPackage = dynamicPlugins.getScannedPackage(plugin); - const pkgDistLocation = path.resolve( - url.fileURLToPath(pluginScannedPackage.location), - 'dist', - ); - - const pkgManifestLocation = path.resolve(pkgDistLocation, ManifestFileName); - if (!fs.existsSync(pkgManifestLocation)) { - logger.warn( - `Could not find '${pkgManifestLocation}' for frontend plugin ${plugin.name}@${plugin.version}`, - ); - continue; - } - - let moduleName: string | undefined; try { - const pkgManifest = JSON.parse( - fs.readFileSync(pkgManifestLocation).toString(), + const pluginScannedPackage = dynamicPlugins.getScannedPackage(plugin); + const pluginScannedPackagePath = path.resolve( + url.fileURLToPath(pluginScannedPackage.location), ); - moduleName = pkgManifest.name; - if (!moduleName) { + 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( - `Dynamic frontend plugin module name not found in manifest for plugin ${plugin.name}@${plugin.version}`, + `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( - `Dynamic frontend plugin manifest could not be loaded for plugin ${plugin.name}@${plugin.version}`, + `Unexpected error when exposing dynamic frontend plugin '${plugin.name}@${plugin.version}'`, + error, ); continue; } - - const remoteAssetsPrefix = `/remotes/${plugin.name}`; - typedRouter.use(remoteAssetsPrefix, express.static(pkgDistLocation)); - - logger.info( - `Exposed dynamic frontend plugin '${plugin.name}' from '${pluginScannedPackage.location}' `, - ); - - frontendPluginManifests[ - plugin.name - ] = `${externalBaseUrl}/remotes/${plugin.name}/${ManifestFileName}`; } - typedRouter.get('/manifests', (_, res) => { - res.status(200).json(frontendPluginManifests); + logger.info(`/remotes => ${JSON.stringify(frontendPluginRemotes)}`); + typedRouter.get('/remotes', (_, res) => { + res.status(200).json(frontendPluginRemotes); }); return typedRouter; From 4335b04da0394e2eb126146392ed527c301580a1 Mon Sep 17 00:00:00 2001 From: David Festal Date: Tue, 8 Apr 2025 21:49:41 +0200 Subject: [PATCH 7/9] Update the frontend dynamic feature loader according to changes in the backend remote asset server. Signed-off-by: David Festal --- .../report.api.md | 14 +- .../src/loader.test.tsx | 750 +++++++++++------- .../src/loader.ts | 156 ++-- .../{DefaultApi.client.ts => Api.client.ts} | 17 +- .../schema/openapi/generated/apis/index.ts | 4 +- .../src/schema/openapi/generated/index.ts | 2 +- .../generated/models/ErrorError.model.ts | 2 +- .../generated/models/ErrorRequest.model.ts | 2 +- .../generated/models/ErrorResponse.model.ts | 2 +- .../generated/models/ModelError.model.ts | 2 +- .../openapi/generated/models/Remote.model.ts | 36 + .../generated/models/RemoteInfo.model.ts | 60 ++ .../schema/openapi/generated/models/index.ts | 4 +- .../src/schema/openapi/generated/pluginId.ts | 4 +- .../src/schema/openapi/index.ts | 2 +- 15 files changed, 639 insertions(+), 418 deletions(-) rename packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/{DefaultApi.client.ts => Api.client.ts} (87%) create mode 100644 packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/Remote.model.ts create mode 100644 packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/models/RemoteInfo.model.ts diff --git a/packages/frontend-dynamic-feature-loader/report.api.md b/packages/frontend-dynamic-feature-loader/report.api.md index 532dddfc43..3188551bad 100644 --- a/packages/frontend-dynamic-feature-loader/report.api.md +++ b/packages/frontend-dynamic-feature-loader/report.api.md @@ -3,16 +3,22 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { CreateAppFeatureLoader } from '@backstage/frontend-defaults'; -import { init } from '@module-federation/enhanced/runtime'; +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, -): CreateAppFeatureLoader; +): FrontendFeatureLoader; // @public (undocumented) export type DynamicFrontendFeaturesLoaderOptions = { - moduleFederation: Omit[0], 'name' | 'remotes'>; + moduleFederation: { + shared?: UserOptions['shared']; + shareStrategy?: ShareStrategy; + plugins?: Array; + }; }; ``` diff --git a/packages/frontend-dynamic-feature-loader/src/loader.test.tsx b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx index c958688e20..87ddc7770f 100644 --- a/packages/frontend-dynamic-feature-loader/src/loader.test.tsx +++ b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx @@ -26,6 +26,7 @@ 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'; @@ -37,6 +38,7 @@ describe('dynamicFrontendFeaturesLoader', () => { 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 => ({})), @@ -53,10 +55,6 @@ describe('dynamicFrontendFeaturesLoader', () => { plugins: [ { name: 'load-entry-mock', - errorLoadRemote: args => { - // eslint-disable-next-line no-console - console.error(args); - }, loadEntry: async args => { return { get: (id: string) => async () => { @@ -103,14 +101,17 @@ describe('dynamicFrontendFeaturesLoader', () => { 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}/api/core.dynamicplugins.frontendRemotes/manifests`, + `${baseUrl}/.backstage/dynamic-plugins/remotes`, (_, res, ctx) => { manifestsEndpointCalled = true; return res(ctx.json({})); @@ -153,16 +154,23 @@ describe('dynamicFrontendFeaturesLoader', () => { it('should load a dynamic frontend plugin with the default exposed remote module', async () => { server.use( rest.get( - `${baseUrl}/api/core.dynamicplugins.frontendRemotes/manifests`, + `${baseUrl}/.backstage/dynamic-features/remotes`, (_, res, ctx) => res( - ctx.json({ - 'test-plugin': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/mf-manifest.json`, - }), + 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}/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/mf-manifest.json`, + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json`, (_, res, ctx) => res( ctx.json({ @@ -223,13 +231,16 @@ describe('dynamicFrontendFeaturesLoader', () => { ]); const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); expect(infoCalls).toEqual([ - "Loading dynamic plugin 'test-plugin' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/mf-manifest.json'", - "Dynamic plugin remote module 'test-plugin' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/mf-manifest.json", + "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', + name: 'test_plugin', }, ]); }); @@ -237,17 +248,31 @@ describe('dynamicFrontendFeaturesLoader', () => { it('should load several dynamic frontend plugins', async () => { server.use( rest.get( - `${baseUrl}/api/core.dynamicplugins.frontendRemotes/manifests`, + `${baseUrl}/.backstage/dynamic-features/remotes`, (_, res, ctx) => res( - ctx.json({ - 'plugin-1': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, - 'plugin-2': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json`, - }), + 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}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json`, (_, res, ctx) => res( ctx.json({ @@ -265,7 +290,7 @@ describe('dynamicFrontendFeaturesLoader', () => { ), ), rest.get( - `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json`, + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json`, (_, res, ctx) => res( ctx.json({ @@ -305,7 +330,6 @@ describe('dynamicFrontendFeaturesLoader', () => { config: mockApis.config({ data: { app: { - packageName: 'app-2', experimental: { packages: { include: [], @@ -338,19 +362,22 @@ describe('dynamicFrontendFeaturesLoader', () => { ]); const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); expect(infoCalls).toEqual([ - "Loading dynamic plugin 'plugin-1' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json'", - "Loading dynamic plugin 'plugin-2' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json'", - "Dynamic plugin remote module 'plugin-1' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json", - "Dynamic plugin remote module 'plugin-2' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json", + "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', + name: 'plugin_1', }, { id: '.', - name: 'plugin-2', + name: 'plugin_2', }, ]); }); @@ -358,16 +385,23 @@ describe('dynamicFrontendFeaturesLoader', () => { it('should load a dynamic frontend plugin with several exposed remote modules', async () => { server.use( rest.get( - `${baseUrl}/api/core.dynamicplugins.frontendRemotes/manifests`, + `${baseUrl}/.backstage/dynamic-features/remotes`, (_, res, ctx) => res( - ctx.json({ - 'test-plugin': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/mf-manifest.json`, - }), + 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}/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/mf-manifest.json`, + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-test-dynamic/mf-manifest.json`, (_, res, ctx) => res( ctx.json({ @@ -418,7 +452,6 @@ describe('dynamicFrontendFeaturesLoader', () => { include: [], }, }, - packageName: 'app-3', }, backend: { baseUrl, @@ -446,18 +479,104 @@ describe('dynamicFrontendFeaturesLoader', () => { ]); const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); expect(infoCalls).toEqual([ - "Loading dynamic plugin 'test-plugin' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/mf-manifest.json'", - "Dynamic plugin remote module 'test-plugin' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/mf-manifest.json", - "Dynamic plugin remote module 'test-plugin/alpha' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/test-plugin/mf-manifest.json", + "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', + name: 'test_plugin', }, { id: './alpha', - name: 'test-plugin', + 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', }, ]); }); @@ -465,7 +584,7 @@ describe('dynamicFrontendFeaturesLoader', () => { it('should warn and recover from a 404 error fetching module feredation configuration', async () => { server.use( rest.get( - `${baseUrl}/api/core.dynamicplugins.frontendRemotes/manifests`, + `${baseUrl}/.backstage/dynamic-features/remotes`, (_, res, ctx) => res(ctx.status(404, 'NOT FOUND')), ), ); @@ -511,59 +630,10 @@ describe('dynamicFrontendFeaturesLoader', () => { expect(mocks.federation.get.mock.calls.flatMap(e => e[0])).toEqual([]); }); - it('should warn and recover from unexpected Json while fetching module feredation configuration', async () => { - server.use( - rest.get( - `${baseUrl}/api/core.dynamicplugins.frontendRemotes/manifests`, - (_, res, ctx) => res(ctx.json('A Json String')), - ), - ); - - 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: Invalid Json content: should be a Json object`, - ]); - 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}/api/core.dynamicplugins.frontendRemotes/manifests`, + `${baseUrl}/.backstage/dynamic-features/remotes`, (_, res, ctx) => res(ctx.status(200)), ), ); @@ -599,7 +669,7 @@ describe('dynamicFrontendFeaturesLoader', () => { 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/api/core.dynamicplugins.frontendRemotes/manifests reason: Unexpected end of JSON input`, + `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([]); @@ -609,206 +679,34 @@ describe('dynamicFrontendFeaturesLoader', () => { expect(mocks.federation.get.mock.calls.flatMap(e => e[0])).toEqual([]); }); - it('should warn on 404 error fetching module feredation manifest, but still load other remotes', async () => { - server.use( - rest.get( - `${baseUrl}/api/core.dynamicplugins.frontendRemotes/manifests`, - (_, res, ctx) => - res( - ctx.json({ - 'plugin-1': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, - 'plugin-2': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json`, - }), - ), - ), - rest.get( - `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, - (_, res, ctx) => res(ctx.json({}), ctx.status(404, 'NOT FOUND')), - ), - rest.get( - `${baseUrl}/api/core.dynamicplugins.frontendRemotes/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: { - packageName: 'app-4', - experimental: { - packages: { - include: [], - }, - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), - }); - - const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); - expect(errorCalls).toEqual([ - "Failed fetching module federation manifest from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json': Error: 404 - NOT FOUND", - ]); - 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([ - "Loading dynamic plugin 'plugin-1' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json'", - "Loading dynamic plugin 'plugin-2' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json'", - "Dynamic plugin remote module 'plugin-2' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/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}/api/core.dynamicplugins.frontendRemotes/manifests`, - (_, res, ctx) => - res( - ctx.json({ - 'plugin-1': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, - 'plugin-2': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json`, - }), - ), - ), - rest.get( - `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, - (_, res, ctx) => res(ctx.json('A Json String')), - ), - rest.get( - `${baseUrl}/api/core.dynamicplugins.frontendRemotes/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: { - packageName: 'app-5', - experimental: { - packages: { - include: [], - }, - }, - }, - backend: { - baseUrl, - }, - dynamicPlugins: {}, - }, - }), - }); - - const errorCalls = mocks.console.error.mock.calls.flatMap(e => e[0]); - expect(errorCalls).toEqual([ - "Failed fetching module federation manifest from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json': Error: Invalid Json content: should be a Json object", - ]); - 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([ - "Loading dynamic plugin 'plugin-1' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json'", - "Loading dynamic plugin 'plugin-2' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json'", - "Dynamic plugin remote module 'plugin-2' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/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 empty module, but still load other remotes', async () => { server.use( rest.get( - `${baseUrl}/api/core.dynamicplugins.frontendRemotes/manifests`, + `${baseUrl}/.backstage/dynamic-features/remotes`, (_, res, ctx) => res( - ctx.json({ - 'plugin-1': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, - 'plugin-2': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json`, - }), + 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}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json`, (_, res, ctx) => res( ctx.json({ @@ -826,7 +724,7 @@ describe('dynamicFrontendFeaturesLoader', () => { ), ), rest.get( - `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json`, + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json`, (_, res, ctx) => res( ctx.json({ @@ -861,7 +759,6 @@ describe('dynamicFrontendFeaturesLoader', () => { config: mockApis.config({ data: { app: { - packageName: 'app-6', experimental: { packages: { include: [], @@ -880,7 +777,7 @@ describe('dynamicFrontendFeaturesLoader', () => { 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'.", + "Skipping empty dynamic plugin remote module 'plugin_1'.", ]); expect(features).toMatchObject([ { @@ -891,36 +788,53 @@ describe('dynamicFrontendFeaturesLoader', () => { ]); const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); expect(infoCalls).toEqual([ - "Loading dynamic plugin 'plugin-1' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json'", - "Loading dynamic plugin 'plugin-2' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json'", - "Dynamic plugin remote module 'plugin-2' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/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', + name: 'plugin_1', }, { id: '.', - name: 'plugin-2', + name: 'plugin_2', }, ]); }); - it('should warn on module without default export, but still load other remotes', async () => { + it('should skip module without default export, but still load other remotes', async () => { server.use( rest.get( - `${baseUrl}/api/core.dynamicplugins.frontendRemotes/manifests`, + `${baseUrl}/.backstage/dynamic-features/remotes`, (_, res, ctx) => res( - ctx.json({ - 'plugin-1': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, - 'plugin-2': `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json`, - }), + 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}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json`, + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-1/mf-manifest.json`, (_, res, ctx) => res( ctx.json({ @@ -938,7 +852,7 @@ describe('dynamicFrontendFeaturesLoader', () => { ), ), rest.get( - `${baseUrl}/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json`, + `${baseUrl}/.backstage/dynamic-features/remotes/plugin-2/mf-manifest.json`, (_, res, ctx) => res( ctx.json({ @@ -975,7 +889,6 @@ describe('dynamicFrontendFeaturesLoader', () => { config: mockApis.config({ data: { app: { - packageName: 'app-7', experimental: { packages: { include: [], @@ -993,9 +906,7 @@ describe('dynamicFrontendFeaturesLoader', () => { 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 dynamic plugin remote module 'plugin-1' since it doesn't export a new 'FrontendFeature' as default export.", - ]); + expect(warnCalls).toEqual([]); expect(features).toMatchObject([ { $$type: '@backstage/FrontendPlugin', @@ -1005,19 +916,242 @@ describe('dynamicFrontendFeaturesLoader', () => { ]); const infoCalls = mocks.console.info.mock.calls.flatMap(e => e[0]); expect(infoCalls).toEqual([ - "Loading dynamic plugin 'plugin-1' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json'", - "Loading dynamic plugin 'plugin-2' from 'http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json'", - "Dynamic plugin remote module 'plugin-1' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-1/mf-manifest.json", - "Dynamic plugin remote module 'plugin-2' loaded from http://localhost:7007/api/core.dynamicplugins.frontendRemotes/remotes/plugin-2/mf-manifest.json", + "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', + name: 'plugin_1', }, { id: '.', - name: 'plugin-2', + 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 index c15dcb9f3c..9e2585d1fa 100644 --- a/packages/frontend-dynamic-feature-loader/src/loader.ts +++ b/packages/frontend-dynamic-feature-loader/src/loader.ts @@ -14,15 +14,19 @@ * limitations under the License. */ -import { init, loadRemote } from '@module-federation/enhanced/runtime'; -import { Manifest, Module } from '@module-federation/sdk'; -import { DefaultApiClient } from './schema/openapi'; -import { FrontendHostDiscovery } from '@backstage/core-app-api'; +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'; /** * @@ -32,7 +36,11 @@ export type DynamicFrontendFeaturesLoaderOptions = { /** * Additional module federation arguments for the Module Federation runtime initialization. */ - moduleFederation: Omit[0], 'name' | 'remotes'>; + moduleFederation: { + shared?: UserOptions['shared']; + shareStrategy?: ShareStrategy; + plugins?: Array; + }; }; /** @@ -60,14 +68,16 @@ export function dynamicFrontendFeaturesLoader( ); } + const backendBaseUrl = config.getString('backend.baseUrl'); + const appPackageName = config.getOptionalString('app.packageName') ?? 'app'; - let frontendPluginManifests: { - [key: string]: string; - }; + let frontendPluginRemotes: Array; try { const apiClient = new DefaultApiClient({ - discoveryApi: FrontendHostDiscovery.fromConfig(config), + discoveryApi: { + getBaseUrl: async rootPath => `${backendBaseUrl}/${rootPath}`, + }, fetchApi: { fetch(input) { return global.fetch(input); @@ -75,14 +85,11 @@ export function dynamicFrontendFeaturesLoader( }, }); - const response = await apiClient.getManifests({}); + const response = await apiClient.getRemotes({}); if (!response.ok) { throw new Error(`${response.status} - ${response.statusText}`); } - frontendPluginManifests = await response.json(); - if (typeof frontendPluginManifests !== 'object') { - throw new Error(`Invalid Json content: should be a Json object`); - } + frontendPluginRemotes = await response.json(); } catch (err) { error( `Failed fetching module federation configuration of dynamic frontend plugins`, @@ -98,12 +105,10 @@ export function dynamicFrontendFeaturesLoader( .replaceAll('@', '') .replaceAll('/', '__') .replaceAll('-', '_'), - remotes: Object.entries(frontendPluginManifests).map( - ([name, manifestLocation]) => ({ - name: name, - entry: manifestLocation, - }), - ), + remotes: frontendPluginRemotes.map(remote => ({ + alias: remote.packageName, + ...remote.remoteInfo, + })), }); } catch (err) { error(`Failed initializing module federation`, err); @@ -112,73 +117,52 @@ export function dynamicFrontendFeaturesLoader( const features = ( await Promise.all( - Object.entries(frontendPluginManifests).map( - async ([name, manifestLocation]) => { - // eslint-disable-next-line no-console - console.info( - `Loading dynamic plugin '${name}' from '${manifestLocation}'`, - ); - let manifest: Manifest; - try { - const response = await fetch(manifestLocation); - if (!response.ok) { - throw new Error( - `${response.status} - ${response.statusText}`, - ); - } - manifest = await response.json(); - if (typeof manifest !== 'object') { - throw new Error( - `Invalid Json content: should be a Json object`, - ); - } - } catch (err) { - error( - `Failed fetching module federation manifest from '${manifestLocation}'`, - err, - ); - return undefined; - } + 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( - manifest.exposes.map(async expose => { - const remote = - expose.name === '.' ? name : `${name}/${expose.name}`; - let module: Module; - try { - module = await loadRemote(remote); - } catch (err) { - error( - `Failed loading dynamic plugin remote module '${remote}'`, - err, - ); - return undefined; - } - if (!module) { - // eslint-disable-next-line no-console - console.warn( - `Skipping empty dynamic plugin remote module '${remote}'.`, - ); - return undefined; - } - // eslint-disable-next-line no-console - console.info( - `Dynamic plugin remote module '${remote}' loaded from ${manifestLocation}`, + 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, ); - const defaultEntry = module.default; - if (!isFrontendPluginOrModule(defaultEntry)) { - // eslint-disable-next-line no-console - console.warn( - `Skipping dynamic plugin remote module '${remote}' since it doesn't export a new 'FrontendFeature' as default export.`, - ); - return undefined; - } - return defaultEntry; - }), - ); - return moduleFeatures; - }, - ), + 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() @@ -189,7 +173,7 @@ export function dynamicFrontendFeaturesLoader( }); } -function isFrontendPluginOrModule(obj: unknown): obj is FrontendFeature { +function isLoadable(obj: unknown): obj is FrontendFeature { if (obj !== null && typeof obj === 'object' && '$$type' in obj) { return ( obj.$$type === '@backstage/FrontendPlugin' || diff --git a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/DefaultApi.client.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/Api.client.ts similarity index 87% rename from packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/DefaultApi.client.ts rename to packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/Api.client.ts index 1ce952d36d..8623dc3c33 100644 --- a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/DefaultApi.client.ts +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/apis/Api.client.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. @@ -22,6 +22,7 @@ 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. @@ -40,14 +41,12 @@ export type TypedResponse = Omit & { export interface RequestOptions { token?: string; } - /** * @public */ -export type GetManifests = {}; +export type GetRemotes = {}; /** - * no description * @public */ export class DefaultApiClient { @@ -63,16 +62,16 @@ export class DefaultApiClient { } /** - * Get the Module Federation manifest files of dynamic frontend plugins. + * Get the Module Federation remote definitions. */ - public async getManifests( + public async getRemotes( // @ts-ignore - request: GetManifests, + request: GetRemotes, options?: RequestOptions, - ): Promise> { + ): Promise>> { const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); - const uriTemplate = `/manifests`; + const uriTemplate = `/remotes`; const uri = parser.parse(uriTemplate).expand({}); 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 index 51dcca33fe..fc7c83b736 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './DefaultApi.client'; +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 index bb399e97a0..dc3055033d 100644 --- a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/index.ts +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. 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 index fe5811628d..e0265e95d7 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. 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 index d44dcb66d9..3eb5e15740 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. 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 index 91c120483d..edbcc32df7 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. 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 index 5526d703e6..958fde7d0b 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. 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 index 4d19a3b2e9..0c54a6585b 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. @@ -18,3 +18,5 @@ 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 index 21cdf38c03..eaea330f99 100644 --- a/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/pluginId.ts +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/generated/pluginId.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. @@ -14,4 +14,4 @@ * limitations under the License. */ -export const pluginId = 'core.dynamicplugins.frontendRemotes'; +export const pluginId = '.backstage/dynamic-features'; diff --git a/packages/frontend-dynamic-feature-loader/src/schema/openapi/index.ts b/packages/frontend-dynamic-feature-loader/src/schema/openapi/index.ts index db98243cbf..196aad553a 100644 --- a/packages/frontend-dynamic-feature-loader/src/schema/openapi/index.ts +++ b/packages/frontend-dynamic-feature-loader/src/schema/openapi/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * 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. From 803d719990809e2099d17a046523591726bf1ce2 Mon Sep 17 00:00:00 2001 From: David Festal Date: Tue, 8 Apr 2025 23:21:47 +0200 Subject: [PATCH 8/9] Additional cleanup Signed-off-by: David Festal --- packages/frontend-dynamic-feature-loader/package.json | 1 - yarn.lock | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/frontend-dynamic-feature-loader/package.json b/packages/frontend-dynamic-feature-loader/package.json index 7b32278058..9865f6b37b 100644 --- a/packages/frontend-dynamic-feature-loader/package.json +++ b/packages/frontend-dynamic-feature-loader/package.json @@ -33,7 +33,6 @@ }, "dependencies": { "@backstage/config": "workspace:^", - "@backstage/core-app-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@module-federation/enhanced": "^0.9.0", "@module-federation/runtime": "^0.9.0", diff --git a/yarn.lock b/yarn.lock index 59f3b1674c..6788bf1204 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4736,7 +4736,6 @@ __metadata: dependencies: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" - "@backstage/core-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/test-utils": "workspace:^" "@module-federation/enhanced": "npm:^0.9.0" From 7a83050098e8e779e0dcd794577156b3350ec4c1 Mon Sep 17 00:00:00 2001 From: David Festal Date: Tue, 8 Apr 2025 23:40:37 +0200 Subject: [PATCH 9/9] Fix config schema naming after review comment changes Signed-off-by: David Festal --- .../dist/{configSchema.json => .config-schema.json} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/{configSchema.json => .config-schema.json} (100%) 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