diff --git a/.changeset/calm-toes-melt.md b/.changeset/calm-toes-melt.md new file mode 100644 index 0000000000..b8a7b12c46 --- /dev/null +++ b/.changeset/calm-toes-melt.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +The `defaultConfigLoader` now also reads configuration from scripts tags with `type="backstage.io/config"`. The tag is expected to contain a JSON-serialized array of `AppConfig` objects. If any of these script tags are present, the injected runtime configuration in the static assets will no longer be used. diff --git a/.changeset/great-zebras-hunt.md b/.changeset/great-zebras-hunt.md new file mode 100644 index 0000000000..252f270092 --- /dev/null +++ b/.changeset/great-zebras-hunt.md @@ -0,0 +1,7 @@ +--- +'@backstage/cli': patch +--- + +The app build process now outputs an additional `index.html.tmpl` file. This is an non-templated version of the `index.html` file, which can be used to delay templating until runtime. + +The new `index.html.tmpl` file also sets a `backstage-public-path` meta tag to be templated at runtime. The meta tag is in turn picked up by the new `@backstage/cli/config/webpack-public-path.js` entry point script, which uses it to set the runtime public path of the Webpack bundle. diff --git a/.changeset/khaki-crews-rule.md b/.changeset/khaki-crews-rule.md new file mode 100644 index 0000000000..4d8eb32083 --- /dev/null +++ b/.changeset/khaki-crews-rule.md @@ -0,0 +1,11 @@ +--- +'@backstage/plugin-app-backend': patch +--- + +**BREAKING**: The app backend now supports the new `index.html.tmpl` output from `@backstage/cli`. If available, the `index.html` will be templated at runtime with the current configuration of the app backend. + +This is marked as a breaking change because you must now supply the app build-time configuration to the backend. This change also affects the public path behavior, where it is no longer necessary to build the app with the correct public path upfront. You now only need to supply a correct `app.baseUrl` to the app backend plugin at runtime. + +An effect that this change has is that the `index.html` will now contain and present the frontend configuration in an easily readable way, which can aid in debugging. This data was always available in the frontend, but it was injected and hidden in the static bundle. + +This templating behavior is enabled by default, but it can be disabled by setting the `app.disableConfigInjection` configuration option to `true`. diff --git a/packages/cli/config/webpack-public-path.js b/packages/cli/config/webpack-public-path.js new file mode 100644 index 0000000000..3e14e96eb9 --- /dev/null +++ b/packages/cli/config/webpack-public-path.js @@ -0,0 +1,31 @@ +/* + * 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. + */ + +// This script is used to pick up and set the public path of the Webpack bundle +// at runtime. The meta tag is injected by the app build, but only present in +// the `index.html.tmpl` file. The runtime value of the meta tag is populated by +// the app backend, when it templates the final `index.html` file. +// +// This is needed for additional chunks to use the correct public path, and it +// is not possible to set the `__webpack_public_path__` variable outside of the +// build itself. The Webpack output also does not read any tags or +// similar, this seems to be the only way to dynamically configure the public +// path at runtime. +const el = document.querySelector('meta[name="backstage-public-path"]'); +const path = el?.getAttribute('content'); +if (path) { + __webpack_public_path__ = path; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 31cfd3747e..9d15efe9e4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -130,6 +130,7 @@ "pirates": "^4.0.6", "postcss": "^8.1.0", "process": "^0.11.10", + "raw-loader": "^4.0.2", "react-dev-utils": "^12.0.0-next.60", "react-refresh": "^0.14.0", "recursive-readdir": "^2.2.2", diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index da7b378fbd..a64bc0fcd8 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -198,6 +198,19 @@ export async function createConfig( }, }), ); + plugins.push( + new HtmlWebpackPlugin({ + meta: { + 'backstage-app-mode': options?.appMode ?? 'public', + // This is added to be written in the later step, and finally read by the extra entry point + 'backstage-public-path': '<%= publicPath %>/', + }, + minify: false, + publicPath: '<%= publicPath %>', + filename: 'index.html.tmpl', + template: `raw-loader!${paths.targetHtml}`, + }), + ); } if (options.moduleFederation) { @@ -341,7 +354,11 @@ export async function createConfig( }, devtool: isDev ? 'eval-cheap-module-source-map' : 'source-map', context: paths.targetPath, - entry: [...(options.additionalEntryPoints ?? []), paths.targetEntry], + entry: [ + require.resolve('@backstage/cli/config/webpack-public-path'), + ...(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/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts index 302b2fbe68..17a6a0c191 100644 --- a/packages/cli/src/lib/bundler/transforms.ts +++ b/packages/cli/src/lib/bundler/transforms.ts @@ -14,12 +14,12 @@ * limitations under the License. */ -import { ModuleOptions, WebpackPluginInstance } from 'webpack'; +import { RuleSetRule, WebpackPluginInstance } from 'webpack'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import { svgrTemplate } from '../svgrTemplate'; type Transforms = { - loaders: ModuleOptions['rules']; + loaders: RuleSetRule[]; plugins: WebpackPluginInstance[]; }; diff --git a/packages/core-app-api/src/app/defaultConfigLoader.test.ts b/packages/core-app-api/src/app/defaultConfigLoader.test.tsx similarity index 67% rename from packages/core-app-api/src/app/defaultConfigLoader.test.ts rename to packages/core-app-api/src/app/defaultConfigLoader.test.tsx index c70ec3fbff..44d93aa625 100644 --- a/packages/core-app-api/src/app/defaultConfigLoader.test.ts +++ b/packages/core-app-api/src/app/defaultConfigLoader.test.tsx @@ -14,7 +14,10 @@ * limitations under the License. */ +import React from 'react'; +import { render } from '@testing-library/react'; import { defaultConfigLoaderSync } from './defaultConfigLoader'; +import { ConfigReader } from '@backstage/config'; (process as any).env = { NODE_ENV: 'test' }; const anyEnv = process.env as any; @@ -53,6 +56,45 @@ describe('defaultConfigLoaderSync', () => { ]); }); + it('loads config from script tags and ignore static config', () => { + anyEnv.APP_CONFIG = []; + + render( + , + ); + + const configs = (defaultConfigLoaderSync as any)('{"my":"runtime-config"}'); + expect(configs).toEqual([ + { data: { my: 'config' }, context: 'a' }, + { data: { my: 'override-config' }, context: 'b' }, + ]); + expect(ConfigReader.fromConfigs(configs).get('my')).toBe('override-config'); + }); + + it('loads config from all script tags in order', () => { + anyEnv.APP_CONFIG = []; + + render( + <> + + + , + ); + + const configs = (defaultConfigLoaderSync as any)('{"my":"runtime-config"}'); + expect(configs).toEqual([ + { data: { my: 'config' }, context: 'a' }, + { data: { my: 'override-config' }, context: 'b' }, + ]); + expect(ConfigReader.fromConfigs(configs).get('my')).toBe('override-config'); + }); + it('fails to load invalid missing config', () => { expect(() => defaultConfigLoaderSync()).toThrow( 'No static configuration provided', diff --git a/packages/core-app-api/src/app/defaultConfigLoader.ts b/packages/core-app-api/src/app/defaultConfigLoader.ts index 301b64a865..bfcef390ec 100644 --- a/packages/core-app-api/src/app/defaultConfigLoader.ts +++ b/packages/core-app-api/src/app/defaultConfigLoader.ts @@ -49,9 +49,36 @@ export function defaultConfigLoaderSync( } const configs = appConfig.slice() as unknown as AppConfig[]; - // Avoiding this string also being replaced at runtime - if ( + // Check if we have any config script tags, otherwise fall back to injected config + const configScripts = document.querySelectorAll( + 'script[type="backstage.io/config"]', + ); + if (configScripts.length > 0) { + for (const el of configScripts) { + try { + const content = el.textContent; + if (!content) { + throw new Error('tag is empty'); + } + let data; + try { + data = JSON.parse(content); + } catch (error) { + throw new Error(`failed to parse config; ${error}`); + } + if (!Array.isArray(data)) { + throw new Error('data is not an array'); + } + configs.push(...data); + } catch (error) { + throw new Error( + `Failed to load config from script tag, ${error.message}`, + ); + } + } + } else if ( runtimeConfigJson !== + // Avoiding this string also being replaced at runtime '__app_injected_runtime_config__'.toLocaleUpperCase('en-US') ) { try { diff --git a/plugins/app-backend/src/lib/config/index.ts b/plugins/app-backend/src/lib/config/index.ts new file mode 100644 index 0000000000..2e685578d0 --- /dev/null +++ b/plugins/app-backend/src/lib/config/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export { injectConfig } from './injectConfig'; +export { readFrontendConfig } from './readFrontendConfig'; diff --git a/plugins/app-backend/src/lib/config/injectConfig.ts b/plugins/app-backend/src/lib/config/injectConfig.ts new file mode 100644 index 0000000000..9d63d219f4 --- /dev/null +++ b/plugins/app-backend/src/lib/config/injectConfig.ts @@ -0,0 +1,36 @@ +/* + * 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 { injectConfigIntoHtml } from './injectConfigIntoHtml'; +import { injectConfigIntoStatic } from './injectConfigIntoStatic'; +import { InjectOptions } from './types'; + +/** + * Injects configs into the app bundle, replacing any existing injected config. + * @internal + */ +export async function injectConfig( + options: InjectOptions, +): Promise { + // In order to minimize the potential impact when rolling out the new config + // injection, we use both methods for a few releases. This allows the frontend + // app to be behind the backend by a version or two, but temporarily increases + // config injection overhead. + // TODO(Rugvip): After the 1.32 release we can stop calling the static injection if the HTML one is successful + await injectConfigIntoHtml(options); + + return injectConfigIntoStatic(options); +} diff --git a/plugins/app-backend/src/lib/config/injectConfigIntoHtml.test.ts b/plugins/app-backend/src/lib/config/injectConfigIntoHtml.test.ts new file mode 100644 index 0000000000..f4f2655f1c --- /dev/null +++ b/plugins/app-backend/src/lib/config/injectConfigIntoHtml.test.ts @@ -0,0 +1,73 @@ +/* + * 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 { + createMockDirectory, + mockServices, +} from '@backstage/backend-test-utils'; +import { injectConfigIntoHtml } from './injectConfigIntoHtml'; + +describe('injectConfigIntoHtml', () => { + const mockDir = createMockDirectory(); + + const baseOptions = { + appConfigs: [], + rootDir: mockDir.path, + staticDir: 'ignored', + logger: mockServices.logger.mock(), + }; + + beforeEach(() => { + mockDir.clear(); + }); + + it('should template html', async () => { + mockDir.setContent({ + 'index.html.tmpl': "<%= config.getNumber('x') %>", + }); + await injectConfigIntoHtml({ + ...baseOptions, + appConfigs: [{ context: 'mock', data: { x: 1 } }], + }); + expect(mockDir.content()).toMatchObject({ + 'index.html': '1', + }); + }); + + it('should inject config', async () => { + mockDir.setContent({ + 'index.html.tmpl': '', + }); + await injectConfigIntoHtml({ + ...baseOptions, + appConfigs: [{ context: 'mock', data: { x: 1 } }], + }); + expect(mockDir.content()).toMatchObject({ + 'index.html': ` + +`, + }); + }); +}); diff --git a/plugins/app-backend/src/lib/config/injectConfigIntoHtml.ts b/plugins/app-backend/src/lib/config/injectConfigIntoHtml.ts new file mode 100644 index 0000000000..604b6a8711 --- /dev/null +++ b/plugins/app-backend/src/lib/config/injectConfigIntoHtml.ts @@ -0,0 +1,78 @@ +/* + * 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 fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; +import { InjectOptions } from './types'; +import compileTemplate from 'lodash/template'; +import { Config, ConfigReader } from '@backstage/config'; + +const HTML_TEMPLATE_NAME = 'index.html.tmpl'; + +/** @internal */ +export async function injectConfigIntoHtml( + options: InjectOptions, +): Promise { + const { rootDir, appConfigs } = options; + + const templatePath = resolvePath(rootDir, HTML_TEMPLATE_NAME); + + if (!(await fs.exists(templatePath))) { + return false; + } + + const templateContent = await fs.readFile( + resolvePath(rootDir, HTML_TEMPLATE_NAME), + 'utf8', + ); + + const config = ConfigReader.fromConfigs(appConfigs); + + const templateSource = compileTemplate(templateContent, { + interpolate: /<%=([\s\S]+?)%>/g, + }); + + const publicPath = resolvePublicPath(config); + const indexHtmlContent = templateSource({ + config, + publicPath, + }); + + const indexHtmlContentWithConfig = indexHtmlContent.replace( + '', + ` + +`, + ); + + await fs.writeFile( + resolvePath(rootDir, 'index.html'), + indexHtmlContentWithConfig, + 'utf8', + ); + + return true; +} + +export function resolvePublicPath(config: Config) { + const baseUrl = new URL( + config.getOptionalString('app.baseUrl') ?? '/', + 'http://localhost:7007', + ); + return baseUrl.pathname.replace(/\/+$/, ''); +} diff --git a/plugins/app-backend/src/lib/config.test.ts b/plugins/app-backend/src/lib/config/injectConfigIntoStatic.test.ts similarity index 91% rename from plugins/app-backend/src/lib/config.test.ts rename to plugins/app-backend/src/lib/config/injectConfigIntoStatic.test.ts index 34f744d5b8..cd5b4dcae2 100644 --- a/plugins/app-backend/src/lib/config.test.ts +++ b/plugins/app-backend/src/lib/config/injectConfigIntoStatic.test.ts @@ -18,13 +18,14 @@ import { createMockDirectory, mockServices, } from '@backstage/backend-test-utils'; -import { injectConfig } from './config'; +import { injectConfigIntoStatic } from './injectConfigIntoStatic'; -describe('injectConfig', () => { +describe('injectConfigIntoStatic', () => { const mockDir = createMockDirectory(); const baseOptions = { appConfigs: [], + rootDir: 'ignored', staticDir: mockDir.path, logger: mockServices.logger.mock(), }; @@ -42,7 +43,7 @@ describe('injectConfig', () => { 'main.js': '"__APP_INJECTED_RUNTIME_CONFIG__"', }); - await injectConfig(baseOptions); + await injectConfigIntoStatic(baseOptions); expect(mockDir.content()).toEqual({ 'main.js': '/*__APP_INJECTED_CONFIG_MARKER__*/"[]"/*__INJECTED_END__*/', @@ -55,7 +56,7 @@ describe('injectConfig', () => { '({a:"__APP_INJECTED_RUNTIME_CONFIG__",b:"__APP_INJECTED_RUNTIME_CONFIG__"})', }); - await injectConfig(baseOptions); + await injectConfigIntoStatic(baseOptions); expect(mockDir.content()).toEqual({ 'main.js': @@ -71,7 +72,7 @@ describe('injectConfig', () => { 'after.js': 'NO_PLACEHOLDER_HERE', }); - await injectConfig({ + await injectConfigIntoStatic({ ...baseOptions, appConfigs: [{ data: { x: 0 }, context: 'test' }], }); @@ -90,7 +91,7 @@ describe('injectConfig', () => { 'main.js': 'JSON.parse("__APP_INJECTED_RUNTIME_CONFIG__")', }); - await injectConfig({ + await injectConfigIntoStatic({ ...baseOptions, appConfigs: [{ data: { x: 0 }, context: 'test' }], }); @@ -100,7 +101,7 @@ describe('injectConfig', () => { 'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"[{\\"data\\":{\\"x\\":0},\\"context\\":\\"test\\"}]"/*__INJECTED_END__*/)', }); - await injectConfig({ + await injectConfigIntoStatic({ ...baseOptions, appConfigs: [{ data: { x: 1, y: 2 }, context: 'test' }], }); @@ -117,7 +118,7 @@ describe('injectConfig', () => { '({ a: JSON.parse("__APP_INJECTED_RUNTIME_CONFIG__"), b: JSON.parse("__APP_INJECTED_RUNTIME_CONFIG__") })', }); - await injectConfig({ + await injectConfigIntoStatic({ ...baseOptions, appConfigs: [{ data: { x: 0 }, context: 'test' }], }); @@ -127,7 +128,7 @@ describe('injectConfig', () => { '({ a: JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"[{\\"data\\":{\\"x\\":0},\\"context\\":\\"test\\"}]"/*__INJECTED_END__*/), b: JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"[{\\"data\\":{\\"x\\":0},\\"context\\":\\"test\\"}]"/*__INJECTED_END__*/) })', }); - await injectConfig({ + await injectConfigIntoStatic({ ...baseOptions, appConfigs: [{ data: { x: 1, y: 2 }, context: 'test' }], }); diff --git a/plugins/app-backend/src/lib/config/injectConfigIntoStatic.ts b/plugins/app-backend/src/lib/config/injectConfigIntoStatic.ts new file mode 100644 index 0000000000..29975b0c2a --- /dev/null +++ b/plugins/app-backend/src/lib/config/injectConfigIntoStatic.ts @@ -0,0 +1,61 @@ +/* + * 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 fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; +import { InjectOptions } from './types'; + +/** + * Injects configs into the app bundle, replacing any existing injected config. + */ +export async function injectConfigIntoStatic( + options: InjectOptions, +): Promise { + const { staticDir, logger, appConfigs } = options; + + const files = await fs.readdir(staticDir); + const jsFiles = files.filter(file => file.endsWith('.js')); + + const escapedData = JSON.stringify(appConfigs).replace(/("|'|\\)/g, '\\$1'); + const injected = `/*__APP_INJECTED_CONFIG_MARKER__*/"${escapedData}"/*__INJECTED_END__*/`; + + for (const jsFile of jsFiles) { + const path = resolvePath(staticDir, jsFile); + + const content = await fs.readFile(path, 'utf8'); + if (content.includes('__APP_INJECTED_RUNTIME_CONFIG__')) { + logger.info(`Injecting env config into ${jsFile}`); + + const newContent = content.replaceAll( + '"__APP_INJECTED_RUNTIME_CONFIG__"', + injected, + ); + await fs.writeFile(path, newContent, 'utf8'); + return path; + } else if (content.includes('__APP_INJECTED_CONFIG_MARKER__')) { + logger.info(`Replacing injected env config in ${jsFile}`); + + const newContent = content.replaceAll( + /\/\*__APP_INJECTED_CONFIG_MARKER__\*\/.*?\/\*__INJECTED_END__\*\//g, + injected, + ); + await fs.writeFile(path, newContent, 'utf8'); + return path; + } + } + logger.info('Env config not injected'); + return undefined; +} diff --git a/plugins/app-backend/src/lib/config.ts b/plugins/app-backend/src/lib/config/readFrontendConfig.ts similarity index 55% rename from plugins/app-backend/src/lib/config.ts rename to plugins/app-backend/src/lib/config/readFrontendConfig.ts index df1146e746..becd1c2f52 100644 --- a/plugins/app-backend/src/lib/config.ts +++ b/plugins/app-backend/src/lib/config/readFrontendConfig.ts @@ -23,69 +23,17 @@ import { loadConfigSchema, readEnvConfig, } from '@backstage/config-loader'; -import { LoggerService } from '@backstage/backend-plugin-api'; - -type InjectOptions = { - appConfigs: AppConfig[]; - // Directory of the static JS files to search for file to inject - staticDir: string; - logger: LoggerService; -}; - -/** - * Injects configs into the app bundle, replacing any existing injected config. - */ -export async function injectConfig( - options: InjectOptions, -): Promise { - const { staticDir, logger, appConfigs } = options; - - const files = await fs.readdir(staticDir); - const jsFiles = files.filter(file => file.endsWith('.js')); - - const escapedData = JSON.stringify(appConfigs).replace(/("|'|\\)/g, '\\$1'); - const injected = `/*__APP_INJECTED_CONFIG_MARKER__*/"${escapedData}"/*__INJECTED_END__*/`; - - for (const jsFile of jsFiles) { - const path = resolvePath(staticDir, jsFile); - - const content = await fs.readFile(path, 'utf8'); - if (content.includes('__APP_INJECTED_RUNTIME_CONFIG__')) { - logger.info(`Injecting env config into ${jsFile}`); - - const newContent = content.replaceAll( - '"__APP_INJECTED_RUNTIME_CONFIG__"', - injected, - ); - await fs.writeFile(path, newContent, 'utf8'); - return path; - } else if (content.includes('__APP_INJECTED_CONFIG_MARKER__')) { - logger.info(`Replacing injected env config in ${jsFile}`); - - const newContent = content.replaceAll( - /\/\*__APP_INJECTED_CONFIG_MARKER__\*\/.*?\/\*__INJECTED_END__\*\//g, - injected, - ); - await fs.writeFile(path, newContent, 'utf8'); - return path; - } - } - logger.info('Env config not injected'); - return undefined; -} - -type ReadOptions = { - env: { [name: string]: string | undefined }; - appDistDir: string; - config: Config; - schema?: ConfigSchema; -}; /** * Read config from environment and process the backend config using the * schema that is embedded in the frontend build. */ -export async function readConfigs(options: ReadOptions): Promise { +export async function readFrontendConfig(options: { + env: { [name: string]: string | undefined }; + appDistDir: string; + config: Config; + schema?: ConfigSchema; +}): Promise { const { env, appDistDir, config } = options; const appConfigs = readEnvConfig(env); diff --git a/plugins/app-backend/src/lib/config/types.ts b/plugins/app-backend/src/lib/config/types.ts new file mode 100644 index 0000000000..e6a0e9141b --- /dev/null +++ b/plugins/app-backend/src/lib/config/types.ts @@ -0,0 +1,27 @@ +/* + * 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 { LoggerService } from '@backstage/backend-plugin-api'; +import { AppConfig } from '@backstage/config'; + +export interface InjectOptions { + appConfigs: AppConfig[]; + /** Directory of the public web content */ + rootDir: string; + /** Directory of the static JS files to search for file to inject */ + staticDir: string; + logger: LoggerService; +} diff --git a/plugins/app-backend/src/service/router.test.ts b/plugins/app-backend/src/service/router.test.ts index b7ad65706c..cdb150e06c 100644 --- a/plugins/app-backend/src/service/router.test.ts +++ b/plugins/app-backend/src/service/router.test.ts @@ -25,7 +25,7 @@ import { mockServices } from '@backstage/backend-test-utils'; jest.mock('../lib/config', () => ({ injectConfig: jest.fn(), - readConfigs: jest.fn(), + readFrontendConfig: jest.fn(), })); global.__non_webpack_require__ = { @@ -120,11 +120,13 @@ describe('createRouter with static fallback handler', () => { describe('createRouter config schema test', () => { const libConfigs = require('../lib/config'); const libConfigsActual = jest.requireActual('../lib/config'); - const readConfigsMock: jest.Mock = libConfigs.readConfigs; + const readFrontendConfigMock: jest.Mock = libConfigs.readFrontendConfig; beforeEach(() => { jest.resetAllMocks(); - readConfigsMock.mockImplementation(libConfigsActual.readConfigs); + readFrontendConfigMock.mockImplementation( + libConfigsActual.readFrontendConfig, + ); }); it('uses an external schema', async () => { @@ -155,7 +157,7 @@ describe('createRouter config schema test', () => { }), }); - const results = readConfigsMock.mock.results; + const results = readFrontendConfigMock.mock.results; expect(results.length).toBe(1); const mockedResult = results[0]; @@ -177,7 +179,7 @@ describe('createRouter config schema test', () => { appPackageName: 'example-app', }); - const results = readConfigsMock.mock.results; + const results = readFrontendConfigMock.mock.results; expect(results.length).toBe(1); const mockedResult = results[0]; diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index b205f16203..cd1c0e4ee2 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -26,7 +26,6 @@ import express from 'express'; import Router from 'express-promise-router'; import fs from 'fs-extra'; import { resolve as resolvePath } from 'path'; -import { injectConfig, readConfigs } from '../lib/config'; import { createStaticAssetMiddleware, findStaticAssets, @@ -44,6 +43,7 @@ import { LoggerService, } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; +import { injectConfig, readFrontendConfig } from '../lib/config'; // express uses mime v1 while we only have types for mime v2 type Mime = { lookup(arg0: string): string }; @@ -147,7 +147,7 @@ export async function createRouter( const appConfigs = disableConfigInjection ? undefined - : await readConfigs({ + : await readFrontendConfig({ config, appDistDir, env: process.env, @@ -266,7 +266,8 @@ async function createEntryPointRouter({ const staticDir = resolvePath(rootDir, 'static'); const injectedConfigPath = - appConfigs && (await injectConfig({ appConfigs, logger, staticDir })); + appConfigs && + (await injectConfig({ appConfigs, logger, rootDir, staticDir })); const router = Router(); diff --git a/yarn.lock b/yarn.lock index 7dcbde7cc0..2270448028 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4008,6 +4008,7 @@ __metadata: pirates: ^4.0.6 postcss: ^8.1.0 process: ^0.11.10 + raw-loader: ^4.0.2 react-dev-utils: ^12.0.0-next.60 react-refresh: ^0.14.0 recursive-readdir: ^2.2.2 @@ -32626,7 +32627,7 @@ __metadata: languageName: node linkType: hard -"loader-utils@npm:^2.0.4": +"loader-utils@npm:^2.0.0, loader-utils@npm:^2.0.4": version: 2.0.4 resolution: "loader-utils@npm:2.0.4" dependencies: @@ -38204,6 +38205,18 @@ __metadata: languageName: node linkType: hard +"raw-loader@npm:^4.0.2": + version: 4.0.2 + resolution: "raw-loader@npm:4.0.2" + dependencies: + loader-utils: ^2.0.0 + schema-utils: ^3.0.0 + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + checksum: 51cc1b0d0e8c37c4336b5318f3b2c9c51d6998ad6f56ea09612afcfefc9c1f596341309e934a744ae907177f28efc9f1654eacd62151e82853fcc6d37450e795 + languageName: node + linkType: hard + "rc-progress@npm:3.5.1": version: 3.5.1 resolution: "rc-progress@npm:3.5.1" @@ -40227,7 +40240,7 @@ __metadata: languageName: node linkType: hard -"schema-utils@npm:^3.1.1, schema-utils@npm:^3.2.0": +"schema-utils@npm:^3.0.0, schema-utils@npm:^3.1.1, schema-utils@npm:^3.2.0": version: 3.3.0 resolution: "schema-utils@npm:3.3.0" dependencies: