UNPKG

@accounter/server

Version:
214 lines (191 loc) • 6.57 kB
import { createServer } from 'node:http'; import { createYoga, useReadinessCheck } from 'graphql-yoga'; import pg from 'pg'; import { shutdownTelemetry, startTelemetry } from './telemetry/index.js'; import 'reflect-metadata'; import { useSchema } from '@envelop/core'; import { useGraphQLModules } from '@envelop/graphql-modules'; import { useHive } from '@graphql-hive/yoga'; import { useDeferStream } from '@graphql-yoga/plugin-defer-stream'; import { env } from './environment.js'; import { cleanupExpiredInvitations, type Logger } from './jobs/cleanup-expired-invitations.js'; import { scheduleDailyAtUtc } from './jobs/utils.js'; import { createGraphQLApp } from './modules-app.js'; import { DBProvider } from './modules/app-providers/db.provider.js'; import { Auth0ManagementProvider } from './modules/auth/providers/auth0-management.provider.js'; import { authPlugin } from './plugins/auth-plugin.js'; import { dbCleanupPlugin } from './plugins/db-cleanup-plugin.js'; import { AccounterContext } from './shared/types/index.js'; const { Pool } = pg; const DAY_IN_MS = 24 * 60 * 60 * 1000; const CLEANUP_SCHEDULE_HOUR_UTC = 2; const CLEANUP_SCHEDULE_MINUTE_UTC = 0; async function main() { await startTelemetry(); // Create a shared connection pool for the entire application const pool = new Pool({ user: env.postgres.user, password: env.postgres.password, host: env.postgres.host, port: Number(env.postgres.port), database: env.postgres.db, ssl: env.postgres.ssl ? { rejectUnauthorized: false } : false, max: env.postgres.max, // maximum number of clients in the pool }); if (env.auth0) { console.log(`Auth0 configured: ${env.auth0.domain}`); } else { console.log('Auth0 not configured'); } const dbProvider = new DBProvider(pool); const auth0Provider = new Auth0ManagementProvider(env); const logger: Logger = { info: (message, meta) => { if (meta !== undefined) { console.log(message, meta); return; } console.log(message); }, error: (message, meta) => { if (meta !== undefined) { console.error(message, meta); return; } console.error(message); }, }; const cleanupSchedule = env.auth0 ? scheduleDailyAtUtc( CLEANUP_SCHEDULE_HOUR_UTC, CLEANUP_SCHEDULE_MINUTE_UTC, DAY_IN_MS, async () => { try { const result = await cleanupExpiredInvitations(dbProvider, auth0Provider, logger); logger.info('Invitation cleanup complete', result); } catch (error) { logger.error('Scheduled invitation cleanup run failed', { error }); } }, ) : null; if (cleanupSchedule) { logger.info('Scheduled invitation cleanup job', { cron: `${CLEANUP_SCHEDULE_MINUTE_UTC} ${CLEANUP_SCHEDULE_HOUR_UTC} * * *`, timezone: 'UTC', }); } const { application, transformedSchema } = await createGraphQLApp(env, pool); const yoga = createYoga({ plugins: [ dbCleanupPlugin(), authPlugin(), useGraphQLModules(application), useSchema(transformedSchema), useDeferStream(), useHive({ enabled: !!env.hive, token: env.hive?.hiveToken ?? '', usage: !!env.hive, }), useReadinessCheck({ check: async () => { const isHealthy = await dbProvider.healthCheck(); if (!isHealthy) { throw new Error('Database health check failed'); } return isHealthy; }, }), ], context: (yogaContext): AccounterContext => { return { ...yogaContext, env, pool, }; }, }); const server = createServer(yoga); // Graceful shutdown handler let isShuttingDown = false; const gracefulShutdown = async ( reason: string, err?: unknown, exitCode: number = 1, ): Promise<void> => { if (isShuttingDown) return; isShuttingDown = true; if (err) { process.stderr.write(`[shutdown] Reason: ${reason} ${String(err)}\n`); } else { process.stderr.write(`[shutdown] Reason: ${reason}\n`); } // Stop accepting new connections await new Promise<void>(resolve => { server.close(() => resolve()); }); cleanupSchedule?.cancel(); // Allow in-flight requests some time to complete, then close the pool const FORCE_EXIT_TIMEOUT_MS = 10_000; const forceExitTimer = setTimeout(() => { process.stderr.write('[shutdown] Force exiting after timeout\n'); process.exit(exitCode); }, FORCE_EXIT_TIMEOUT_MS); // Do not keep the event loop alive just for the timer forceExitTimer.unref(); try { await pool.end(); } catch (e) { process.stderr.write(`[shutdown] Error while closing DB pool ${String(e)}\n`); } try { await shutdownTelemetry(); } catch (e) { process.stderr.write(`[shutdown] Error while shutting down telemetry ${String(e)}\n`); } finally { clearTimeout(forceExitTimer); process.exit(exitCode); } }; // Pool errors: log but don't shutdown on transient errors // The pool will attempt to recover from connection issues automatically pool.on('error', err => { process.stderr.write(`[pool] Unexpected error on idle client: ${String(err)}\n`); // Only shutdown on critical errors that indicate the pool is permanently broken // For now, log and let the pool attempt recovery // If queries start failing consistently, they'll be caught by application error handlers }); // OS signals process.on('SIGINT', () => gracefulShutdown('SIGINT', undefined, 0).catch(e => process.stderr.write(`[shutdown] Fatal error during shutdown: ${String(e)}\n`), ), ); process.on('SIGTERM', () => gracefulShutdown('SIGTERM', undefined, 0).catch(e => process.stderr.write(`[shutdown] Fatal error during shutdown: ${String(e)}\n`), ), ); // Unhandled errors process.on('unhandledRejection', reason => gracefulShutdown('unhandledRejection', reason, 1).catch(e => process.stderr.write(`[shutdown] Fatal error during shutdown: ${String(e)}\n`), ), ); process.on('uncaughtException', err => gracefulShutdown('uncaughtException', err, 1).catch(e => process.stderr.write(`[shutdown] Fatal error during shutdown: ${String(e)}\n`), ), ); server.listen( { port: 4000, }, () => { process.stdout.write('GraphQL API located at http://localhost:4000/graphql\n'); }, ); } main();