From 708294012180f435dd61c87a827b752b6f3fb974 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Wed, 17 Jul 2024 18:01:01 +0100 Subject: [PATCH 1/9] cli: add support for frontend-dynamic-container role in yarn start Signed-off-by: MT Lewis --- .../cli/src/commands/build/buildFrontend.ts | 27 +----------- packages/cli/src/commands/start/command.ts | 8 ++++ .../cli/src/commands/start/startFrontend.ts | 11 ++++- packages/cli/src/lib/bundler/index.ts | 1 + .../cli/src/lib/bundler/moduleFederation.ts | 44 +++++++++++++++++++ packages/cli/src/lib/bundler/types.ts | 1 + 6 files changed, 65 insertions(+), 27 deletions(-) create mode 100644 packages/cli/src/lib/bundler/moduleFederation.ts diff --git a/packages/cli/src/commands/build/buildFrontend.ts b/packages/cli/src/commands/build/buildFrontend.ts index 62da74e6d3..f29e0bdd38 100644 --- a/packages/cli/src/commands/build/buildFrontend.ts +++ b/packages/cli/src/commands/build/buildFrontend.ts @@ -16,11 +16,9 @@ import fs from 'fs-extra'; import { resolve as resolvePath } from 'path'; -import { buildBundle } from '../../lib/bundler'; +import { buildBundle, getModuleFederationOptions } from '../../lib/bundler'; import { getEnvironmentParallelism } from '../../lib/parallel'; import { loadCliConfig } from '../../lib/config'; -import chalk from 'chalk'; -import { BuildOptions } from '../../lib/bundler/types'; interface BuildAppOptions { targetDir: string; @@ -29,29 +27,6 @@ interface BuildAppOptions { isModuleFederationRemote?: true; } -function getModuleFederationOptions( - name: string, - isRemote?: boolean, -): BuildOptions['moduleFederation'] { - if (!isRemote && !process.env.EXPERIMENTAL_MODULE_FEDERATION) { - return undefined; - } - - console.log( - chalk.yellow( - `⚠️ WARNING: Module federation is experimental and will receive immediate breaking changes in the future.`, - ), - ); - - return { - mode: isRemote ? 'remote' : 'host', - // The default output mode requires the name to be a usable as a code - // symbol, there might be better options here but for now we need to - // sanitize the name. - name: name.replaceAll('@', '').replaceAll('/', '__').replaceAll('-', '_'), - }; -} - export async function buildFrontend(options: BuildAppOptions) { const { targetDir, writeStats, configPaths } = options; const { name } = await fs.readJson(resolvePath(targetDir, 'package.json')); diff --git a/packages/cli/src/commands/start/command.ts b/packages/cli/src/commands/start/command.ts index 2f2af95d85..aa46b717bb 100644 --- a/packages/cli/src/commands/start/command.ts +++ b/packages/cli/src/commands/start/command.ts @@ -15,6 +15,7 @@ */ import { OptionValues } from 'commander'; +import { PackageRole } from '@backstage/cli-node'; import { findRoleFromCommand } from '../../lib/role'; import { startBackend, startBackendPlugin } from './startBackend'; import { startFrontend } from './startFrontend'; @@ -47,6 +48,13 @@ export async function command(opts: OptionValues): Promise { case 'frontend-plugin': case 'frontend-plugin-module': return startFrontend({ entry: 'dev/index', ...options }); + case 'frontend-dynamic-container' as PackageRole: // experimental + return startFrontend({ + entry: 'src/index', + ...options, + skipOpenBrowser: true, + isModuleFederationRemote: true, + }); default: throw new Error( `Start command is not supported for package role '${role}'`, diff --git a/packages/cli/src/commands/start/startFrontend.ts b/packages/cli/src/commands/start/startFrontend.ts index a809473788..627da1bd1a 100644 --- a/packages/cli/src/commands/start/startFrontend.ts +++ b/packages/cli/src/commands/start/startFrontend.ts @@ -14,7 +14,9 @@ * limitations under the License. */ -import { serveBundle } from '../../lib/bundler'; +import { readJson } from 'fs-extra'; +import { getModuleFederationOptions, serveBundle } from '../../lib/bundler'; +import { paths } from '../../lib/paths'; interface StartAppOptions { verifyVersions?: boolean; @@ -22,14 +24,21 @@ interface StartAppOptions { checksEnabled: boolean; configPaths: string[]; + isModuleFederationRemote?: boolean; } export async function startFrontend(options: StartAppOptions) { + const { name } = await readJson(paths.resolveTarget('package.json')); + const waitForExit = await serveBundle({ entry: options.entry, checksEnabled: options.checksEnabled, configPaths: options.configPaths, verifyVersions: options.verifyVersions, + moduleFederation: getModuleFederationOptions( + name, + options.isModuleFederationRemote, + ), }); await waitForExit(); diff --git a/packages/cli/src/lib/bundler/index.ts b/packages/cli/src/lib/bundler/index.ts index a3b584efc4..2219784dc9 100644 --- a/packages/cli/src/lib/bundler/index.ts +++ b/packages/cli/src/lib/bundler/index.ts @@ -16,4 +16,5 @@ export { serveBackend } from './backend'; export { buildBundle } from './bundle'; +export { getModuleFederationOptions } from './moduleFederation'; export { serveBundle } from './server'; diff --git a/packages/cli/src/lib/bundler/moduleFederation.ts b/packages/cli/src/lib/bundler/moduleFederation.ts new file mode 100644 index 0000000000..ae3f9ab048 --- /dev/null +++ b/packages/cli/src/lib/bundler/moduleFederation.ts @@ -0,0 +1,44 @@ +/* + * 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 chalk from 'chalk'; +import { ModuleFederationOptions } from './types'; + +export function getModuleFederationOptions( + name: string, + isModuleFederationRemote?: boolean, +): ModuleFederationOptions | undefined { + if ( + !isModuleFederationRemote && + !process.env.EXPERIMENTAL_MODULE_FEDERATION + ) { + return undefined; + } + + console.log( + chalk.yellow( + `⚠️ WARNING: Module federation is experimental and will receive immediate breaking changes in the future.`, + ), + ); + + return { + mode: isModuleFederationRemote ? 'remote' : 'host', + // The default output mode requires the name to be a usable as a code + // symbol, there might be better options here but for now we need to + // sanitize the name. + name: name.replaceAll('@', '').replaceAll('/', '__').replaceAll('-', '_'), + }; +} diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index 7291ac0818..60c59f757b 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -43,6 +43,7 @@ export type ServeOptions = BundlingPathsOptions & { checksEnabled: boolean; configPaths: string[]; verifyVersions?: boolean; + moduleFederation?: ModuleFederationOptions; }; export type BuildOptions = BundlingPathsOptions & { From 94ab20ebfa48ae8197be8180dfa86b7ee4ea6bfa Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Wed, 17 Jul 2024 18:04:36 +0100 Subject: [PATCH 2/9] cli: skip opening browser when serving bundle for packages with frontend-dynamic-container role Signed-off-by: MT Lewis --- packages/cli/src/commands/start/startFrontend.ts | 2 ++ packages/cli/src/lib/bundler/server.ts | 5 ++++- packages/cli/src/lib/bundler/types.ts | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/start/startFrontend.ts b/packages/cli/src/commands/start/startFrontend.ts index 627da1bd1a..20faf4a9ff 100644 --- a/packages/cli/src/commands/start/startFrontend.ts +++ b/packages/cli/src/commands/start/startFrontend.ts @@ -24,6 +24,7 @@ interface StartAppOptions { checksEnabled: boolean; configPaths: string[]; + skipOpenBrowser?: boolean; isModuleFederationRemote?: boolean; } @@ -35,6 +36,7 @@ export async function startFrontend(options: StartAppOptions) { checksEnabled: options.checksEnabled, configPaths: options.configPaths, verifyVersions: options.verifyVersions, + skipOpenBrowser: options.skipOpenBrowser, moduleFederation: getModuleFederationOptions( name, options.isModuleFederationRemote, diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index 1da15cd37e..cf35f65518 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -162,6 +162,7 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be const config = await createConfig(paths, { ...commonConfigOptions, additionalEntryPoints: detectedModulesEntryPoint, + moduleFederation: options.moduleFederation, }); if (process.env.EXPERIMENTAL_VITE) { @@ -278,7 +279,9 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be } }); - openBrowser(url.href); + if (!options.skipOpenBrowser) { + openBrowser(url.href); + } const waitForExit = async () => { for (const signal of ['SIGINT', 'SIGTERM'] as const) { diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index 60c59f757b..f8e228c5c4 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -43,6 +43,7 @@ export type ServeOptions = BundlingPathsOptions & { checksEnabled: boolean; configPaths: string[]; verifyVersions?: boolean; + skipOpenBrowser?: boolean; moduleFederation?: ModuleFederationOptions; }; From b7709255e8b3540176b7db675e9ad77e658920cc Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Wed, 17 Jul 2024 18:12:16 +0100 Subject: [PATCH 3/9] cli: pass websocket host and port explicitly in configuration When configuring the ReactRefreshPlugin and the WebpackDevServer, we previously inferred the host and port from the browser location. This is no longer sufficient, since dynamic plugin containers may be served from separate dev servers. https://webpack.js.org/configuration/dev-server/#websocketurl https://github.com/pmmmwh/react-refresh-webpack-plugin/blob/main/docs/API.md#sockhost Signed-off-by: MT Lewis --- packages/cli/src/lib/bundler/config.ts | 30 ++++++++++++++++++++++ packages/cli/src/lib/bundler/server.ts | 12 +++------ packages/cli/src/lib/bundler/transforms.ts | 11 +------- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 9a611a5954..1f08f74a85 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -29,6 +29,7 @@ import { ModuleFederationPlugin } from '@module-federation/enhanced/webpack'; import { LinkedPackageResolvePlugin } from './LinkedPackageResolvePlugin'; import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin'; import { RunScriptWebpackPlugin } from 'run-script-webpack-plugin'; +import ReactRefreshPlugin from '@pmmmwh/react-refresh-webpack-plugin'; import { paths as cliPaths } from '../../lib/paths'; import fs from 'fs-extra'; import { getPackages } from '@manypkg/get-packages'; @@ -54,6 +55,21 @@ export function resolveBaseUrl(config: Config): URL { } } +export function resolveEndpoint(config: Config): { + host: string; + port: number; +} { + const url = resolveBaseUrl(config); + + return { + host: config.getOptionalString('app.listen.host') ?? url.hostname, + port: + config.getOptionalNumber('app.listen.port') ?? + Number(url.port) ?? + (url.protocol === 'https:' ? 443 : 80), + }; +} + async function readBuildInfo() { const timestamp = Date.now(); @@ -108,6 +124,20 @@ export async function createConfig( publicPath = `${publicPath}${publicSubPath}`.replace('//', '/'); } + if (isDev) { + const { host, port } = resolveEndpoint(options.frontendConfig); + + plugins.push( + new ReactRefreshPlugin({ + overlay: { + sockProtocol: 'ws', + sockHost: host, + sockPort: port, + }, + }), + ); + } + if (checksEnabled) { plugins.push( new ForkTsCheckerWebpackPlugin({ diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index cf35f65518..cbd76b4ead 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -30,7 +30,7 @@ import { import { paths as libPaths } from '../../lib/paths'; import { loadCliConfig } from '../config'; import { Lockfile } from '../versioning'; -import { createConfig, resolveBaseUrl } from './config'; +import { createConfig, resolveBaseUrl, resolveEndpoint } from './config'; import { createDetectedModulesEntryPoint } from './packageDetection'; import { resolveBundlingPaths, resolveOptionalBundlingPaths } from './paths'; import { ServeOptions } from './types'; @@ -131,13 +131,7 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be const { frontendConfig, fullConfig } = cliConfig; const url = resolveBaseUrl(frontendConfig); - - const host = - frontendConfig.getOptionalString('app.listen.host') || url.hostname; - const port = - frontendConfig.getOptionalNumber('app.listen.port') || - Number(url.port) || - (url.protocol === 'https:' ? 443 : 80); + const { host, port } = resolveEndpoint(frontendConfig); const detectedModulesEntryPoint = await createDetectedModulesEntryPoint({ config: fullConfig, @@ -257,7 +251,7 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be // When the dev server is behind a proxy, the host and public hostname differ allowedHosts: [url.hostname], client: { - webSocketURL: 'auto://0.0.0.0:0/ws', + webSocketURL: { hostname: host, port }, }, }, compiler, diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts index da1eec4e11..0900b8e48e 100644 --- a/packages/cli/src/lib/bundler/transforms.ts +++ b/packages/cli/src/lib/bundler/transforms.ts @@ -17,7 +17,6 @@ import { ModuleOptions, WebpackPluginInstance } from 'webpack'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import { svgrTemplate } from '../svgrTemplate'; -import ReactRefreshPlugin from '@pmmmwh/react-refresh-webpack-plugin'; type Transforms = { loaders: ModuleOptions['rules']; @@ -193,15 +192,7 @@ export const transforms = (options: TransformOptions): Transforms => { const plugins = new Array(); - if (isDev) { - if (!isBackend) { - plugins.push( - new ReactRefreshPlugin({ - overlay: { sockProtocol: 'ws' }, - }), - ); - } - } else { + if (!isDev) { plugins.push( new MiniCssExtractPlugin({ filename: 'static/[name].[contenthash:8].css', From be1ee471cf2b09d455256ad6edfa84031ca16489 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Wed, 17 Jul 2024 18:18:20 +0100 Subject: [PATCH 4/9] cli: disable historyApiFallback for module federation remotes Module federation remotes only serve static assets, and aren't intended to be loaded directly in a browser. As such, the historyApiFallback isn't useful and should be disabled in this context. Signed-off-by: MT Lewis --- packages/cli/src/lib/bundler/server.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index cbd76b4ead..a3cbaeacc7 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -227,14 +227,17 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be directory: paths.targetPublic, } : undefined, - historyApiFallback: { - // Paths with dots should still use the history fallback. - // See https://github.com/facebookincubator/create-react-app/issues/387. - disableDotRule: true, + historyApiFallback: + options.moduleFederation?.mode === 'remote' + ? false + : { + // Paths with dots should still use the history fallback. + // See https://github.com/facebookincubator/create-react-app/issues/387. + disableDotRule: true, - // The index needs to be rewritten relative to the new public path, including subroutes. - index: `${config.output?.publicPath}index.html`, - }, + // The index needs to be rewritten relative to the new public path, including subroutes. + index: `${config.output?.publicPath}index.html`, + }, server: url.protocol === 'https:' ? { From f93ae496ce956598c56254d5420438be92faf0c0 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Wed, 17 Jul 2024 18:19:32 +0100 Subject: [PATCH 5/9] cli: add CORS headers for webpack-dev-server content This allows loading static assets from dev-servers for dynamic plugin containers. Signed-off-by: MT Lewis --- packages/cli/src/lib/bundler/server.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index a3cbaeacc7..0421f4d019 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -256,6 +256,12 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be client: { webSocketURL: { hostname: host, port }, }, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': + 'X-Requested-With, content-type, Authorization', + }, }, compiler, ); From 493feaca4d4af19c4174a531d268f4d6e63c1a9e Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Thu, 18 Jul 2024 11:10:18 +0100 Subject: [PATCH 6/9] cli: allow running with no configuration files Co-authored-by: Patrik Oldsberg Signed-off-by: MT Lewis --- .changeset/rich-mugs-dress.md | 7 ++++++ packages/cli/src/lib/bundler/config.ts | 6 ++++- packages/cli/src/lib/config.ts | 8 ++++--- packages/config-loader/api-report.md | 1 + .../src/sources/ConfigSources.test.ts | 13 +++++++++- .../src/sources/ConfigSources.ts | 24 +++++++++++++------ 6 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 .changeset/rich-mugs-dress.md diff --git a/.changeset/rich-mugs-dress.md b/.changeset/rich-mugs-dress.md new file mode 100644 index 0000000000..5008e33a70 --- /dev/null +++ b/.changeset/rich-mugs-dress.md @@ -0,0 +1,7 @@ +--- +'@backstage/config-loader': patch +--- + +Add boolean `allowMissingDefaultConfig` option to `ConfigSources.default` and +`ConfigSources.defaultForTargets`, which results in omission of a ConfigSource +for the default app-config.yaml configuration file if it's not present. diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 1f08f74a85..042f74d1f3 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -48,8 +48,12 @@ const BUILD_CACHE_ENV_VAR = 'BACKSTAGE_CLI_EXPERIMENTAL_BUILD_CACHE'; export function resolveBaseUrl(config: Config): URL { const baseUrl = config.getOptionalString('app.baseUrl'); + try { - return new URL(baseUrl ?? '/', 'http://localhost:3000'); + return new URL( + baseUrl ?? '/', + `http://localhost:${process.env.PORT ?? '3000'}`, + ); } catch (error) { throw new Error(`Invalid app.baseUrl, ${error}`); } diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 0c657b640e..0be20b0185 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -96,11 +96,13 @@ export async function loadCliConfig(options: Options) { }, }); + const configurationLoadedMessage = appConfigs.length + ? `Loaded config from ${appConfigs.map(c => c.context).join(', ')}` + : `No configuration files found, running without config`; + // printing to stderr to not clobber stdout in case the cli command // outputs structured data (e.g. as config:schema does) - process.stderr.write( - `Loaded config from ${appConfigs.map(c => c.context).join(', ')}\n`, - ); + process.stderr.write(`${configurationLoadedMessage}\n`); try { const frontendAppConfigs = schema.process(appConfigs, { diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index bc8f217bbb..835e53351d 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -21,6 +21,7 @@ export type AsyncConfigSourceGenerator = AsyncGenerator< // @public export interface BaseConfigSourcesOptions { + allowMissingDefaultConfig?: boolean; // (undocumented) remote?: Pick; // (undocumented) diff --git a/packages/config-loader/src/sources/ConfigSources.test.ts b/packages/config-loader/src/sources/ConfigSources.test.ts index 4b14572666..e19ac1d51f 100644 --- a/packages/config-loader/src/sources/ConfigSources.test.ts +++ b/packages/config-loader/src/sources/ConfigSources.test.ts @@ -69,13 +69,18 @@ describe('ConfigSources', () => { }); it('should create default sources for targets', () => { + const fsSpy = jest.spyOn(fs, 'pathExistsSync').mockImplementation(path => { + return path === `${root}app-config.yaml`; + }); + expect( mergeSources( ConfigSources.defaultForTargets({ rootDir: '/', targets: [] }), ), ).toEqual([{ name: 'FileConfigSource', path: `${root}app-config.yaml` }]); - const fsSpy = jest.spyOn(fs, 'pathExistsSync').mockReturnValue(true); + fsSpy.mockReturnValue(true); + expect( mergeSources( ConfigSources.defaultForTargets({ rootDir: '/', targets: [] }), @@ -159,6 +164,10 @@ describe('ConfigSources', () => { }); it('should create a default source', () => { + const fsSpy = jest.spyOn(fs, 'pathExistsSync').mockImplementation(path => { + return path === `${root}app-config.yaml`; + }); + expect( mergeSources( ConfigSources.default({ @@ -184,6 +193,8 @@ describe('ConfigSources', () => { { name: 'FileConfigSource', path: resolvePath('b.yaml') }, { name: 'EnvConfigSource', env: { HOME: '/' } }, ]); + + fsSpy.mockRestore(); }); it('should merge sources', () => { diff --git a/packages/config-loader/src/sources/ConfigSources.ts b/packages/config-loader/src/sources/ConfigSources.ts index ecff39da2d..be8892f9a9 100644 --- a/packages/config-loader/src/sources/ConfigSources.ts +++ b/packages/config-loader/src/sources/ConfigSources.ts @@ -74,6 +74,11 @@ export interface BaseConfigSourcesOptions { watch?: boolean; rootDir?: string; remote?: Pick; + /** + * Allow the default app-config.yaml to be missing, in which case the source + * will not be created. + */ + allowMissingDefaultConfig?: boolean; /** * A custom substitution function that overrides the default one. @@ -177,14 +182,19 @@ export class ConfigSources { if (argSources.length === 0) { const defaultPath = resolvePath(rootDir, 'app-config.yaml'); const localPath = resolvePath(rootDir, 'app-config.local.yaml'); + const alwaysIncludeDefaultConfigSource = + !options.allowMissingDefaultConfig; + + if (alwaysIncludeDefaultConfigSource || fs.pathExistsSync(defaultPath)) { + argSources.push( + FileConfigSource.create({ + watch: options.watch, + path: defaultPath, + substitutionFunc: options.substitutionFunc, + }), + ); + } - argSources.push( - FileConfigSource.create({ - watch: options.watch, - path: defaultPath, - substitutionFunc: options.substitutionFunc, - }), - ); if (fs.pathExistsSync(localPath)) { argSources.push( FileConfigSource.create({ From 0eedec304dbe8e4f8b1e982a1e29b081cbed3ddd Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Thu, 18 Jul 2024 11:18:07 +0100 Subject: [PATCH 7/9] cli: add changeset for module federation local development changes Signed-off-by: MT Lewis --- .changeset/strong-otters-compete.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/strong-otters-compete.md diff --git a/.changeset/strong-otters-compete.md b/.changeset/strong-otters-compete.md new file mode 100644 index 0000000000..fdcb982b88 --- /dev/null +++ b/.changeset/strong-otters-compete.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Add support for dynamic plugins via the EXPERIMENTAL_MODULE_FEDERATION environment variable when running `yarn start`. From 9d661bb3807a534cf023dc84d816cee5d3c81472 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 19 Jul 2024 14:28:13 +0100 Subject: [PATCH 8/9] cli: only consider PORT environment variable for module federation remotes Signed-off-by: MT Lewis --- packages/cli/src/lib/bundler/config.ts | 43 +++++++++++++++++++------- packages/cli/src/lib/bundler/server.ts | 7 +++-- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 042f74d1f3..42cdae24e3 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { BackendBundlingOptions, BundlingOptions } from './types'; +import { + BackendBundlingOptions, + BundlingOptions, + ModuleFederationOptions, +} from './types'; import { posix as posixPath, resolve as resolvePath, dirname } from 'path'; import chalk from 'chalk'; import webpack, { ProvidePlugin } from 'webpack'; @@ -46,24 +50,32 @@ import { hasReactDomClient } from './hasReactDomClient'; const BUILD_CACHE_ENV_VAR = 'BACKSTAGE_CLI_EXPERIMENTAL_BUILD_CACHE'; -export function resolveBaseUrl(config: Config): URL { +export function resolveBaseUrl( + config: Config, + moduleFederation?: ModuleFederationOptions, +): URL { const baseUrl = config.getOptionalString('app.baseUrl'); + const defaultBaseUrl = + moduleFederation?.mode === 'remote' + ? `http://localhost:${process.env.PORT ?? '3000'}` + : 'http://localhost:3000'; + try { - return new URL( - baseUrl ?? '/', - `http://localhost:${process.env.PORT ?? '3000'}`, - ); + return new URL(baseUrl ?? '/', defaultBaseUrl); } catch (error) { throw new Error(`Invalid app.baseUrl, ${error}`); } } -export function resolveEndpoint(config: Config): { +export function resolveEndpoint( + config: Config, + moduleFederation?: ModuleFederationOptions, +): { host: string; port: number; } { - const url = resolveBaseUrl(config); + const url = resolveBaseUrl(config, moduleFederation); return { host: config.getOptionalString('app.listen.host') ?? url.hostname, @@ -114,7 +126,13 @@ export async function createConfig( paths: BundlingPaths, options: BundlingOptions, ): Promise { - const { checksEnabled, isDev, frontendConfig, publicSubPath = '' } = options; + const { + checksEnabled, + isDev, + frontendConfig, + moduleFederation, + publicSubPath = '', + } = options; const { plugins, loaders } = transforms(options); // Any package that is part of the monorepo but outside the monorepo root dir need @@ -122,14 +140,17 @@ export async function createConfig( const { packages } = await getPackages(cliPaths.targetDir); const externalPkgs = packages.filter(p => !isChildPath(paths.root, p.dir)); - const validBaseUrl = resolveBaseUrl(frontendConfig); + const validBaseUrl = resolveBaseUrl(frontendConfig, moduleFederation); let publicPath = validBaseUrl.pathname.replace(/\/$/, ''); if (publicSubPath) { publicPath = `${publicPath}${publicSubPath}`.replace('//', '/'); } if (isDev) { - const { host, port } = resolveEndpoint(options.frontendConfig); + const { host, port } = resolveEndpoint( + options.frontendConfig, + options.moduleFederation, + ); plugins.push( new ReactRefreshPlugin({ diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index 0421f4d019..6b89847fb6 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -130,8 +130,11 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be } const { frontendConfig, fullConfig } = cliConfig; - const url = resolveBaseUrl(frontendConfig); - const { host, port } = resolveEndpoint(frontendConfig); + const url = resolveBaseUrl(frontendConfig, options.moduleFederation); + const { host, port } = resolveEndpoint( + frontendConfig, + options.moduleFederation, + ); const detectedModulesEntryPoint = await createDetectedModulesEntryPoint({ config: fullConfig, From 239dffc970b4b040ffdf9aee24f6937c4994f5ba Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Mon, 22 Jul 2024 11:06:42 +0100 Subject: [PATCH 9/9] cli: remove usage of deprecated functionality from @backstage/config-loader Co-authored-by: Patrik Oldsberg Signed-off-by: MT Lewis --- .changeset/ninety-icons-smile.md | 5 +++ packages/cli/src/lib/config.ts | 76 +++++++++++++++++++------------- 2 files changed, 51 insertions(+), 30 deletions(-) create mode 100644 .changeset/ninety-icons-smile.md diff --git a/.changeset/ninety-icons-smile.md b/.changeset/ninety-icons-smile.md new file mode 100644 index 0000000000..f137879706 --- /dev/null +++ b/.changeset/ninety-icons-smile.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Remove usage of deprecated functionality from @backstage/config-loader diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 0be20b0185..49cce1f1ed 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -14,14 +14,9 @@ * limitations under the License. */ -import { - ConfigTarget, - loadConfig, - loadConfigSchema, -} from '@backstage/config-loader'; +import { ConfigSources, loadConfigSchema } from '@backstage/config-loader'; import { AppConfig, ConfigReader } from '@backstage/config'; import { paths } from './paths'; -import { isValidUrl } from './urls'; import { getPackages } from '@manypkg/get-packages'; import { PackageGraph } from '@backstage/cli-node'; @@ -37,13 +32,6 @@ type Options = { }; export async function loadCliConfig(options: Options) { - const configTargets: ConfigTarget[] = []; - options.args.forEach(arg => { - if (!isValidUrl(arg)) { - configTargets.push({ path: paths.resolveTarget(arg) }); - } - }); - // Consider all packages in the monorepo when loading in config const { packages } = await getPackages(paths.targetDir); @@ -75,25 +63,53 @@ export async function loadCliConfig(options: Options) { noUndeclaredProperties: options.strict, }); - const { appConfigs } = await loadConfig({ - experimentalEnvFunc: options.mockEnv + const source = ConfigSources.default({ + allowMissingDefaultConfig: true, + substitutionFunc: options.mockEnv ? async name => process.env[name] || 'x' : 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); - }, - }, + watch: Boolean(options.watch), + rootDir: paths.targetRoot, + argv: options.args, + }); + + const appConfigs = await new Promise((resolve, reject) => { + async function loadConfigReaderLoop() { + let loaded = false; + + try { + const abortController = new AbortController(); + for await (const { configs } of source.readConfigData({ + signal: abortController.signal, + })) { + if (loaded) { + const newFrontendAppConfigs = schema.process(configs, { + visibility: options.fullVisibility + ? ['frontend', 'backend', 'secret'] + : ['frontend'], + withFilteredKeys: options.withFilteredKeys, + withDeprecatedKeys: options.withDeprecatedKeys, + ignoreSchemaErrors: !options.strict, + }); + options.watch?.(newFrontendAppConfigs); + } else { + resolve(configs); + loaded = true; + + if (!options.watch) { + abortController.abort(); + } + } + } + } catch (error) { + if (loaded) { + console.error(`Failed to reload configuration, ${error}`); + } else { + reject(error); + } + } + } + loadConfigReaderLoop(); }); const configurationLoadedMessage = appConfigs.length