Skip to content

Commit 9d79a18

Browse files
Merge pull request #229 from CodeForPhilly/fix/spa-html-no-cache
fix(api): never cache the SPA entry HTML
2 parents 10557ca + 517ae71 commit 9d79a18

3 files changed

Lines changed: 26 additions & 0 deletions

File tree

‎apps/api/src/plugins/static-web.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ async function staticWebPlugin(fastify: FastifyInstance): Promise<void> {
5454
cacheControl: true,
5555
maxAge: '1y',
5656
immutable: true,
57+
// Never let the entry HTML carry those headers: with the default index
58+
// option `/` was served as index.html marked immutable for a year, so a
59+
// browser that had visited before a release kept the old SPA on every
60+
// plain navigation (specs/architecture.md → "Deployment"). `/` now falls
61+
// through to the handler below; a direct /index.html gets no-cache too.
62+
index: false,
63+
});
64+
65+
// A direct /index.html is served by the static plugin above; override the
66+
// immutable header it would stamp on it (the plugin's own setHeaders runs
67+
// before send writes Cache-Control, so a hook after the handler is needed).
68+
fastify.addHook('onSend', async (request, reply) => {
69+
if (request.url.split('?')[0] === '/index.html') reply.header('cache-control', 'no-cache');
5770
});
5871

5972
// Read index.html once at boot — it's small and avoids per-request disk IO.

‎apps/api/tests/deploy.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,21 @@ describe('static-web plugin', () => {
101101
expect(spaRes.body).toContain('<title>cfp</title>');
102102
expect(spaRes.headers['cache-control']).toContain('no-cache');
103103

104+
// The entry HTML is never cached — `/` and /index.html included. A
105+
// year-long immutable `/` kept old visitors on the pre-release SPA.
106+
for (const url of ['/', '/index.html']) {
107+
const res = await app.inject({ method: 'GET', url });
108+
expect(res.statusCode, url).toBe(200);
109+
expect(res.headers['content-type'], url).toMatch(/text\/html/);
110+
expect(res.body, url).toContain('<title>cfp</title>');
111+
expect(res.headers['cache-control'], url).toBe('no-cache');
112+
}
113+
104114
// Hashed asset served directly with long cache
105115
const assetRes = await app.inject({ method: 'GET', url: '/assets/app-deadbeef.js' });
106116
expect(assetRes.statusCode).toBe(200);
107117
expect(assetRes.body).toContain('console.log');
118+
expect(assetRes.headers['cache-control']).toContain('immutable');
108119

109120
// /api/* unknown route still returns JSON 404, not HTML
110121
const apiRes = await app.inject({ method: 'GET', url: '/api/does-not-exist' });

‎specs/architecture.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ npm run type-check # tsc --noEmit across workspaces
166166

167167
A single Docker image bundles the built API and serves the static `apps/web/dist` from the same Fastify instance via `@fastify/static`. One container, one ingress.
168168

169+
Caching: the hashed files under `/assets/` are immutable for a year; the entry HTML (`/`, `/index.html`, and every SPA fallthrough path) is `no-cache`, so a browser revalidates it on every navigation and picks up a release as soon as it lands. The HTML is the only file that decides which assets load next; caching it caches the old site.
170+
169171
Runtime configuration (sealed-secrets in our cluster):
170172

171173
| Env var | Purpose |

0 commit comments

Comments
 (0)