Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
# `health-checker/health-checker.helpers`
## Purpose
`health-checker.helpers` carries the pure helpers that back the `HealthChecker` class: the Zod schemas that validate constructor + per-check options, the default `Clock` / `TimersLike` pair, the aggregate-status function, the raw-result normaliser, and the timeout-enforcing wrapper. They are split out of `health-checker.ts` to keep the main class file inside the project line-count budget and to make each helper independently unit-testable through `HealthChecker`'s tests.
## Features
- `dependencyCheckSchema` + `optionsSchema` — Zod boundary schemas; throw `HealthCheckError(INVALID_OPTIONS)` on failure when wired in.
- `defaultClock` — `() => Date.now()`.
- `defaultTimers` — `setTimeout` / `clearTimeout` adapter for the `TimersLike` contract.
- `aggregateStatus(results, isCritical)` — pure function mapping a result list to overall `CheckStatus`.
- `normalizeResult(name, raw, elapsed, clock)` — pure normaliser preserving raw `name` / `timestamp` when present.
- `executeWithTimeout(thunk, ms, timers)` — schedules a `HealthCheckError(CHECK_TIMEOUT)` rejection through the supplied `TimersLike`.
## Usage: Basic
```ts
import { aggregateStatus } from "@suaveplan/health-check/health-checker";
const status = aggregateStatus(results, (name) => criticalNames.has(name));
```
## Usage: Advanced
```ts
import { executeWithTimeout } from "@suaveplan/health-check/health-checker";
const result = await executeWithTimeout(
() => probe(),
250,
{ setTimer: setTimeout, clearTimer: clearTimeout },
);
```
## API Reference
- `DEFAULT_TIMEOUT: 5000`
- `defaultClock: Clock`
- `defaultTimers: TimersLike`
- `dependencyCheckSchema: ZodObject`
- `optionsSchema: ZodObject`
- `aggregateStatus(results, isCritical) → CheckStatus`
- `normalizeResult(name, raw, elapsed, clock) → CheckResult`
- `executeWithTimeout<T>(thunk, ms, timers) → Promise<T>`
## Implementation Notes
`executeWithTimeout` carries a `settled` flag to absorb the race between the timer firing and the thunk resolving. Either path is responsible for flipping the flag; the other path no-ops. `clearTimer` is only called on the resolution / rejection side because the timer-fired side has already executed and there is nothing to cancel. Rejections from the thunk are coerced into `HealthCheckError(CHECK_EXECUTION_FAILED)` so the outer `runCheck` always sees a typed error and can route on `code` instead of `instanceof Error`. The Zod schemas are constructed at module top level — instantiating them once per import keeps the per-call overhead to a single `safeParse` invocation.

View File

@@ -0,0 +1,156 @@
import type { Clock, TimersLike } from "@suaveplan/types";
import z from "zod";
import {
HEALTH_CHECK_CODES,
HealthCheckError,
} from "../error-codes/error-codes.js";
import type {
CheckResult,
CheckStatus,
DependencyCheckConfig,
} from "../types/types.js";
/** Built-in default per-check timeout. */
export const DEFAULT_TIMEOUT = 5000;
/** Default wall-clock — `Date.now()`. */
export const defaultClock: Clock = () => Date.now();
/** Default `TimersLike` backed by `globalThis.setTimeout` / `clearTimeout`. */
export const defaultTimers: TimersLike = {
setTimer: (cb, ms) => setTimeout(cb, ms) as unknown as number,
clearTimer: (h) => {
clearTimeout(h as unknown as ReturnType<typeof setTimeout>);
},
};
/** Zod schema for a single dependency-check registration. */
export const dependencyCheckSchema = z.object({
name: z.string().min(1, "check name must be non-empty"),
check: z.custom<DependencyCheckConfig["check"]>(
(v) => typeof v === "function",
{ message: "check must be a function" }
),
critical: z.boolean().optional(),
timeout: z.number().int().positive().optional(),
cacheTtlMs: z.number().int().nonnegative().optional(),
});
/** Zod schema for the `HealthChecker` constructor options. */
export const optionsSchema = z.object({
name: z.string().min(1, "service name must be non-empty"),
version: z.string().optional(),
defaultTimeout: z.number().int().positive().optional(),
defaultCacheTtlMs: z.number().int().nonnegative().optional(),
checks: z.array(dependencyCheckSchema).optional(),
});
/**
* Aggregate a set of `CheckResult` values into the overall `CheckStatus`.
* Critical-failure short-circuits to `unhealthy`; any non-critical
* failure or degraded result downgrades to `degraded`; otherwise `healthy`.
*/
export function aggregateStatus(
results: ReadonlyArray<CheckResult>,
isCritical: (name: string) => boolean
): CheckStatus {
let hasDegraded = false;
for (const result of results) {
if (result.status === "unhealthy" && isCritical(result.name)) {
return "unhealthy";
}
if (result.status === "unhealthy" || result.status === "degraded") {
hasDegraded = true;
}
}
return hasDegraded ? "degraded" : "healthy";
}
/**
* Normalise a raw check result onto the canonical `CheckResult` shape.
* Preserves `name` and `timestamp` when the raw value provides them;
* otherwise stamps the supplied defaults.
*/
export function normalizeResult(
name: string,
raw: CheckResult | Omit<CheckResult, "name" | "timestamp">,
elapsed: number,
clock: Clock
): CheckResult {
const rawAny = raw as CheckResult;
const result: CheckResult = {
name:
"name" in raw && typeof rawAny.name === "string"
? rawAny.name
: name,
status: raw.status,
responseTime: raw.responseTime ?? elapsed,
timestamp:
"timestamp" in raw && typeof rawAny.timestamp === "string"
? rawAny.timestamp
: new Date(clock()).toISOString(),
};
if (raw.message !== undefined) {
result.message = raw.message;
}
if (raw.details !== undefined) {
result.details = raw.details;
}
return result;
}
/**
* Run `thunk` with a `TimersLike`-scheduled timeout. Resolves with the
* thunk's value, rejects with `HealthCheckError(CHECK_TIMEOUT)` when the
* timer fires first, or `HealthCheckError(CHECK_EXECUTION_FAILED)` when
* the thunk itself rejects.
*/
export function executeWithTimeout<T>(
thunk: () => Promise<T>,
timeoutMs: number,
timers: TimersLike
): Promise<T> {
return new Promise<T>((resolve, reject) => {
let settled = false;
const handle = timers.setTimer(() => {
if (settled) {
return;
}
settled = true;
reject(
new HealthCheckError(
HEALTH_CHECK_CODES.CHECK_TIMEOUT,
`check timed out after ${timeoutMs}ms`,
{ context: { timeoutMs } }
)
);
}, timeoutMs);
thunk().then(
(value) => {
if (settled) {
return;
}
settled = true;
timers.clearTimer(handle);
resolve(value);
},
(err: unknown) => {
if (settled) {
return;
}
settled = true;
timers.clearTimer(handle);
reject(
new HealthCheckError(
HEALTH_CHECK_CODES.CHECK_EXECUTION_FAILED,
err instanceof Error
? err.message
: "check execution failed",
{ cause: err }
)
);
}
);
});
}

View File

@@ -0,0 +1,65 @@
# `health-checker/health-checker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`HealthChecker` is the orchestration core of `@suaveplan/health-check`. It owns a registry of dependency checks, runs them on demand, enforces per-check timeouts through an injected `TimersLike`, caches results for a configurable TTL, and produces standardised health, readiness, and liveness payloads suitable for the `/health`, `/ready`, and `/live` endpoints common to Kubernetes, ECS, Nomad, and reverse-proxy probes. Wall-clock readings come from the injected `Clock`, so tests can drive deterministic uptime + cache-expiry behaviour.
## Features
- Registry of `DependencyCheckConfig` with per-check `critical`, `timeout`, and `cacheTtlMs` overrides.
- Result aggregation that returns `healthy` / `degraded` / `unhealthy`: critical failures short-circuit, non-critical or degraded results downgrade to `degraded`.
- Typed `EventBus<HealthEventMap>` emitting `check:passed`, `check:failed`, and `status:changed` events.
- Zod-validated boundaries — every option object is parsed before use; failures throw `HealthCheckError` with `INVALID_OPTIONS`.
- OTel instrumentation: every check run records on `health_check_runs_total` + `health_check_duration_ms`, and every aggregate evaluation increments `health_check_aggregate_status_total`.
- Optional `LoggerLike` injection for `debug` registration logs and `warn` on failed checks.
## Usage: Basic
```ts
import { HealthChecker, createHttpCheck } from "@suaveplan/health-check";
const checker = new HealthChecker({ name: "api", version: "1.0.0" });
checker.addCheck({
name: "upstream",
check: createHttpCheck({ url: "https://up.example.com/health" }),
});
const response = await checker.health();
```
## Usage: Advanced
```ts
import { HealthChecker, HealthEvent } from "@suaveplan/health-check";
const checker = new HealthChecker({
name: "api",
defaultTimeout: 2_000,
defaultCacheTtlMs: 5_000,
clock: () => performance.now() + epoch,
timers: fakeTimers,
logger,
});
checker.events.on(HealthEvent.STATUS_CHANGED, ({ previous, current }) => {
pagerduty.notify({ previous, current });
});
```
## API Reference
- `new HealthChecker(options)` — `options.name` required; `version`, `defaultTimeout`, `defaultCacheTtlMs`, `checks`, `clock`, `timers`, `logger` optional.
- `addCheck(config)` / `removeCheck(name)` / `getCheckNames()`.
- `runCheck(name)` → `CheckResult` (single check, honours cache).
- `health()` → `HealthResponse` (runs all, aggregates).
- `ready()` → `ReadinessResponse` (critical-only failures).
- `live()` → `LivenessResponse` (process is responding).
- `clearCache()` / `getUptimeMs()`.
- `events: EventBus<HealthEventMap>`.
## Implementation Notes
Timeout enforcement is delegated to the injected `TimersLike` rather than `setTimeout` so tests can drive timeouts deterministically without `vi.useFakeTimers()`. The cache is keyed by check name and stores a frozen `CheckResult` with an absolute `expiresAt` computed against `this.clock()` — clock skew between writes and reads cannot leak negative TTLs because we re-read the clock on every check. Cached reads tag results with `cached: true` so consumers can distinguish them. Failures inside `executeWithTimeout` are wrapped in `HealthCheckError(CHECK_EXECUTION_FAILED)` so the outer `runCheck` always sees a typed error; the `CHECK_TIMEOUT` branch is preserved separately so it can be surfaced in `details.timedOut`.

View File

@@ -0,0 +1,494 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { TimersLike } from "@suaveplan/types";
import {
HEALTH_CHECK_CODES,
HealthCheckError,
} from "../error-codes/error-codes.js";
import type { CheckFn } from "../types/types.js";
import { HealthEvent } from "../types/types.js";
import { HealthChecker } from "./health-checker.js";
/** FakeClock returns a counter that advances on call. Caller can poke `now`. */
function makeClock(start = 1_000) {
const state = { now: start };
const clock = () => state.now;
return { state, clock };
}
/** Real timer-pair backed by setTimeout — used in most tests. */
const realTimers: TimersLike = {
setTimer: (cb, ms) => setTimeout(cb, ms) as unknown as number,
clearTimer: (h) =>
clearTimeout(h as unknown as ReturnType<typeof setTimeout>),
};
/** Always-pending timer; used to exercise the timeout path deterministically. */
function controlledTimers() {
const pending = new Map<number, () => void>();
let id = 0;
const timers: TimersLike = {
setTimer: (cb) => {
id += 1;
pending.set(id, cb);
return id;
},
clearTimer: (h) => {
pending.delete(h);
},
};
const fire = (h: number) => {
const cb = pending.get(h);
if (cb !== undefined) {
pending.delete(h);
cb();
}
};
return { timers, pending, fire };
}
describe("HealthChecker — construction & registration", () => {
it("rejects empty service name", { timeout: 5000 }, () => {
expect(() => new HealthChecker({ name: "" })).toThrow(HealthCheckError);
});
it("rejects invalid defaultTimeout", { timeout: 5000 }, () => {
const bad = {
name: "svc",
defaultTimeout: -1,
} as unknown as ConstructorParameters<typeof HealthChecker>[0];
try {
new HealthChecker(bad);
expect.fail("should throw");
} catch (err) {
expect(err).toBeInstanceOf(HealthCheckError);
expect((err as HealthCheckError).code).toBe(
HEALTH_CHECK_CODES.INVALID_OPTIONS
);
}
});
it("registers initial checks via options", { timeout: 5000 }, () => {
const checker = new HealthChecker({
name: "svc",
checks: [
{
name: "ok",
check: async () => ({ status: "healthy", responseTime: 1 }),
},
],
});
expect(checker.getCheckNames()).toEqual(["ok"]);
});
it("rejects duplicate check registration", { timeout: 5000 }, () => {
const check: CheckFn = async () => ({
status: "healthy",
responseTime: 1,
});
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({ name: "db", check });
try {
checker.addCheck({ name: "db", check });
expect.fail("should throw");
} catch (err) {
expect((err as HealthCheckError).code).toBe(
HEALTH_CHECK_CODES.DUPLICATE_CHECK
);
}
});
it("rejects invalid addCheck arguments", { timeout: 5000 }, () => {
const checker = new HealthChecker({ name: "svc" });
try {
checker.addCheck({
name: "",
check: async () => ({ status: "healthy", responseTime: 0 }),
});
expect.fail("should throw");
} catch (err) {
expect((err as HealthCheckError).code).toBe(
HEALTH_CHECK_CODES.INVALID_OPTIONS
);
}
});
it("removeCheck returns false for unknown check", { timeout: 5000 }, () => {
const checker = new HealthChecker({ name: "svc" });
expect(checker.removeCheck("nope")).toBe(false);
});
it("removeCheck clears cache + returns true", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "db",
check: async () => ({ status: "healthy", responseTime: 1 }),
cacheTtlMs: 60_000,
});
await checker.runCheck("db");
expect(checker.removeCheck("db")).toBe(true);
expect(checker.getCheckNames()).toEqual([]);
});
});
describe("HealthChecker — runCheck", () => {
let logCalls: Array<{ level: string; msg: string }>;
let logger: {
debug: (m: string) => void;
info: (m: string) => void;
warn: (m: string) => void;
error: (m: string) => void;
};
beforeEach(() => {
logCalls = [];
logger = {
debug: (m) => {
logCalls.push({ level: "debug", msg: m });
},
info: (m) => {
logCalls.push({ level: "info", msg: m });
},
warn: (m) => {
logCalls.push({ level: "warn", msg: m });
},
error: (m) => {
logCalls.push({ level: "error", msg: m });
},
};
});
it("throws UNKNOWN_CHECK for unregistered name", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc" });
await expect(checker.runCheck("nope")).rejects.toBeInstanceOf(
HealthCheckError
);
});
it("records success result + emits CHECK_PASSED", {
timeout: 5000,
}, async () => {
const { clock, state } = makeClock();
const checker = new HealthChecker({
name: "svc",
clock,
timers: realTimers,
});
const events: string[] = [];
checker.events.on(HealthEvent.CHECK_PASSED, () => events.push("pass"));
checker.addCheck({
name: "db",
check: async () => {
state.now += 7;
return { status: "healthy", responseTime: 0 };
},
});
const result = await checker.runCheck("db");
expect(result.status).toBe("healthy");
expect(result.name).toBe("db");
expect(events).toEqual(["pass"]);
});
it("records failure result + emits CHECK_FAILED + logs warn", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc", logger });
const events: string[] = [];
checker.events.on(HealthEvent.CHECK_FAILED, () => events.push("fail"));
checker.addCheck({
name: "db",
check: async () => {
throw new Error("boom");
},
});
const result = await checker.runCheck("db");
expect(result.status).toBe("unhealthy");
expect(result.message).toBe("boom");
expect(events).toEqual(["fail"]);
expect(logCalls.some((c) => c.level === "warn")).toBe(true);
});
it("falls back to 'Unknown error' on non-Error throw", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc" });
const throwString = async (): Promise<never> => {
const v: unknown = "boom";
throw v;
};
checker.addCheck({
name: "db",
check: throwString,
});
const result = await checker.runCheck("db");
// executeWithTimeout wraps in HealthCheckError with its own message, but
// we still get an Error instance, so message is preserved.
expect(result.status).toBe("unhealthy");
expect(typeof result.message).toBe("string");
});
it("enforces timeout via TimersLike", { timeout: 5000 }, async () => {
const { timers, pending, fire } = controlledTimers();
const checker = new HealthChecker({ name: "svc", timers });
checker.addCheck({
name: "slow",
check: () => new Promise(() => undefined),
timeout: 50,
});
const promise = checker.runCheck("slow");
// Fire the registered timeout to simulate elapse.
const [handle] = pending.keys();
fire(handle);
const result = await promise;
expect(result.status).toBe("unhealthy");
expect(result.message).toContain("timed out");
expect((result.details as { timedOut?: boolean }).timedOut).toBe(true);
});
it("ignores late thunk resolution after timeout fires", {
timeout: 5000,
}, async () => {
const { timers, pending, fire } = controlledTimers();
let resolveThunk:
| ((v: { status: "healthy"; responseTime: number }) => void)
| undefined;
const checker = new HealthChecker({ name: "svc", timers });
checker.addCheck({
name: "slow",
check: () =>
new Promise((resolve) => {
resolveThunk = resolve;
}),
timeout: 50,
});
const promise = checker.runCheck("slow");
const [handle] = pending.keys();
fire(handle as number);
// Now resolve the thunk — its resolution branch must short-circuit.
resolveThunk?.({ status: "healthy", responseTime: 1 });
const result = await promise;
expect(result.status).toBe("unhealthy");
expect(result.message).toContain("timed out");
});
it("ignores timer firing after thunk resolves", {
timeout: 5000,
}, async () => {
// Use timers that record but do NOT remove on clearTimer, so we can
// manually fire the registered callback after the thunk resolves.
const handlers: Array<() => void> = [];
const timers: TimersLike = {
setTimer: (cb) => {
handlers.push(cb);
return handlers.length - 1;
},
clearTimer: () => undefined,
};
const checker = new HealthChecker({ name: "svc", timers });
checker.addCheck({
name: "ok",
check: async () => ({ status: "healthy", responseTime: 1 }),
timeout: 50,
});
const result = await checker.runCheck("ok");
expect(result.status).toBe("healthy");
// Manually fire the still-registered timer callback. The settled guard
// inside executeWithTimeout MUST short-circuit so no error is thrown.
expect(() => handlers[0]?.()).not.toThrow();
});
it("ignores late thunk rejection after timeout fires", {
timeout: 5000,
}, async () => {
const { timers, pending, fire } = controlledTimers();
let rejectThunk: ((err: unknown) => void) | undefined;
const checker = new HealthChecker({ name: "svc", timers });
checker.addCheck({
name: "slow",
check: () =>
new Promise((_resolve, reject) => {
rejectThunk = reject;
}),
timeout: 50,
});
const promise = checker.runCheck("slow");
const [handle] = pending.keys();
fire(handle as number);
rejectThunk?.(new Error("late-error"));
const result = await promise;
expect(result.status).toBe("unhealthy");
expect(result.message).toContain("timed out");
});
it("returns cached result on second call", { timeout: 5000 }, async () => {
const { clock, state } = makeClock();
let calls = 0;
const checker = new HealthChecker({ name: "svc", clock });
checker.addCheck({
name: "db",
check: async () => {
calls += 1;
return { status: "healthy", responseTime: 1 };
},
cacheTtlMs: 1_000,
});
await checker.runCheck("db");
const second = await checker.runCheck("db");
expect(calls).toBe(1);
expect(second.cached).toBe(true);
// After TTL expires the cache rebuilds.
state.now += 5_000;
await checker.runCheck("db");
expect(calls).toBe(2);
});
it("clearCache forces re-run", { timeout: 5000 }, async () => {
let calls = 0;
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "db",
check: async () => {
calls += 1;
return { status: "healthy", responseTime: 1 };
},
cacheTtlMs: 60_000,
});
await checker.runCheck("db");
checker.clearCache();
await checker.runCheck("db");
expect(calls).toBe(2);
});
it("falls back to elapsed when raw responseTime is missing", {
timeout: 5000,
}, async () => {
const { clock, state } = makeClock();
const checker = new HealthChecker({ name: "svc", clock });
checker.addCheck({
name: "db",
check: async () => {
state.now += 12;
const partial = { status: "healthy" } as unknown as {
status: "healthy";
responseTime: number;
};
return partial;
},
});
const r = await checker.runCheck("db");
expect(r.responseTime).toBe(12);
});
it("preserves raw result name + timestamp when supplied", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "db",
check: async () => ({
name: "renamed",
status: "degraded",
responseTime: 3,
timestamp: "2026-01-01T00:00:00.000Z",
message: "slow",
details: { latencyMs: 3 },
}),
});
const r = await checker.runCheck("db");
expect(r.name).toBe("renamed");
expect(r.timestamp).toBe("2026-01-01T00:00:00.000Z");
expect(r.message).toBe("slow");
expect(r.details).toEqual({ latencyMs: 3 });
});
});
describe("HealthChecker — health/ready/live aggregation", () => {
it("aggregates healthy when all critical checks pass", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc", version: "1.0.0" });
checker.addCheck({
name: "db",
check: async () => ({ status: "healthy", responseTime: 1 }),
});
const response = await checker.health();
expect(response.status).toBe("healthy");
expect(response.name).toBe("svc");
expect(response.version).toBe("1.0.0");
expect(response.checks.db?.status).toBe("healthy");
expect(response.uptimeMs).toBeGreaterThanOrEqual(0);
});
it("emits STATUS_CHANGED on transition", { timeout: 5000 }, async () => {
const checker = new HealthChecker({ name: "svc" });
const events: Array<{ previous: string; current: string }> = [];
checker.events.on(HealthEvent.STATUS_CHANGED, (p) => events.push(p));
let healthy = true;
checker.addCheck({
name: "db",
check: async () => ({
status: healthy ? "healthy" : "unhealthy",
responseTime: 1,
}),
});
await checker.health();
healthy = false;
await checker.health();
expect(events).toEqual([{ previous: "healthy", current: "unhealthy" }]);
});
it("degrades when non-critical check fails", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "cache",
critical: false,
check: async () => ({ status: "unhealthy", responseTime: 1 }),
});
const response = await checker.health();
expect(response.status).toBe("degraded");
});
it("aggregates degraded when a check is degraded", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "x",
check: async () => ({ status: "degraded", responseTime: 1 }),
});
const response = await checker.health();
expect(response.status).toBe("degraded");
});
it("ready reports notReady for unhealthy critical checks", {
timeout: 5000,
}, async () => {
const checker = new HealthChecker({ name: "svc" });
checker.addCheck({
name: "db",
check: async () => ({ status: "unhealthy", responseTime: 1 }),
});
checker.addCheck({
name: "cache",
critical: false,
check: async () => ({ status: "unhealthy", responseTime: 1 }),
});
const response = await checker.ready();
expect(response.ready).toBe(false);
expect(response.notReady).toEqual(["db"]);
});
it("live always reports alive=true", { timeout: 5000 }, () => {
const checker = new HealthChecker({ name: "svc" });
const response = checker.live();
expect(response.alive).toBe(true);
expect(response.uptimeMs).toBeGreaterThanOrEqual(0);
expect(typeof response.timestamp).toBe("string");
});
});

View File

@@ -0,0 +1,297 @@
import { EventBus } from "@suaveplan/events";
import type { Clock, LoggerLike, TimersLike } from "@suaveplan/types";
import {
HEALTH_CHECK_CODES,
HealthCheckError,
} from "../error-codes/error-codes.js";
import {
recordAggregateStatus,
recordCheckDuration,
recordCheckRun,
} from "../telemetry/telemetry.js";
import {
type CheckResult,
type CheckStatus,
type DependencyCheckConfig,
type HealthCheckerOptions,
HealthEvent,
type HealthEventMap,
type HealthResponse,
type LivenessResponse,
type ReadinessResponse,
} from "../types/types.js";
import {
aggregateStatus,
DEFAULT_TIMEOUT,
defaultClock,
defaultTimers,
dependencyCheckSchema,
executeWithTimeout,
normalizeResult,
optionsSchema,
} from "./health-checker.helpers.js";
export type { HealthCheckerOptions } from "../types/types.js";
interface CachedResult {
readonly result: CheckResult;
readonly expiresAt: number;
}
/**
* Core health checker that manages dependency checks and produces
* standardised health, readiness, and liveness responses.
*
* Wall-clock readings come from the injected `Clock`. Per-check timeouts
* are scheduled through the injected `TimersLike`. Lifecycle events are
* broadcast on a typed `EventBus<HealthEventMap>` accessible via `events`.
*/
export class HealthChecker {
public readonly events: EventBus<HealthEventMap>;
private readonly clock: Clock;
private readonly timers: TimersLike;
private readonly logger: LoggerLike | undefined;
private readonly name: string;
private readonly version: string | undefined;
private readonly defaultTimeout: number;
private readonly defaultCacheTtlMs: number;
private readonly checks = new Map<
string,
Required<DependencyCheckConfig>
>();
private readonly cache = new Map<string, CachedResult>();
private readonly startTime: number;
private lastStatus: CheckStatus = "healthy";
public constructor(options: HealthCheckerOptions) {
const parsed = optionsSchema.safeParse({
name: options.name,
version: options.version,
defaultTimeout: options.defaultTimeout,
defaultCacheTtlMs: options.defaultCacheTtlMs,
checks: options.checks,
});
if (!parsed.success) {
throw new HealthCheckError(
HEALTH_CHECK_CODES.INVALID_OPTIONS,
"invalid HealthChecker options",
{ context: { issues: parsed.error.issues } }
);
}
this.name = options.name;
this.version = options.version;
this.defaultTimeout = options.defaultTimeout ?? DEFAULT_TIMEOUT;
this.defaultCacheTtlMs = options.defaultCacheTtlMs ?? 0;
this.clock = options.clock ?? defaultClock;
this.timers = options.timers ?? defaultTimers;
this.logger = options.logger;
this.events = new EventBus<HealthEventMap>();
this.startTime = this.clock();
if (options.checks !== undefined) {
for (const check of options.checks) {
this.addCheck(check);
}
}
}
/** Register a new dependency check. */
public addCheck(config: DependencyCheckConfig): void {
const parsed = dependencyCheckSchema.safeParse(config);
if (!parsed.success) {
throw new HealthCheckError(
HEALTH_CHECK_CODES.INVALID_OPTIONS,
"invalid dependency-check config",
{ context: { issues: parsed.error.issues } }
);
}
if (this.checks.has(config.name)) {
throw new HealthCheckError(
HEALTH_CHECK_CODES.DUPLICATE_CHECK,
`check "${config.name}" is already registered`,
{ context: { name: config.name } }
);
}
this.checks.set(config.name, {
name: config.name,
check: config.check,
critical: config.critical ?? true,
timeout: config.timeout ?? this.defaultTimeout,
cacheTtlMs: config.cacheTtlMs ?? this.defaultCacheTtlMs,
});
this.logger?.debug("health-check registered", { name: config.name });
}
/** Remove a registered dependency check. Returns `true` if removed. */
public removeCheck(name: string): boolean {
this.cache.delete(name);
return this.checks.delete(name);
}
/** Get names of all registered checks. */
public getCheckNames(): string[] {
return Array.from(this.checks.keys());
}
/** Get the service uptime in milliseconds. */
public getUptimeMs(): number {
return this.clock() - this.startTime;
}
/** Execute a single check with timeout enforcement and caching. */
public async runCheck(name: string): Promise<CheckResult> {
const config = this.checks.get(name);
if (config === undefined) {
throw new HealthCheckError(
HEALTH_CHECK_CODES.UNKNOWN_CHECK,
`check "${name}" is not registered`,
{ context: { name } }
);
}
const cached = this.getCachedResult(name);
if (cached !== null) {
return cached;
}
const start = this.clock();
try {
const raw = await executeWithTimeout(
() => config.check(),
config.timeout,
this.timers
);
const elapsed = this.clock() - start;
const result = normalizeResult(name, raw, elapsed, this.clock);
this.cacheResult(name, result, config.cacheTtlMs);
this.emitCheckEvent(result);
recordCheckRun(name, result.status);
recordCheckDuration(name, result.responseTime);
return result;
} catch (error) {
const elapsed = this.clock() - start;
const isTimeout =
error instanceof HealthCheckError &&
error.code === HEALTH_CHECK_CODES.CHECK_TIMEOUT;
// executeWithTimeout always wraps in HealthCheckError → Error.
const message = (error as Error).message;
const result: CheckResult = {
name,
status: "unhealthy",
responseTime: elapsed,
message,
timestamp: new Date(this.clock()).toISOString(),
details: { timedOut: isTimeout },
};
this.cacheResult(name, result, config.cacheTtlMs);
this.emitCheckEvent(result);
recordCheckRun(name, result.status);
recordCheckDuration(name, result.responseTime);
this.logger?.warn("health-check failed", { name, message });
return result;
}
}
/** Run all registered checks and return a full health response. */
public async health(): Promise<HealthResponse> {
const results = await this.runAllChecks();
const status = aggregateStatus(results, (name) => {
const config = this.checks.get(name);
return config?.critical === true;
});
if (status !== this.lastStatus) {
const previous = this.lastStatus;
this.lastStatus = status;
this.events.emit(HealthEvent.STATUS_CHANGED, {
previous,
current: status,
});
}
recordAggregateStatus(status);
const response: HealthResponse = {
status,
timestamp: new Date(this.clock()).toISOString(),
uptimeMs: this.getUptimeMs(),
name: this.name,
checks: Object.fromEntries(results.map((r) => [r.name, r])),
};
if (this.version !== undefined) {
response.version = this.version;
}
return response;
}
/** Check if the service is ready (all critical checks pass). */
public async ready(): Promise<ReadinessResponse> {
const results = await this.runAllChecks();
const notReady = results
.filter((r) => {
const config = this.checks.get(r.name);
return config?.critical === true && r.status === "unhealthy";
})
.map((r) => r.name);
return {
ready: notReady.length === 0,
timestamp: new Date(this.clock()).toISOString(),
notReady,
};
}
/** Check if the service is alive (basic liveness). */
public live(): LivenessResponse {
return {
alive: true,
timestamp: new Date(this.clock()).toISOString(),
uptimeMs: this.getUptimeMs(),
};
}
/** Clear all cached results. */
public clearCache(): void {
this.cache.clear();
}
private async runAllChecks(): Promise<CheckResult[]> {
const names = this.getCheckNames();
return Promise.all(names.map((name) => this.runCheck(name)));
}
private getCachedResult(name: string): CheckResult | null {
const cached = this.cache.get(name);
if (cached === undefined) {
return null;
}
if (this.clock() > cached.expiresAt) {
this.cache.delete(name);
return null;
}
return { ...cached.result, cached: true };
}
private cacheResult(
name: string,
result: CheckResult,
ttlMs: number
): void {
if (ttlMs <= 0) {
return;
}
this.cache.set(name, {
result,
expiresAt: this.clock() + ttlMs,
});
}
private emitCheckEvent(result: CheckResult): void {
if (result.status === "healthy") {
this.events.emit(HealthEvent.CHECK_PASSED, result);
} else {
this.events.emit(HealthEvent.CHECK_FAILED, result);
}
}
}

View File

@@ -0,0 +1,2 @@
export type { HealthCheckerOptions } from "./health-checker.js";
export { HealthChecker } from "./health-checker.js";