From 90ec4ce5dba88c0c7422b9930313d1c244a4eb72 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Wed, 9 Sep 2026 03:55:54 +0000 Subject: [PATCH 1/2] serve hashed client chunks as immutable so warm page loads stop revalidating all 186 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The built UI was served with express.static defaults, so every content-hashed chunk under /assets/ went out with `Cache-Control: public, max-age=0`. A browser treats that as stale on arrival: measured against a spare-port server with a request-counting proxy and Chrome with service workers blocked (the default plain-HTTP tailnet posture, where no service worker can register), a warm dashboard load re-sent all 186 chunks as conditional GETs and got 186 304s back before the app could start. Over HTTP/1.1 at six connections per host that is ~31 sequential round-trip waves of pure revalidation on every reload. Vite renames a chunk whenever its bytes change and the `no-cache` index.html is what points at the current set, so `/assets/**` is safe to serve with `immutable, max-age=1y`. `mountClientDist` in services/assetMounts.js does that as a first tier and keeps the revalidate-per-load default for the rest of dist/ — `sw.js`, `manifest.json`, `fonts/`, `sky/`, `hdri/` are client/public files copied under stable names whose bytes do change. `index: false` stays on both so `/` still reaches the build-id-stamped SPA fallback, and a chunk the current build no longer ships still misses both tiers and 404s, which the stale-chunk reload depends on. Same measurement after the change: 0 asset requests on a warm load or reload, and a warm navigation to a not-yet-visited route fetches only its own new chunks (14 instead of 42). --- server/index.js | 10 +++--- server/services/assetMounts.js | 36 +++++++++++++++++++ server/services/assetMounts.test.js | 55 ++++++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/server/index.js b/server/index.js index 4ba5432d53..279665f8d3 100644 --- a/server/index.js +++ b/server/index.js @@ -3,7 +3,7 @@ import { Server } from 'socket.io'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { PATHS } from './lib/fileUtils.js'; -import { mountAssetRoutes } from './services/assetMounts.js'; +import { mountAssetRoutes, mountClientDist } from './services/assetMounts.js'; import { existsSync } from 'fs'; import { createTailscaleServers } from '../lib/tailscale-https.js'; import { certPaths } from '../lib/certPaths.js'; @@ -453,10 +453,10 @@ mountAssetRoutes(app); // Serve built client UI (production mode — no Vite dev server needed) const CLIENT_DIST = join(__dirname, '..', 'client', 'dist'); if (existsSync(CLIENT_DIST)) { - // `index: false` keeps express.static from short-circuiting `/` (and any - // bare directory) with the raw index.html — that path needs to flow through - // the splat handler below so the meta-tag injection runs. - app.use(express.static(CLIENT_DIST, { index: false })); + // Hashed `/assets/**` as immutable, the rest of dist/ revalidated per load, + // and no index short-circuit on `/` — that path has to reach the splat + // handler below so the meta-tag injection runs. See services/assetMounts.js. + mountClientDist(app, CLIENT_DIST); // SPA fallback: serve index.html for page navigations only // Skip asset requests (.js, .css, etc.) so stale chunk requests get a proper 404 // instead of index.html with text/html MIME type. We serve the stamped HTML diff --git a/server/services/assetMounts.js b/server/services/assetMounts.js index 4ab3a4d10b..96357332eb 100644 --- a/server/services/assetMounts.js +++ b/server/services/assetMounts.js @@ -15,6 +15,7 @@ * resolve after the mock, not at import. */ import express from 'express'; +import { join } from 'path'; import { PATHS } from '../lib/fileUtils.js'; import { ServerError, sendErrorResponse } from '../lib/errorHandler.js'; import { ASSET_ROUTE_PREFIXES, SERVER_OWNED_PREFIXES } from '../lib/assetRoutePrefixes.js'; @@ -29,6 +30,23 @@ import { escapeRegExp } from '../lib/textUtils.js'; // retry to restart from byte 0 on a multi-MB PNG / video. const ASSET_STATIC_OPTS = { acceptRanges: true }; +// Vite names every chunk, entry, stylesheet and imported asset it emits under +// `dist/assets/` by content hash (`index-B5J1S4I5.js`), so the bytes behind one +// of those URLs can never change — a rebuild produces new names, and the +// `no-cache` index.html (served by the SPA fallback in `server/index.js`) is +// what points the browser at them. Say so with `immutable` and the one-year +// ceiling, and the browser stops asking. Without it serve-static's default +// `max-age=0` made every hashed chunk stale the moment it landed: measured on +// the dashboard with no service worker (the default plain-HTTP tailnet posture, +// where none can register), a warm page load re-sent all 186 chunks as +// conditional GETs and got 186 `304`s back — pure round trips, at HTTP/1.1's +// six-per-host, before the app could start. The rest of `dist/` is +// `client/public/` copied verbatim under STABLE names (`sw.js`, `manifest.json`, +// `fonts/`, `sky/`, `hdri/`), whose bytes do change under the same URL, so +// those keep the default and revalidate by ETag per load. +const IMMUTABLE_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1000; +const CLIENT_ASSET_STATIC_OPTS = { immutable: true, maxAge: IMMUTABLE_MAX_AGE_MS, index: false }; + // Only `/drafts/.md` is needed for federation body pulls. // Without this gate the static root would also serve adjacent work-metadata // JSON (manifest.json / manifest.imported.json on file-backend/migrated @@ -147,3 +165,21 @@ export function mountAssetRoutes(app, ownedPrefixes = SERVER_OWNED_PREFIXES) { }); }); } + +/** + * Serve the built client (`client/dist`) in two tiers — see + * `CLIENT_ASSET_STATIC_OPTS`: the content-hashed `/assets/**` as immutable, and + * everything else in `dist/` with serve-static's revalidate-per-load default. + * + * `index: false` on both keeps express.static from short-circuiting `/` (and + * any bare directory) with the raw index.html — that request has to reach the + * SPA fallback in `server/index.js`, which serves the build-id-stamped copy + * with `Cache-Control: no-cache`. A chunk the current build no longer ships + * (a browser reloading across a rebuild) misses both tiers and falls through + * to that fallback's extension guard, which 404s it rather than answering with + * HTML — the stale-chunk reload in the client relies on that 404. + */ +export function mountClientDist(app, distDir) { + app.use('/assets', express.static(join(distDir, 'assets'), CLIENT_ASSET_STATIC_OPTS)); + app.use(express.static(distDir, { index: false })); +} diff --git a/server/services/assetMounts.test.js b/server/services/assetMounts.test.js index f2a5e94578..d3b3dbfe57 100644 --- a/server/services/assetMounts.test.js +++ b/server/services/assetMounts.test.js @@ -31,7 +31,7 @@ afterAll(() => rmSync(tempRoot, { recursive: true, force: true })); // Dynamic, not a static import: the `vi.mock` factory above closes over // `tempRoot`, and a static import would be hoisted above that binding. -const { ASSET_DIR_ROUTES, ASSET_MOUNTS, mountAssetRoutes } = await import('./assetMounts.js'); +const { ASSET_DIR_ROUTES, ASSET_MOUNTS, mountAssetRoutes, mountClientDist } = await import('./assetMounts.js'); // Stand-in for the SPA fallback `server/index.js` installs after the asset // mounts — same extension guard, so the test exercises the real interaction @@ -171,3 +171,56 @@ describe('ASSET_MOUNTS', () => { expect(ASSET_MOUNTS.every(({ dir }) => typeof dir === 'function')).toBe(true); }); }); + +// The built client is served in two cache tiers. Vite content-hashes everything +// under `dist/assets/`, so a URL there can never change bytes and is safe to +// mark immutable — serve-static's default `max-age=0` had the browser re-send +// every one of the dashboard's 186 chunks as a conditional GET on each warm +// load (no service worker registers over plain HTTP, the default posture). +// Everything else in `dist/` is `client/public/` copied under a STABLE name and +// must keep revalidating, `sw.js` above all: a worker script pinned for a year +// would keep a stale caching strategy in every browser that had it. +describe('the built client mount', () => { + const dist = join(tempRoot, 'client-dist'); + let clientApp; + + beforeAll(() => { + mkdirSync(join(dist, 'assets'), { recursive: true }); + writeFileSync(join(dist, 'assets', 'index-B5J1S4I5.js'), 'CHUNKBYTES'); + writeFileSync(join(dist, 'sw.js'), 'WORKERBYTES'); + writeFileSync(join(dist, 'manifest.json'), '{"name":"PortOS"}'); + writeFileSync(join(dist, 'index.html'), 'RAW'); + clientApp = express(); + mountClientDist(clientApp, dist); + clientApp.use(spaFallback); + }); + + it('serves a hashed chunk as immutable for a year', async () => { + const res = await request(clientApp).get('/assets/index-B5J1S4I5.js'); + expect(res.status).toBe(200); + expect(res.text).toBe('CHUNKBYTES'); + expect(res.headers['cache-control']).toBe('public, max-age=31536000, immutable'); + }); + + it('keeps the service worker and the other stable-named files revalidating per load', async () => { + for (const path of ['/sw.js', '/manifest.json']) { + const res = await request(clientApp).get(path); + expect(res.status, path).toBe(200); + expect(res.headers['cache-control'], path).toBe('public, max-age=0'); + expect(res.headers.etag, path).toBeTruthy(); + } + }); + + it('lets / through to the SPA fallback instead of the raw index.html', async () => { + const res = await request(clientApp).get('/'); + expect(res.text).toContain('PortOS'); + expect(res.text).not.toContain('RAW'); + expect(res.headers['cache-control']).toBeUndefined(); + }); + + it('404s a chunk the current build no longer ships rather than answering with HTML', async () => { + const res = await request(clientApp).get('/assets/index-STALEHASH.js'); + expect(res.status).toBe(404); + expect(res.text).not.toContain('PortOS'); + }); +}); From fe044489e28c80485ba8abb8279159208d06914d Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Wed, 9 Sep 2026 03:58:15 +0000 Subject: [PATCH 2/2] name the stale-chunk test for what it asserts: not the SPA index, rather than not HTML --- server/services/assetMounts.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/services/assetMounts.test.js b/server/services/assetMounts.test.js index d3b3dbfe57..695c72c79e 100644 --- a/server/services/assetMounts.test.js +++ b/server/services/assetMounts.test.js @@ -218,7 +218,7 @@ describe('the built client mount', () => { expect(res.headers['cache-control']).toBeUndefined(); }); - it('404s a chunk the current build no longer ships rather than answering with HTML', async () => { + it('404s a chunk the current build no longer ships instead of answering with the SPA index', async () => { const res = await request(clientApp).get('/assets/index-STALEHASH.js'); expect(res.status).toBe(404); expect(res.text).not.toContain('PortOS');