@beignet/core
Version:
Core framework primitives for Beignet
272 lines (245 loc) • 7.21 kB
text/typescript
/**
* 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" },
};
};
}