app-backend: serve templated index.html from memory

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-11-15 18:10:54 -07:00
parent 2f12077bf2
commit 815b702148
7 changed files with 81 additions and 43 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app-backend': patch
---
The `index.html` templating is now done and served from memory rather than written to the filesystem. This means that you can now use config injection with a read-only filesystem, and you no longer need to use the `app.disableConfigInjection` flag.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app-backend': minor
---
Configuration is no longer injected into static assets if a `index.html.tmpl` file is present.
@@ -24,13 +24,12 @@ import { InjectOptions } from './types';
*/
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);
): Promise<{ injectedPath?: string | undefined; indexHtmlContent?: Buffer }> {
const indexHtmlContent = await injectConfigIntoHtml(options);
if (indexHtmlContent) {
return { indexHtmlContent };
}
return injectConfigIntoStatic(options);
const injectedPath = await injectConfigIntoStatic(options);
return { injectedPath };
}
@@ -38,25 +38,22 @@ describe('injectConfigIntoHtml', () => {
mockDir.setContent({
'index.html.tmpl': "<html><%= config.getNumber('x') %></html>",
});
await injectConfigIntoHtml({
const result = await injectConfigIntoHtml({
...baseOptions,
appConfigs: [{ context: 'mock', data: { x: 1 } }],
});
expect(mockDir.content()).toMatchObject({
'index.html': '<html>1</html>',
});
expect(result?.toString('utf8')).toBe('<html>1</html>');
});
it('should inject config', async () => {
mockDir.setContent({
'index.html.tmpl': '<html><head></head></html>',
});
await injectConfigIntoHtml({
const result = await injectConfigIntoHtml({
...baseOptions,
appConfigs: [{ context: 'mock', data: { x: 1 } }],
});
expect(mockDir.content()).toMatchObject({
'index.html': `<html><head>
expect(result?.toString('utf8')).toBe(`<html><head>
<script type="backstage.io/config">
[
{
@@ -67,15 +64,14 @@ describe('injectConfigIntoHtml', () => {
}
]
</script>
</head></html>`,
});
</head></html>`);
});
it('should trim script tag endings from injected config', async () => {
mockDir.setContent({
'index.html.tmpl': '<html><head></head></html>',
});
await injectConfigIntoHtml({
const result = await injectConfigIntoHtml({
...baseOptions,
appConfigs: [
{
@@ -84,8 +80,7 @@ describe('injectConfigIntoHtml', () => {
},
],
});
expect(mockDir.content()).toMatchObject({
'index.html': `<html><head>
expect(result?.toString('utf8')).toBe(`<html><head>
<script type="backstage.io/config">
[
{
@@ -96,7 +91,6 @@ describe('injectConfigIntoHtml', () => {
}
]
</script>
</head></html>`,
});
</head></html>`);
});
});
@@ -25,13 +25,13 @@ const HTML_TEMPLATE_NAME = 'index.html.tmpl';
/** @internal */
export async function injectConfigIntoHtml(
options: InjectOptions,
): Promise<boolean> {
): Promise<Buffer | undefined> {
const { rootDir, appConfigs } = options;
const templatePath = resolvePath(rootDir, HTML_TEMPLATE_NAME);
if (!(await fs.exists(templatePath))) {
return false;
return undefined;
}
const templateContent = await fs.readFile(
@@ -67,13 +67,7 @@ ${JSON.stringify(appConfigs, null, 2)
</head>`,
);
await fs.writeFile(
resolvePath(rootDir, 'index.html'),
indexHtmlContentWithConfig,
'utf8',
);
return true;
return Buffer.from(indexHtmlContentWithConfig, 'utf8');
}
export function resolvePublicPath(config: Config) {
@@ -34,7 +34,11 @@ overridePackagePathResolution({
createRootLogger();
describe('appPlugin', () => {
beforeEach(() => {
afterEach(() => {
mockDir.clear();
});
it('boots', async () => {
mockDir.setContent({
'package.json': '{}',
dist: {
@@ -42,9 +46,7 @@ describe('appPlugin', () => {
'index.html': 'winning',
},
});
});
it('boots', async () => {
const { server } = await startTestBackend({
features: [
appPlugin,
@@ -67,4 +69,37 @@ describe('appPlugin', () => {
fetch(`http://localhost:${server.port()}`).then(res => res.text()),
).resolves.toBe('winning');
});
it('injects config into index.html', async () => {
mockDir.setContent({
'package.json': '{}',
dist: {
static: {},
'index.html.tmpl': '<html><head></head></html>',
},
});
const { server } = await startTestBackend({
features: [
appPlugin,
mockServices.rootConfig.factory({
data: {
app: {
disableStaticFallbackCache: true,
},
},
}),
],
});
const htmlContent = await fetch(
`http://localhost:${server.port()}/api/app/some/html5/route`,
).then(res => res.text());
expect(htmlContent).toBe(`<html><head>
<script type="backstage.io/config">
[]
</script>
</head></html>`);
});
});
+15 -9
View File
@@ -267,7 +267,7 @@ async function createEntryPointRouter({
}) {
const staticDir = resolvePath(rootDir, 'static');
const injectedConfigPath =
const injectResult =
appConfigs &&
(await injectConfig({ appConfigs, logger, rootDir, staticDir }));
@@ -278,7 +278,7 @@ async function createEntryPointRouter({
staticRouter.use(
express.static(staticDir, {
setHeaders: (res, path) => {
if (path === injectedConfigPath) {
if (injectResult?.injectedPath === path) {
res.setHeader('Cache-Control', CACHE_CONTROL_REVALIDATE_CACHE);
} else {
res.setHeader('Cache-Control', CACHE_CONTROL_MAX_CACHE);
@@ -317,13 +317,19 @@ async function createEntryPointRouter({
);
router.get('/*', (_req, res) => {
res.sendFile(resolvePath(rootDir, 'index.html'), {
headers: {
// The Cache-Control header instructs the browser to not cache the index.html since it might
// link to static assets from recently deployed versions.
'cache-control': CACHE_CONTROL_NO_CACHE,
},
});
if (injectResult?.indexHtmlContent) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', CACHE_CONTROL_NO_CACHE);
res.send(injectResult.indexHtmlContent);
} else {
res.sendFile(resolvePath(rootDir, 'index.html'), {
headers: {
// The Cache-Control header instructs the browser to not cache the index.html since it might
// link to static assets from recently deployed versions.
'cache-control': CACHE_CONTROL_NO_CACHE,
},
});
}
});
return router;