diff --git a/node-health/src/index.ts b/node-health/src/index.ts index 30365a9..0749868 100644 --- a/node-health/src/index.ts +++ b/node-health/src/index.ts @@ -1,30 +1,2 @@ -/** - * Simple liveness & readiness helpers - */ - -export type ReadinessResult = { - ok: boolean; - details: { name: string; ok: boolean; error?: string }[]; -}; - -export function liveness() { - return { - status: 'ok', - timestamp: Date.now(), - }; -} - -export async function readiness( - checks: Array<{ name: string; fn: () => Promise | boolean }> -): Promise { - const results: ReadinessResult['details'] = []; - for (const c of checks) { - try { - const r = await Promise.resolve(c.fn()); - results.push({ name: c.name, ok: !!r }); - } catch (err: any) { - results.push({ name: c.name, ok: false, error: err?.message ?? String(err) }); - } - } - return { ok: results.every((r) => r.ok), details: results }; -} +export * from './liveness'; +export * from './readiness'; diff --git a/node-health/src/liveness.ts b/node-health/src/liveness.ts new file mode 100644 index 0000000..d751104 --- /dev/null +++ b/node-health/src/liveness.ts @@ -0,0 +1,31 @@ +/** + * Type representing the result of a liveness check. + */ +export type LivenessResult = { + /** Status of the service (always 'ok'). */ + status: 'ok'; + /** Timestamp of the liveness check in milliseconds since the Unix epoch. */ + timestamp: number; +}; + +/** + * Liveness check - indicates if the service is running. + * @returns A LivenessResult object. + */ +export function liveness() { + return { + status: 'ok', + timestamp: Date.now(), + } as LivenessResult; +} + +/** + * Handler for liveness HTTP requests. + * @returns A Response object with LivenessResult in JSON format and status 200. + */ +export const handleLiveness = (): Response => { + return new Response(JSON.stringify(liveness()), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +}; diff --git a/node-health/src/readiness.ts b/node-health/src/readiness.ts new file mode 100644 index 0000000..ed3d357 --- /dev/null +++ b/node-health/src/readiness.ts @@ -0,0 +1,199 @@ +/** Function that performs a readiness check */ +export type ReadinessFunction = () => Promise | ReadinessDetail; + +/** Status of a readiness check */ +export type ReadinessStatus = 'ok' | 'error' | 'degraded'; + +const aggregateStatus = (statuses: ReadinessStatus[]): ReadinessStatus => { + if (statuses.includes('error')) return 'error'; + if (statuses.includes('degraded')) return 'degraded'; + return 'ok'; +}; + +/** Result of a system readiness check */ +export type ReadinessResult = { + /** + * Status of the system readiness check, aggregated as the worst status + * among individual checks. 'unknown' is a special case indicating that + * no checks were performed, used by ScheduledReadiness before the first run. + * */ + status: ReadinessStatus | 'unknown'; + /** Start time of the system readiness check in milliseconds since the Unix epoch */ + start: number; + /** Duration of the system readiness check in milliseconds */ + duration: number; + /** Details of individual readiness checks */ + details: ReadinessDetail[]; +}; + +/** Detail of an individual readiness check */ +export type ReadinessDetail = { + name: string; + status: ReadinessStatus; + message?: string; +}; + +/** + * Performs a readiness check by executing the provided readiness functions. + * @param checks - An array of readiness functions to execute. + * @returns A Promise that resolves to a ReadinessResult object. + */ +export const readiness = async (checks: ReadinessFunction[]): Promise => { + const start = Date.now(); + const details: ReadinessDetail[] = []; + + for (const check of checks) { + try { + const result = await Promise.resolve(check()); + details.push(result); + } catch (err) { + details.push({ + name: 'unknown', + status: 'error', + message: err instanceof Error ? err.message : String(err), + }); + } + } + + const duration = Date.now() - start; + + return { + status: aggregateStatus(details.map((d) => d.status)), + start, + duration, + details, + }; +}; + +/** + * Creates a handler function for readiness HTTP requests. Warning: this runs all + * checks on each request and may be slow. + * @param checks - An array of readiness functions to execute. + * @returns A function that returns a Response object with ReadinessResult in JSON format. + */ +export const createReadinessHandler = (checks: ReadinessFunction[]): (() => Promise) => { + return async () => { + const result = await readiness(checks); + return respondWithResult(result); + }; +}; + +/** + * Class that schedules periodic readiness checks. + */ +export class ScheduledReadiness { + private checks: ReadinessFunction[]; + private interval: number; + private started: boolean = false; + private timer: NodeJS.Timeout | null = null; + private latestResult: ReadinessResult | null = null; + private nextResult: Promise | null = null; + + /** + * Creates an instance of ScheduledReadiness. + * @param checks - An array of readiness functions to execute. + * @param interval - Interval in milliseconds between readiness checks. + */ + constructor(checks: ReadinessFunction[], interval: number) { + this.checks = checks; + this.interval = interval; + } + + /** Starts the scheduled readiness checks */ + async start() { + if (this.started) return; // Already started + this.started = true; + + const runCheck = async () => { + // Prevent concurrent runs + if (this.nextResult) return; + + this.nextResult = readiness(this.checks); + try { + this.latestResult = await this.nextResult; + } finally { + this.nextResult = null; + } + }; + + await runCheck(); // Initial run + this.timer = setInterval(runCheck, this.interval); + } + + /** Stops the scheduled readiness checks */ + stop() { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + this.started = false; + } + + /** Gets the next readiness result, waiting if a check is in progress */ + async getNextResult(): Promise { + if (this.nextResult) { + return await this.nextResult; + } + return this.latestResult; + } + + /** Gets the latest readiness result without waiting */ + getResult(): ReadinessResult | null { + return this.latestResult; + } + + /** + * Sets the interval for readiness checks and restarts the timer if already started. + * @param ms - Interval in milliseconds. + */ + setInterval(ms: number) { + this.interval = ms; + if (this.timer) { + this.stop(); + this.start(); + } + } + + /** + * Creates a handler function for readiness HTTP requests using the latest scheduled result. + * Scheduled handler always returns the most recent result, or 'unknown' if no checks have run yet. + * @returns A function that returns a Response object with ReadinessResult in JSON format. + */ + createHandler(): () => Promise { + return async () => { + const result = await this.getNextResult(); + if (!result) { + return new Response( + JSON.stringify({ + status: 'unknown', + start: Date.now(), + duration: 0, + details: [], + } as ReadinessResult), + { + status: httpStatusFromReadiness('unknown'), + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + return respondWithResult(result); + }; + } +} + +/** Returns a Response object with the given ReadinessResult in JSON format */ +const respondWithResult = (result: ReadinessResult) => { + return new Response(JSON.stringify(result), { + status: httpStatusFromReadiness(result.status), + headers: { 'Content-Type': 'application/json' }, + }); +}; + +/** Returns the HTTP status code corresponding to a given readiness status */ +const httpStatusFromReadiness = (status: ReadinessStatus | 'unknown'): number => { + if (status === 'ok') return 200; + if (status === 'degraded') return 200; // 206 could also be suitable, but let's avoid false alarms + if (status === 'error') return 503; + return 200; // unknown, treat as ok to avoid false alarms +};