diff --git a/.changeset/good-humans-draw.md b/.changeset/good-humans-draw.md new file mode 100644 index 0000000000..3e97fa15b6 --- /dev/null +++ b/.changeset/good-humans-draw.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-app-backend': patch +--- + +Add a `Cache-Control: no-store, max-age=0` header to the `index.html` response to instruct the browser to not cache the pages. +This tells the browser to not serve a cached `index.html` that might link to static assets from a previous deployment that are not available anymore. diff --git a/plugins/app-backend/src/service/router.test.ts b/plugins/app-backend/src/service/router.test.ts index 35dd9287a6..cabe4c59b9 100644 --- a/plugins/app-backend/src/service/router.test.ts +++ b/plugins/app-backend/src/service/router.test.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { resolve as resolvePath } from 'path'; import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import express from 'express'; import Router from 'express-promise-router'; +import { resolve as resolvePath } from 'path'; import request from 'supertest'; import { createRouter } from './router'; @@ -68,6 +68,22 @@ describe('createRouter', () => { expect(response.status).toBe(200); expect(response.text.trim()).toBe('this is index.html'); }); + + it.each(['/index.html', '/other.html', '/missing.html'])( + 'returns %s with no-store Cache-Control header', + async file => { + const response = await request(app).get(file); + expect(response.header['cache-control']).toBe('no-store, max-age=0'); + }, + ); + + it.each(['/static/main.txt'])( + 'returns %s with default Cache-Control header', + async file => { + const response = await request(app).get(file); + expect(response.header['cache-control']).toBe('public, max-age=0'); + }, + ); }); describe('createRouter with static fallback handler', () => { diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index 4379fcccb9..8fe8355bc3 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -14,14 +14,17 @@ * limitations under the License. */ -import { resolve as resolvePath } from 'path'; -import express from 'express'; -import Router from 'express-promise-router'; -import { Logger } from 'winston'; import { notFoundHandler, resolvePackagePath } from '@backstage/backend-common'; import { Config } from '@backstage/config'; -import { injectConfig, readConfigs } from '../lib/config'; +import express from 'express'; +import Router from 'express-promise-router'; import fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; +import { Logger } from 'winston'; +import { injectConfig, readConfigs } from '../lib/config'; + +// express uses mime v1 while we only have types for mime v2 +type Mime = { lookup(arg0: string): string }; export interface RouterOptions { config: Config; @@ -95,9 +98,28 @@ export async function createRouter( staticRouter.use(notFoundHandler()); router.use('/static', staticRouter); - router.use(express.static(appDistDir)); + router.use( + express.static(appDistDir, { + setHeaders: (res, path) => { + // The Cache-Control header instructs the browser to not cache html files since it might + // link to static assets from recently deployed versions. + if ( + ((express.static.mime as unknown) as Mime).lookup(path) === + 'text/html' + ) { + res.setHeader('Cache-Control', 'no-store, max-age=0'); + } + }, + }), + ); router.get('/*', (_req, res) => { - res.sendFile(resolvePath(appDistDir, 'index.html')); + res.sendFile(resolvePath(appDistDir, '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': 'no-store, max-age=0', + }, + }); }); return router;