diff --git a/.changeset/light-drinks-rush.md b/.changeset/light-drinks-rush.md
new file mode 100644
index 0000000000..08383ab089
--- /dev/null
+++ b/.changeset/light-drinks-rush.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Add experimental support for frontend package discovery.
diff --git a/packages/app-next-example-plugin/.eslintrc.js b/packages/app-next-example-plugin/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/packages/app-next-example-plugin/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/packages/app-next-example-plugin/api-report.md b/packages/app-next-example-plugin/api-report.md
new file mode 100644
index 0000000000..08e283b879
--- /dev/null
+++ b/packages/app-next-example-plugin/api-report.md
@@ -0,0 +1,16 @@
+## API Report File for "app-next-example-plugin"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+import { BackstagePlugin } from '@backstage/frontend-plugin-api';
+import { default as React_2 } from 'react';
+
+// @public (undocumented)
+export const examplePlugin: BackstagePlugin;
+
+// @public (undocumented)
+export const ExampleSidebarItem: () => React_2.JSX.Element;
+
+// (No @packageDocumentation comment for this package)
+```
diff --git a/packages/app-next-example-plugin/package.json b/packages/app-next-example-plugin/package.json
new file mode 100644
index 0000000000..62eeaba08b
--- /dev/null
+++ b/packages/app-next-example-plugin/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "app-next-example-plugin",
+ "description": "Backstage internal example plugin",
+ "version": "0.0.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "private": true,
+ "backstage": {
+ "role": "frontend-plugin"
+ },
+ "homepage": "https://backstage.io",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/backstage/backstage",
+ "directory": "packages/app-next-example-plugin"
+ },
+ "keywords": [
+ "backstage"
+ ],
+ "license": "Apache-2.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "scripts": {
+ "build": "backstage-cli package build",
+ "start": "backstage-cli package start",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack",
+ "clean": "backstage-cli package clean"
+ },
+ "dependencies": {
+ "@backstage/core-components": "workspace:^",
+ "@backstage/frontend-plugin-api": "workspace:^",
+ "@material-ui/icons": "^4.9.1"
+ },
+ "peerDependencies": {
+ "react": "^16.13.1 || ^17.0.0",
+ "react-dom": "^16.13.1 || ^17.0.0",
+ "react-router-dom": "6.0.0-beta.0 || ^6.3.0"
+ },
+ "devDependencies": {
+ "@backstage/cli": "workspace:^",
+ "cross-fetch": "^3.1.5",
+ "msw": "^1.0.0"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/packages/app-next-example-plugin/src/Component.tsx b/packages/app-next-example-plugin/src/Component.tsx
new file mode 100644
index 0000000000..e90be162ed
--- /dev/null
+++ b/packages/app-next-example-plugin/src/Component.tsx
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+
+export const Component = () => (
+
+
SHIP IT!
+
+);
diff --git a/packages/app-next-example-plugin/src/ExampleSidebarItem.tsx b/packages/app-next-example-plugin/src/ExampleSidebarItem.tsx
new file mode 100644
index 0000000000..20e84f1933
--- /dev/null
+++ b/packages/app-next-example-plugin/src/ExampleSidebarItem.tsx
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { SidebarItem } from '@backstage/core-components';
+import SaveIcon from '@material-ui/icons/Save';
+
+/** @public */
+export const ExampleSidebarItem = () => (
+
+);
diff --git a/packages/app-next-example-plugin/src/index.ts b/packages/app-next-example-plugin/src/index.ts
new file mode 100644
index 0000000000..dae1eea379
--- /dev/null
+++ b/packages/app-next-example-plugin/src/index.ts
@@ -0,0 +1,20 @@
+/*
+ * 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.
+ */
+
+export { examplePlugin } from './plugin';
+
+// TODO: This should be an extension created & exported in the `plugin.tsx`
+export { ExampleSidebarItem } from './ExampleSidebarItem';
diff --git a/packages/app-next-example-plugin/src/plugin.tsx b/packages/app-next-example-plugin/src/plugin.tsx
new file mode 100644
index 0000000000..5b5adeab2f
--- /dev/null
+++ b/packages/app-next-example-plugin/src/plugin.tsx
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import {
+ createPageExtension,
+ createPlugin,
+} from '@backstage/frontend-plugin-api';
+
+export const ExamplePage = createPageExtension({
+ id: 'example.page',
+ defaultPath: '/example',
+ component: () => import('./Component').then(m => ),
+});
+
+/** @public */
+export const examplePlugin = createPlugin({
+ id: 'example',
+ extensions: [ExamplePage],
+});
diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml
index 3d29f19c13..c94d900b21 100644
--- a/packages/app-next/app-config.yaml
+++ b/packages/app-next/app-config.yaml
@@ -1,5 +1,6 @@
app:
- packages: 'all' # ✨
+ experimental:
+ packages: 'all' # ✨
# scmAuthExtension: >-
diff --git a/packages/app-next/package.json b/packages/app-next/package.json
index f724699227..2775fe8208 100644
--- a/packages/app-next/package.json
+++ b/packages/app-next/package.json
@@ -86,6 +86,7 @@
"@roadiehq/backstage-plugin-github-insights": "^2.0.5",
"@roadiehq/backstage-plugin-github-pull-requests": "^2.2.7",
"@roadiehq/backstage-plugin-travis-ci": "^2.0.5",
+ "app-next-example-plugin": "workspace:^",
"history": "^5.0.0",
"lodash": "^4.17.21",
"prop-types": "^15.7.2",
diff --git a/packages/cli/src/lib/bundler/bundle.ts b/packages/cli/src/lib/bundler/bundle.ts
index 7c45528c5c..11e18320f1 100644
--- a/packages/cli/src/lib/bundler/bundle.ts
+++ b/packages/cli/src/lib/bundler/bundle.ts
@@ -27,6 +27,7 @@ import { createConfig, resolveBaseUrl } from './config';
import { BuildOptions } from './types';
import { resolveBundlingPaths } from './paths';
import chalk from 'chalk';
+import { createDetectedModulesEntryPoint } from './packageDetection';
// TODO(Rugvip): Limits from CRA, we might want to tweak these though.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
@@ -40,12 +41,19 @@ export async function buildBundle(options: BuildOptions) {
const { statsJsonEnabled, schema: configSchema } = options;
const paths = resolveBundlingPaths(options);
+
+ const detectedModulesEntryPoint = await createDetectedModulesEntryPoint({
+ config: options.fullConfig,
+ targetPath: paths.targetPath,
+ });
+
const config = await createConfig(paths, {
...options,
checksEnabled: false,
isDev: false,
baseUrl: resolveBaseUrl(options.frontendConfig),
getFrontendAppConfigs: () => options.frontendAppConfigs,
+ additionalEntryPoints: detectedModulesEntryPoint,
});
const isCi = yn(process.env.CI, { default: false });
diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts
index 2f09a66e18..c9238e7e48 100644
--- a/packages/cli/src/lib/bundler/config.ts
+++ b/packages/cli/src/lib/bundler/config.ts
@@ -161,7 +161,7 @@ export async function createConfig(
},
devtool: isDev ? 'eval-cheap-module-source-map' : 'source-map',
context: paths.targetPath,
- entry: [paths.targetEntry],
+ entry: [...(options.additionalEntryPoints ?? []), paths.targetEntry],
resolve: {
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx', '.json', '.wasm'],
mainFields: ['browser', 'module', 'main'],
diff --git a/packages/cli/src/lib/bundler/packageDetection.ts b/packages/cli/src/lib/bundler/packageDetection.ts
new file mode 100644
index 0000000000..293ef74f81
--- /dev/null
+++ b/packages/cli/src/lib/bundler/packageDetection.ts
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2020 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 { BackstagePackageJson } from '@backstage/cli-node';
+import { Config } from '@backstage/config';
+import chokidar from 'chokidar';
+import fs from 'fs-extra';
+import { join as joinPath, resolve as resolvePath } from 'path';
+import { paths as cliPaths } from '../paths';
+
+const DETECTED_MODULES_MODULE_NAME = '__backstage-autodetected-plugins__';
+
+interface PackageDetectionConfig {
+ include?: string[];
+ exclude?: string[];
+}
+
+function readPackageDetectionConfig(
+ config: Config,
+): PackageDetectionConfig | undefined {
+ const packages = config.getOptional('app.experimental.packages');
+ if (packages === undefined || packages === null) {
+ return undefined;
+ }
+
+ if (typeof packages === 'string') {
+ if (packages !== 'all') {
+ throw new Error(
+ `Invalid app.experimental.packages mode, got '${packages}', expected 'all'`,
+ );
+ }
+ return {};
+ }
+
+ return {
+ include: config.getOptionalStringArray('app.experimental.packages.include'),
+ exclude: config.getOptionalStringArray('app.experimental.packages.exclude'),
+ };
+}
+
+async function detectPackages(
+ targetPath: string,
+ { include, exclude }: PackageDetectionConfig,
+) {
+ const pkg: BackstagePackageJson = await fs.readJson(
+ resolvePath(targetPath, 'package.json'),
+ );
+
+ return Object.keys(pkg.dependencies ?? {})
+ .flatMap(depName => {
+ const depPackageJson: BackstagePackageJson = require(require.resolve(
+ `${depName}/package.json`,
+ { paths: [targetPath] },
+ ));
+ if (
+ ['frontend-plugin', 'frontend-plugin-module'].includes(
+ depPackageJson.backstage?.role ?? '',
+ )
+ ) {
+ return [depName];
+ }
+ return [];
+ })
+ .filter(name => {
+ if (exclude?.includes(name)) {
+ return false;
+ }
+ if (include && !include.includes(name)) {
+ return false;
+ }
+ return true;
+ });
+}
+
+async function writeDetectedPackagesModule(packageNames: string[]) {
+ const requirePackageScript = packageNames
+ ?.map(pkg => `{name: '${pkg}', module: require('${pkg}')}`)
+ .join(',');
+
+ await fs.writeFile(
+ joinPath(
+ cliPaths.targetRoot,
+ 'node_modules',
+ `${DETECTED_MODULES_MODULE_NAME}.js`,
+ ),
+ `window['__@backstage/discovered__'] = { modules: [${requirePackageScript}] };`,
+ );
+}
+
+export async function createDetectedModulesEntryPoint(options: {
+ config: Config;
+ targetPath: string;
+ watch?: () => void;
+}): Promise {
+ const { config, watch, targetPath } = options;
+
+ const detectionConfig = readPackageDetectionConfig(config);
+ if (!detectionConfig) {
+ return [];
+ }
+
+ if (watch) {
+ const watcher = chokidar.watch(resolvePath(targetPath, 'package.json'));
+
+ watcher.on('change', async () => {
+ await writeDetectedPackagesModule(
+ await detectPackages(targetPath, detectionConfig),
+ );
+ watch();
+ });
+ }
+
+ await writeDetectedPackagesModule(
+ await detectPackages(targetPath, detectionConfig),
+ );
+
+ return [DETECTED_MODULES_MODULE_NAME];
+}
diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts
index 74c6b2a53c..78f6bb7823 100644
--- a/packages/cli/src/lib/bundler/server.ts
+++ b/packages/cli/src/lib/bundler/server.ts
@@ -14,27 +14,31 @@
* limitations under the License.
*/
+import { PackageGraph } from '@backstage/cli-node';
+import { AppConfig } from '@backstage/config';
+import chalk from 'chalk';
import fs from 'fs-extra';
+import uniq from 'lodash/uniq';
+import openBrowser from 'react-dev-utils/openBrowser';
import webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server';
-import openBrowser from 'react-dev-utils/openBrowser';
-import uniq from 'lodash/uniq';
-import { createConfig, resolveBaseUrl } from './config';
-import { ServeOptions } from './types';
-import { resolveBundlingPaths } from './paths';
-import { paths as libPaths } from '../../lib/paths';
-import { loadCliConfig } from '../config';
-import chalk from 'chalk';
-import { AppConfig } from '@backstage/config';
-import { PackageGraph } from '@backstage/cli-node';
-import { Lockfile } from '../versioning';
import {
forbiddenDuplicatesFilter,
includedFilter,
} from '../../commands/versions/lint';
+import { paths as libPaths } from '../../lib/paths';
+import { loadCliConfig } from '../config';
+import { Lockfile } from '../versioning';
+import { createConfig, resolveBaseUrl } from './config';
+import { createDetectedModulesEntryPoint } from './packageDetection';
+import { resolveBundlingPaths } from './paths';
+import { ServeOptions } from './types';
export async function serveBundle(options: ServeOptions) {
+ const paths = resolveBundlingPaths(options);
+ const targetPkg = await fs.readJson(paths.targetPackageJson);
+
if (options.verifyVersions) {
const lockfile = await Lockfile.load(
libPaths.resolveTargetRoot('yarn.lock'),
@@ -115,10 +119,16 @@ export async function serveBundle(options: ServeOptions) {
Number(url.port) ||
(url.protocol === 'https:' ? 443 : 80);
- const paths = resolveBundlingPaths(options);
- const pkgPath = paths.targetPackageJson;
- const pkg = await fs.readJson(pkgPath);
+ const detectedModulesEntryPoint = await createDetectedModulesEntryPoint({
+ config: fullConfig,
+ targetPath: paths.targetPath,
+ watch() {
+ server?.invalidate();
+ },
+ });
+
const config = await createConfig(paths, {
+ ...options,
checksEnabled: options.checksEnabled,
isDev: true,
baseUrl: url,
@@ -126,6 +136,7 @@ export async function serveBundle(options: ServeOptions) {
getFrontendAppConfigs: () => {
return latestFrontendAppConfigs;
},
+ additionalEntryPoints: detectedModulesEntryPoint,
});
const compiler = webpack(config);
@@ -160,7 +171,7 @@ export async function serveBundle(options: ServeOptions) {
: false,
host,
port,
- proxy: pkg.proxy,
+ proxy: targetPkg.proxy,
// When the dev server is behind a proxy, the host and public hostname differ
allowedHosts: [url.hostname],
client: {
diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts
index 69e75b113d..414414fa81 100644
--- a/packages/cli/src/lib/bundler/types.ts
+++ b/packages/cli/src/lib/bundler/types.ts
@@ -25,6 +25,7 @@ export type BundlingOptions = {
getFrontendAppConfigs(): AppConfig[];
baseUrl: URL;
parallelism?: number;
+ additionalEntryPoints?: string[];
};
export type ServeOptions = BundlingPathsOptions & {
@@ -41,6 +42,7 @@ export type BuildOptions = BundlingPathsOptions & {
schema?: ConfigSchema;
frontendConfig: Config;
frontendAppConfigs: AppConfig[];
+ fullConfig: Config;
};
export type BackendBundlingOptions = {
diff --git a/packages/frontend-app-api/src/createApp.tsx b/packages/frontend-app-api/src/createApp.tsx
index 728610679d..c50b507642 100644
--- a/packages/frontend-app-api/src/createApp.tsx
+++ b/packages/frontend-app-api/src/createApp.tsx
@@ -32,6 +32,7 @@ import {
} from './wiring/parameters';
import { RoutingProvider } from './routing/RoutingContext';
import { RouteRef } from '@backstage/core-plugin-api';
+import { getAvailablePlugins } from './wiring/discovery';
/** @public */
export function createApp(options: { plugins: BackstagePlugin[] }): {
@@ -40,11 +41,12 @@ export function createApp(options: { plugins: BackstagePlugin[] }): {
const appConfig = ConfigReader.fromConfigs(process.env.APP_CONFIG as any);
const builtinExtensions = [CoreRouter];
+ const discoveredPlugins = getAvailablePlugins();
// pull in default extension instance from discovered packages
// apply config to adjust default extension instances and add more
const extensionParams = mergeExtensionParameters({
- sources: options.plugins,
+ sources: [...options.plugins, ...discoveredPlugins],
builtinExtensions,
parameters: readAppExtensionParameters(appConfig),
});
diff --git a/packages/frontend-app-api/src/wiring/discovery.test.ts b/packages/frontend-app-api/src/wiring/discovery.test.ts
new file mode 100644
index 0000000000..a93f853c1b
--- /dev/null
+++ b/packages/frontend-app-api/src/wiring/discovery.test.ts
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createPlugin } from '@backstage/frontend-plugin-api';
+import { getAvailablePlugins } from './discovery';
+
+const globalSpy = jest.fn();
+Object.defineProperty(global, '__@backstage/discovered__', {
+ get: globalSpy,
+});
+
+describe('getAvailablePlugins', () => {
+ afterEach(jest.resetAllMocks);
+
+ it('should discover nothing with undefined global', () => {
+ expect(getAvailablePlugins()).toEqual([]);
+ });
+
+ it('should discover nothing with empty global', () => {
+ globalSpy.mockReturnValue({
+ modules: [],
+ });
+ expect(getAvailablePlugins()).toEqual([]);
+ });
+
+ it('should discover a plugin', () => {
+ const testPlugin = createPlugin({ id: 'test' });
+ globalSpy.mockReturnValue({
+ modules: [
+ {
+ module: {
+ testPlugin,
+ },
+ },
+ ],
+ });
+ expect(getAvailablePlugins()).toEqual([testPlugin]);
+ });
+
+ it('should ignore garbage', () => {
+ const testPlugin = createPlugin({ id: 'test' });
+ globalSpy.mockReturnValue({
+ modules: [
+ {
+ module: {
+ testPlugin,
+ a: 'a',
+ b: null,
+ c: undefined,
+ d: Symbol('wat'),
+ e: () => {},
+ f: [],
+ g: {},
+ h: class {},
+ i: NaN,
+ j: Infinity,
+ k: -Infinity,
+ l: new Date(),
+ m: new RegExp('wat'),
+ n: new Error('wat'),
+ o: new Map(),
+ p: new Set(),
+ q: new WeakMap(),
+ r: new WeakSet(),
+ s: new ArrayBuffer(1),
+ t: new DataView(new ArrayBuffer(1)),
+ u: false,
+ v: true,
+ w: 0,
+ x: 1,
+ y: -1,
+ z: '',
+ },
+ },
+ ],
+ });
+ expect(getAvailablePlugins()).toEqual([testPlugin]);
+ });
+
+ it('should discover multiple plugins', () => {
+ const test1Plugin = createPlugin({ id: 'test1' });
+ const test2Plugin = createPlugin({ id: 'test2' });
+ const test3Plugin = createPlugin({ id: 'test3' });
+ globalSpy.mockReturnValue({
+ modules: [
+ {
+ module: {
+ test1Plugin,
+ test2Plugin,
+ },
+ },
+ {
+ module: {
+ test3Plugin,
+ },
+ },
+ ],
+ });
+ expect(getAvailablePlugins()).toEqual([
+ test1Plugin,
+ test2Plugin,
+ test3Plugin,
+ ]);
+ });
+});
diff --git a/packages/frontend-app-api/src/wiring/discovery.ts b/packages/frontend-app-api/src/wiring/discovery.ts
new file mode 100644
index 0000000000..7b0f4a50e4
--- /dev/null
+++ b/packages/frontend-app-api/src/wiring/discovery.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { BackstagePlugin } from '@backstage/frontend-plugin-api';
+
+interface DiscoveryGlobal {
+ modules: Array<{ name: string; module: object }>;
+}
+
+/**
+ * @public
+ */
+export function getAvailablePlugins(): BackstagePlugin[] {
+ const discovered = (
+ window as { '__@backstage/discovered__'?: DiscoveryGlobal }
+ )['__@backstage/discovered__'];
+
+ return (
+ discovered?.modules.flatMap(({ module: mod }) =>
+ Object.values(mod).filter(isBackstagePlugin),
+ ) ?? []
+ );
+}
+
+function isBackstagePlugin(obj: unknown): obj is BackstagePlugin {
+ if (obj !== null && typeof obj === 'object' && '$$type' in obj) {
+ return obj.$$type === 'backstage-plugin';
+ }
+ return false;
+}
diff --git a/yarn.lock b/yarn.lock
index 2432d5be6e..008da25b3d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -19749,6 +19749,23 @@ __metadata:
languageName: node
linkType: hard
+"app-next-example-plugin@workspace:^, app-next-example-plugin@workspace:packages/app-next-example-plugin":
+ version: 0.0.0-use.local
+ resolution: "app-next-example-plugin@workspace:packages/app-next-example-plugin"
+ dependencies:
+ "@backstage/cli": "workspace:^"
+ "@backstage/core-components": "workspace:^"
+ "@backstage/frontend-plugin-api": "workspace:^"
+ "@material-ui/icons": ^4.9.1
+ cross-fetch: ^3.1.5
+ msw: ^1.0.0
+ peerDependencies:
+ react: ^16.13.1 || ^17.0.0
+ react-dom: ^16.13.1 || ^17.0.0
+ react-router-dom: 6.0.0-beta.0 || ^6.3.0
+ languageName: unknown
+ linkType: soft
+
"append-field@npm:^1.0.0":
version: 1.0.0
resolution: "append-field@npm:1.0.0"
@@ -25634,6 +25651,7 @@ __metadata:
"@types/react": "*"
"@types/react-dom": "*"
"@types/zen-observable": ^0.8.0
+ app-next-example-plugin: "workspace:^"
cross-env: ^7.0.0
cypress: ^10.0.0
eslint-plugin-cypress: ^2.10.3