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/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();