import http from 'http' import { Server as SocketIOServer } from 'socket.io' import type { Socket } from 'socket.io' import { redis } from './lib/redis' import { prisma } from './lib/prisma' import { assertStorageConfiguration } from './lib/storage' import { createApp, corsOrigins } from './app' import { verifyAnyActorToken } from './security/tokens' import { getSessionCookieName } from './security/sessionCookies' import { startOutboxWorker, startScheduledJobs } from './workers/jobs' const app = createApp() const server = http.createServer(app) assertStorageConfiguration() const io = new SocketIOServer(server, { cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] }, }) function readCookieFromHeader(cookieHeader: string | undefined, name: string): string | null { if (!cookieHeader) return null for (const chunk of cookieHeader.split(';')) { const [rawName, ...rawValue] = chunk.trim().split('=') if (rawName === name) return decodeURIComponent(rawValue.join('=')) } return null } function getSocketSessionToken(socket: Socket): string | undefined { const explicitToken = socket.handshake.auth?.token if (typeof explicitToken === 'string' && explicitToken.trim()) return explicitToken.trim() const cookieHeader = socket.request.headers.cookie return ( readCookieFromHeader(cookieHeader, getSessionCookieName('employee')) ?? readCookieFromHeader(cookieHeader, getSessionCookieName('admin')) ?? readCookieFromHeader(cookieHeader, getSessionCookieName('renter')) ?? undefined ) } io.use((socket, next) => { const token = getSocketSessionToken(socket) if (!token) return next() try { const payload = verifyAnyActorToken(token) ;(socket as any).authenticatedUserId = payload.sub next() } catch { next(new Error('invalid_token')) } }) io.on('connection', (socket) => { const userId = (socket as any).authenticatedUserId as string | undefined if (userId) socket.join(`user:${userId}`) }) const subscriber = redis.duplicate() subscriber.psubscribe('notifications:*', (err) => { if (err) console.error('[Redis] Subscribe error:', err) }) subscriber.on('pmessage', (_pattern, channel, message) => { try { const parsed = JSON.parse(message) const userId = channel.replace('notifications:', '') io.to(`user:${userId}`).emit('notification', parsed) } catch (err) { console.error('[Redis] Invalid notification message:', err) } }) // Embedded jobs only when explicitly enabled (single-process local/dev). // Production should run `npm run worker` / Compose `api-worker` instead. if (process.env.ENABLE_EMBEDDED_JOBS === 'true') { console.warn('[API] ENABLE_EMBEDDED_JOBS=true — running outbox/cron inside the API process') startOutboxWorker() startScheduledJobs() } const PORT = Number(process.env.API_PORT ?? 4000) const HOST = process.env.API_HOST ?? '0.0.0.0' server.listen(PORT, HOST, () => { console.log(`[API] Server running on ${HOST}:${PORT}`) }) let shuttingDown = false async function shutdown(signal: string) { if (shuttingDown) return shuttingDown = true console.log(`[API] ${signal} received, draining`) server.close((err) => { if (err) console.error('[API] HTTP close error:', err.message) }) try { io.close() } catch (err: any) { console.error('[API] Socket.IO close error:', err?.message ?? err) } try { await subscriber.quit() } catch { subscriber.disconnect() } try { await redis.quit() } catch { redis.disconnect() } try { await prisma.$disconnect() } catch (err: any) { console.error('[API] Prisma disconnect error:', err?.message ?? err) } process.exit(0) } process.on('SIGTERM', () => void shutdown('SIGTERM')) process.on('SIGINT', () => void shutdown('SIGINT')) export { app, io }