app-backend: add support for templating the new index.html.tmpl
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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`.
|
||||
@@ -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';
|
||||
@@ -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<string | undefined> {
|
||||
// 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);
|
||||
}
|
||||
@@ -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': "<html><%= config.getNumber('x') %></html>",
|
||||
});
|
||||
await injectConfigIntoHtml({
|
||||
...baseOptions,
|
||||
appConfigs: [{ context: 'mock', data: { x: 1 } }],
|
||||
});
|
||||
expect(mockDir.content()).toMatchObject({
|
||||
'index.html': '<html>1</html>',
|
||||
});
|
||||
});
|
||||
|
||||
it('should inject config', async () => {
|
||||
mockDir.setContent({
|
||||
'index.html.tmpl': '<html><head></head></html>',
|
||||
});
|
||||
await injectConfigIntoHtml({
|
||||
...baseOptions,
|
||||
appConfigs: [{ context: 'mock', data: { x: 1 } }],
|
||||
});
|
||||
expect(mockDir.content()).toMatchObject({
|
||||
'index.html': `<html><head>
|
||||
<script type="backstage.io/config">
|
||||
[
|
||||
{
|
||||
"context": "mock",
|
||||
"data": {
|
||||
"x": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
</script>
|
||||
</head></html>`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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(
|
||||
'</head>',
|
||||
`
|
||||
<script type="backstage.io/config">
|
||||
${JSON.stringify(appConfigs, null, 2)}
|
||||
</script>
|
||||
</head>`,
|
||||
);
|
||||
|
||||
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(/\/+$/, '');
|
||||
}
|
||||
+10
-9
@@ -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' }],
|
||||
});
|
||||
@@ -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<string | undefined> {
|
||||
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;
|
||||
}
|
||||
+6
-58
@@ -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<string | undefined> {
|
||||
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<AppConfig[]> {
|
||||
export async function readFrontendConfig(options: {
|
||||
env: { [name: string]: string | undefined };
|
||||
appDistDir: string;
|
||||
config: Config;
|
||||
schema?: ConfigSchema;
|
||||
}): Promise<AppConfig[]> {
|
||||
const { env, appDistDir, config } = options;
|
||||
|
||||
const appConfigs = readEnvConfig(env);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user