diff --git a/.changeset/metal-carrots-brush.md b/.changeset/metal-carrots-brush.md new file mode 100644 index 0000000000..776e1d3e56 --- /dev/null +++ b/.changeset/metal-carrots-brush.md @@ -0,0 +1,9 @@ +--- +'@backstage/cli': patch +--- + +When using the experimental Rspack flag the app build and dev server now injects configuration via a `` tag in `index.html` rather than the `process.env.APP_CONFIG` definition, which will now be defined as an empty array instead. + +This requires the app to be using the config loader from the 1.31 release of Backstage. Make sure your app is using at least that version if you are upgrading to this version of the CLI. + +If you have copied the implementation of the `defaultConfigLoader`, make sure to update it to the new implementation. In particular the config loader needs to be able to read configuration from `script` tags with the type `backstage.io/config`. diff --git a/.changeset/sweet-pigs-pump.md b/.changeset/sweet-pigs-pump.md new file mode 100644 index 0000000000..9e88e65dbc --- /dev/null +++ b/.changeset/sweet-pigs-pump.md @@ -0,0 +1,6 @@ +--- +'@backstage/core-app-api': patch +'@backstage/frontend-defaults': patch +--- + +The default config loader no longer requires `process.env.APP_CONFIG` to be set, allowing config to be read from other sources instead. diff --git a/packages/cli/src/lib/bundler/ConfigInjectingHtmlWebpackPlugin.ts b/packages/cli/src/lib/bundler/ConfigInjectingHtmlWebpackPlugin.ts new file mode 100644 index 0000000000..5aa8b75098 --- /dev/null +++ b/packages/cli/src/lib/bundler/ConfigInjectingHtmlWebpackPlugin.ts @@ -0,0 +1,55 @@ +/* + * 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 { AppConfig } from '@backstage/config'; +import HtmlWebpackPlugin from 'html-webpack-plugin'; + +export class ConfigInjectingHtmlWebpackPlugin extends HtmlWebpackPlugin { + readonly name = 'ConfigInjectingHtmlWebpackPlugin'; + readonly #getFrontendAppConfigs: () => AppConfig[]; + + constructor( + options: HtmlWebpackPlugin.Options, + getFrontendAppConfigs: () => AppConfig[], + ) { + super(options); + this.#getFrontendAppConfigs = getFrontendAppConfigs; + } + + apply: HtmlWebpackPlugin['apply'] = compiler => { + super.apply(compiler); + + compiler.hooks.compilation.tap(this.name, compilation => { + const hooks = HtmlWebpackPlugin.getCompilationHooks(compilation); + hooks.alterAssetTagGroups.tap(this.name, ctx => { + if (ctx.plugin !== this) { + return ctx; + } + return { + ...ctx, + headTags: [ + ...ctx.headTags, + HtmlWebpackPlugin.createHtmlTagObject( + 'script', + { type: 'backstage.io/config' }, + `\n${JSON.stringify(this.#getFrontendAppConfigs(), null, 2)}\n`, + ), + ], + }; + }); + }); + }; +} diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 4e9f67c4fa..5e379783ba 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -37,6 +37,7 @@ import { version } from '../../lib/version'; import yn from 'yn'; import { hasReactDomClient } from './hasReactDomClient'; import { createWorkspaceLinkingPlugins } from './linkWorkspaces'; +import { ConfigInjectingHtmlWebpackPlugin } from './ConfigInjectingHtmlWebpackPlugin'; const BUILD_CACHE_ENV_VAR = 'BACKSTAGE_CLI_EXPERIMENTAL_BUILD_CACHE'; @@ -182,19 +183,35 @@ export async function createConfig( ); if (options.moduleFederation?.mode !== 'remote') { - plugins.push( - // `rspack.HtmlRspackPlugin` does not support object type `templateParameters` value, `frontendConfig` in this case - new HtmlWebpackPlugin({ - meta: { - 'backstage-app-mode': options?.appMode ?? 'public', - }, - template: paths.targetHtml, - templateParameters: { - publicPath, - config: frontendConfig, - }, - }), - ); + const templateOptions = { + meta: { + 'backstage-app-mode': options?.appMode ?? 'public', + }, + template: paths.targetHtml, + templateParameters: { + publicPath, + config: frontendConfig, + }, + }; + if (rspack) { + // With Rspack we inject config via index.html, this is both because we + // can't use APP_CONFIG due to the lack of support for runtime values, but + // also because we are able to do it and it lines up better with what the + // app-backend is doing. + // + // We still use the html plugin from WebPack, since the Rspack one won't + // let us inject complex objects like the config. + plugins.push( + new ConfigInjectingHtmlWebpackPlugin( + templateOptions, + options.getFrontendAppConfigs, + ), + ); + } else { + // Config injection via index.html doesn't work across reloads with + // WebPack, so we rely on the APP_CONFIG injection instead + plugins.push(new HtmlWebpackPlugin(templateOptions)); + } plugins.push( new HtmlWebpackPlugin({ meta: { @@ -284,8 +301,7 @@ export async function createConfig( new bundler.DefinePlugin({ 'process.env.BUILD_INFO': JSON.stringify(buildInfo), 'process.env.APP_CONFIG': rspack - ? // FIXME: see also https://github.com/web-infra-dev/rspack/issues/5606 - JSON.stringify(options.getFrontendAppConfigs()) + ? JSON.stringify([]) // Inject via index.html instead : bundler.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 44d6d54379..f8d259f467 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -59,6 +59,26 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be let latestFrontendAppConfigs: AppConfig[] = []; + /** Triggers a full reload of all clients */ + const triggerReload = () => { + if (viteServer) { + viteServer.restart(); + } + + if (webpackServer) { + webpackServer.invalidate(); + + // For the Rspack server it's not enough to invalidate, we also need to + // tell the browser to reload, which we do with a 'static-changed' message + if (process.env.EXPERIMENTAL_RSPACK) { + webpackServer.sendMessage( + webpackServer.webSocketServer?.clients ?? [], + 'static-changed', + ); + } + } + }; + const cliConfig = await loadCliConfig({ args: options.configPaths, fromPackage: name, @@ -66,8 +86,7 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be watch(appConfigs) { latestFrontendAppConfigs = appConfigs; - webpackServer?.invalidate(); - viteServer?.restart(); + triggerReload(); }, }); latestFrontendAppConfigs = cliConfig.frontendAppConfigs; @@ -102,8 +121,7 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be config: fullConfig, targetPath: paths.targetPath, watch() { - webpackServer?.invalidate(); - viteServer?.restart(); + triggerReload(); }, }); diff --git a/packages/core-app-api/src/app/defaultConfigLoader.test.tsx b/packages/core-app-api/src/app/defaultConfigLoader.test.tsx index 44d93aa625..556e4c928e 100644 --- a/packages/core-app-api/src/app/defaultConfigLoader.test.tsx +++ b/packages/core-app-api/src/app/defaultConfigLoader.test.tsx @@ -29,6 +29,10 @@ describe('defaultConfigLoaderSync', () => { delete anyWindow.__APP_CONFIG__; }); + it('loads nothing is config is missing', () => { + expect(defaultConfigLoaderSync()).toEqual([]); + }); + it('loads static config', () => { anyEnv.APP_CONFIG = [ { data: { my: 'config' }, context: 'a' }, @@ -95,12 +99,6 @@ describe('defaultConfigLoaderSync', () => { expect(ConfigReader.fromConfigs(configs).get('my')).toBe('override-config'); }); - it('fails to load invalid missing config', () => { - expect(() => defaultConfigLoaderSync()).toThrow( - 'No static configuration provided', - ); - }); - it('fails to load invalid static config', () => { anyEnv.APP_CONFIG = { my: 'invalid-config' }; expect(() => defaultConfigLoaderSync()).toThrow( diff --git a/packages/core-app-api/src/app/defaultConfigLoader.ts b/packages/core-app-api/src/app/defaultConfigLoader.ts index bfcef390ec..0a65112041 100644 --- a/packages/core-app-api/src/app/defaultConfigLoader.ts +++ b/packages/core-app-api/src/app/defaultConfigLoader.ts @@ -40,14 +40,15 @@ export function defaultConfigLoaderSync( // It's a param so we can test it, but at runtime this will always fall back to default. runtimeConfigJson: string = '__APP_INJECTED_RUNTIME_CONFIG__', ) { - const appConfig = process.env.APP_CONFIG; - if (!appConfig) { - throw new Error('No static configuration provided'); + const configs = new Array(); + + const staticConfig = process.env.APP_CONFIG; + if (staticConfig) { + if (!Array.isArray(staticConfig)) { + throw new Error('Static configuration has invalid format'); + } + configs.push(...staticConfig); } - if (!Array.isArray(appConfig)) { - throw new Error('Static configuration has invalid format'); - } - const configs = appConfig.slice() as unknown as AppConfig[]; // Check if we have any config script tags, otherwise fall back to injected config const configScripts = document.querySelectorAll(