Fix static serving — og.png was returning index.html

The server mounted static files at /assets only, so every top-level file fell
through to the SPA catch-all. og.png, apple-touch-icon.png, icon-192.png and
manifest.webmanifest each returned `200 text/html` containing the app shell.

This is a nearly invisible failure. The app works. The tab shows an icon,
because browsers cache the SVG. Nothing errors anywhere. But a link shared to
iMessage, Slack or X fetches og.png, receives HTML, and renders a card with no
image — which is the entire point of having made one.

Now the whole dist directory is served, with /api excluded so routes are never
answered from disk, and the SPA fallback still catching client-side routes that
have no file behind them.

Found by fetching the asset from an outside host instead of trusting that
adding the file was enough. Verified: og.png is image/png at 53KB, the icons
and manifest carry their real types, /margin and /capacity still receive the
shell, and the API is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 19:51:58 -07:00
parent d154a78d48
commit 7c134140ff
+25 -1
View File
@@ -25,7 +25,31 @@ const app = createApp(config, db);
// it on its own port with hot reload.
const webDist = join(process.cwd(), 'apps/web/dist');
if (existsSync(webDist)) {
app.use('/assets/*', serveStatic({ root: './apps/web/dist' }));
/*
* Serve the ENTIRE dist directory, not just /assets.
*
* Serving only /assets and letting everything else fall through to the SPA
* shell quietly breaks every top-level file: og.png, the icons, the manifest
* and robots.txt each returned `200 text/html` containing index.html.
*
* That failure is close to invisible. The app works, the browser tab shows
* an icon (from the SVG, which browsers cache aggressively), and nothing
* errors — but a link shared to iMessage, Slack or X fetches og.png, gets
* HTML, and renders a card with no image. Verified with a real request from
* an outside host rather than assumed, because that is the only way this
* class of bug shows up.
*
* serveStatic calls next() when there is no matching file, so unmatched
* paths still reach the SPA fallback below.
*/
app.use('*', async (c, next) => {
// API routes must never be answered from disk.
if (new URL(c.req.url).pathname.startsWith('/api/')) return next();
return serveStatic({ root: './apps/web/dist' })(c, next);
});
// Client-side routes (/margin, /capacity, …) have no file on disk and must
// receive the shell so the router can take over.
app.get('*', serveStatic({ path: './apps/web/dist/index.html' }));
console.log('[pig] serving front end from', webDist);
}