/** * Custom server entry point. * * Boots Next.js, attaches Socket.io to the same HTTP server, and in * development mode also registers BullMQ recurring jobs and starts the * workers inline (so you don't need the separate crm-worker container). * * In production the workers run in the crm-worker container (Dockerfile.worker * → dist/worker.js) and this file only handles Next.js + Socket.io. */ import { createServer, type Server as HttpServer } from 'node:http'; import next from 'next'; import { initSocketServer, closeSocketServer } from '@/lib/socket/server'; import { logger } from '@/lib/logger'; import { env } from '@/lib/env'; import { redis } from '@/lib/redis'; const dev = env.NODE_ENV !== 'production'; async function gracefulShutdown(signal: string, httpServer: HttpServer): Promise { logger.info({ signal }, 'Shutdown signal received; closing connections'); // Order matters: close Socket.io first so it stops accepting new // sockets and emits disconnect events while the HTTP server is still // up to flush them. `httpServer.close` only stops new connections; // it waits for keep-alive HTTP and long-poll websockets to drain on // their own, so without an explicit io.close() upfront the polls hold // the server past the compose stop_grace_period and the process gets // SIGKILL'd mid-frame. await closeSocketServer().catch((err) => logger.warn({ err }, 'closeSocketServer error')); // Then drain the HTTP layer. await new Promise((resolve) => { httpServer.close((err) => { if (err) logger.warn({ err }, 'httpServer.close emitted error'); resolve(); }); // Hard timeout — 25s leaves headroom under a 30s compose grace // period before SIGKILL would arrive anyway. setTimeout(() => resolve(), 25_000).unref(); }); try { redis.disconnect(); } catch (err) { logger.warn({ err }, 'redis.disconnect error'); } logger.info({ signal }, 'Shutdown complete'); } async function main(): Promise { const app = next({ dev, port: env.PORT }); const handle = app.getRequestHandler(); await app.prepare(); const httpServer = createServer((req, res) => { handle(req, res); }); // Attach Socket.io to the HTTP server (uses Redis adapter for pub/sub) initSocketServer(httpServer); logger.info('Socket.io initialized'); // In development, run BullMQ workers inline so a single `pnpm dev` is enough if (dev) { const { registerRecurringJobs } = await import('@/lib/queue/scheduler'); const { emailWorker } = await import('@/lib/queue/workers/email'); const { documentsWorker } = await import('@/lib/queue/workers/documents'); const { notificationsWorker } = await import('@/lib/queue/workers/notifications'); const { importWorker } = await import('@/lib/queue/workers/import'); const { exportWorker } = await import('@/lib/queue/workers/export'); await registerRecurringJobs(); logger.info('BullMQ recurring jobs registered (dev mode)'); // Keep a reference so workers aren't GC'd void [emailWorker, documentsWorker, notificationsWorker, importWorker, exportWorker]; } httpServer.listen(env.PORT, () => { logger.info({ port: env.PORT, env: env.NODE_ENV }, 'Port Nimara CRM server listening'); }); // Graceful stop on container restart / deploy. Without this, every // `docker compose up -d` rolling restart drops in-flight uploads, EOI // generation, Documenso requests, and Socket.io frames mid-statement. // Match docker-compose `stop_grace_period: 30s` (or longer) so the // 25s drain inside gracefulShutdown can complete before SIGKILL. for (const sig of ['SIGTERM', 'SIGINT'] as const) { process.once(sig, () => { void gracefulShutdown(sig, httpServer).finally(() => process.exit(0)); }); } } main().catch((err) => { logger.error(err, 'Failed to start server'); process.exit(1); });