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 <dfestal@redhat.com>
This commit is contained in:
David Festal
2025-04-08 21:48:44 +02:00
parent 7452c175b7
commit f7886f62fd
28 changed files with 1250 additions and 212 deletions
@@ -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<RemoteInfo, 'name' | 'entry'>;
// @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<FrontendRemoteResolver> | 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<Pick<BackstagePackageJson, 'main'>> &
@@ -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
}
@@ -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"
}
]
}
@@ -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() {})();
@@ -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"
}]
}
@@ -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"
}]
}
@@ -25,5 +25,9 @@
},
"shared": [],
"remotes": [],
"exposes": []
"exposes": [{
"name": "."
},{
"name": "alpha"
}]
}
@@ -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:
</head>`);
});
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",
),
);
});
});
});
@@ -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,
];
}
@@ -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';
@@ -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:
@@ -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<Remote> | Error | Error;
};
/**
* no description
*/
export type EndpointMap = {
'#get|/manifests': GetManifests;
'#get|/remotes': GetRemotes;
};
@@ -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';
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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<string>;
}
@@ -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';
@@ -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';
@@ -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',
@@ -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.
@@ -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 } = {};
@@ -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<RemoteInfo, 'name' | 'entry'>;
/**
*
* @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<FrontendRemoteResolver> | 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<DynamicPluginsFrontendRemotesService>({
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;
},
};
},
});
@@ -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';
@@ -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',
});
},
});
},
});
@@ -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<express.Router> {
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;