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,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