UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

272 lines (245 loc) 7.21 kB
/** * Health check handler * Health check handler for Beignet server adapters. */ import type { AnyPorts } from "../ports/index.js"; import type { HttpRequestLike, HttpResponseLike } from "./types.js"; /** * Per-dependency health detail returned by readiness checks. */ export interface HealthCheckDetail { /** * Whether this dependency is healthy. */ ok: boolean; /** * Optional human-readable status. Avoid secrets and raw provider credentials. */ message?: string; /** * Milliseconds spent running this dependency check. */ durationMs?: number; /** * Optional safe metadata for operators. */ metadata?: Record<string, unknown>; } /** * Health check result returned by health handlers. */ export interface HealthCheckResult { /** * Whether the app is healthy. */ ok: boolean; /** * Optional per-dependency health details. */ details?: Record<string, HealthCheckDetail>; } /** * A named dependency check run by {@link runHealthChecks}. */ export type HealthCheck<Ports> = ( ports: Ports, ) => | Promise<HealthCheckDetail | boolean | undefined> | HealthCheckDetail | boolean | undefined; /** * Named dependency checks for an app-owned readiness endpoint. */ export type HealthChecks<Ports> = Record<string, HealthCheck<Ports>>; /** * Options for running named dependency checks. */ export interface RunHealthChecksOptions { /** * Maximum time to wait for each dependency check. * * Defaults to 2000ms. */ timeoutMs?: number; /** * Include thrown error messages in dependency details. * * Defaults to true. Set false in production responses when provider errors * may include sensitive details. */ includeErrorDetails?: boolean; } /** * Health check configuration. */ export interface HealthConfig<Ports> { /** Enable health endpoint (default: false) */ enabled?: boolean; /** * Suggested path for the health endpoint (e.g., "/api/health"). * NOTE: This field is for documentation/metadata only and does not control routing. * You must manually wire the healthHandler to your desired route. */ suggestedPath?: string; /** Custom health check function */ check?: (ports: Ports) => Promise<HealthCheckResult>; /** Named dependency checks for an app-owned readiness endpoint */ checks?: HealthChecks<Ports>; /** Per-check timeout for named dependency checks */ timeoutMs?: number; } /** * Application environment. */ export type AppEnvironment = "development" | "production" | "test"; const DEFAULT_HEALTH_TIMEOUT_MS = 2000; function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function assertTimeout(timeoutMs: number): void { if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) { throw new Error("Health check timeoutMs must be a positive integer."); } } function normalizeHealthDetail( result: HealthCheckDetail | boolean | undefined, ): HealthCheckDetail { if (typeof result === "boolean") return { ok: result }; return result ?? { ok: true }; } function redactHealthDetail( detail: HealthCheckDetail, includeErrorDetails: boolean, ): HealthCheckDetail { if (includeErrorDetails || detail.ok || detail.message === undefined) { return detail; } return { ...detail, message: "Health check failed", }; } function withTimeout<T>( promise: Promise<T>, timeoutMs: number, name: string, ): Promise<T> { let timeout: ReturnType<typeof setTimeout> | undefined; const timeoutPromise = new Promise<never>((_, reject) => { timeout = setTimeout(() => { reject( new Error( `Health check "${name}" did not complete within ${timeoutMs}ms.`, ), ); }, timeoutMs); }); return Promise.race([promise, timeoutPromise]).finally(() => { if (timeout) clearTimeout(timeout); }); } /** * Run named dependency health checks in parallel and aggregate the result. * * This is intended for app-owned readiness endpoints. Checks should be cheap, * bounded, non-mutating probes such as `SELECT 1`, Redis `PING`, or provider * health endpoints. Do not start workers, drains, migrations, or polling loops * from readiness checks. */ export async function runHealthChecks<Ports extends AnyPorts>( ports: Ports, checks: HealthChecks<Ports>, options: RunHealthChecksOptions = {}, ): Promise<HealthCheckResult> { const timeoutMs = options.timeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS; assertTimeout(timeoutMs); const includeErrorDetails = options.includeErrorDetails ?? true; const entries = await Promise.all( Object.entries(checks).map(async ([name, check]) => { const startedAt = Date.now(); try { const detail = redactHealthDetail( normalizeHealthDetail( await withTimeout(Promise.resolve(check(ports)), timeoutMs, name), ), includeErrorDetails, ); return [ name, { ...detail, durationMs: Date.now() - startedAt, }, ] as const; } catch (error) { return [ name, { ok: false, message: includeErrorDetails ? errorMessage(error) : "Health check failed", durationMs: Date.now() - startedAt, }, ] as const; } }), ); const details = Object.fromEntries(entries); return { ok: Object.values(details).every((detail) => detail.ok), details, }; } /** * Create a framework-neutral health check handler. * * The returned handler reports 200 when healthy and 503 when unhealthy. Thrown * health check errors include details in development/test and use a generic * message in production. */ export function createHealthHandler<Ports extends AnyPorts>( ports: Ports, healthConfig: HealthConfig<Ports> | undefined, env: AppEnvironment, ): (req: HttpRequestLike) => Promise<HttpResponseLike> { return async (_req: HttpRequestLike): Promise<HttpResponseLike> => { let result: HealthCheckResult; if (healthConfig?.check) { try { result = await healthConfig.check(ports); } catch (error) { // Health check function threw - treat as unhealthy // Only include error details in development/test to avoid leaking sensitive info const includeErrorDetails = env === "development" || env === "test"; result = { ok: false, details: { error: { ok: false, message: includeErrorDetails ? error instanceof Error ? error.message : String(error) : "Health check failed", }, }, }; } } else if (healthConfig?.checks) { result = await runHealthChecks(ports, healthConfig.checks, { timeoutMs: healthConfig.timeoutMs, includeErrorDetails: env !== "production", }); } else { result = { ok: true }; } const status = result.ok ? 200 : 503; return { status, body: result, headers: { "Content-Type": "application/json" }, }; }; }