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:
@@ -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'>> &
|
||||
|
||||
+25
@@ -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
|
||||
}
|
||||
+36
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+17
@@ -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() {})();
|
||||
+33
@@ -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"
|
||||
}]
|
||||
}
|
||||
+32
@@ -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"
|
||||
}]
|
||||
}
|
||||
+5
-1
@@ -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:
|
||||
|
||||
+7
-8
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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.
|
||||
|
||||
+36
@@ -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>;
|
||||
}
|
||||
+60
@@ -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';
|
||||
+3
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user