/** * Composition root. The only place allowed to wire modules together. * Cross-cutting HTTP behavior (request id, logging, errors) is explicit here. */ import { randomUUID } from 'node:crypto'; import { performance } from 'node:perf_hooks'; import Fastify, { type FastifyInstance } from 'fastify'; import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'; import type { IncomingMessage } from 'node:http'; import type pg from 'pg'; import { registerHealthRoutes } from '../modules/health/index.js'; import { registerIdentityRoutes } from '../modules/identity/index.js'; import { createFlagStore, type FeatureFlagProvider } from '../modules/flags/index.js'; import { AppError, errorEnvelope } from '../shared/errors.js'; import { createLogger, type Logger } from '../infrastructure/logging/logger.js'; declare module 'fastify' { interface FastifyInstance { flags: FeatureFlagProvider; } } const REQUEST_ID_HEADER = 'x-request-id'; const SAFE_REQUEST_ID = /^[A-Za-z0-9._-]{1,128}$/; export interface BuildAppDeps { /** Injectable logger so tests can capture structured output. */ logger?: Logger; /** Feature flags. Default: empty store, every flag OFF (fail-safe). */ flags?: FeatureFlagProvider; /** Database pool. When present, DB-backed modules (identity) are wired. */ pool?: pg.Pool; /** Secure cookie flag forwarded to identity routes. */ cookieSecure?: boolean; } function generateRequestId(raw: IncomingMessage): string { const incoming = raw.headers[REQUEST_ID_HEADER]; const value = Array.isArray(incoming) ? incoming[0] : incoming; if (typeof value === 'string' && SAFE_REQUEST_ID.test(value)) { return value; } return randomUUID(); } /** * Composition root. The only place allowed to wire modules together. */ export async function buildApp(deps: BuildAppDeps = {}): Promise { const logger = deps.logger ?? createLogger(); const flags = deps.flags ?? createFlagStore(); const startTimes = new WeakMap(); const app = Fastify({ logger: false, genReqId: generateRequestId }); app.decorate('flags', flags); app.addHook('onRequest', async (request, reply) => { startTimes.set(request, performance.now()); void reply.header(REQUEST_ID_HEADER, request.id); }); app.addHook('onResponse', async (request, reply) => { const startedAt = startTimes.get(request); const durationMs = startedAt === undefined ? 0 : Math.round((performance.now() - startedAt) * 100) / 100; logger.info( { requestId: request.id, method: request.method, url: request.url, statusCode: reply.statusCode, durationMs, }, 'request completed', ); }); app.setErrorHandler((error: FastifyError, request: FastifyRequest, reply: FastifyReply) => { const requestId = request.id; let statusCode = error.statusCode !== undefined && error.statusCode >= 400 && error.statusCode < 600 ? error.statusCode : 500; let code = 'INTERNAL_ERROR'; let message = 'Internal Server Error'; let details: AppError['details']; if (error instanceof AppError) { statusCode = error.statusCode; code = error.code; details = error.details; message = statusCode < 500 ? error.message : 'Internal Server Error'; } else if (statusCode < 500) { code = error.code ?? 'BAD_REQUEST'; message = error.message; } if (statusCode >= 500) { // Stack stays server-side: logs only, tagged with the request id. logger.error({ err: error, requestId }, 'request failed'); } else { logger.info({ requestId, statusCode, code }, 'request rejected'); } void reply.code(statusCode).send(errorEnvelope(statusCode, code, message, requestId, details)); }); app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => { void reply.code(404).send(errorEnvelope(404, 'NOT_FOUND', 'Not Found', request.id)); }); await app.register(async (instance) => { await registerHealthRoutes(instance); }); if (deps.pool) { await app.register(async (instance) => { await registerIdentityRoutes(instance, { pool: deps.pool as pg.Pool, cookieSecure: deps.cookieSecure, }); }); } return app; }