From f7886f62fdaee092c85d068ceecde51e4bfb99cd Mon Sep 17 00:00:00 2001 From: David Festal Date: Tue, 8 Apr 2025 21:48:44 +0200 Subject: [PATCH] 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;