Merge pull request #21764 from davidfestal/meet-api-design-style-guidelines

Refactor the `backend-plugin-manager`to meet Backstage API design style guidelines
This commit is contained in:
Fredrik Adelöw
2023-12-20 15:26:01 +01:00
committed by GitHub
8 changed files with 274 additions and 134 deletions
+46 -21
View File
@@ -65,6 +65,44 @@ export interface BaseDynamicPlugin {
// @public (undocumented)
export type DynamicPlugin = FrontendDynamicPlugin | BackendDynamicPlugin;
// @public (undocumented)
export class DynamicPluginManager implements DynamicPluginProvider {
// (undocumented)
addBackendPlugin(plugin: BackendDynamicPlugin): void;
// (undocumented)
get availablePackages(): ScannedPluginPackage[];
// (undocumented)
backendPlugins(): BackendDynamicPlugin[];
// (undocumented)
static create(
options: DynamicPluginManagerOptions,
): Promise<DynamicPluginManager>;
// (undocumented)
frontendPlugins(): FrontendDynamicPlugin[];
// (undocumented)
plugins(): DynamicPlugin[];
}
// @public (undocumented)
export interface DynamicPluginManagerOptions {
// (undocumented)
config: Config;
// (undocumented)
logger: LoggerService;
// (undocumented)
moduleLoader?: ModuleLoader;
// (undocumented)
preferAlpha?: boolean;
}
// @public (undocumented)
export interface DynamicPluginProvider
extends FrontendPluginProvider,
BackendPluginProvider {
// (undocumented)
plugins(): DynamicPlugin[];
}
// @public (undocumented)
export interface DynamicPluginsFactoryOptions {
// (undocumented)
@@ -80,11 +118,11 @@ export const dynamicPluginsFeatureDiscoveryServiceFactory: () => ServiceFactory<
// @public (undocumented)
export const dynamicPluginsServiceFactory: (
options?: DynamicPluginsFactoryOptions | undefined,
) => ServiceFactory<BackendPluginProvider, 'root'>;
) => ServiceFactory<DynamicPluginProvider, 'root'>;
// @public (undocumented)
export const dynamicPluginsServiceRef: ServiceRef<
BackendPluginProvider,
DynamicPluginProvider,
'root'
>;
@@ -94,6 +132,12 @@ export interface FrontendDynamicPlugin extends BaseDynamicPlugin {
platform: 'web';
}
// @public (undocumented)
export interface FrontendPluginProvider {
// (undocumented)
frontendPlugins(): FrontendDynamicPlugin[];
}
// @public (undocumented)
export function isBackendDynamicPluginInstaller(
obj: any,
@@ -161,25 +205,6 @@ export interface NewBackendPluginInstaller {
kind: 'new';
}
// @public (undocumented)
export class PluginManager implements BackendPluginProvider {
// (undocumented)
addBackendPlugin(plugin: BackendDynamicPlugin): void;
// (undocumented)
get availablePackages(): ScannedPluginPackage[];
// (undocumented)
backendPlugins(): BackendDynamicPlugin[];
// (undocumented)
static fromConfig(
config: Config,
logger: LoggerService,
preferAlpha?: boolean,
moduleLoader?: ModuleLoader,
): Promise<PluginManager>;
// (undocumented)
readonly plugins: DynamicPlugin[];
}
// @public (undocumented)
export type ScannedPluginManifest = BackstagePackageJson &
Required<Pick<BackstagePackageJson, 'main'>> &
@@ -25,14 +25,19 @@ export type {
NewBackendPluginInstaller,
LegacyBackendPluginInstaller,
LegacyPluginEnvironment,
DynamicPluginProvider,
FrontendPluginProvider,
BackendPluginProvider,
} from './types';
export {
PluginManager,
DynamicPluginManager,
dynamicPluginsFeatureDiscoveryServiceFactory,
dynamicPluginsServiceFactory,
dynamicPluginsServiceRef,
} from './plugin-manager';
export type { DynamicPluginsFactoryOptions } from './plugin-manager';
export type {
DynamicPluginManagerOptions,
DynamicPluginsFactoryOptions,
} from './plugin-manager';
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { PluginManager, dynamicPluginsServiceFactory } from './plugin-manager';
import {
DynamicPluginManager,
dynamicPluginsServiceFactory,
} from './plugin-manager';
import {
BackendFeature,
coreServices,
@@ -462,12 +465,16 @@ describe('backend-plugin-manager', () => {
mockDir.setContent(mockedFiles);
const logger = new MockedLogger();
const pluginManager = new (PluginManager as any)(logger, [plugin], {
const pluginManager = new (DynamicPluginManager as any)(
logger,
async bootstrap(_: string, __: string[]): Promise<void> {},
load: async (packagePath: string) =>
await require(/* webpackIgnore: true */ packagePath),
});
[plugin],
{
logger,
async bootstrap(_: string, __: string[]): Promise<void> {},
load: async (packagePath: string) =>
await require(/* webpackIgnore: true */ packagePath),
},
);
const loadedPlugins: DynamicPlugin[] = await pluginManager.loadPlugins();
@@ -483,7 +490,10 @@ describe('backend-plugin-manager', () => {
describe('backendPlugins', () => {
it('should return only backend plugins and modules', async () => {
const logger = new MockedLogger();
const pluginManager = new (PluginManager as any)(logger, '', []);
const pluginManager = new (DynamicPluginManager as any)(
logger,
[],
) as DynamicPluginManager;
const plugins: BaseDynamicPlugin[] = [
{
name: 'a-frontend-plugin',
@@ -504,7 +514,7 @@ describe('backend-plugin-manager', () => {
version: '0.0.0',
},
];
pluginManager.plugins = plugins;
(pluginManager as any)._plugins = plugins;
expect(pluginManager.backendPlugins()).toEqual([
{
name: 'a-backend-plugin',
@@ -522,6 +532,57 @@ describe('backend-plugin-manager', () => {
});
});
describe('frontendPlugins', () => {
it('should return only frontend plugins', async () => {
const logger = new MockedLogger();
const pluginManager = new (DynamicPluginManager as any)(
logger,
[],
) as DynamicPluginManager;
const plugins: BaseDynamicPlugin[] = [
{
name: 'a-frontend-plugin',
platform: 'web',
role: 'frontend-plugin',
version: '0.0.0',
},
{
name: 'a-frontend-module',
platform: 'web',
role: 'frontend-plugin-module',
version: '0.0.0',
},
{
name: 'a-backend-plugin',
platform: 'node',
role: 'backend-plugin',
version: '0.0.0',
},
{
name: 'a-backend-module',
platform: 'node',
role: 'backend-plugin-module',
version: '0.0.0',
},
];
(pluginManager as any)._plugins = plugins;
expect(pluginManager.frontendPlugins()).toEqual([
{
name: 'a-frontend-plugin',
platform: 'web',
role: 'frontend-plugin',
version: '0.0.0',
},
{
name: 'a-frontend-module',
platform: 'web',
role: 'frontend-plugin-module',
version: '0.0.0',
},
]);
});
});
describe('dynamicPluginsServiceFactory', () => {
const otherMockDir = createMockDirectory();
@@ -547,27 +608,29 @@ describe('backend-plugin-manager', () => {
'a-dynamic-plugin': {},
});
const fromConfigSpier = jest.spyOn(PluginManager, 'fromConfig');
const fromConfigSpier = jest.spyOn(DynamicPluginManager, 'create');
const applyConfigSpier = jest
.spyOn(PluginScanner.prototype as any, 'applyConfig')
.mockImplementation(() => {});
const scanRootSpier = jest
.spyOn(PluginScanner.prototype, 'scanRoot')
.mockImplementation(async () => [
{
location: url.pathToFileURL(
mockDir.resolve('dynamic-plugins-root/a-dynamic-plugin'),
),
manifest: {
name: 'test',
version: '0.0.0',
main: 'dist/index.cjs.js',
backstage: {
role: 'backend-plugin',
.mockImplementation(async () => ({
packages: [
{
location: url.pathToFileURL(
mockDir.resolve('dynamic-plugins-root/a-dynamic-plugin'),
),
manifest: {
name: 'test',
version: '0.0.0',
main: 'dist/index.cjs.js',
backstage: {
role: 'backend-plugin',
},
},
},
},
]);
],
}));
const mockedModuleLoader = {
logger,
bootstrap: jest.fn(),
@@ -15,10 +15,11 @@
*/
import { Config } from '@backstage/config';
import {
BackendPluginProvider,
DynamicPluginProvider,
BackendDynamicPlugin,
isBackendDynamicPluginInstaller,
DynamicPlugin,
FrontendDynamicPlugin,
} from './types';
import { ScannedPluginPackage } from '../scanner';
import { PluginScanner } from '../scanner/plugin-scanner';
@@ -44,24 +45,37 @@ import {
/**
* @public
*/
export class PluginManager implements BackendPluginProvider {
static async fromConfig(
config: Config,
logger: LoggerService,
preferAlpha: boolean = false,
moduleLoader: ModuleLoader = new CommonJSModuleLoader(logger),
): Promise<PluginManager> {
export interface DynamicPluginManagerOptions {
config: Config;
logger: LoggerService;
preferAlpha?: boolean;
moduleLoader?: ModuleLoader;
}
/**
* @public
*/
export class DynamicPluginManager implements DynamicPluginProvider {
static async create(
options: DynamicPluginManagerOptions,
): Promise<DynamicPluginManager> {
/* eslint-disable-next-line no-restricted-syntax */
const backstageRoot = findPaths(__dirname).targetRoot;
const scanner = new PluginScanner(
config,
logger,
const scanner = PluginScanner.create({
config: options.config,
logger: options.logger,
backstageRoot,
preferAlpha,
);
const scannedPlugins = await scanner.scanRoot();
preferAlpha: options.preferAlpha,
});
const scannedPlugins = (await scanner.scanRoot()).packages;
scanner.trackChanges();
const manager = new PluginManager(logger, scannedPlugins, moduleLoader);
const moduleLoader =
options.moduleLoader || new CommonJSModuleLoader(options.logger);
const manager = new DynamicPluginManager(
options.logger,
scannedPlugins,
moduleLoader,
);
const dynamicPluginsPaths = scannedPlugins.map(p =>
fs.realpathSync(
@@ -76,15 +90,15 @@ export class PluginManager implements BackendPluginProvider {
moduleLoader.bootstrap(backstageRoot, dynamicPluginsPaths);
scanner.subscribeToRootDirectoryChange(async () => {
manager._availablePackages = await scanner.scanRoot();
manager._availablePackages = (await scanner.scanRoot()).packages;
// TODO: do not store _scannedPlugins again, but instead store a diff of the changes
});
manager.plugins.push(...(await manager.loadPlugins()));
manager._plugins.push(...(await manager.loadPlugins()));
return manager;
}
readonly plugins: DynamicPlugin[];
private readonly _plugins: DynamicPlugin[];
private _availablePackages: ScannedPluginPackage[];
private constructor(
@@ -92,7 +106,7 @@ export class PluginManager implements BackendPluginProvider {
private packages: ScannedPluginPackage[],
private readonly moduleLoader: ModuleLoader,
) {
this.plugins = [];
this._plugins = [];
this._availablePackages = packages;
}
@@ -101,7 +115,7 @@ export class PluginManager implements BackendPluginProvider {
}
addBackendPlugin(plugin: BackendDynamicPlugin): void {
this.plugins.push(plugin);
this._plugins.push(plugin);
}
private async loadPlugins(): Promise<DynamicPlugin[]> {
@@ -182,16 +196,26 @@ export class PluginManager implements BackendPluginProvider {
}
backendPlugins(): BackendDynamicPlugin[] {
return this.plugins.filter(
return this._plugins.filter(
(p): p is BackendDynamicPlugin => p.platform === 'node',
);
}
frontendPlugins(): FrontendDynamicPlugin[] {
return this._plugins.filter(
(p): p is FrontendDynamicPlugin => p.platform === 'web',
);
}
plugins(): DynamicPlugin[] {
return this._plugins;
}
}
/**
* @public
*/
export const dynamicPluginsServiceRef = createServiceRef<BackendPluginProvider>(
export const dynamicPluginsServiceRef = createServiceRef<DynamicPluginProvider>(
{
id: 'core.dynamicplugins',
scope: 'root',
@@ -216,15 +240,12 @@ export const dynamicPluginsServiceFactory = createServiceFactory(
logger: coreServices.rootLogger,
},
async factory({ config, logger }) {
if (options?.moduleLoader) {
return await PluginManager.fromConfig(
config,
logger,
true,
options.moduleLoader(logger),
);
}
return await PluginManager.fromConfig(config, logger, true);
return await DynamicPluginManager.create({
config,
logger,
preferAlpha: true,
moduleLoader: options?.moduleLoader?.(logger),
});
},
}),
);
@@ -233,7 +254,7 @@ class DynamicPluginsEnabledFeatureDiscoveryService
implements FeatureDiscoveryService
{
constructor(
private readonly dynamicPlugins: BackendPluginProvider,
private readonly dynamicPlugins: DynamicPluginProvider,
private readonly featureDiscoveryService?: FeatureDiscoveryService,
) {}
@@ -67,6 +67,15 @@ export type LegacyPluginEnvironment = {
pluginProvider: BackendPluginProvider;
};
/**
* @public
*/
export interface DynamicPluginProvider
extends FrontendPluginProvider,
BackendPluginProvider {
plugins(): DynamicPlugin[];
}
/**
* @public
*/
@@ -74,6 +83,13 @@ export interface BackendPluginProvider {
backendPlugins(): BackendDynamicPlugin[];
}
/**
* @public
*/
export interface FrontendPluginProvider {
frontendPlugins(): FrontendDynamicPlugin[];
}
/**
* @public
*/
@@ -64,22 +64,23 @@ describe('plugin-scanner', () => {
unsubscribe: configUnsubscribe,
};
};
const pluginScanner = new PluginScanner(
const pluginScanner = PluginScanner.create({
config,
logger,
backstageRootDirectory,
false,
);
backstageRoot: backstageRootDirectory,
preferAlpha: false,
});
await pluginScanner.trackChanges();
expect(onConfigChange).toBeDefined();
let scannedPlugins: ScannedPluginPackage[] =
await pluginScanner.scanRoot();
let scannedPlugins: ScannedPluginPackage[] = (
await pluginScanner.scanRoot()
).packages;
expect(scannedPlugins).toEqual([]);
const rootDirectorySubscriber = jest.fn(async () => {
scannedPlugins = await pluginScanner.scanRoot();
scannedPlugins = (await pluginScanner.scanRoot()).packages;
});
pluginScanner.subscribeToRootDirectoryChange(rootDirectorySubscriber);
@@ -206,11 +206,11 @@ Please add '${mockDir.resolve(
const logger = new MockedLogger();
const backstageRoot = tc.backstageRoot ? tc.backstageRoot : '';
function toTest(): PluginScanner {
return new PluginScanner(
new ConfigReader(tc.config),
return PluginScanner.create({
config: new ConfigReader(tc.config),
logger,
backstageRoot,
);
});
}
if (tc.fileSystem) {
mockDir.setContent(tc.fileSystem);
@@ -609,8 +609,8 @@ Please add '${mockDir.resolve(
const logger = new MockedLogger();
const backstageRoot = mockDir.resolve('backstageRoot');
async function toTest(): Promise<ScannedPluginPackage[]> {
const pluginScanner = new PluginScanner(
new ConfigReader(
const pluginScanner = PluginScanner.create({
config: new ConfigReader(
tc.fileSystem
? {
dynamicPlugins: {
@@ -621,9 +621,9 @@ Please add '${mockDir.resolve(
),
logger,
backstageRoot,
tc.preferAlpha === undefined ? false : tc.preferAlpha,
);
return await pluginScanner.scanRoot();
preferAlpha: tc.preferAlpha,
});
return (await pluginScanner.scanRoot()).packages;
}
if (tc.fileSystem) {
mockDir.setContent(tc.fileSystem);
@@ -24,28 +24,39 @@ import debounce from 'lodash/debounce';
import { PackagePlatform, PackageRoles } from '@backstage/cli-node';
import { LoggerService } from '@backstage/backend-plugin-api';
export interface DynamicPluginScannerOptions {
config: Config;
backstageRoot: string;
logger: LoggerService;
preferAlpha?: boolean;
}
export interface ScanRootResponse {
packages: ScannedPluginPackage[];
}
export class PluginScanner {
private readonly logger: LoggerService;
private backstageRoot: string;
readonly #config: Config;
private _rootDirectory?: string;
private readonly preferAlpha: boolean;
private configUnsubscribe?: () => void;
private rootDirectoryWatcher?: chokidar.FSWatcher;
private subscribers: (() => void)[] = [];
constructor(
config: Config,
logger: LoggerService,
backstageRoot: string,
preferAlpha: boolean = false,
) {
this.backstageRoot = backstageRoot;
this.logger = logger;
this.preferAlpha = preferAlpha;
this.#config = config;
private constructor(
private readonly config: Config,
private readonly logger: LoggerService,
private readonly backstageRoot: string,
private readonly preferAlpha: boolean,
) {}
this.applyConfig();
static create(options: DynamicPluginScannerOptions): PluginScanner {
const scanner = new PluginScanner(
options.config,
options.logger,
options.backstageRoot,
options.preferAlpha || false,
);
scanner.applyConfig();
return scanner;
}
subscribeToRootDirectoryChange(subscriber: () => void) {
@@ -57,7 +68,7 @@ export class PluginScanner {
}
private applyConfig(): void | never {
const dynamicPlugins = this.#config.getOptional('dynamicPlugins');
const dynamicPlugins = this.config.getOptional('dynamicPlugins');
if (!dynamicPlugins) {
this.logger.info("'dynamicPlugins' config entry not found.");
this._rootDirectory = undefined;
@@ -114,9 +125,9 @@ export class PluginScanner {
this._rootDirectory = dynamicPluginsRootPath;
}
async scanRoot(): Promise<ScannedPluginPackage[]> {
async scanRoot(): Promise<ScanRootResponse> {
if (!this._rootDirectory) {
return [];
return { packages: [] };
}
const dynamicPluginsLocation = this._rootDirectory;
@@ -189,7 +200,7 @@ export class PluginScanner {
scannedPlugins.push(scannedPlugin);
}
return scannedPlugins;
return { packages: scannedPlugins };
}
private async scanDir(pluginHome: string): Promise<ScannedPluginPackage> {
@@ -263,30 +274,28 @@ export class PluginScanner {
};
await setupRootDirectoryWatcher();
if (this.#config.subscribe) {
const { unsubscribe } = this.#config.subscribe(
async (): Promise<void> => {
const oldRootDirectory = this._rootDirectory;
try {
this.applyConfig();
} catch (e) {
this.logger.error(
'failed to apply new config for dynamic plugins',
e,
);
if (this.config.subscribe) {
const { unsubscribe } = this.config.subscribe(async (): Promise<void> => {
const oldRootDirectory = this._rootDirectory;
try {
this.applyConfig();
} catch (e) {
this.logger.error(
'failed to apply new config for dynamic plugins',
e,
);
}
if (oldRootDirectory !== this._rootDirectory) {
this.logger.info(
`rootDirectory changed in Config from '${oldRootDirectory}' to '${this._rootDirectory}'`,
);
this.subscribers.forEach(s => s());
if (this.rootDirectoryWatcher) {
await this.rootDirectoryWatcher.close();
}
if (oldRootDirectory !== this._rootDirectory) {
this.logger.info(
`rootDirectory changed in Config from '${oldRootDirectory}' to '${this._rootDirectory}'`,
);
this.subscribers.forEach(s => s());
if (this.rootDirectoryWatcher) {
await this.rootDirectoryWatcher.close();
}
await setupRootDirectoryWatcher();
}
},
);
await setupRootDirectoryWatcher();
}
});
this.configUnsubscribe = unsubscribe;
}
}