diff --git a/.changeset/loud-garlics-press.md b/.changeset/loud-garlics-press.md new file mode 100644 index 0000000000..28aee20619 --- /dev/null +++ b/.changeset/loud-garlics-press.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Reload the frontend when app config changes diff --git a/packages/cli/src/commands/start/startFrontend.ts b/packages/cli/src/commands/start/startFrontend.ts index 00f7818b41..a809473788 100644 --- a/packages/cli/src/commands/start/startFrontend.ts +++ b/packages/cli/src/commands/start/startFrontend.ts @@ -14,15 +14,7 @@ * limitations under the License. */ -import fs from 'fs-extra'; -import chalk from 'chalk'; -import uniq from 'lodash/uniq'; import { serveBundle } from '../../lib/bundler'; -import { loadCliConfig } from '../../lib/config'; -import { PackageGraph } from '@backstage/cli-node'; -import { Lockfile } from '../../lib/versioning'; -import { forbiddenDuplicatesFilter, includedFilter } from '../versions/lint'; -import { paths } from '../../lib/paths'; interface StartAppOptions { verifyVersions?: boolean; @@ -32,94 +24,12 @@ interface StartAppOptions { configPaths: string[]; } -function checkReactVersion() { - try { - // Make sure we're looking at the root of the target repo - const reactPkgPath = require.resolve('react/package.json', { - paths: [paths.targetRoot], - }); - const reactPkg = require(reactPkgPath); - if (reactPkg.version.startsWith('16.')) { - console.log( - chalk.yellow( - ` -⚠️ ⚠️ -⚠️ You are using React version 16, which is deprecated for use in Backstage. ⚠️ -⚠️ Please upgrade to React 17 by updating your packages/app dependencies. ⚠️ -⚠️ ⚠️ -`, - ), - ); - } - } catch { - /* ignored */ - } -} - export async function startFrontend(options: StartAppOptions) { - if (options.verifyVersions) { - const lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock')); - const result = lockfile.analyze({ - filter: includedFilter, - localPackages: PackageGraph.fromPackages( - await PackageGraph.listTargetPackages(), - ), - }); - const problemPackages = [...result.newVersions, ...result.newRanges] - .map(({ name }) => name) - .filter(forbiddenDuplicatesFilter); - - if (problemPackages.length > 1) { - console.log( - chalk.yellow( - `⚠️ Some of the following packages may be outdated or have duplicate installations: - - ${uniq(problemPackages).join(', ')} - `, - ), - ); - console.log( - chalk.yellow( - `⚠️ This can be resolved using the following command: - - yarn backstage-cli versions:check --fix - `, - ), - ); - } - } - - checkReactVersion(); - - const { name } = await fs.readJson(paths.resolveTarget('package.json')); - const config = await loadCliConfig({ - args: options.configPaths, - fromPackage: name, - withFilteredKeys: true, - }); - - const appBaseUrl = config.frontendConfig.getString('app.baseUrl'); - const backendBaseUrl = config.frontendConfig.getString('backend.baseUrl'); - if (appBaseUrl === backendBaseUrl) { - console.log( - chalk.yellow( - `⚠️ Conflict between app baseUrl and backend baseUrl: - - app.baseUrl: ${appBaseUrl} - backend.baseUrl: ${backendBaseUrl} - - Must have unique hostname and/or ports. - - This can be resolved by changing app.baseUrl and backend.baseUrl to point to their respective local development ports. -`, - ), - ); - } - const waitForExit = await serveBundle({ entry: options.entry, checksEnabled: options.checksEnabled, - ...config, + configPaths: options.configPaths, + verifyVersions: options.verifyVersions, }); await waitForExit(); diff --git a/packages/cli/src/lib/bundler/bundle.ts b/packages/cli/src/lib/bundler/bundle.ts index b26c2f9f0d..7c45528c5c 100644 --- a/packages/cli/src/lib/bundler/bundle.ts +++ b/packages/cli/src/lib/bundler/bundle.ts @@ -45,6 +45,7 @@ export async function buildBundle(options: BuildOptions) { checksEnabled: false, isDev: false, baseUrl: resolveBaseUrl(options.frontendConfig), + getFrontendAppConfigs: () => options.frontendAppConfigs, }); 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 94b2a1c561..2f09a66e18 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -117,12 +117,6 @@ export async function createConfig( }), ); - plugins.push( - new webpack.EnvironmentPlugin({ - APP_CONFIG: options.frontendAppConfigs, - }), - ); - plugins.push( new HtmlWebpackPlugin({ template: paths.targetHtml, @@ -137,6 +131,10 @@ export async function createConfig( plugins.push( new webpack.DefinePlugin({ 'process.env.BUILD_INFO': JSON.stringify(buildInfo), + 'process.env.APP_CONFIG': webpack.DefinePlugin.runtimeValue( + () => JSON.stringify(options.getFrontendAppConfigs()), + true, + ), }), ); diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index cef55d3265..74c6b2a53c 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -18,17 +18,100 @@ import fs from 'fs-extra'; 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'; export async function serveBundle(options: ServeOptions) { - const url = resolveBaseUrl(options.frontendConfig); + if (options.verifyVersions) { + const lockfile = await Lockfile.load( + libPaths.resolveTargetRoot('yarn.lock'), + ); + const result = lockfile.analyze({ + filter: includedFilter, + localPackages: PackageGraph.fromPackages( + await PackageGraph.listTargetPackages(), + ), + }); + const problemPackages = [...result.newVersions, ...result.newRanges] + .map(({ name }) => name) + .filter(forbiddenDuplicatesFilter); + + if (problemPackages.length > 1) { + console.log( + chalk.yellow( + `⚠️ Some of the following packages may be outdated or have duplicate installations: + + ${uniq(problemPackages).join(', ')} + `, + ), + ); + console.log( + chalk.yellow( + `⚠️ This can be resolved using the following command: + + yarn backstage-cli versions:check --fix + `, + ), + ); + } + } + + checkReactVersion(); + + const { name } = await fs.readJson(libPaths.resolveTarget('package.json')); + + let server: WebpackDevServer | undefined = undefined; + let latestFrontendAppConfigs: AppConfig[] = []; + + const cliConfig = await loadCliConfig({ + args: options.configPaths, + fromPackage: name, + withFilteredKeys: true, + watch(appConfigs) { + latestFrontendAppConfigs = appConfigs; + server?.invalidate(); + }, + }); + latestFrontendAppConfigs = cliConfig.frontendAppConfigs; + + const appBaseUrl = cliConfig.frontendConfig.getString('app.baseUrl'); + const backendBaseUrl = cliConfig.frontendConfig.getString('backend.baseUrl'); + if (appBaseUrl === backendBaseUrl) { + console.log( + chalk.yellow( + `⚠️ Conflict between app baseUrl and backend baseUrl: + + app.baseUrl: ${appBaseUrl} + backend.baseUrl: ${backendBaseUrl} + + Must have unique hostname and/or ports. + + This can be resolved by changing app.baseUrl and backend.baseUrl to point to their respective local development ports. +`, + ), + ); + } + + const { frontendConfig, fullConfig } = cliConfig; + const url = resolveBaseUrl(frontendConfig); const host = - options.frontendConfig.getOptionalString('app.listen.host') || url.hostname; + frontendConfig.getOptionalString('app.listen.host') || url.hostname; const port = - options.frontendConfig.getOptionalNumber('app.listen.port') || + frontendConfig.getOptionalNumber('app.listen.port') || Number(url.port) || (url.protocol === 'https:' ? 443 : 80); @@ -36,14 +119,18 @@ export async function serveBundle(options: ServeOptions) { const pkgPath = paths.targetPackageJson; const pkg = await fs.readJson(pkgPath); const config = await createConfig(paths, { - ...options, + checksEnabled: options.checksEnabled, isDev: true, baseUrl: url, + frontendConfig, + getFrontendAppConfigs: () => { + return latestFrontendAppConfigs; + }, }); const compiler = webpack(config); - const server = new WebpackDevServer( + server = new WebpackDevServer( { hot: !process.env.CI, devMiddleware: { @@ -67,8 +154,8 @@ export async function serveBundle(options: ServeOptions) { https: url.protocol === 'https:' ? { - cert: options.fullConfig.getString('app.https.certificate.cert'), - key: options.fullConfig.getString('app.https.certificate.key'), + cert: fullConfig.getString('app.https.certificate.cert'), + key: fullConfig.getString('app.https.certificate.key'), } : false, host, @@ -84,7 +171,7 @@ export async function serveBundle(options: ServeOptions) { ); await new Promise((resolve, reject) => { - server.startCallback((err?: Error) => { + server?.startCallback((err?: Error) => { if (err) { reject(err); return; @@ -98,7 +185,7 @@ export async function serveBundle(options: ServeOptions) { const waitForExit = async () => { for (const signal of ['SIGINT', 'SIGTERM'] as const) { process.on(signal, () => { - server.close(); + server?.close(); // exit instead of resolve. The process is shutting down and resolving a promise here logs an error process.exit(); }); @@ -110,3 +197,27 @@ export async function serveBundle(options: ServeOptions) { return waitForExit; } + +function checkReactVersion() { + try { + // Make sure we're looking at the root of the target repo + const reactPkgPath = require.resolve('react/package.json', { + paths: [libPaths.targetRoot], + }); + const reactPkg = require(reactPkgPath); + if (reactPkg.version.startsWith('16.')) { + console.log( + chalk.yellow( + ` +⚠️ ⚠️ +⚠️ You are using React version 16, which is deprecated for use in Backstage. ⚠️ +⚠️ Please upgrade to React 17 by updating your packages/app dependencies. ⚠️ +⚠️ ⚠️ +`, + ), + ); + } + } catch { + /* ignored */ + } +} diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index 64916e349c..69e75b113d 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -22,16 +22,15 @@ export type BundlingOptions = { checksEnabled: boolean; isDev: boolean; frontendConfig: Config; - frontendAppConfigs: AppConfig[]; + getFrontendAppConfigs(): AppConfig[]; baseUrl: URL; parallelism?: number; }; export type ServeOptions = BundlingPathsOptions & { checksEnabled: boolean; - frontendConfig: Config; - frontendAppConfigs: AppConfig[]; - fullConfig: Config; + configPaths: string[]; + verifyVersions?: boolean; }; export type BuildOptions = BundlingPathsOptions & { diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index aaf3fcfcb9..0c657b640e 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -19,7 +19,7 @@ import { loadConfig, loadConfigSchema, } from '@backstage/config-loader'; -import { ConfigReader } from '@backstage/config'; +import { AppConfig, ConfigReader } from '@backstage/config'; import { paths } from './paths'; import { isValidUrl } from './urls'; import { getPackages } from '@manypkg/get-packages'; @@ -33,6 +33,7 @@ type Options = { withDeprecatedKeys?: boolean; fullVisibility?: boolean; strict?: boolean; + watch?: (newFrontendAppConfigs: AppConfig[]) => void; }; export async function loadCliConfig(options: Options) { @@ -80,6 +81,19 @@ export async function loadCliConfig(options: Options) { : undefined, configRoot: paths.targetRoot, configTargets: configTargets, + watch: options.watch && { + onChange(newAppConfigs) { + const newFrontendAppConfigs = schema.process(newAppConfigs, { + visibility: options.fullVisibility + ? ['frontend', 'backend', 'secret'] + : ['frontend'], + withFilteredKeys: options.withFilteredKeys, + withDeprecatedKeys: options.withDeprecatedKeys, + ignoreSchemaErrors: !options.strict, + }); + options.watch?.(newFrontendAppConfigs); + }, + }, }); // printing to stderr to not clobber stdout in case the cli command