import fs from 'fs'; import path from 'path'; import { execSync } from 'child_process'; import { fileURLToPath, pathToFileURL } from 'url'; import express from 'express'; import cookieParser from 'cookie-parser'; import { loggerMiddleware } from './middleware/logger.js'; import env from '../config/env.js'; import { authMiddleware } from './middleware/auth.js'; import { ensureConnection } from '../db/store.js'; import { closeAll as closeAllSse } from './sse.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); async function loadRoutes(app, basePath = '') { const routesDir = path.join(__dirname, 'routes'); async function walk(dir) { for (const file of fs.readdirSync(dir)) { const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { await walk(fullPath); } else if (file.endsWith('.js')) { const routeModule = await import(pathToFileURL(fullPath).href); if (routeModule.register) { routeModule.register(app, basePath); } } } } await walk(routesDir); } let server; let starting; let startedAt; function corsMiddleware(req, res, next) { const origin = req.headers.origin; if (origin && env.corsOrigins.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); } if (req.method === 'OPTIONS') { return res.sendStatus(204); } next(); } async function createApi() { const app = express(); app.use((req, res, next) => { // Do not attach no-store to SSE endpoint if (req.path !== '/api/events') { res.setHeader('Cache-Control', 'no-store'); } next(); }); app.use(corsMiddleware); app.use(express.json({ limit: env.bodyLimit })); app.use(cookieParser()); app.use(loggerMiddleware); app.use(authMiddleware); if (startedAt) { app.use((req, res, next) => { res.setHeader('X-Server-Started-At', startedAt); next(); }); } await loadRoutes(app, '/api'); app.use((err, _req, res, next) => { if (err.type === 'entity.too.large') { return res.status(413).json({ error: 'Payload too large' }); } if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { return res.status(400).json({ error: 'Invalid JSON body' }); } next(err); }); return app; } export async function startApi() { if (server) { return server; } if (starting) { return starting; } starting = (async () => { await ensureConnection(); startedAt = new Date().toISOString(); const app = await createApi(); await new Promise((resolve, reject) => { const instance = app.listen(env.port); instance.once('listening', () => { server = instance; console.log(`API listening on port ${env.port}`); resolve(); }); instance.once('error', (err) => { if (err.code === 'EADDRINUSE') { let detail = ''; try { const pids = execSync(`lsof -ti :${env.port}`, { encoding: 'utf8' }).trim(); if (pids) detail = ` (PID ${pids.replace(/\n/g, ', ')})`; } catch { // lsof unavailable or no match } console.error(`Port ${env.port} is already in use${detail}. Check with: lsof -i :${env.port}`); } else { console.error('Server error:', err.message); } reject(err); }); }); return server; })(); try { return await starting; } finally { starting = undefined; } } export async function stopApi() { if (!server) return; // Long-lived SSE (and keep-alive) sockets otherwise block server.close() // forever — node --watch hangs on "Waiting for graceful termination...". closeAllSse(); await new Promise((resolve, reject) => { server.close((err) => (err ? reject(err) : resolve())); server.closeAllConnections(); }); server = undefined; }