From 7c134140ffc0040c07a3b088f2b19f2fb003dbe0 Mon Sep 17 00:00:00 2001 From: karti Date: Wed, 12 Aug 2026 19:51:58 -0700 Subject: [PATCH] =?UTF-8?q?Fix=20static=20serving=20=E2=80=94=20og.png=20w?= =?UTF-8?q?as=20returning=20index.html?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/server.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index d7047a2..d51047b 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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); }