app-backend: add support for templating the new index.html.tmpl

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-08-30 15:34:22 +02:00
parent 1c8d7571c5
commit 590fb2dfd7
11 changed files with 331 additions and 75 deletions
@@ -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(/\/+$/, '');
}
@@ -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;
}
@@ -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];
+4 -3
View File
@@ -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();