From afcf4ddd2f00f50457fef41ee03c742d134cf699 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Tue, 31 Mar 2026 12:01:25 +0100 Subject: [PATCH 01/37] chore(config): add @interfaces/* and @indicators/* path aliases --- lint-staged.config.js => lint-staged.config.mjs | 0 tsconfig.json | 4 +++- 2 files changed, 3 insertions(+), 1 deletion(-) rename lint-staged.config.js => lint-staged.config.mjs (100%) diff --git a/lint-staged.config.js b/lint-staged.config.mjs similarity index 100% rename from lint-staged.config.js rename to lint-staged.config.mjs diff --git a/tsconfig.json b/tsconfig.json index e92d316..513d618 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,7 +30,9 @@ "@config/*": ["src/config/*"], "@filters/*": ["src/filters/*"], "@middleware/*": ["src/middleware/*"], - "@utils/*": ["src/utils/*"] + "@utils/*": ["src/utils/*"], + "@interfaces/*": ["src/interfaces/*"], + "@indicators/*": ["src/indicators/*"] } }, "include": ["src/**/*.ts", "test/**/*.ts"], From 2e42a1d874c5db07920a343f1282b8a740de1ac2 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Tue, 31 Mar 2026 12:11:07 +0100 Subject: [PATCH 02/37] chore(config): rename eslint.config.js to .mjs to fix ESM loading --- eslint.config.js | 80 ----------------------------------------------- eslint.config.mjs | 76 +++++++++++++++++++++++++++++++++----------- jest.config.ts | 2 ++ 3 files changed, 59 insertions(+), 99 deletions(-) delete mode 100644 eslint.config.js diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 6da30ca..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,80 +0,0 @@ -// @ts-check -import eslint from "@eslint/js"; -import globals from "globals"; -import importPlugin from "eslint-plugin-import"; -import tseslint from "typescript-eslint"; - -export default [ - { - ignores: [ - "dist/**", - "coverage/**", - "node_modules/**", - // Ignore all example files for CSR architecture - "src/example-kit.*", - "src/controllers/example.controller.ts", - "src/services/example.service.ts", - "src/entities/example.entity.ts", - "src/repositories/example.repository.ts", - "src/guards/example.guard.ts", - "src/decorators/example.decorator.ts", - "src/dto/create-example.dto.ts", - "src/dto/update-example.dto.ts", - ], - }, - - eslint.configs.recommended, - - // TypeScript ESLint (includes recommended rules) - ...tseslint.configs.recommended, - - // Base TS rules (all TS files) - { - files: ["**/*.ts"], - languageOptions: { - parser: tseslint.parser, - parserOptions: { - project: "./tsconfig.eslint.json", - tsconfigRootDir: import.meta.dirname, - ecmaVersion: "latest", - sourceType: "module", - }, - globals: { ...globals.node, ...globals.jest }, - }, - plugins: { - "@typescript-eslint": tseslint.plugin, - import: importPlugin, - }, - rules: { - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], - "@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }], - - "import/no-duplicates": "error", - "import/order": [ - "error", - { - "newlines-between": "always", - alphabetize: { order: "asc", caseInsensitive: true }, - }, - ], - }, - }, - - // Architecture boundary: core must not import Nest - { - files: ["src/core/**/*.ts"], - rules: { - "no-restricted-imports": [ - "error", - { - patterns: [ - { - group: ["@nestjs/*"], - message: "Do not import NestJS in core/. Keep core framework-free.", - }, - ], - }, - ], - }, - }, -]; diff --git a/eslint.config.mjs b/eslint.config.mjs index f6a4faf..6da30ca 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,42 +1,80 @@ // @ts-check import eslint from "@eslint/js"; -import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; import globals from "globals"; +import importPlugin from "eslint-plugin-import"; import tseslint from "typescript-eslint"; -export default tseslint.config( +export default [ { - ignores: ["eslint.config.mjs"], + ignores: [ + "dist/**", + "coverage/**", + "node_modules/**", + // Ignore all example files for CSR architecture + "src/example-kit.*", + "src/controllers/example.controller.ts", + "src/services/example.service.ts", + "src/entities/example.entity.ts", + "src/repositories/example.repository.ts", + "src/guards/example.guard.ts", + "src/decorators/example.decorator.ts", + "src/dto/create-example.dto.ts", + "src/dto/update-example.dto.ts", + ], }, + eslint.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, - eslintPluginPrettierRecommended, + + // TypeScript ESLint (includes recommended rules) + ...tseslint.configs.recommended, + + // Base TS rules (all TS files) { + files: ["**/*.ts"], languageOptions: { - globals: { - ...globals.node, - ...globals.jest, - }, - sourceType: "commonjs", + parser: tseslint.parser, parserOptions: { - projectService: true, + project: "./tsconfig.eslint.json", tsconfigRootDir: import.meta.dirname, + ecmaVersion: "latest", + sourceType: "module", }, + globals: { ...globals.node, ...globals.jest }, + }, + plugins: { + "@typescript-eslint": tseslint.plugin, + import: importPlugin, + }, + rules: { + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }], + + "import/no-duplicates": "error", + "import/order": [ + "error", + { + "newlines-between": "always", + alphabetize: { order: "asc", caseInsensitive: true }, + }, + ], }, }, + + // Architecture boundary: core must not import Nest { + files: ["src/core/**/*.ts"], rules: { - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-floating-promises": "warn", - "@typescript-eslint/no-unsafe-argument": "warn", - "@typescript-eslint/no-unused-vars": [ + "no-restricted-imports": [ "error", { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", + patterns: [ + { + group: ["@nestjs/*"], + message: "Do not import NestJS in core/. Keep core framework-free.", + }, + ], }, ], - "no-unused-vars": "off", }, }, -); +]; diff --git a/jest.config.ts b/jest.config.ts index 1d7bc2e..2b69a86 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -46,6 +46,8 @@ const config: Config = { "^@filters/(.*)$": "/src/filters/$1", "^@middleware/(.*)$": "/src/middleware/$1", "^@utils/(.*)$": "/src/utils/$1", + "^@interfaces/(.*)$": "/src/interfaces/$1", + "^@indicators/(.*)$": "/src/indicators/$1", }, }; From f85613726383b9040f591677855154266ba4c0f0 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Tue, 31 Mar 2026 12:19:30 +0100 Subject: [PATCH 03/37] chore(git): fix husky pre-commit hook for v10 compatibility --- .husky/pre-commit | 3 --- 1 file changed, 3 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 0312b76..d0a7784 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - npx lint-staged \ No newline at end of file From c74b8aeca0ad8e5ecaae35de93875d1c386e643d Mon Sep 17 00:00:00 2001 From: saad moumou Date: Tue, 31 Mar 2026 12:19:49 +0100 Subject: [PATCH 04/37] feat(interfaces): add IHealthIndicator, HealthStatus, HealthIndicatorResult --- src/interfaces/health-indicator.interface.ts | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/interfaces/health-indicator.interface.ts diff --git a/src/interfaces/health-indicator.interface.ts b/src/interfaces/health-indicator.interface.ts new file mode 100644 index 0000000..93fcab8 --- /dev/null +++ b/src/interfaces/health-indicator.interface.ts @@ -0,0 +1,35 @@ +/** + * The possible statuses for a health check result. + */ +export type HealthStatus = "up" | "down"; + +/** + * The result returned by every health indicator's `check()` method. + */ +export interface HealthIndicatorResult { + /** Unique name identifying this indicator (e.g. "postgres", "redis"). */ + name: string; + /** Whether the dependency is healthy. */ + status: HealthStatus; + /** Optional human-readable message (required when status is "down"). */ + message?: string; +} + +/** + * Contract that every health indicator must satisfy. + * + * Implement this interface for built-in indicators (Postgres, Redis, HTTP) + * and for user-supplied custom indicators. + * + * @example + * ```typescript + * class MyIndicator implements IHealthIndicator { + * async check(): Promise { + * return { name: 'my-service', status: 'up' }; + * } + * } + * ``` + */ +export interface IHealthIndicator { + check(): Promise; +} From 41d7af37d3cb42fa6aec3db04b9a502eb3062cec Mon Sep 17 00:00:00 2001 From: saad moumou Date: Tue, 31 Mar 2026 12:20:08 +0100 Subject: [PATCH 05/37] feat(indicators): add PostgresHealthIndicator (SELECT 1 + timeout) --- src/indicators/postgres.indicator.ts | 60 ++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/indicators/postgres.indicator.ts diff --git a/src/indicators/postgres.indicator.ts b/src/indicators/postgres.indicator.ts new file mode 100644 index 0000000..9ebf80c --- /dev/null +++ b/src/indicators/postgres.indicator.ts @@ -0,0 +1,60 @@ +import type { + IHealthIndicator, + HealthIndicatorResult, +} from "@interfaces/health-indicator.interface"; +import { Injectable } from "@nestjs/common"; + +/** + * A minimal duck-typed interface for any postgres-compatible query client. + * Accepts `pg.Pool`, TypeORM `DataSource`, or any object that exposes `query()`. + */ +export interface PostgresClient { + query(sql: string): Promise; +} + +const DEFAULT_TIMEOUT_MS = 3_000; + +/** + * Built-in health indicator for a PostgreSQL dependency. + * + * Executes `SELECT 1` to verify the database connection is alive. + * Returns `"down"` if the query fails or exceeds the configured timeout. + * + * @example + * ```typescript + * // With a pg.Pool + * const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + * + * HealthModule.register({ + * path: 'health', + * liveness: [], + * readiness: [new PostgresHealthIndicator(pool)], + * }); + * ``` + */ +@Injectable() +export class PostgresHealthIndicator implements IHealthIndicator { + constructor( + private readonly client: PostgresClient, + private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS, + ) {} + + async check(): Promise { + try { + await Promise.race([this.client.query("SELECT 1"), this._timeout()]); + return { name: "postgres", status: "up" }; + } catch (error) { + return { + name: "postgres", + status: "down", + message: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + private _timeout(): Promise { + return new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout")), this.timeoutMs), + ); + } +} From 8f9af5ef88fc519f403214869eb987a2559f3081 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Tue, 31 Mar 2026 12:20:23 +0100 Subject: [PATCH 06/37] test(indicators): add PostgresHealthIndicator unit tests (success/error/timeout) --- src/indicators/postgres.indicator.spec.ts | 63 +++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/indicators/postgres.indicator.spec.ts diff --git a/src/indicators/postgres.indicator.spec.ts b/src/indicators/postgres.indicator.spec.ts new file mode 100644 index 0000000..c7ebcbc --- /dev/null +++ b/src/indicators/postgres.indicator.spec.ts @@ -0,0 +1,63 @@ +import { PostgresHealthIndicator } from "./postgres.indicator"; + +describe("PostgresHealthIndicator", () => { + const mockClient = { query: jest.fn() }; + + // ── Success ────────────────────────────────────────────────────────────── + + it("returns 'up' when SELECT 1 succeeds", async () => { + mockClient.query.mockResolvedValue({}); + + const indicator = new PostgresHealthIndicator(mockClient); + const result = await indicator.check(); + + expect(result).toEqual({ name: "postgres", status: "up" }); + expect(mockClient.query).toHaveBeenCalledWith("SELECT 1"); + }); + + // ── Error ───────────────────────────────────────────────────────────────── + + it("returns 'down' with message when query throws", async () => { + mockClient.query.mockRejectedValue(new Error("Connection refused")); + + const indicator = new PostgresHealthIndicator(mockClient); + const result = await indicator.check(); + + expect(result).toEqual({ + name: "postgres", + status: "down", + message: "Connection refused", + }); + }); + + it("returns 'down' with 'Unknown error' for non-Error rejections", async () => { + mockClient.query.mockRejectedValue("raw string error"); + + const indicator = new PostgresHealthIndicator(mockClient); + const result = await indicator.check(); + + expect(result).toEqual({ + name: "postgres", + status: "down", + message: "Unknown error", + }); + }); + + // ── Timeout ─────────────────────────────────────────────────────────────── + + it("returns 'down' when query exceeds the configured timeout", async () => { + jest.useFakeTimers(); + // Simulate a query that never resolves + mockClient.query.mockImplementation(() => new Promise(() => {})); + + const indicator = new PostgresHealthIndicator(mockClient, 100); + const checkPromise = indicator.check(); + + jest.advanceTimersByTime(150); + + const result = await checkPromise; + expect(result).toEqual({ name: "postgres", status: "down", message: "Timeout" }); + + jest.useRealTimers(); + }); +}); From 5dff0e20171fc7260066a6869d5ce68f92266547 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Tue, 31 Mar 2026 12:20:37 +0100 Subject: [PATCH 07/37] feat(indicators): add RedisHealthIndicator (PING + timeout) --- src/indicators/redis.indicator.ts | 61 +++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/indicators/redis.indicator.ts diff --git a/src/indicators/redis.indicator.ts b/src/indicators/redis.indicator.ts new file mode 100644 index 0000000..bdab59c --- /dev/null +++ b/src/indicators/redis.indicator.ts @@ -0,0 +1,61 @@ +import type { + IHealthIndicator, + HealthIndicatorResult, +} from "@interfaces/health-indicator.interface"; +import { Injectable } from "@nestjs/common"; + +/** + * A minimal duck-typed interface for any Redis-compatible client. + * Accepts `ioredis` Redis/Cluster instances or any client that exposes `ping()`. + */ +export interface RedisClient { + ping(): Promise; +} + +const DEFAULT_TIMEOUT_MS = 3_000; + +/** + * Built-in health indicator for a Redis dependency. + * + * Sends a `PING` command and expects a `"PONG"` response. + * Returns `"down"` if the command fails or exceeds the configured timeout. + * + * @example + * ```typescript + * import Redis from 'ioredis'; + * + * const redis = new Redis({ host: 'localhost', port: 6379 }); + * + * HealthModule.register({ + * path: 'health', + * liveness: [], + * readiness: [new RedisHealthIndicator(redis)], + * }); + * ``` + */ +@Injectable() +export class RedisHealthIndicator implements IHealthIndicator { + constructor( + private readonly client: RedisClient, + private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS, + ) {} + + async check(): Promise { + try { + await Promise.race([this.client.ping(), this._timeout()]); + return { name: "redis", status: "up" }; + } catch (error) { + return { + name: "redis", + status: "down", + message: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + private _timeout(): Promise { + return new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout")), this.timeoutMs), + ); + } +} From af6f235a294b4f7490ea8e2adf57aa494ba2d242 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Tue, 31 Mar 2026 12:21:56 +0100 Subject: [PATCH 08/37] test(indicators): add RedisHealthIndicator unit tests (success/error/timeout) --- src/indicators/redis.indicator.spec.ts | 62 ++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/indicators/redis.indicator.spec.ts diff --git a/src/indicators/redis.indicator.spec.ts b/src/indicators/redis.indicator.spec.ts new file mode 100644 index 0000000..6ddc310 --- /dev/null +++ b/src/indicators/redis.indicator.spec.ts @@ -0,0 +1,62 @@ +import { RedisHealthIndicator } from "./redis.indicator"; + +describe("RedisHealthIndicator", () => { + const mockClient = { ping: jest.fn() }; + + // ── Success ─────────────────────────────────────────────────────────────── + + it("returns 'up' when PING succeeds", async () => { + mockClient.ping.mockResolvedValue("PONG"); + + const indicator = new RedisHealthIndicator(mockClient); + const result = await indicator.check(); + + expect(result).toEqual({ name: "redis", status: "up" }); + expect(mockClient.ping).toHaveBeenCalledTimes(1); + }); + + // ── Error ───────────────────────────────────────────────────────────────── + + it("returns 'down' with message when PING throws", async () => { + mockClient.ping.mockRejectedValue(new Error("ECONNREFUSED")); + + const indicator = new RedisHealthIndicator(mockClient); + const result = await indicator.check(); + + expect(result).toEqual({ + name: "redis", + status: "down", + message: "ECONNREFUSED", + }); + }); + + it("returns 'down' with 'Unknown error' for non-Error rejections", async () => { + mockClient.ping.mockRejectedValue(42); + + const indicator = new RedisHealthIndicator(mockClient); + const result = await indicator.check(); + + expect(result).toEqual({ + name: "redis", + status: "down", + message: "Unknown error", + }); + }); + + // ── Timeout ─────────────────────────────────────────────────────────────── + + it("returns 'down' when PING exceeds the configured timeout", async () => { + jest.useFakeTimers(); + mockClient.ping.mockImplementation(() => new Promise(() => {})); + + const indicator = new RedisHealthIndicator(mockClient, 100); + const checkPromise = indicator.check(); + + jest.advanceTimersByTime(150); + + const result = await checkPromise; + expect(result).toEqual({ name: "redis", status: "down", message: "Timeout" }); + + jest.useRealTimers(); + }); +}); From a5d8377d00441a399e66300bbf9eb9fb328e387d Mon Sep 17 00:00:00 2001 From: saad moumou Date: Tue, 31 Mar 2026 12:22:56 +0100 Subject: [PATCH 09/37] feat(indicators): add HttpHealthIndicator (GET + 2xx check + timeout) --- src/indicators/http.indicator.ts | 67 ++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/indicators/http.indicator.ts diff --git a/src/indicators/http.indicator.ts b/src/indicators/http.indicator.ts new file mode 100644 index 0000000..5865fb2 --- /dev/null +++ b/src/indicators/http.indicator.ts @@ -0,0 +1,67 @@ +import type { + IHealthIndicator, + HealthIndicatorResult, +} from "@interfaces/health-indicator.interface"; +import { Injectable } from "@nestjs/common"; + +const DEFAULT_TIMEOUT_MS = 3_000; + +/** + * Built-in health indicator for an HTTP dependency. + * + * Performs a GET request to the provided URL using the native `fetch` API + * (available on Node ≥ 20, which is this package's minimum engine requirement). + * Any 2xx response is treated as healthy. Non-2xx, network errors, and timeouts + * are all reported as `"down"`. + * + * @example + * ```typescript + * HealthModule.register({ + * path: 'health', + * liveness: [], + * readiness: [ + * new HttpHealthIndicator('https://api.example.com/health'), + * ], + * }); + * ``` + */ +@Injectable() +export class HttpHealthIndicator implements IHealthIndicator { + constructor( + private readonly url: string, + private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS, + ) {} + + async check(): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const response = await fetch(this.url, { + method: "GET", + signal: controller.signal, + }); + + if (response.ok) { + return { name: "http", status: "up" }; + } + + return { + name: "http", + status: "down", + message: `HTTP ${response.status} ${response.statusText}`, + }; + } catch (error) { + const isTimeout = + error instanceof Error && (error.name === "AbortError" || error.message === "Timeout"); + + return { + name: "http", + status: "down", + message: isTimeout ? "Timeout" : error instanceof Error ? error.message : "Unknown error", + }; + } finally { + clearTimeout(timer); + } + } +} From 914f34161ce70913a4e76777a6773b73d265dc0c Mon Sep 17 00:00:00 2001 From: saad moumou Date: Tue, 31 Mar 2026 12:23:15 +0100 Subject: [PATCH 10/37] test(indicators): add HttpHealthIndicator unit tests (2xx/non-2xx/network/timeout) --- src/indicators/http.indicator.spec.ts | 102 ++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/indicators/http.indicator.spec.ts diff --git a/src/indicators/http.indicator.spec.ts b/src/indicators/http.indicator.spec.ts new file mode 100644 index 0000000..a922bc4 --- /dev/null +++ b/src/indicators/http.indicator.spec.ts @@ -0,0 +1,102 @@ +import { HttpHealthIndicator } from "./http.indicator"; + +// Spy on globalThis.fetch so every test in this file uses a Jest mock. +// globalThis is correctly typed via "DOM" in tsconfig lib, so no cast needed. +let fetchSpy: jest.SpyInstance; + +beforeAll(() => { + fetchSpy = jest.spyOn(globalThis, "fetch").mockImplementation(jest.fn()); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +afterAll(() => { + fetchSpy.mockRestore(); +}); + +describe("HttpHealthIndicator", () => { + // ── Success (2xx) ───────────────────────────────────────────────────────── + + it("returns 'up' when the endpoint responds with 200", async () => { + fetchSpy.mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); + + const indicator = new HttpHealthIndicator("https://example.com/health"); + const result = await indicator.check(); + + expect(result).toEqual({ name: "http", status: "up" }); + expect(fetchSpy).toHaveBeenCalledWith( + "https://example.com/health", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("returns 'up' for any 2xx status code", async () => { + fetchSpy.mockResolvedValue({ ok: true, status: 204, statusText: "No Content" }); + + const result = await new HttpHealthIndicator("https://example.com/health").check(); + + expect(result).toEqual({ name: "http", status: "up" }); + }); + + // ── Non-2xx ─────────────────────────────────────────────────────────────── + + it("returns 'down' with HTTP status when endpoint responds with 503", async () => { + fetchSpy.mockResolvedValue({ ok: false, status: 503, statusText: "Service Unavailable" }); + + const result = await new HttpHealthIndicator("https://example.com/health").check(); + + expect(result).toEqual({ + name: "http", + status: "down", + message: "HTTP 503 Service Unavailable", + }); + }); + + it("returns 'down' with HTTP status when endpoint responds with 404", async () => { + fetchSpy.mockResolvedValue({ ok: false, status: 404, statusText: "Not Found" }); + + const result = await new HttpHealthIndicator("https://example.com/health").check(); + + expect(result).toEqual({ + name: "http", + status: "down", + message: "HTTP 404 Not Found", + }); + }); + + // ── Network error ───────────────────────────────────────────────────────── + + it("returns 'down' with message when fetch throws a network error", async () => { + fetchSpy.mockRejectedValue(new Error("Network failure")); + + const result = await new HttpHealthIndicator("https://example.com/health").check(); + + expect(result).toEqual({ + name: "http", + status: "down", + message: "Network failure", + }); + }); + + it("returns 'down' with 'Unknown error' for non-Error rejections", async () => { + fetchSpy.mockRejectedValue("string error"); + + const result = await new HttpHealthIndicator("https://example.com/health").check(); + + expect(result).toEqual({ name: "http", status: "down", message: "Unknown error" }); + }); + + // ── Timeout ─────────────────────────────────────────────────────────────── + + it("returns 'down' with 'Timeout' when fetch is aborted due to timeout", async () => { + const abortError = new Error("The operation was aborted"); + abortError.name = "AbortError"; + fetchSpy.mockRejectedValue(abortError); + + const result = await new HttpHealthIndicator("https://example.com/health", 100).check(); + + expect(result).toEqual({ name: "http", status: "down", message: "Timeout" }); + }); +}); From 60eedae174736c6d7abe93ec09ced17566ad9395 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Tue, 31 Mar 2026 12:23:36 +0100 Subject: [PATCH 11/37] chore(deps): update package-lock after npm install --- package-lock.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package-lock.json b/package-lock.json index 8698ff7..a216370 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@changesets/cli": "^2.27.7", + "@eslint/js": "^9.18.0", "@nestjs/common": "^10.4.0", "@nestjs/core": "^10.4.0", "@nestjs/mapped-types": "^2.0.0", From 7891e1e466511983b961002163264f7a84aa96f5 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Thu, 2 Apr 2026 14:04:07 +0100 Subject: [PATCH 12/37] fix(interfaces): add optional details field to HealthIndicatorResult --- src/interfaces/health-indicator.interface.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/interfaces/health-indicator.interface.ts b/src/interfaces/health-indicator.interface.ts index 93fcab8..2c7fe5d 100644 --- a/src/interfaces/health-indicator.interface.ts +++ b/src/interfaces/health-indicator.interface.ts @@ -13,6 +13,8 @@ export interface HealthIndicatorResult { status: HealthStatus; /** Optional human-readable message (required when status is "down"). */ message?: string; + /** Optional structured metadata (e.g. response time, version). */ + details?: Record; } /** From b9e39f6c8f5a578d3780cbdfc1b93f98aa9f654b Mon Sep 17 00:00:00 2001 From: saad moumou Date: Wed, 1 Apr 2026 13:20:34 +0100 Subject: [PATCH 13/37] chore(config): set module to CommonJS and moduleResolution to Node for dist output --- tsconfig.build.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tsconfig.build.json b/tsconfig.build.json index 22fa5d9..73298fd 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,6 +1,8 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", "noEmit": false, "emitDeclarationOnly": false, "outDir": "dist" From dbe9e5286584d38ddcb0e7c2b435145b76381abd Mon Sep 17 00:00:00 2001 From: saad moumou Date: Wed, 1 Apr 2026 13:22:43 +0100 Subject: [PATCH 14/37] chore(package): rename to @ciscode/health-kit --- package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 54f5ef5..9657d46 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "@ciscode/nestjs-developerkit", + "name": "@ciscode/health-kit", "version": "1.0.0", - "description": "Template for NestJS developer kits (npm packages).", + "description": "NestJS health-check module — liveness & readiness probes with built-in Postgres, Redis, and HTTP indicators.", "author": "CisCode", "publishConfig": { "access": "public" @@ -45,7 +45,6 @@ "peerDependencies": { "@nestjs/common": "^10 || ^11", "@nestjs/core": "^10 || ^11", - "@nestjs/platform-express": "^10 || ^11", "reflect-metadata": "^0.2.2", "rxjs": "^7" }, From 801e6487225f547c3c8ffe93ecca49b52fc2674e Mon Sep 17 00:00:00 2001 From: saad moumou Date: Wed, 1 Apr 2026 13:23:40 +0100 Subject: [PATCH 15/37] chore(deps): update package-lock --- package-lock.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a216370..c7af5c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@ciscode/nestjs-developerkit", + "name": "@ciscode/health-kit", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@ciscode/nestjs-developerkit", + "name": "@ciscode/health-kit", "version": "1.0.0", "license": "MIT", "dependencies": { @@ -44,7 +44,6 @@ "peerDependencies": { "@nestjs/common": "^10 || ^11", "@nestjs/core": "^10 || ^11", - "@nestjs/platform-express": "^10 || ^11", "reflect-metadata": "^0.2.2", "rxjs": "^7" } From 2e5d55956e76c353d057479badb97427a6bef20c Mon Sep 17 00:00:00 2001 From: saad moumou Date: Wed, 1 Apr 2026 13:24:44 +0100 Subject: [PATCH 16/37] feat(indicators): add MongoHealthIndicator with ping command and timeout --- src/indicators/mongo.indicator.ts | 70 +++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/indicators/mongo.indicator.ts diff --git a/src/indicators/mongo.indicator.ts b/src/indicators/mongo.indicator.ts new file mode 100644 index 0000000..2956d09 --- /dev/null +++ b/src/indicators/mongo.indicator.ts @@ -0,0 +1,70 @@ +import type { + IHealthIndicator, + HealthIndicatorResult, +} from "@interfaces/health-indicator.interface"; +import { Injectable } from "@nestjs/common"; + +/** + * Minimal duck-typed interface for a MongoDB database handle. + * Accepts `mongoose.connection.db` (a Mongoose `Db` object) or any + * object that exposes a `command()` method (native `mongodb` driver `Db`). + * + * @example + * ```typescript + * // mongoose + * new MongoHealthIndicator(mongoose.connection.db); + * + * // native driver + * const client = new MongoClient(uri); + * new MongoHealthIndicator(client.db()); + * ``` + */ +export interface MongoDb { + command(command: Record): Promise; +} + +const DEFAULT_TIMEOUT_MS = 3_000; + +/** + * Built-in health indicator for a MongoDB dependency. + * + * Runs `{ ping: 1 }` — the standard MongoDB server-health command. + * Returns `"down"` if the command fails or exceeds the configured timeout. + * + * @example + * ```typescript + * import mongoose from 'mongoose'; + * + * HealthModule.register({ + * path: 'health', + * liveness: [], + * readiness: [new MongoHealthIndicator(mongoose.connection.db)], + * }); + * ``` + */ +@Injectable() +export class MongoHealthIndicator implements IHealthIndicator { + constructor( + private readonly db: MongoDb, + private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS, + ) {} + + async check(): Promise { + try { + await Promise.race([this.db.command({ ping: 1 }), this._timeout()]); + return { name: "mongo", status: "up" }; + } catch (error) { + return { + name: "mongo", + status: "down", + message: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + private _timeout(): Promise { + return new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout")), this.timeoutMs), + ); + } +} From 83cd1d6fe300e68c585dc46411b97473b1a77405 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Wed, 1 Apr 2026 13:25:47 +0100 Subject: [PATCH 17/37] test(indicators): add MongoHealthIndicator unit tests (success/error/timeout) --- src/indicators/mongo.indicator.spec.ts | 62 ++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/indicators/mongo.indicator.spec.ts diff --git a/src/indicators/mongo.indicator.spec.ts b/src/indicators/mongo.indicator.spec.ts new file mode 100644 index 0000000..155b27e --- /dev/null +++ b/src/indicators/mongo.indicator.spec.ts @@ -0,0 +1,62 @@ +import { MongoHealthIndicator } from "./mongo.indicator"; + +describe("MongoHealthIndicator", () => { + const mockDb = { command: jest.fn() }; + + // ── Success ─────────────────────────────────────────────────────────────── + + it("returns 'up' when ping command succeeds", async () => { + mockDb.command.mockResolvedValue({ ok: 1 }); + + const indicator = new MongoHealthIndicator(mockDb); + const result = await indicator.check(); + + expect(result).toEqual({ name: "mongo", status: "up" }); + expect(mockDb.command).toHaveBeenCalledWith({ ping: 1 }); + }); + + // ── Error ───────────────────────────────────────────────────────────────── + + it("returns 'down' with message when command throws", async () => { + mockDb.command.mockRejectedValue(new Error("MongoNetworkError")); + + const indicator = new MongoHealthIndicator(mockDb); + const result = await indicator.check(); + + expect(result).toEqual({ + name: "mongo", + status: "down", + message: "MongoNetworkError", + }); + }); + + it("returns 'down' with 'Unknown error' for non-Error rejections", async () => { + mockDb.command.mockRejectedValue("raw error"); + + const indicator = new MongoHealthIndicator(mockDb); + const result = await indicator.check(); + + expect(result).toEqual({ + name: "mongo", + status: "down", + message: "Unknown error", + }); + }); + + // ── Timeout ─────────────────────────────────────────────────────────────── + + it("returns 'down' when command exceeds the configured timeout", async () => { + jest.useFakeTimers(); + mockDb.command.mockImplementation(() => new Promise(() => {})); + + const indicator = new MongoHealthIndicator(mockDb, 100); + const checkPromise = indicator.check(); + + jest.advanceTimersByTime(150); + + const result = await checkPromise; + expect(result).toEqual({ name: "mongo", status: "down", message: "Timeout" }); + + jest.useRealTimers(); + }); +}); From 0b633fa39327ee2eef614031f538750473764032 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Wed, 1 Apr 2026 13:26:34 +0100 Subject: [PATCH 18/37] feat(services): add HealthService with Promise.allSettled orchestration --- src/services/health.service.ts | 50 ++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/services/health.service.ts diff --git a/src/services/health.service.ts b/src/services/health.service.ts new file mode 100644 index 0000000..925940a --- /dev/null +++ b/src/services/health.service.ts @@ -0,0 +1,50 @@ +import type { + IHealthIndicator, + HealthIndicatorResult, +} from "@interfaces/health-indicator.interface"; +import { Injectable } from "@nestjs/common"; + +export interface HealthCheckResult { + status: "ok" | "error"; + indicators: HealthIndicatorResult[]; +} + +/** + * Orchestrates health indicator execution. + * + * Runs all registered indicators concurrently via `Promise.allSettled` so a + * single slow/failing dependency never blocks the others. + * Returns `status: "ok"` only when every indicator reports `"up"`. + */ +@Injectable() +export class HealthService { + constructor( + private readonly livenessIndicators: IHealthIndicator[], + private readonly readinessIndicators: IHealthIndicator[], + ) {} + + async checkLiveness(): Promise { + return this._run(this.livenessIndicators); + } + + async checkReadiness(): Promise { + return this._run(this.readinessIndicators); + } + + private async _run(indicators: IHealthIndicator[]): Promise { + const settled = await Promise.allSettled(indicators.map((i) => i.check())); + + const results: HealthIndicatorResult[] = settled.map((outcome, idx) => { + if (outcome.status === "fulfilled") return outcome.value; + // A thrown exception counts as "down" + return { + name: indicators[idx]?.constructor.name ?? `indicator-${idx}`, + status: "down" as const, + message: outcome.reason instanceof Error ? outcome.reason.message : "Unknown error", + }; + }); + + const allUp = results.every((r) => r.status === "up"); + return { status: allUp ? "ok" : "error", indicators: results }; + } +} From 10b44232536e9881d4c4fae1924fcf66f3d04bc8 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Wed, 1 Apr 2026 13:26:58 +0100 Subject: [PATCH 19/37] test(services): add HealthService unit tests (liveness/readiness/concurrency) --- src/services/health.service.spec.ts | 101 ++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/services/health.service.spec.ts diff --git a/src/services/health.service.spec.ts b/src/services/health.service.spec.ts new file mode 100644 index 0000000..c952fb2 --- /dev/null +++ b/src/services/health.service.spec.ts @@ -0,0 +1,101 @@ +import type { + IHealthIndicator, + HealthIndicatorResult, +} from "@interfaces/health-indicator.interface"; + +import { HealthService } from "./health.service"; + +const up = (name: string): HealthIndicatorResult => ({ name, status: "up" }); +const down = (name: string, message = "error"): HealthIndicatorResult => ({ + name, + status: "down", + message, +}); + +const mockIndicator = (result: HealthIndicatorResult): IHealthIndicator => ({ + check: jest.fn().mockResolvedValue(result), +}); + +const throwingIndicator = (message: string): IHealthIndicator => ({ + check: jest.fn().mockRejectedValue(new Error(message)), +}); + +describe("HealthService", () => { + // ── checkLiveness ───────────────────────────────────────────────────────── + + describe("checkLiveness()", () => { + it("returns status 'ok' when all liveness indicators are up", async () => { + const service = new HealthService([mockIndicator(up("proc"))], []); + const result = await service.checkLiveness(); + + expect(result.status).toBe("ok"); + expect(result.indicators).toEqual([up("proc")]); + }); + + it("returns status 'error' when any liveness indicator is down", async () => { + const service = new HealthService( + [mockIndicator(up("proc")), mockIndicator(down("memory"))], + [], + ); + const result = await service.checkLiveness(); + + expect(result.status).toBe("error"); + }); + + it("returns status 'ok' with empty indicators", async () => { + const service = new HealthService([], []); + const result = await service.checkLiveness(); + + expect(result.status).toBe("ok"); + expect(result.indicators).toEqual([]); + }); + }); + + // ── checkReadiness ──────────────────────────────────────────────────────── + + describe("checkReadiness()", () => { + it("returns status 'ok' when all readiness indicators are up", async () => { + const service = new HealthService( + [], + [mockIndicator(up("postgres")), mockIndicator(up("redis"))], + ); + const result = await service.checkReadiness(); + + expect(result.status).toBe("ok"); + expect(result.indicators).toHaveLength(2); + }); + + it("returns status 'error' when any readiness indicator is down", async () => { + const service = new HealthService( + [], + [mockIndicator(up("redis")), mockIndicator(down("postgres"))], + ); + const result = await service.checkReadiness(); + + expect(result.status).toBe("error"); + }); + }); + + // ── Concurrency (Promise.allSettled) ────────────────────────────────────── + + it("runs all indicators concurrently and catches thrown exceptions", async () => { + const service = new HealthService( + [], + [mockIndicator(up("redis")), throwingIndicator("ECONNREFUSED"), mockIndicator(up("http"))], + ); + const result = await service.checkReadiness(); + + expect(result.status).toBe("error"); + const failed = result.indicators.find((r) => r.status === "down"); + expect(failed?.message).toBe("ECONNREFUSED"); + }); + + it("does not short-circuit: all indicators run even if one throws", async () => { + const slow = mockIndicator(up("slow")); + const service = new HealthService([], [throwingIndicator("boom"), slow]); + + await service.checkReadiness(); + + expect(slow.check).toHaveBeenCalledTimes(1); + }); +}); From 4fdefba388e6599956ceda793518ccaeb0d80ab6 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Wed, 1 Apr 2026 13:27:17 +0100 Subject: [PATCH 20/37] feat(controllers): add HealthController factory (GET live/ready, platform-agnostic) --- src/controllers/health.controller.ts | 43 ++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/controllers/health.controller.ts diff --git a/src/controllers/health.controller.ts b/src/controllers/health.controller.ts new file mode 100644 index 0000000..ec26bbc --- /dev/null +++ b/src/controllers/health.controller.ts @@ -0,0 +1,43 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + ServiceUnavailableException, + Type, +} from "@nestjs/common"; +import { HealthService } from "@services/health.service"; +import type { HealthCheckResult } from "@services/health.service"; + +/** + * Factory that returns a NestJS controller class configured with the + * caller-supplied `path` prefix (e.g. `"health"`). + * + * Platform-agnostic — works with Express and Fastify. + * Returns 200 when all indicators are "up", + * throws ServiceUnavailableException (503) when any indicator is "down". + */ +export function createHealthController(path: string): Type { + @Controller(path) + class HealthController { + constructor(private readonly healthService: HealthService) {} + + @Get("live") + @HttpCode(HttpStatus.OK) + async live(): Promise { + const result = await this.healthService.checkLiveness(); + if (result.status === "error") throw new ServiceUnavailableException(result); + return result; + } + + @Get("ready") + @HttpCode(HttpStatus.OK) + async ready(): Promise { + const result = await this.healthService.checkReadiness(); + if (result.status === "error") throw new ServiceUnavailableException(result); + return result; + } + } + + return HealthController; +} From bbb1f1822cd66f399cc50fc0ba22a95ab515ad08 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Wed, 1 Apr 2026 13:30:09 +0100 Subject: [PATCH 21/37] test(controllers): add HealthController unit tests (200 ok / 503 ServiceUnavailableException) --- src/controllers/health.controller.spec.ts | 62 +++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/controllers/health.controller.spec.ts diff --git a/src/controllers/health.controller.spec.ts b/src/controllers/health.controller.spec.ts new file mode 100644 index 0000000..c9e8d2d --- /dev/null +++ b/src/controllers/health.controller.spec.ts @@ -0,0 +1,62 @@ +import { ServiceUnavailableException } from "@nestjs/common"; +import type { TestingModule } from "@nestjs/testing"; +import { Test } from "@nestjs/testing"; +import { HealthService } from "@services/health.service"; +import type { HealthCheckResult } from "@services/health.service"; + +import { createHealthController } from "./health.controller"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeService = (liveness: "ok" | "error", readiness: "ok" | "error") => + ({ + checkLiveness: jest.fn().mockResolvedValue({ status: liveness, indicators: [] }), + checkReadiness: jest.fn().mockResolvedValue({ status: readiness, indicators: [] }), + }) as unknown as HealthService; + +interface HealthControllerInstance { + live(): Promise; + ready(): Promise; +} + +async function buildController( + liveness: "ok" | "error", + readiness: "ok" | "error", +): Promise { + const HealthController = createHealthController("health"); + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [HealthController], + providers: [{ provide: HealthService, useValue: makeService(liveness, readiness) }], + }).compile(); + return moduleRef.get(HealthController as never); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("HealthController (factory)", () => { + describe("GET /health/live", () => { + it("returns result when all liveness indicators are up", async () => { + const controller = await buildController("ok", "ok"); + const result = await controller.live(); + expect(result.status).toBe("ok"); + }); + + it("throws ServiceUnavailableException (503) when any liveness indicator is down", async () => { + const controller = await buildController("error", "ok"); + await expect(controller.live()).rejects.toThrow(ServiceUnavailableException); + }); + }); + + describe("GET /health/ready", () => { + it("returns result when all readiness indicators are up", async () => { + const controller = await buildController("ok", "ok"); + const result = await controller.ready(); + expect(result.status).toBe("ok"); + }); + + it("throws ServiceUnavailableException (503) when any readiness indicator is down", async () => { + const controller = await buildController("ok", "error"); + await expect(controller.ready()).rejects.toThrow(ServiceUnavailableException); + }); + }); +}); From 80cdcafeab8950c7eb13613dd1d43242e0921755 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Wed, 1 Apr 2026 13:30:29 +0100 Subject: [PATCH 22/37] feat(module): add HealthKitModule.register() dynamic module --- src/health-kit.module.ts | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/health-kit.module.ts diff --git a/src/health-kit.module.ts b/src/health-kit.module.ts new file mode 100644 index 0000000..6c4416b --- /dev/null +++ b/src/health-kit.module.ts @@ -0,0 +1,67 @@ +import { createHealthController } from "@controllers/health.controller"; +import type { IHealthIndicator } from "@interfaces/health-indicator.interface"; +import { Module, DynamicModule, Provider } from "@nestjs/common"; +import { HealthService } from "@services/health.service"; + +export const HEALTH_LIVENESS_INDICATORS = "HEALTH_LIVENESS_INDICATORS"; +export const HEALTH_READINESS_INDICATORS = "HEALTH_READINESS_INDICATORS"; + +export interface HealthModuleOptions { + /** URL path prefix for the health endpoints (e.g. `"health"` → `/health/live`, `/health/ready`). */ + path: string; + /** Indicators checked by `GET /{path}/live`. */ + liveness: IHealthIndicator[]; + /** Indicators checked by `GET /{path}/ready`. */ + readiness: IHealthIndicator[]; +} + +/** + * `@ciscode/health-kit` — NestJS health-check module. + * + * @example + * ```typescript + * import { HealthModule } from '@ciscode/health-kit'; + * import { MongoHealthIndicator } from '@ciscode/health-kit'; + * + * @Module({ + * imports: [ + * HealthModule.register({ + * path: 'health', + * liveness: [], + * readiness: [new MongoHealthIndicator(dataSource)], + * }), + * ], + * }) + * export class AppModule {} + * ``` + */ +@Module({}) +export class HealthKitModule { + static register(options: HealthModuleOptions): DynamicModule { + const providers: Provider[] = [ + { + provide: HEALTH_LIVENESS_INDICATORS, + useValue: options.liveness, + }, + { + provide: HEALTH_READINESS_INDICATORS, + useValue: options.readiness, + }, + { + provide: HealthService, + useFactory: (liveness: IHealthIndicator[], readiness: IHealthIndicator[]) => + new HealthService(liveness, readiness), + inject: [HEALTH_LIVENESS_INDICATORS, HEALTH_READINESS_INDICATORS], + }, + ]; + + const HealthController = createHealthController(options.path); + + return { + module: HealthKitModule, + controllers: [HealthController], + providers, + exports: [HealthService], + }; + } +} From e29be844b408de14dfd54e0a339e370b002f80c2 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Wed, 1 Apr 2026 13:30:51 +0100 Subject: [PATCH 23/37] feat(exports): update public API exports for health-kit --- src/index.ts | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/index.ts b/src/index.ts index 3026198..af6b99d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,41 +3,41 @@ import "reflect-metadata"; // ============================================================================ // PUBLIC API EXPORTS // ============================================================================ -// This file defines what consumers of your module can import. -// ONLY export what is necessary for external use. -// Keep entities, repositories, and internal implementation details private. // ============================================================================ // MODULE // ============================================================================ -export { ExampleKitModule } from "./example-kit.module"; -export type { ExampleKitOptions, ExampleKitAsyncOptions } from "./example-kit.module"; +export { HealthKitModule } from "./health-kit.module"; +export type { HealthModuleOptions } from "./health-kit.module"; // ============================================================================ -// SERVICES (Main API) +// SERVICE (Programmatic API) // ============================================================================ -// Export services that consumers will interact with -export { ExampleService } from "./services/example.service"; +export { HealthService } from "./services/health.service"; +export type { HealthCheckResult } from "./services/health.service"; // ============================================================================ -// DTOs (Public Contracts) +// BUILT-IN INDICATORS // ============================================================================ -// DTOs are the public interface for your API -// Consumers depend on these, so they must be stable -export { CreateExampleDto } from "./dto/create-example.dto"; -export { UpdateExampleDto } from "./dto/update-example.dto"; +export { PostgresHealthIndicator } from "./indicators/postgres.indicator"; +export type { PostgresClient } from "./indicators/postgres.indicator"; -// ============================================================================ -// GUARDS (For Route Protection) -// ============================================================================ -// Export guards so consumers can use them in their apps -export { ExampleGuard } from "./guards/example.guard"; +export { RedisHealthIndicator } from "./indicators/redis.indicator"; +export type { RedisClient } from "./indicators/redis.indicator"; + +export { HttpHealthIndicator } from "./indicators/http.indicator"; + +export { MongoHealthIndicator } from "./indicators/mongo.indicator"; +export type { MongoDb } from "./indicators/mongo.indicator"; // ============================================================================ -// DECORATORS (For Dependency Injection & Metadata) +// TYPES & INTERFACES // ============================================================================ -// Export decorators for use in consumer controllers/services -export { ExampleData, ExampleParam } from "./decorators/example.decorator"; +export type { + IHealthIndicator, + HealthIndicatorResult, + HealthStatus, +} from "./interfaces/health-indicator.interface"; // ============================================================================ // TYPES & INTERFACES (For TypeScript Typing) From eff497800cb54c48fa972e942531ed260ea83cd5 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Thu, 2 Apr 2026 14:05:15 +0100 Subject: [PATCH 24/37] fix(services): rename indicators to results in HealthCheckResult response --- src/services/health.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/health.service.ts b/src/services/health.service.ts index 925940a..723190c 100644 --- a/src/services/health.service.ts +++ b/src/services/health.service.ts @@ -6,7 +6,7 @@ import { Injectable } from "@nestjs/common"; export interface HealthCheckResult { status: "ok" | "error"; - indicators: HealthIndicatorResult[]; + results: HealthIndicatorResult[]; } /** @@ -45,6 +45,6 @@ export class HealthService { }); const allUp = results.every((r) => r.status === "up"); - return { status: allUp ? "ok" : "error", indicators: results }; + return { status: allUp ? "ok" : "error", results }; } } From a73cb21e28d20a17c06be4022fb71a6fdff2fe83 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Thu, 2 Apr 2026 12:04:11 +0100 Subject: [PATCH 25/37] feat(indicators): add createIndicator inline factory with timeout support --- src/indicators/create-indicator.ts | 46 ++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/indicators/create-indicator.ts diff --git a/src/indicators/create-indicator.ts b/src/indicators/create-indicator.ts new file mode 100644 index 0000000..7105167 --- /dev/null +++ b/src/indicators/create-indicator.ts @@ -0,0 +1,46 @@ +import type { + HealthIndicatorResult, + IHealthIndicator, +} from "@interfaces/health-indicator.interface"; + +/** + * Factory that creates an {@link IHealthIndicator} from an inline async function. + * + * The check function may return: + * - `false` → indicator reports `"down"` + * - `true` or `void` → indicator reports `"up"` + * - throws → indicator reports `"down"` with the error message + * + * @example + * ```typescript + * const diskIndicator = createIndicator('disk', async () => { + * const free = await getDiskFreeSpace(); + * return free > MIN_FREE_BYTES; + * }); + * ``` + */ +export function createIndicator( + name: string, + checkFn: () => Promise, + options?: { timeout?: number }, +): IHealthIndicator { + const timeout = options?.timeout ?? 3000; + + return { + async check(): Promise { + const run = async (): Promise => { + const result = await checkFn(); + if (result === false) { + return { name, status: "down" }; + } + return { name, status: "up" }; + }; + + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error(`${name} timed out after ${timeout}ms`)), timeout), + ); + + return Promise.race([run(), timeoutPromise]); + }, + }; +} From 7898821caedd93170900c400c673bea3cf3e28f8 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Thu, 2 Apr 2026 12:04:41 +0100 Subject: [PATCH 26/37] test(indicators): add createIndicator unit tests (true/false/void/throw/timeout) --- src/indicators/create-indicator.spec.ts | 56 +++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/indicators/create-indicator.spec.ts diff --git a/src/indicators/create-indicator.spec.ts b/src/indicators/create-indicator.spec.ts new file mode 100644 index 0000000..d11159b --- /dev/null +++ b/src/indicators/create-indicator.spec.ts @@ -0,0 +1,56 @@ +import { createIndicator } from "./create-indicator"; + +describe("createIndicator", () => { + it("returns status up when checkFn resolves true", async () => { + const indicator = createIndicator("my-check", async () => true); + const result = await indicator.check(); + expect(result).toEqual({ name: "my-check", status: "up" }); + }); + + it("returns status up when checkFn resolves void/undefined", async () => { + const indicator = createIndicator("my-check", async () => undefined); + const result = await indicator.check(); + expect(result).toEqual({ name: "my-check", status: "up" }); + }); + + it("returns status down when checkFn returns false", async () => { + const indicator = createIndicator("my-check", async () => false); + const result = await indicator.check(); + expect(result).toEqual({ name: "my-check", status: "down" }); + }); + + it("propagates rejection from checkFn", async () => { + const indicator = createIndicator("my-check", async () => { + throw new Error("dependency failed"); + }); + await expect(indicator.check()).rejects.toThrow("dependency failed"); + }); + + it("rejects with timeout error when checkFn exceeds timeout", async () => { + jest.useFakeTimers(); + + const slowFn = () => new Promise((resolve) => setTimeout(resolve, 5000)); + const indicator = createIndicator("slow-check", slowFn, { timeout: 100 }); + + const promise = indicator.check(); + jest.advanceTimersByTime(200); + + await expect(promise).rejects.toThrow("slow-check timed out after 100ms"); + + jest.useRealTimers(); + }); + + it("uses default timeout of 3000ms", async () => { + jest.useFakeTimers(); + + const slowFn = () => new Promise((resolve) => setTimeout(resolve, 5000)); + const indicator = createIndicator("slow-check", slowFn); + + const promise = indicator.check(); + jest.advanceTimersByTime(3100); + + await expect(promise).rejects.toThrow("slow-check timed out after 3000ms"); + + jest.useRealTimers(); + }); +}); From 543a07a1257e932989e5c09ad3f32f1f46b7b2c8 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Thu, 2 Apr 2026 12:04:57 +0100 Subject: [PATCH 27/37] feat(indicators): add BaseHealthIndicator abstract class with result() helper --- src/indicators/base.indicator.ts | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/indicators/base.indicator.ts diff --git a/src/indicators/base.indicator.ts b/src/indicators/base.indicator.ts new file mode 100644 index 0000000..8dfdb9a --- /dev/null +++ b/src/indicators/base.indicator.ts @@ -0,0 +1,49 @@ +import type { + HealthIndicatorResult, + HealthStatus, + IHealthIndicator, +} from "@interfaces/health-indicator.interface"; + +/** + * Abstract base class for DI-based custom health indicators. + * + * Extend this class and inject it into `HealthKitModule.register({ indicators: [...] })` + * alongside the `@HealthIndicator` decorator to auto-register it into liveness or readiness. + * + * @example + * ```typescript + * @HealthIndicator('readiness') + * @Injectable() + * export class DatabaseIndicator extends BaseHealthIndicator { + * readonly name = 'database'; + * + * constructor(private readonly orm: TypeOrmModule) { super(); } + * + * async check(): Promise { + * try { + * await this.orm.query('SELECT 1'); + * return this.result('up'); + * } catch (err) { + * return this.result('down', (err as Error).message); + * } + * } + * } + * ``` + */ +export abstract class BaseHealthIndicator implements IHealthIndicator { + /** Unique display name used in health-check responses. */ + abstract readonly name: string; + + abstract check(): Promise; + + /** + * Helper to build a {@link HealthIndicatorResult} using this indicator's name. + */ + protected result(status: HealthStatus, message?: string): HealthIndicatorResult { + return { + name: this.name, + status, + ...(message !== undefined ? { message } : {}), + }; + } +} From 63c13366b584edb3ffab9e6a617311ac0f2019eb Mon Sep 17 00:00:00 2001 From: saad moumou Date: Thu, 2 Apr 2026 12:05:12 +0100 Subject: [PATCH 28/37] test(indicators): add BaseHealthIndicator unit tests --- src/indicators/base.indicator.spec.ts | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/indicators/base.indicator.spec.ts diff --git a/src/indicators/base.indicator.spec.ts b/src/indicators/base.indicator.spec.ts new file mode 100644 index 0000000..ec8c3ee --- /dev/null +++ b/src/indicators/base.indicator.spec.ts @@ -0,0 +1,50 @@ +import type { HealthIndicatorResult } from "@interfaces/health-indicator.interface"; + +import { BaseHealthIndicator } from "./base.indicator"; + +// Concrete implementation for testing +class ConcreteIndicator extends BaseHealthIndicator { + readonly name = "test-service"; + + async check(): Promise { + return this.result("up"); + } +} + +class ConcreteIndicatorWithMessage extends BaseHealthIndicator { + readonly name = "test-service"; + + async check(): Promise { + return this.result("down", "connection refused"); + } +} + +describe("BaseHealthIndicator", () => { + it("can be instantiated via a concrete subclass", () => { + const indicator = new ConcreteIndicator(); + expect(indicator).toBeInstanceOf(BaseHealthIndicator); + expect(indicator.name).toBe("test-service"); + }); + + it("result() returns up result with name and status", async () => { + const indicator = new ConcreteIndicator(); + const result = await indicator.check(); + expect(result).toEqual({ name: "test-service", status: "up" }); + }); + + it("result() includes message when provided", async () => { + const indicator = new ConcreteIndicatorWithMessage(); + const result = await indicator.check(); + expect(result).toEqual({ + name: "test-service", + status: "down", + message: "connection refused", + }); + }); + + it("result() omits message property when not provided", async () => { + const indicator = new ConcreteIndicator(); + const result = await indicator.check(); + expect(result).not.toHaveProperty("message"); + }); +}); From f8e8e015c9185e26d6fd3883445006e7135f2a15 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Thu, 2 Apr 2026 12:05:34 +0100 Subject: [PATCH 29/37] feat(decorators): add @HealthIndicator decorator for auto-registration by scope --- src/decorators/health-indicator.decorator.ts | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/decorators/health-indicator.decorator.ts diff --git a/src/decorators/health-indicator.decorator.ts b/src/decorators/health-indicator.decorator.ts new file mode 100644 index 0000000..1046947 --- /dev/null +++ b/src/decorators/health-indicator.decorator.ts @@ -0,0 +1,34 @@ +import "reflect-metadata"; + +export const HEALTH_INDICATOR_METADATA = "ciscode:health-indicator:scope"; + +export type HealthIndicatorScope = "liveness" | "readiness"; + +/** + * Class decorator that marks a {@link BaseHealthIndicator} subclass for + * auto-registration into `HealthKitModule`. + * + * Pass the decorated class to `HealthKitModule.register({ indicators: [...] })` and + * the module will automatically inject it into the correct liveness or readiness list. + * + * @example + * ```typescript + * @HealthIndicator('readiness') + * @Injectable() + * export class DatabaseIndicator extends BaseHealthIndicator { + * readonly name = 'database'; + * async check() { ... } + * } + * + * // In AppModule: + * HealthKitModule.register({ + * path: 'health', + * indicators: [DatabaseIndicator], + * }) + * ``` + */ +export function HealthIndicator(scope: HealthIndicatorScope): ClassDecorator { + return (target) => { + Reflect.defineMetadata(HEALTH_INDICATOR_METADATA, scope, target); + }; +} From d4947d731e59c02db9be302421578ceec4c498b1 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Thu, 2 Apr 2026 12:05:52 +0100 Subject: [PATCH 30/37] test(decorators): add @HealthIndicator decorator unit tests --- .../health-indicator.decorator.spec.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/decorators/health-indicator.decorator.spec.ts diff --git a/src/decorators/health-indicator.decorator.spec.ts b/src/decorators/health-indicator.decorator.spec.ts new file mode 100644 index 0000000..f3dd012 --- /dev/null +++ b/src/decorators/health-indicator.decorator.spec.ts @@ -0,0 +1,36 @@ +import "reflect-metadata"; +import { HEALTH_INDICATOR_METADATA, HealthIndicator } from "./health-indicator.decorator"; + +class SomeIndicator {} +class AnotherIndicator {} +class UndecotratedIndicator {} + +@HealthIndicator("liveness") +class LivenessIndicator extends SomeIndicator {} + +@HealthIndicator("readiness") +class ReadinessIndicator extends AnotherIndicator {} + +describe("@HealthIndicator decorator", () => { + it("attaches liveness metadata to the target class", () => { + const scope = Reflect.getMetadata(HEALTH_INDICATOR_METADATA, LivenessIndicator); + expect(scope).toBe("liveness"); + }); + + it("attaches readiness metadata to the target class", () => { + const scope = Reflect.getMetadata(HEALTH_INDICATOR_METADATA, ReadinessIndicator); + expect(scope).toBe("readiness"); + }); + + it("returns undefined for undecorated classes", () => { + const scope = Reflect.getMetadata(HEALTH_INDICATOR_METADATA, UndecotratedIndicator); + expect(scope).toBeUndefined(); + }); + + it("does not affect other classes when decorating one", () => { + const livScope = Reflect.getMetadata(HEALTH_INDICATOR_METADATA, LivenessIndicator); + const readScope = Reflect.getMetadata(HEALTH_INDICATOR_METADATA, ReadinessIndicator); + expect(livScope).toBe("liveness"); + expect(readScope).toBe("readiness"); + }); +}); From 36a99abc9b23e4bfab9736c5197e11d2df628e9b Mon Sep 17 00:00:00 2001 From: saad moumou Date: Thu, 2 Apr 2026 12:06:13 +0100 Subject: [PATCH 31/37] feat(module): extend HealthKitModule.register() with indicators[] option for DI-based auto-registration --- src/health-kit.module.ts | 46 ++++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/src/health-kit.module.ts b/src/health-kit.module.ts index 6c4416b..cba8808 100644 --- a/src/health-kit.module.ts +++ b/src/health-kit.module.ts @@ -1,6 +1,8 @@ import { createHealthController } from "@controllers/health.controller"; +import { HEALTH_INDICATOR_METADATA } from "@decorators/health-indicator.decorator"; +import type { BaseHealthIndicator } from "@indicators/base.indicator"; import type { IHealthIndicator } from "@interfaces/health-indicator.interface"; -import { Module, DynamicModule, Provider } from "@nestjs/common"; +import { Module, DynamicModule, Provider, Type } from "@nestjs/common"; import { HealthService } from "@services/health.service"; export const HEALTH_LIVENESS_INDICATORS = "HEALTH_LIVENESS_INDICATORS"; @@ -9,10 +11,15 @@ export const HEALTH_READINESS_INDICATORS = "HEALTH_READINESS_INDICATORS"; export interface HealthModuleOptions { /** URL path prefix for the health endpoints (e.g. `"health"` → `/health/live`, `/health/ready`). */ path: string; - /** Indicators checked by `GET /{path}/live`. */ - liveness: IHealthIndicator[]; - /** Indicators checked by `GET /{path}/ready`. */ - readiness: IHealthIndicator[]; + /** Explicit indicator instances checked by `GET /{path}/live`. */ + liveness?: IHealthIndicator[]; + /** Explicit indicator instances checked by `GET /{path}/ready`. */ + readiness?: IHealthIndicator[]; + /** + * DI-based indicator classes decorated with `@HealthIndicator('liveness'|'readiness')`. + * The module resolves them via NestJS DI and auto-registers them in the correct list. + */ + indicators?: Type[]; } /** @@ -38,14 +45,39 @@ export interface HealthModuleOptions { @Module({}) export class HealthKitModule { static register(options: HealthModuleOptions): DynamicModule { + const indicatorClasses = options.indicators ?? []; + + // Separate DI-based indicator classes by scope using decorator metadata + const livenessClasses = indicatorClasses.filter( + (cls) => Reflect.getMetadata(HEALTH_INDICATOR_METADATA, cls) === "liveness", + ); + const readinessClasses = indicatorClasses.filter( + (cls) => Reflect.getMetadata(HEALTH_INDICATOR_METADATA, cls) === "readiness", + ); + + // Create a NestJS provider for each indicator class (enables DI injection) + const indicatorProviders: Provider[] = indicatorClasses.map((cls) => ({ + provide: cls, + useClass: cls, + })); + const providers: Provider[] = [ + ...indicatorProviders, { provide: HEALTH_LIVENESS_INDICATORS, - useValue: options.liveness, + useFactory: (...injected: BaseHealthIndicator[]) => [ + ...(options.liveness ?? []), + ...injected, + ], + inject: livenessClasses, }, { provide: HEALTH_READINESS_INDICATORS, - useValue: options.readiness, + useFactory: (...injected: BaseHealthIndicator[]) => [ + ...(options.readiness ?? []), + ...injected, + ], + inject: readinessClasses, }, { provide: HealthService, From 15c05f7de351a96a55f581dd3454cda61a9c3765 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Thu, 2 Apr 2026 12:06:58 +0100 Subject: [PATCH 32/37] feat(exports): export createIndicator, BaseHealthIndicator, @HealthIndicator, HealthIndicatorScope --- src/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/index.ts b/src/index.ts index af6b99d..af2a06b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,17 @@ export { HttpHealthIndicator } from "./indicators/http.indicator"; export { MongoHealthIndicator } from "./indicators/mongo.indicator"; export type { MongoDb } from "./indicators/mongo.indicator"; +// ============================================================================ +// CUSTOM INDICATOR API +// ============================================================================ +export { createIndicator } from "./indicators/create-indicator"; +export { BaseHealthIndicator } from "./indicators/base.indicator"; +export { + HealthIndicator, + HEALTH_INDICATOR_METADATA, +} from "./decorators/health-indicator.decorator"; +export type { HealthIndicatorScope } from "./decorators/health-indicator.decorator"; + // ============================================================================ // TYPES & INTERFACES // ============================================================================ From 042c48c2b65bd5a2f2476a985c8703c00b4c3465 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Thu, 2 Apr 2026 12:07:19 +0100 Subject: [PATCH 33/37] chore(package): update description to mention MongoDB --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9657d46..b233823 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@ciscode/health-kit", "version": "1.0.0", - "description": "NestJS health-check module — liveness & readiness probes with built-in Postgres, Redis, and HTTP indicators.", + "description": "NestJS health-check module — liveness & readiness probes with built-in MongoDB, Redis, and HTTP indicators.", "author": "CisCode", "publishConfig": { "access": "public" From f63633f4ddcafdf33e3e1bfeccbae77511fd768b Mon Sep 17 00:00:00 2001 From: saad moumou Date: Thu, 2 Apr 2026 14:07:37 +0100 Subject: [PATCH 34/37] chore(indicators): remove MongoHealthIndicator --- src/indicators/mongo.indicator.ts | 70 ------------------------------- 1 file changed, 70 deletions(-) delete mode 100644 src/indicators/mongo.indicator.ts diff --git a/src/indicators/mongo.indicator.ts b/src/indicators/mongo.indicator.ts deleted file mode 100644 index 2956d09..0000000 --- a/src/indicators/mongo.indicator.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { - IHealthIndicator, - HealthIndicatorResult, -} from "@interfaces/health-indicator.interface"; -import { Injectable } from "@nestjs/common"; - -/** - * Minimal duck-typed interface for a MongoDB database handle. - * Accepts `mongoose.connection.db` (a Mongoose `Db` object) or any - * object that exposes a `command()` method (native `mongodb` driver `Db`). - * - * @example - * ```typescript - * // mongoose - * new MongoHealthIndicator(mongoose.connection.db); - * - * // native driver - * const client = new MongoClient(uri); - * new MongoHealthIndicator(client.db()); - * ``` - */ -export interface MongoDb { - command(command: Record): Promise; -} - -const DEFAULT_TIMEOUT_MS = 3_000; - -/** - * Built-in health indicator for a MongoDB dependency. - * - * Runs `{ ping: 1 }` — the standard MongoDB server-health command. - * Returns `"down"` if the command fails or exceeds the configured timeout. - * - * @example - * ```typescript - * import mongoose from 'mongoose'; - * - * HealthModule.register({ - * path: 'health', - * liveness: [], - * readiness: [new MongoHealthIndicator(mongoose.connection.db)], - * }); - * ``` - */ -@Injectable() -export class MongoHealthIndicator implements IHealthIndicator { - constructor( - private readonly db: MongoDb, - private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS, - ) {} - - async check(): Promise { - try { - await Promise.race([this.db.command({ ping: 1 }), this._timeout()]); - return { name: "mongo", status: "up" }; - } catch (error) { - return { - name: "mongo", - status: "down", - message: error instanceof Error ? error.message : "Unknown error", - }; - } - } - - private _timeout(): Promise { - return new Promise((_, reject) => - setTimeout(() => reject(new Error("Timeout")), this.timeoutMs), - ); - } -} From 052cecf075ea9d92bc0dfe2af96cdd5448e6de36 Mon Sep 17 00:00:00 2001 From: saad moumou Date: Thu, 2 Apr 2026 14:08:08 +0100 Subject: [PATCH 35/37] feat(module): make path optional with default 'health', add registerAsync --- src/health-kit.module.ts | 83 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/src/health-kit.module.ts b/src/health-kit.module.ts index cba8808..2945d5c 100644 --- a/src/health-kit.module.ts +++ b/src/health-kit.module.ts @@ -7,10 +7,11 @@ import { HealthService } from "@services/health.service"; export const HEALTH_LIVENESS_INDICATORS = "HEALTH_LIVENESS_INDICATORS"; export const HEALTH_READINESS_INDICATORS = "HEALTH_READINESS_INDICATORS"; +const HEALTH_MODULE_OPTIONS = "HEALTH_MODULE_OPTIONS"; export interface HealthModuleOptions { - /** URL path prefix for the health endpoints (e.g. `"health"` → `/health/live`, `/health/ready`). */ - path: string; + /** URL path prefix for the health endpoints. Defaults to `"health"` → `/health/live`, `/health/ready`. */ + path?: string; /** Explicit indicator instances checked by `GET /{path}/live`. */ liveness?: IHealthIndicator[]; /** Explicit indicator instances checked by `GET /{path}/ready`. */ @@ -22,6 +23,21 @@ export interface HealthModuleOptions { indicators?: Type[]; } +export interface HealthModuleAsyncOptions { + /** URL path prefix. Defaults to `"health"`. Provided upfront (needed for controller registration). */ + path?: string; + /** NestJS modules to import (e.g. `ConfigModule`). */ + imports?: DynamicModule["imports"]; + /** Tokens to inject into `useFactory`. */ + inject?: unknown[]; + /** Factory that returns liveness/readiness/indicators options. */ + useFactory: ( + ...args: unknown[] + ) => Promise> | Omit; + /** DI-based indicator classes (must be known upfront for provider registration). */ + indicators?: Type[]; +} + /** * `@ciscode/health-kit` — NestJS health-check module. * @@ -44,7 +60,8 @@ export interface HealthModuleOptions { */ @Module({}) export class HealthKitModule { - static register(options: HealthModuleOptions): DynamicModule { + static register(options: HealthModuleOptions = {}): DynamicModule { + const path = options.path ?? "health"; const indicatorClasses = options.indicators ?? []; // Separate DI-based indicator classes by scope using decorator metadata @@ -87,10 +104,68 @@ export class HealthKitModule { }, ]; - const HealthController = createHealthController(options.path); + const HealthController = createHealthController(path); + + return { + module: HealthKitModule, + controllers: [HealthController], + providers, + exports: [HealthService], + }; + } + + static registerAsync(options: HealthModuleAsyncOptions): DynamicModule { + const path = options.path ?? "health"; + const indicatorClasses = options.indicators ?? []; + + const livenessClasses = indicatorClasses.filter( + (cls) => Reflect.getMetadata(HEALTH_INDICATOR_METADATA, cls) === "liveness", + ); + const readinessClasses = indicatorClasses.filter( + (cls) => Reflect.getMetadata(HEALTH_INDICATOR_METADATA, cls) === "readiness", + ); + + const indicatorProviders: Provider[] = indicatorClasses.map((cls) => ({ + provide: cls, + useClass: cls, + })); + + const providers: Provider[] = [ + ...indicatorProviders, + { + provide: HEALTH_MODULE_OPTIONS, + useFactory: options.useFactory, + inject: (options.inject as never[]) ?? [], + }, + { + provide: HEALTH_LIVENESS_INDICATORS, + useFactory: (opts: HealthModuleOptions, ...injected: BaseHealthIndicator[]) => [ + ...(opts.liveness ?? []), + ...injected, + ], + inject: [HEALTH_MODULE_OPTIONS, ...livenessClasses], + }, + { + provide: HEALTH_READINESS_INDICATORS, + useFactory: (opts: HealthModuleOptions, ...injected: BaseHealthIndicator[]) => [ + ...(opts.readiness ?? []), + ...injected, + ], + inject: [HEALTH_MODULE_OPTIONS, ...readinessClasses], + }, + { + provide: HealthService, + useFactory: (liveness: IHealthIndicator[], readiness: IHealthIndicator[]) => + new HealthService(liveness, readiness), + inject: [HEALTH_LIVENESS_INDICATORS, HEALTH_READINESS_INDICATORS], + }, + ]; + + const HealthController = createHealthController(path); return { module: HealthKitModule, + imports: options.imports ?? [], controllers: [HealthController], providers, exports: [HealthService], From 86f470c764c8cf5fa3073b4984f34f8877e12add Mon Sep 17 00:00:00 2001 From: saad moumou Date: Thu, 2 Apr 2026 14:08:24 +0100 Subject: [PATCH 36/37] feat(exports): remove MongoHealthIndicator, export HealthModuleAsyncOptions --- src/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index af2a06b..a317a1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import "reflect-metadata"; // MODULE // ============================================================================ export { HealthKitModule } from "./health-kit.module"; -export type { HealthModuleOptions } from "./health-kit.module"; +export type { HealthModuleOptions, HealthModuleAsyncOptions } from "./health-kit.module"; // ============================================================================ // SERVICE (Programmatic API) @@ -27,9 +27,6 @@ export type { RedisClient } from "./indicators/redis.indicator"; export { HttpHealthIndicator } from "./indicators/http.indicator"; -export { MongoHealthIndicator } from "./indicators/mongo.indicator"; -export type { MongoDb } from "./indicators/mongo.indicator"; - // ============================================================================ // CUSTOM INDICATOR API // ============================================================================ From 8eae15f50ea4e1544b7245a842181257d17fe30b Mon Sep 17 00:00:00 2001 From: saad moumou Date: Thu, 2 Apr 2026 14:08:42 +0100 Subject: [PATCH 37/37] chore(package): add @nestjs/terminus peer dep, fix description --- package.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b233823..8a1680e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@ciscode/health-kit", "version": "1.0.0", - "description": "NestJS health-check module — liveness & readiness probes with built-in MongoDB, Redis, and HTTP indicators.", + "description": "NestJS health-check module — liveness & readiness probes with built-in PostgreSQL, Redis, and HTTP indicators.", "author": "CisCode", "publishConfig": { "access": "public" @@ -45,9 +45,15 @@ "peerDependencies": { "@nestjs/common": "^10 || ^11", "@nestjs/core": "^10 || ^11", + "@nestjs/terminus": "^10 || ^11", "reflect-metadata": "^0.2.2", "rxjs": "^7" }, + "peerDependenciesMeta": { + "@nestjs/terminus": { + "optional": true + } + }, "dependencies": { "class-transformer": "^0.5.1", "class-validator": "^0.14.1"