Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions server/services/assetMounts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 `<workId>/drafts/<draftId>.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
Expand Down Expand Up @@ -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 }));
}
55 changes: 54 additions & 1 deletion server/services/assetMounts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'), '<!DOCTYPE html><html><body>RAW</body></html>');
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 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');
});
});