Merge pull request #19787 from backstage/mob/package-discovery

cli,frontend-app-api: add experimental support for frontend package discovery
This commit is contained in:
Patrik Oldsberg
2023-09-06 13:00:19 +02:00
committed by GitHub
19 changed files with 528 additions and 18 deletions
+8
View File
@@ -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 });
+1 -1
View File
@@ -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'],
@@ -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<string[]> {
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];
}
+26 -15
View File
@@ -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: {
+2
View File
@@ -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 = {