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); }