fix(pwa): recover from a dead chunk and a broken precache after an update (#2524)

After an update, a tab or a device whose service worker still serves the
previous build asks for chunks the update removed. The server answered those
requests with index.html and a 200, so the import failed with a MIME error,
and everything caching in between was free to keep the HTML as the chunk. A
precache install that met a missing file stored it, and Workbox reuses hashed
entries by URL, so the device showed "A new version is available" on every
load, whatever the reload or the cleared browser cache, and carried the broken
entry into every later release that keeps the file's name.

The SPA fallback now answers a GET for a build file that is not on disk with
a plain 404 (no-store) instead of index.html. A precache install refuses it,
so the worker whose cache is complete stays in charge until the new build is.
index.html is sent relative to the public dir, which also stops an install
living under a dot directory from answering every deep link with a 404.

On the client, the reload after a dead chunk (the automatic one and the
button on the update screen) no longer goes straight back to the worker that
served the broken shell. When that same worker is still in charge and the
server answers, its precache and registration are dropped first, so the
reload reaches the build the server runs now. Offline, and in offline mode,
the shell is kept. Every recovery reload, and the reload of the version
handover, asks for the page under a one-off query parameter, so a proxy or
CDN that keeps index.html against its headers cannot hand back the stale
shell either.

A chunk the entry imports statically fails before any of that code runs.
shell-guard.js, a small classic script loaded ahead of the entry, watches for
exactly that failure and runs the same recovery, once per session.

A stylesheet, font or icon that holds a page raises no error at all, so once
the app has started and the browser is idle, it looks through the precache
for entries that hold a page under a build file's name, fetches each from
the server past every cache and puts it back in place. A repaired stylesheet
is loaded again, and so are the app's stylesheets when a font was repaired,
since the browser does not try a face that failed again in the same
document. Only the headers are read, a precache found sound is not looked at
again until its entries change, nothing is fetched while nothing is broken,
and nothing happens offline, in offline mode or without storage. An answer
that is not the file (an auth wall, its login page, a server error) leaves
the entry for the next launch.

A page the recovery loaded from the network already runs the version the
server reports, so the version handover now records it instead of reloading
it a second time once the new worker takes over. A page loaded past an older
worker that is still in charge, as a hard reload does, still asks for the new
worker and records the version only once that has taken over, so the next
tab or launch cannot get the old shell without a handover.
This commit is contained in:
Maurice
2026-09-25 22:50:59 +00:00
parent 26ab4a58b1
commit bea3af4b45
17 changed files with 1867 additions and 54 deletions
+39 -5
View File
@@ -20,6 +20,43 @@ import { StorageInvalidKeyError, StorageNotFoundError, type StorageCategory } fr
export const PUBLIC_DIR = path.join(__dirname, '../../../public');
// File types the client build emits. No page of the app ends in one of these.
const BUILD_FILE_RE = /\.(?:m?js|css|map|wasm|webmanifest|json|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf)$/i;
/**
* Whether a request asks for a file of the built client rather than a page of it.
*
* Such a request that the static handler could not answer is a file that does not
* exist, and it must end in a 404. It used to get index.html like a page (#2524).
* A tab still running the previous build asks for a chunk the update removed and
* gets HTML with a 200, which the module loader rejects. Worse, a 200 is a success
* to everything that caches: a service worker's precache stores the HTML under the
* chunk's name and keeps reusing it in every later version that keeps the name,
* and a reverse proxy that caches static files by extension hands it out as the
* chunk. A 404 is refused by the precache and never stands in for the file.
*/
export function isBuildFilePath(pathname: string): boolean {
return pathname.startsWith('/assets/') || BUILD_FILE_RE.test(pathname);
}
/**
* The production answer to a GET that nothing else matched: index.html for a page
* of the app, a plain 404 in the TREK envelope for a build file that is not there.
* Shared by both fallback forms (applyPlatformSpa and SpaFallbackFilter).
*/
export function answerUnmatchedGet(req: Request, res: Response, message = 'Not Found'): void {
if (isBuildFilePath(req.path)) {
res.setHeader('Cache-Control', 'no-store');
res.status(404).json({ error: message });
return;
}
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
// Relative to root, so that send() checks only 'index.html' for dot segments. Given
// the absolute path it refused an install living under a dot directory (a
// ~/.local checkout, say) and every deep link came back as a 404.
res.sendFile('index.html', { root: PUBLIC_DIR });
}
/**
* express.static replacement for the four public /uploads mounts (storage
* slice 3). Parity contract (spec §Serving): identical ETag / Last-Modified /
@@ -172,17 +209,14 @@ export function applyPlatformUploads(app: express.Application, storage: StorageS
* NOT use this (its router terminates unmatched requests with a 404 before any
* post-init route runs, and Express 5's path-to-regexp rejects a bare '*'); it serves
* the SPA via the SpaFallbackFilter instead. Both produce the identical result:
* unmatched GET → index.html in production.
* unmatched GET → index.html in production, or a 404 for a missing build file.
*/
export function applyPlatformSpa(app: express.Application): void {
applyPlatformStatic(app);
// Case-sensitive on purpose (legacy parity).
if (readEnv().app.nodeEnv !== 'production') return;
// /.*/ rather than '*' so the helper is Express-4 and Express-5 safe.
app.get(/.*/, (_req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
});
app.get(/.*/, (req: Request, res: Response) => answerUnmatchedGet(req, res));
}
/**
@@ -1,8 +1,7 @@
import { ArgumentsHost, Catch, ExceptionFilter, NotFoundException } from '@nestjs/common';
import type { Request, Response } from 'express';
import path from 'node:path';
import { readEnv } from '../../app-config';
import { PUBLIC_DIR } from './platform.routes';
import { answerUnmatchedGet } from './platform.routes';
/**
* Serves the built SPA (index.html) for any request the NestJS router did not
@@ -17,6 +16,9 @@ import { PUBLIC_DIR } from './platform.routes';
* client) keeps the standard TREK `{ error }` 404 envelope. The `@Catch(NotFoundException)`
* is more specific than the global TrekExceptionFilter, so Nest routes 404s here
* while every other error still flows through TrekExceptionFilter.
*
* A GET for a build file that is not on disk (a chunk of the previous release,
* say) is not a page and gets the 404 envelope too, never index.html (#2524).
*/
@Catch(NotFoundException)
export class SpaFallbackFilter implements ExceptionFilter {
@@ -27,8 +29,7 @@ export class SpaFallbackFilter implements ExceptionFilter {
// Case-sensitive on purpose (legacy parity).
if (readEnv().app.nodeEnv === 'production' && req.method === 'GET') {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
answerUnmatchedGet(req, res, exception.message || 'Not Found');
return;
}
+112
View File
@@ -0,0 +1,112 @@
/**
* SPA fallback e2e (#2524). Boots a Nest app with the production static layer and
* the two global filters wired the way AppModule and bootstrap wire them, then asks
* over HTTP for a page, for a build file that exists, and for build files an
* update removed.
*
* The last case is the bug: a tab or service worker still on the previous release
* asks for its chunks, and those requests used to be answered with index.html and a
* 200, which a precache then kept as the chunk. They must be a plain 404.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import request from 'supertest';
import type { Server } from 'http';
import { Module, type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { SpaFallbackFilter } from '../../src/nest/platform/spa-fallback.filter';
import { applyPlatformStatic, PUBLIC_DIR } from '../../src/nest/platform/platform.routes';
@Module({})
class NoRoutesModule {}
const PRESENT_CHUNK = 'assets/e2e-2524-present-Q1w2E3r4.js';
const created: string[] = [];
const createdDirs: string[] = [];
/** Writes a file below the served public dir unless the build already put one there. */
function ensurePublicFile(rel: string, content: string): void {
const file = path.join(PUBLIC_DIR, rel);
if (fs.existsSync(file)) return;
const dir = path.dirname(file);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
createdDirs.push(dir);
}
fs.writeFileSync(file, content);
created.push(file);
}
describe('SPA fallback e2e: pages get index.html, missing build files a 404 (#2524)', () => {
const previousEnv = process.env.NODE_ENV;
let app: INestApplication;
let server: Server;
beforeAll(async () => {
ensurePublicFile('index.html', '<!DOCTYPE html><html><body><div id="root"></div></body></html>');
ensurePublicFile(PRESENT_CHUNK, 'export default 1;\n');
process.env.NODE_ENV = 'production';
const moduleRef = await Test.createTestingModule({ imports: [NoRoutesModule] }).compile();
app = moduleRef.createNestApplication();
// Before init, like bootstrap.ts: a real file is answered ahead of Nest's router.
applyPlatformStatic(app.getHttpAdapter().getInstance());
// Same pair and order as the APP_FILTER providers in AppModule.
app.useGlobalFilters(new TrekExceptionFilter(), new SpaFallbackFilter());
await app.init();
server = app.getHttpServer();
});
afterAll(async () => {
await app?.close();
for (const file of created) fs.rmSync(file, { force: true });
for (const dir of createdDirs.reverse()) fs.rmSync(dir, { recursive: true, force: true });
if (previousEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = previousEnv;
});
it('answers a page of the app with index.html', async () => {
const res = await request(server).get('/dashboard');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/html');
expect(res.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
expect(res.text.toLowerCase()).toContain('<html');
});
it('serves a build file that exists', async () => {
const res = await request(server).get(`/${PRESENT_CHUNK}`);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('javascript');
});
it('answers a chunk of the previous release with a 404, not index.html', async () => {
const res = await request(server).get('/assets/DashboardPage-v4ODOTOr.js');
expect(res.status).toBe(404);
expect(res.headers['content-type']).toContain('application/json');
expect(res.headers['cache-control']).toBe('no-store');
expect(res.body).toEqual({ error: 'Cannot GET /assets/DashboardPage-v4ODOTOr.js' });
});
// The whole answer is asserted, not just the status: a checkout under a dot
// directory (~/.local) made the old fallback fail with a 404 of its own, which
// a status check alone would count as the fix.
it.each([
['a cache-busted retry of that chunk', '/assets/DashboardPage-v4ODOTOr.js?t=1790358416100'],
['its stylesheet', '/assets/DashboardPage-v4ODOTOr.css'],
['a top-level build file', '/manifest-e2e-2524.webmanifest'],
])('does the same for %s', async (_case, url) => {
const res = await request(server).get(url);
expect(res.status).toBe(404);
expect(res.headers['content-type']).toContain('application/json');
expect(res.headers['cache-control']).toBe('no-store');
expect(res.body).toEqual({ error: `Cannot GET ${url}` });
});
it('keeps the JSON envelope for a non-GET miss', async () => {
const res = await request(server).post('/dashboard').send({});
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Cannot POST /dashboard' });
});
});
@@ -17,6 +17,8 @@ import {
applyPlatformSpa,
applyPlatformStatic,
storageStaticHandler,
isBuildFilePath,
PUBLIC_DIR,
} from '../../../src/nest/platform/platform.routes';
import { SpaFallbackFilter } from '../../../src/nest/platform/spa-fallback.filter';
import { StorageNotFoundError, StorageInvalidKeyError } from '../../../src/nest/storage/storage.types';
@@ -405,10 +407,56 @@ describe('applyPlatformSpa', () => {
const catchAll = calls.find((c) => c.method === 'get');
expect(catchAll).toBeDefined();
const res = makeRes();
catchAll!.handlers[0]({}, res);
catchAll!.handlers[0]({ path: '/trips/7' }, res);
expect(res.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate');
expect(String(res.body)).toContain('FILE:');
expect(String(res.body)).toContain('index.html');
expect(res.sendFile).toHaveBeenCalledWith('index.html', { root: PUBLIC_DIR });
});
it('answers a missing build file with a 404, not index.html (#2524)', () => {
process.env.NODE_ENV = 'production';
const { app, calls } = fakeApp();
applyPlatformSpa(app);
const catchAll = calls.find((c) => c.method === 'get');
const res = makeRes();
catchAll!.handlers[0]({ path: '/assets/DashboardPage-v4ODOTOr.js' }, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Not Found' });
expect(res.headers['Cache-Control']).toBe('no-store');
expect(res.sendFile).not.toHaveBeenCalled();
});
});
describe('isBuildFilePath', () => {
it.each([
'/assets/DashboardPage-v4ODOTOr.js',
'/assets/index-cFGeBWen.css',
'/assets/poppins-latin-400-normal-abc.woff2',
'/assets/anything-without-a-known-extension',
'/sw.js',
'/workbox-2da51cb1.js',
'/registerSW.js',
'/manifest.webmanifest',
'/icons/icon-192x192.png',
'/favicon.ico',
'/fonts/Inter.TTF',
])('treats %s as a build file', (p) => {
expect(isBuildFilePath(p)).toBe(true);
});
it.each([
'/',
'/dashboard',
'/trips/12/files',
'/journey/3/studio',
'/plugins/trip-todos',
'/help/Atlas',
'/shared/0f3a9c',
'/public/journey/AbC_-12',
'/oauth/consent',
])('treats the page %s as a page', (p) => {
expect(isBuildFilePath(p)).toBe(false);
});
});
@@ -416,22 +464,55 @@ describe('SpaFallbackFilter', () => {
const original = process.env.NODE_ENV;
afterEach(() => { process.env.NODE_ENV = original; });
function host(req: { method: string }, res: ReturnType<typeof makeRes>) {
function host(req: { method: string; path?: string }, res: ReturnType<typeof makeRes>) {
return { switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }) } as never;
}
it('serves index.html for an unmatched GET in production', () => {
process.env.NODE_ENV = 'production';
const res = makeRes();
new SpaFallbackFilter().catch(new NotFoundException('nope'), host({ method: 'GET' }, res));
new SpaFallbackFilter().catch(new NotFoundException('nope'), host({ method: 'GET', path: '/dashboard' }, res));
expect(res.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate');
expect(String(res.body)).toContain('index.html');
});
it('answers a GET for a chunk that is not on disk with a 404 in production (#2524)', () => {
process.env.NODE_ENV = 'production';
const res = makeRes();
new SpaFallbackFilter().catch(
new NotFoundException('Cannot GET /assets/DashboardPage-v4ODOTOr.js'),
host({ method: 'GET', path: '/assets/DashboardPage-v4ODOTOr.js' }, res),
);
// A 200 with index.html here is what a service worker precached as the chunk.
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Cannot GET /assets/DashboardPage-v4ODOTOr.js' });
expect(res.headers['Cache-Control']).toBe('no-store');
expect(res.sendFile).not.toHaveBeenCalled();
});
it('answers a missing top-level build file with a 404 too', () => {
process.env.NODE_ENV = 'production';
const res = makeRes();
new SpaFallbackFilter().catch(new NotFoundException(), host({ method: 'GET', path: '/workbox-0000aaaa.js' }, res));
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Not Found' });
});
it('answers a missing build file whose exception has no message with Not Found (#2524)', () => {
process.env.NODE_ENV = 'production';
const res = makeRes();
const exc = new NotFoundException();
Object.defineProperty(exc, 'message', { value: '' });
new SpaFallbackFilter().catch(exc, host({ method: 'GET', path: '/assets/gone-0000aaaa.js' }, res));
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Not Found' });
expect(res.sendFile).not.toHaveBeenCalled();
});
it('keeps the JSON 404 envelope for a non-GET miss in production', () => {
process.env.NODE_ENV = 'production';
const res = makeRes();
new SpaFallbackFilter().catch(new NotFoundException('gone'), host({ method: 'POST' }, res));
new SpaFallbackFilter().catch(new NotFoundException('gone'), host({ method: 'POST', path: '/dashboard' }, res));
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'gone' });
});
@@ -439,7 +520,7 @@ describe('SpaFallbackFilter', () => {
it('keeps the JSON 404 envelope outside production even for GET', () => {
process.env.NODE_ENV = 'development';
const res = makeRes();
new SpaFallbackFilter().catch(new NotFoundException('missing'), host({ method: 'GET' }, res));
new SpaFallbackFilter().catch(new NotFoundException('missing'), host({ method: 'GET', path: '/dashboard' }, res));
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'missing' });
});
@@ -450,7 +531,7 @@ describe('SpaFallbackFilter', () => {
const exc = new NotFoundException();
// force an empty message so the || branch is taken
Object.defineProperty(exc, 'message', { value: '' });
new SpaFallbackFilter().catch(exc, host({ method: 'GET' }, res));
new SpaFallbackFilter().catch(exc, host({ method: 'GET', path: '/dashboard' }, res));
expect(res.body).toEqual({ error: 'Not Found' });
});
});