/** * The PIG server. * * Serves the API and, in production, the built front end from the same origin. * Same-origin matters more than it might appear: the browser holds its auth * session per origin, so splitting the app across two hostnames turns sign-in * into a loop that looks like a broken deployment. */ import { serve } from '@hono/node-server'; import { serveStatic } from '@hono/node-server/serve-static'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { createDatabase } from '@pig/db'; import { createApp } from './app'; import { loadConfig } from './lib/config'; import { startPrimeSync } from './services/sync'; import { reconcileInviteCode } from './services/bootstrap'; import { CapacityService } from './services/capacity'; import { effectivePrimeSyncConfig } from './routes/admin-settings'; import { NotificationOutbox, NotificationWorker } from './services/notification-outbox'; import { SlackNotifier } from './services/slack'; import { BuzzNotifier } from './services/buzz'; const config = loadConfig(); const db = createDatabase({ url: config.DATABASE_URL }); let stopSync = () => {}; async function reloadPrimeSync(): Promise { stopSync(); stopSync = startPrimeSync(await effectivePrimeSyncConfig(config, db), db); } const app = createApp(config, db, undefined, { onPlatformSettingsChanged: reloadPrimeSync }); // Serve the built SPA when it exists. Absent in development, where Vite serves // it on its own port with hot reload. const webDist = join(process.cwd(), 'apps/web/dist'); if (existsSync(webDist)) { /* * 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. * * The `/api/` guard is repeated here deliberately. Without it an unknown API * path — a typo, a renamed endpoint, an older client — falls through to this * fallback and returns **HTTP 200 with the SPA's HTML**. That is close to the * worst possible failure for an API consumer: `response.ok` is true, so * nothing treats it as an error, and the caller then fails on `JSON.parse` * with "Unexpected token '<'" a long way from the actual cause. The MCP * server, the CLI and Piggy all consume this API and would all have hit it. * * Confirmed against production before fixing: an authenticated GET to * /api/keys (the real path is /api/api-keys) returned 200 text/html. */ app.get('*', async (c, next) => { if (new URL(c.req.url).pathname.startsWith('/api/')) return next(); return serveStatic({ path: './apps/web/dist/index.html' })(c, next); }); console.log('[pig] serving front end from', webDist); } // Reconcile configuration into the database before accepting traffic, so a // freshly set invite code works on the first request rather than the second. await reconcileInviteCode(config, db).catch((error) => console.error('[pig] could not reconcile the invite code:', error), ); const server = serve({ fetch: app.fetch, port: config.PIG_PORT }, (info) => { console.log(`[pig] listening on http://localhost:${info.port}`); console.log(`[pig] environment: ${config.NODE_ENV}`); }); // Background work. Both are optional and the application is fully usable with // neither running. await reloadPrimeSync(); const capacity = new CapacityService(db); const notificationOutbox = new NotificationOutbox(db); const slackNotificationsEnabled = Boolean(config.SLACK_BOT_TOKEN); const buzzNotificationsEnabled = Boolean(config.BUZZ_RELAY_URL && config.BUZZ_PRIVATE_KEY); const stopSlackNotifications = config.SLACK_BOT_TOKEN ? new NotificationWorker(db, new SlackNotifier({ botToken: config.SLACK_BOT_TOKEN })).start() : () => {}; const stopBuzzNotifications = config.BUZZ_RELAY_URL && config.BUZZ_PRIVATE_KEY ? new NotificationWorker( db, new BuzzNotifier({ relayUrl: config.BUZZ_RELAY_URL, privateKey: config.BUZZ_PRIVATE_KEY, authTag: config.BUZZ_AUTH_TAG, }), ).start() : () => {}; let checkingIdleCapacity = false; const checkIdleCapacity = () => { if (checkingIdleCapacity || (!slackNotificationsEnabled && !buzzNotificationsEnabled)) return; checkingIdleCapacity = true; void capacity .idleCapacity({ thresholdPct: 0.25, withinDays: 30 }) .then((rows) => notificationOutbox.enqueueIdleCapacity(rows)) .catch((error) => console.error('[pig] idle-capacity notification queue failed', error)) .finally(() => { checkingIdleCapacity = false; }); }; const idleNotificationTimer = setInterval(checkIdleCapacity, 6 * 60 * 60 * 1_000); idleNotificationTimer.unref(); checkIdleCapacity(); const holdSweeper = setInterval( () => { void capacity .sweepExpiredHolds() .then((n) => n > 0 && console.log(`[pig] released ${n} expired capacity hold(s)`)) .catch((error) => console.error('[pig] hold sweep failed', error)); }, 5 * 60 * 1000, ); /** * Shut down cleanly so that in-flight requests finish and Postgres connections * are returned rather than left for the server to reap. */ function shutdown(signal: string) { console.log(`[pig] ${signal} received, shutting down`); clearInterval(holdSweeper); clearInterval(idleNotificationTimer); stopSlackNotifications(); stopBuzzNotifications(); stopSync(); server.close(() => process.exit(0)); // Do not hang forever if a connection refuses to drain. setTimeout(() => process.exit(1), 10_000).unref(); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));