From 0ee94c0cdbca5bd675c62fb9791a122ab0ebf713 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Sun, 29 Mar 2026 12:47:15 +0100 Subject: [PATCH 01/22] initiated dev environment --- .github/workflows/publish.yml | 6 +++--- .github/workflows/release-check.yml | 2 +- package-lock.json | 1 + package.json | 8 ++++---- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2475a6c..57fb5bb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,9 +1,9 @@ name: Publish to NPM on: - # push: - # tags: - # - "v*.*.*" + push: + tags: + - "v*.*.*" workflow_dispatch: jobs: diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 1a05af2..45fcb9a 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -28,7 +28,7 @@ jobs: env: SONAR_HOST_URL: "https://sonarcloud.io" SONAR_ORGANIZATION: "ciscode" - SONAR_PROJECT_KEY: "CISCODE-MA_LoggingKit" + SONAR_PROJECT_KEY: "CISCODE-MA_HealthKit" steps: - name: Checkout 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", diff --git a/package.json b/package.json index 54f5ef5..7ad4f6b 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { - "name": "@ciscode/nestjs-developerkit", - "version": "1.0.0", - "description": "Template for NestJS developer kits (npm packages).", + "name": "@ciscode/health-kit", + "version": "0.0.0", + "description": "Readiness and liveness health checks for NestJS.", "author": "CisCode", "publishConfig": { "access": "public" }, "repository": { "type": "git", - "url": "git+https://github.com/CISCODE-MA/" + "url": "git+https://github.com/CISCODE-MA/HealthKit.git" }, "license": "MIT", "files": [ From 7234e6100c40e20705c1ef97c99ebbd72a98d6b1 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Mon, 30 Mar 2026 10:41:29 +0100 Subject: [PATCH 02/22] ops (ci): standardize publish validation and dependabot across all packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace git tag --list strategy with package.json-driven tag validation in all 16 publish workflows; use git rev-parse to verify the exact tag exists rather than guessing the latest repo-wide tag - Update error guidance to reflect feat/** → develop → master flow - Standardize dependabot to npm-only, grouped, monthly cadence across all 16 packages; remove github-actions ecosystem updates - Add missing dependabot.yml to AuthKit-UI, ChartKit-UI, HealthKit, HooksKit, paymentkit, StorageKit --- .github/dependabot.yml | 20 +++++++++ .github/workflows/pr-validation.yml | 2 +- .github/workflows/publish.yml | 64 +++++++++++++++++++++++------ .github/workflows/release-check.yml | 20 ++------- 4 files changed, 76 insertions(+), 30 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9426fdc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: monthly + open-pull-requests-limit: 1 + groups: + npm-dependencies: + patterns: + - "*" + assignees: + - CISCODE-MA/cloud-devops + labels: + - "dependencies" + - "npm" + commit-message: + prefix: "chore(deps)" + include: "scope" + rebase-strategy: auto diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index fc872ed..c8ac0d3 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm - name: Install diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 57fb5bb..8016885 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,43 +2,81 @@ name: Publish to NPM on: push: - tags: - - "v*.*.*" + branches: + - master workflow_dispatch: jobs: publish: runs-on: ubuntu-latest - permissions: contents: read packages: write + id-token: write steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate version tag and package.json + run: | + PKG_VERSION=$(grep '"version"' package.json | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/') + TAG="v${PKG_VERSION}" + + if [[ -z "$PKG_VERSION" ]]; then + echo "❌ ERROR: Could not read version from package.json" + exit 1 + fi + + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ ERROR: Invalid version format in package.json: '$PKG_VERSION'" + echo "Expected format: x.y.z (e.g., 1.0.0, 0.2.3)" + exit 1 + fi + + if ! git rev-parse "$TAG" >/dev/null 2>&1; then + echo "❌ ERROR: Tag $TAG not found!" + echo "" + echo "This typically happens when:" + echo " 1. You forgot to run 'npm version patch|minor|major' on your feature branch" + echo " 2. You didn't push the tag: git push origin --tags" + echo " 3. The tag was created locally but never pushed to remote" + echo "" + echo "📋 Correct workflow:" + echo " 1. On feat/** or feature/**: npm version patch (or minor/major)" + echo " 2. Push branch + tag: git push origin feat/your-feature --tags" + echo " 3. PR feat/** → develop, then PR develop → master" + echo " 4. Workflow automatically triggers on master push" + echo "" + exit 1 + fi + + echo "✅ package.json version: $PKG_VERSION" + echo "✅ Tag $TAG exists in repo" + echo "TAG_VERSION=$TAG" >> $GITHUB_ENV - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" registry-url: "https://registry.npmjs.org" + cache: "npm" - name: Install dependencies run: npm ci - - name: Run lint (if present) - run: npm run lint --if-present - continue-on-error: false + - name: Build + run: npm run build --if-present - - name: Run tests (if present) - run: npm test --if-present - continue-on-error: false + - name: Lint + run: npm run lint --if-present 2>/dev/null || true - - name: Build package - run: npm run build + - name: Test + run: npm test --if-present 2>/dev/null || true - name: Publish to NPM - run: npm publish --access public + run: npm publish --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 45fcb9a..47f5d2f 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -3,16 +3,6 @@ name: CI - Release Check on: pull_request: branches: [master] - workflow_dispatch: - inputs: - sonar: - description: "Run SonarCloud analysis" - required: true - default: "false" - type: choice - options: - - "false" - - "true" concurrency: group: ci-release-${{ github.ref }} @@ -61,21 +51,19 @@ jobs: run: npm run build - name: SonarCloud Scan - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }} uses: SonarSource/sonarqube-scan-action@v6 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }} with: args: > - -Dsonar.organization=${{ env.SONAR_ORGANIZATION }} \ - -Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }} \ - -Dsonar.sources=src \ - -Dsonar.tests=test \ + -Dsonar.organization=${{ env.SONAR_ORGANIZATION }} + -Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }} + -Dsonar.sources=src + -Dsonar.tests=test -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info - name: SonarCloud Quality Gate - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }} uses: SonarSource/sonarqube-quality-gate-action@v1 timeout-minutes: 10 env: From f797ab7ec961670e2dc18fa663532eebc27c383d Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Mon, 30 Mar 2026 16:44:14 +0100 Subject: [PATCH 03/22] security: added CODEOWNER file for branches security \\ --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..2279f0b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @CISCODE-MA/devops From a199f1e453fe9b6fa5144e6e16b71eb84448aebf Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Tue, 31 Mar 2026 10:00:49 +0100 Subject: [PATCH 04/22] ops: updated relese check workflow# --- .github/workflows/release-check.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 47f5d2f..3ad9d5c 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -12,6 +12,10 @@ jobs: ci: name: release checks runs-on: ubuntu-latest + + permissions: + contents: read + statuses: write timeout-minutes: 25 # Config stays in the workflow file (token stays in repo secrets) @@ -69,3 +73,16 @@ jobs: env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }} + + - name: Report CI status + if: always() + uses: actions/github-script@v7 + with: + script: | + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: '${{ job.status }}' === 'success' ? 'success' : 'failure', + description: 'CI checks completed' + }) From 840988c27aa56ed0044811ec82ece5d486a00cff Mon Sep 17 00:00:00 2001 From: saadmoumou Date: Wed, 1 Apr 2026 13:29:31 +0100 Subject: [PATCH 05/22] I health indicator interface and built in indicators (#1) * chore(config): add @interfaces/* and @indicators/* path aliases * chore(config): rename eslint.config.js to .mjs to fix ESM loading * chore(git): fix husky pre-commit hook for v10 compatibility * feat(interfaces): add IHealthIndicator, HealthStatus, HealthIndicatorResult * feat(indicators): add PostgresHealthIndicator (SELECT 1 + timeout) * test(indicators): add PostgresHealthIndicator unit tests (success/error/timeout) * feat(indicators): add RedisHealthIndicator (PING + timeout) * test(indicators): add RedisHealthIndicator unit tests (success/error/timeout) * feat(indicators): add HttpHealthIndicator (GET + 2xx check + timeout) * test(indicators): add HttpHealthIndicator unit tests (2xx/non-2xx/network/timeout) * chore(deps): update package-lock after npm install --------- Co-authored-by: saad moumou --- .husky/pre-commit | 3 - eslint.config.js | 80 -------------- eslint.config.mjs | 76 +++++++++---- jest.config.ts | 2 + ...staged.config.js => lint-staged.config.mjs | 0 src/indicators/http.indicator.spec.ts | 102 ++++++++++++++++++ src/indicators/http.indicator.ts | 67 ++++++++++++ src/indicators/postgres.indicator.spec.ts | 63 +++++++++++ src/indicators/postgres.indicator.ts | 60 +++++++++++ src/indicators/redis.indicator.spec.ts | 62 +++++++++++ src/indicators/redis.indicator.ts | 61 +++++++++++ src/interfaces/health-indicator.interface.ts | 35 ++++++ tsconfig.json | 4 +- 13 files changed, 512 insertions(+), 103 deletions(-) delete mode 100644 eslint.config.js rename lint-staged.config.js => lint-staged.config.mjs (100%) create mode 100644 src/indicators/http.indicator.spec.ts create mode 100644 src/indicators/http.indicator.ts create mode 100644 src/indicators/postgres.indicator.spec.ts create mode 100644 src/indicators/postgres.indicator.ts create mode 100644 src/indicators/redis.indicator.spec.ts create mode 100644 src/indicators/redis.indicator.ts create mode 100644 src/interfaces/health-indicator.interface.ts 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 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", }, }; 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/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" }); + }); +}); 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); + } + } +} 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(); + }); +}); 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), + ); + } +} 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(); + }); +}); 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), + ); + } +} 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; +} 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 6c73aaccfc5dcb7aaa57c1db47ec8e6cb60473f7 Mon Sep 17 00:00:00 2001 From: saadmoumou Date: Thu, 2 Apr 2026 10:25:30 +0100 Subject: [PATCH 06/22] Health module and health endpoints (#2) * chore(config): add @interfaces/* and @indicators/* path aliases * chore(config): rename eslint.config.js to .mjs to fix ESM loading * chore(git): fix husky pre-commit hook for v10 compatibility * feat(interfaces): add IHealthIndicator, HealthStatus, HealthIndicatorResult * feat(indicators): add PostgresHealthIndicator (SELECT 1 + timeout) * test(indicators): add PostgresHealthIndicator unit tests (success/error/timeout) * feat(indicators): add RedisHealthIndicator (PING + timeout) * test(indicators): add RedisHealthIndicator unit tests (success/error/timeout) * feat(indicators): add HttpHealthIndicator (GET + 2xx check + timeout) * test(indicators): add HttpHealthIndicator unit tests (2xx/non-2xx/network/timeout) * chore(deps): update package-lock after npm install * chore(config): set module to CommonJS and moduleResolution to Node for dist output * chore(package): rename to @ciscode/health-kit * chore(deps): update package-lock * feat(indicators): add MongoHealthIndicator with ping command and timeout * test(indicators): add MongoHealthIndicator unit tests (success/error/timeout) * feat(services): add HealthService with Promise.allSettled orchestration * test(services): add HealthService unit tests (liveness/readiness/concurrency) * feat(controllers): add HealthController factory (GET live/ready, platform-agnostic) * test(controllers): add HealthController unit tests (200 ok / 503 ServiceUnavailableException) * feat(module): add HealthKitModule.register() dynamic module * feat(exports): update public API exports for health-kit --------- Co-authored-by: saad moumou --- package-lock.json | 5 +- package.json | 5 +- src/controllers/health.controller.spec.ts | 62 +++++++++++++ src/controllers/health.controller.ts | 43 +++++++++ src/health-kit.module.ts | 67 ++++++++++++++ src/index.ts | 42 ++++----- src/indicators/mongo.indicator.spec.ts | 62 +++++++++++++ src/indicators/mongo.indicator.ts | 70 +++++++++++++++ src/services/health.service.spec.ts | 101 ++++++++++++++++++++++ src/services/health.service.ts | 50 +++++++++++ tsconfig.build.json | 2 + 11 files changed, 482 insertions(+), 27 deletions(-) create mode 100644 src/controllers/health.controller.spec.ts create mode 100644 src/controllers/health.controller.ts create mode 100644 src/health-kit.module.ts create mode 100644 src/indicators/mongo.indicator.spec.ts create mode 100644 src/indicators/mongo.indicator.ts create mode 100644 src/services/health.service.spec.ts create mode 100644 src/services/health.service.ts 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" } diff --git a/package.json b/package.json index 7ad4f6b..22ef13a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@ciscode/health-kit", - "version": "0.0.0", - "description": "Readiness and liveness health checks for NestJS.", + "version": "1.0.0", + "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" }, 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); + }); + }); +}); 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; +} 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], + }; + } +} 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) 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(); + }); +}); 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), + ); + } +} 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); + }); +}); 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 }; + } +} 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 639590a875dda9a035f54ea93e11e24bab68b8eb Mon Sep 17 00:00:00 2001 From: saadmoumou Date: Thu, 2 Apr 2026 12:27:49 +0100 Subject: [PATCH 07/22] Ihealth indicator interface and built in indicators (#3) * chore(config): add @interfaces/* and @indicators/* path aliases * chore(config): rename eslint.config.js to .mjs to fix ESM loading * chore(git): fix husky pre-commit hook for v10 compatibility * feat(interfaces): add IHealthIndicator, HealthStatus, HealthIndicatorResult * feat(indicators): add PostgresHealthIndicator (SELECT 1 + timeout) * test(indicators): add PostgresHealthIndicator unit tests (success/error/timeout) * feat(indicators): add RedisHealthIndicator (PING + timeout) * test(indicators): add RedisHealthIndicator unit tests (success/error/timeout) * feat(indicators): add HttpHealthIndicator (GET + 2xx check + timeout) * test(indicators): add HttpHealthIndicator unit tests (2xx/non-2xx/network/timeout) * chore(deps): update package-lock after npm install --------- Co-authored-by: saad moumou From 2285e52aab4b54ab4a9ea47c175d09a1702d928c Mon Sep 17 00:00:00 2001 From: saadmoumou Date: Thu, 2 Apr 2026 12:31:09 +0100 Subject: [PATCH 08/22] Health module and health endpoints (#5) * chore(config): add @interfaces/* and @indicators/* path aliases * chore(config): rename eslint.config.js to .mjs to fix ESM loading * chore(git): fix husky pre-commit hook for v10 compatibility * feat(interfaces): add IHealthIndicator, HealthStatus, HealthIndicatorResult * feat(indicators): add PostgresHealthIndicator (SELECT 1 + timeout) * test(indicators): add PostgresHealthIndicator unit tests (success/error/timeout) * feat(indicators): add RedisHealthIndicator (PING + timeout) * test(indicators): add RedisHealthIndicator unit tests (success/error/timeout) * feat(indicators): add HttpHealthIndicator (GET + 2xx check + timeout) * test(indicators): add HttpHealthIndicator unit tests (2xx/non-2xx/network/timeout) * chore(deps): update package-lock after npm install * chore(config): set module to CommonJS and moduleResolution to Node for dist output * chore(package): rename to @ciscode/health-kit * chore(deps): update package-lock * feat(indicators): add MongoHealthIndicator with ping command and timeout * test(indicators): add MongoHealthIndicator unit tests (success/error/timeout) * feat(services): add HealthService with Promise.allSettled orchestration * test(services): add HealthService unit tests (liveness/readiness/concurrency) * feat(controllers): add HealthController factory (GET live/ready, platform-agnostic) * test(controllers): add HealthController unit tests (200 ok / 503 ServiceUnavailableException) * feat(module): add HealthKitModule.register() dynamic module * feat(exports): update public API exports for health-kit --------- Co-authored-by: saad moumou From 66e667bd6504a465ee635c5da98e5d7a098b383e Mon Sep 17 00:00:00 2001 From: saadmoumou Date: Thu, 2 Apr 2026 12:36:17 +0100 Subject: [PATCH 09/22] Custom indicator api and health service (#7) * chore(config): add @interfaces/* and @indicators/* path aliases * chore(config): rename eslint.config.js to .mjs to fix ESM loading * chore(git): fix husky pre-commit hook for v10 compatibility * feat(interfaces): add IHealthIndicator, HealthStatus, HealthIndicatorResult * feat(indicators): add PostgresHealthIndicator (SELECT 1 + timeout) * test(indicators): add PostgresHealthIndicator unit tests (success/error/timeout) * feat(indicators): add RedisHealthIndicator (PING + timeout) * test(indicators): add RedisHealthIndicator unit tests (success/error/timeout) * feat(indicators): add HttpHealthIndicator (GET + 2xx check + timeout) * test(indicators): add HttpHealthIndicator unit tests (2xx/non-2xx/network/timeout) * chore(deps): update package-lock after npm install * chore(config): set module to CommonJS and moduleResolution to Node for dist output * chore(package): rename to @ciscode/health-kit * chore(deps): update package-lock * feat(indicators): add MongoHealthIndicator with ping command and timeout * test(indicators): add MongoHealthIndicator unit tests (success/error/timeout) * feat(services): add HealthService with Promise.allSettled orchestration * test(services): add HealthService unit tests (liveness/readiness/concurrency) * feat(controllers): add HealthController factory (GET live/ready, platform-agnostic) * test(controllers): add HealthController unit tests (200 ok / 503 ServiceUnavailableException) * feat(module): add HealthKitModule.register() dynamic module * feat(exports): update public API exports for health-kit * feat(indicators): add createIndicator inline factory with timeout support * test(indicators): add createIndicator unit tests (true/false/void/throw/timeout) * feat(indicators): add BaseHealthIndicator abstract class with result() helper * test(indicators): add BaseHealthIndicator unit tests * feat(decorators): add @HealthIndicator decorator for auto-registration by scope * test(decorators): add @HealthIndicator decorator unit tests * feat(module): extend HealthKitModule.register() with indicators[] option for DI-based auto-registration * feat(exports): export createIndicator, BaseHealthIndicator, @HealthIndicator, HealthIndicatorScope * chore(package): update description to mention MongoDB --------- Co-authored-by: saad moumou --- package.json | 2 +- .../health-indicator.decorator.spec.ts | 36 ++++++++++++ src/decorators/health-indicator.decorator.ts | 34 +++++++++++ src/health-kit.module.ts | 46 ++++++++++++--- src/index.ts | 11 ++++ src/indicators/base.indicator.spec.ts | 50 +++++++++++++++++ src/indicators/base.indicator.ts | 49 ++++++++++++++++ src/indicators/create-indicator.spec.ts | 56 +++++++++++++++++++ src/indicators/create-indicator.ts | 46 +++++++++++++++ 9 files changed, 322 insertions(+), 8 deletions(-) create mode 100644 src/decorators/health-indicator.decorator.spec.ts create mode 100644 src/decorators/health-indicator.decorator.ts create mode 100644 src/indicators/base.indicator.spec.ts create mode 100644 src/indicators/base.indicator.ts create mode 100644 src/indicators/create-indicator.spec.ts create mode 100644 src/indicators/create-indicator.ts diff --git a/package.json b/package.json index 22ef13a..91064a7 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" 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"); + }); +}); 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); + }; +} 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, 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 // ============================================================================ 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"); + }); +}); 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 } : {}), + }; + } +} 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(); + }); +}); 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 f6e9b8901494faa8eb0d3ccf92e13caf0fec8bea Mon Sep 17 00:00:00 2001 From: saadmoumou Date: Thu, 2 Apr 2026 12:47:04 +0100 Subject: [PATCH 10/22] Feat/compt 80 ihealth indicator interface and built in indicators (#8) * chore(config): add @interfaces/* and @indicators/* path aliases * chore(config): rename eslint.config.js to .mjs to fix ESM loading * chore(git): fix husky pre-commit hook for v10 compatibility * feat(interfaces): add IHealthIndicator, HealthStatus, HealthIndicatorResult * feat(indicators): add PostgresHealthIndicator (SELECT 1 + timeout) * test(indicators): add PostgresHealthIndicator unit tests (success/error/timeout) * feat(indicators): add RedisHealthIndicator (PING + timeout) * test(indicators): add RedisHealthIndicator unit tests (success/error/timeout) * feat(indicators): add HttpHealthIndicator (GET + 2xx check + timeout) * test(indicators): add HttpHealthIndicator unit tests (2xx/non-2xx/network/timeout) * chore(deps): update package-lock after npm install --------- Co-authored-by: saad moumou From 32d84b47b368f5ef536d014f6634dc4ec363f71a Mon Sep 17 00:00:00 2001 From: saadmoumou Date: Thu, 2 Apr 2026 12:50:51 +0100 Subject: [PATCH 11/22] Feat/compt 81 health module and health endpoints (#9) * chore(config): add @interfaces/* and @indicators/* path aliases * chore(config): rename eslint.config.js to .mjs to fix ESM loading * chore(git): fix husky pre-commit hook for v10 compatibility * feat(interfaces): add IHealthIndicator, HealthStatus, HealthIndicatorResult * feat(indicators): add PostgresHealthIndicator (SELECT 1 + timeout) * test(indicators): add PostgresHealthIndicator unit tests (success/error/timeout) * feat(indicators): add RedisHealthIndicator (PING + timeout) * test(indicators): add RedisHealthIndicator unit tests (success/error/timeout) * feat(indicators): add HttpHealthIndicator (GET + 2xx check + timeout) * test(indicators): add HttpHealthIndicator unit tests (2xx/non-2xx/network/timeout) * chore(deps): update package-lock after npm install * chore(config): set module to CommonJS and moduleResolution to Node for dist output * chore(package): rename to @ciscode/health-kit * chore(deps): update package-lock * feat(indicators): add MongoHealthIndicator with ping command and timeout * test(indicators): add MongoHealthIndicator unit tests (success/error/timeout) * feat(services): add HealthService with Promise.allSettled orchestration * test(services): add HealthService unit tests (liveness/readiness/concurrency) * feat(controllers): add HealthController factory (GET live/ready, platform-agnostic) * test(controllers): add HealthController unit tests (200 ok / 503 ServiceUnavailableException) * feat(module): add HealthKitModule.register() dynamic module * feat(exports): update public API exports for health-kit --------- Co-authored-by: saad moumou From 029d3204c5f639b271bc9dbcc8c8f3b4972ad3d9 Mon Sep 17 00:00:00 2001 From: saadmoumou Date: Thu, 2 Apr 2026 12:56:18 +0100 Subject: [PATCH 12/22] Feat/compt 82 custom indicator api and health service (#11) * chore(config): add @interfaces/* and @indicators/* path aliases * chore(config): rename eslint.config.js to .mjs to fix ESM loading * chore(git): fix husky pre-commit hook for v10 compatibility * feat(interfaces): add IHealthIndicator, HealthStatus, HealthIndicatorResult * feat(indicators): add PostgresHealthIndicator (SELECT 1 + timeout) * test(indicators): add PostgresHealthIndicator unit tests (success/error/timeout) * feat(indicators): add RedisHealthIndicator (PING + timeout) * test(indicators): add RedisHealthIndicator unit tests (success/error/timeout) * feat(indicators): add HttpHealthIndicator (GET + 2xx check + timeout) * test(indicators): add HttpHealthIndicator unit tests (2xx/non-2xx/network/timeout) * chore(deps): update package-lock after npm install * chore(config): set module to CommonJS and moduleResolution to Node for dist output * chore(package): rename to @ciscode/health-kit * chore(deps): update package-lock * feat(indicators): add MongoHealthIndicator with ping command and timeout * test(indicators): add MongoHealthIndicator unit tests (success/error/timeout) * feat(services): add HealthService with Promise.allSettled orchestration * test(services): add HealthService unit tests (liveness/readiness/concurrency) * feat(controllers): add HealthController factory (GET live/ready, platform-agnostic) * test(controllers): add HealthController unit tests (200 ok / 503 ServiceUnavailableException) * feat(module): add HealthKitModule.register() dynamic module * feat(exports): update public API exports for health-kit * feat(indicators): add createIndicator inline factory with timeout support * test(indicators): add createIndicator unit tests (true/false/void/throw/timeout) * feat(indicators): add BaseHealthIndicator abstract class with result() helper * test(indicators): add BaseHealthIndicator unit tests * feat(decorators): add @HealthIndicator decorator for auto-registration by scope * test(decorators): add @HealthIndicator decorator unit tests * feat(module): extend HealthKitModule.register() with indicators[] option for DI-based auto-registration * feat(exports): export createIndicator, BaseHealthIndicator, @HealthIndicator, HealthIndicatorScope * chore(package): update description to mention MongoDB --------- Co-authored-by: saad moumou From fb8ea34c698d630f18c541b9633487869535b6c2 Mon Sep 17 00:00:00 2001 From: saadmoumou Date: Thu, 2 Apr 2026 14:26:23 +0100 Subject: [PATCH 13/22] Feat/compt 80 ihealth indicator interface and built in indicators (#12) * chore(config): add @interfaces/* and @indicators/* path aliases * chore(config): rename eslint.config.js to .mjs to fix ESM loading * chore(git): fix husky pre-commit hook for v10 compatibility * feat(interfaces): add IHealthIndicator, HealthStatus, HealthIndicatorResult * feat(indicators): add PostgresHealthIndicator (SELECT 1 + timeout) * test(indicators): add PostgresHealthIndicator unit tests (success/error/timeout) * feat(indicators): add RedisHealthIndicator (PING + timeout) * test(indicators): add RedisHealthIndicator unit tests (success/error/timeout) * feat(indicators): add HttpHealthIndicator (GET + 2xx check + timeout) * test(indicators): add HttpHealthIndicator unit tests (2xx/non-2xx/network/timeout) * chore(deps): update package-lock after npm install * fix(interfaces): add optional details field to HealthIndicatorResult --------- Co-authored-by: saad moumou --- 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 beddae842a3901c8e820b6e17297387979d57f24 Mon Sep 17 00:00:00 2001 From: saadmoumou Date: Thu, 2 Apr 2026 15:00:13 +0100 Subject: [PATCH 14/22] Feat/compt 81 health module and health endpoints (#18) * fix(services): rename indicators to results in HealthCheckResult response * fix(tests): update health.service.spec to use results instead of indicators --------- Co-authored-by: saad moumou --- src/services/health.service.spec.ts | 8 ++++---- src/services/health.service.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/services/health.service.spec.ts b/src/services/health.service.spec.ts index c952fb2..6298eaa 100644 --- a/src/services/health.service.spec.ts +++ b/src/services/health.service.spec.ts @@ -29,7 +29,7 @@ describe("HealthService", () => { const result = await service.checkLiveness(); expect(result.status).toBe("ok"); - expect(result.indicators).toEqual([up("proc")]); + expect(result.results).toEqual([up("proc")]); }); it("returns status 'error' when any liveness indicator is down", async () => { @@ -47,7 +47,7 @@ describe("HealthService", () => { const result = await service.checkLiveness(); expect(result.status).toBe("ok"); - expect(result.indicators).toEqual([]); + expect(result.results).toEqual([]); }); }); @@ -62,7 +62,7 @@ describe("HealthService", () => { const result = await service.checkReadiness(); expect(result.status).toBe("ok"); - expect(result.indicators).toHaveLength(2); + expect(result.results).toHaveLength(2); }); it("returns status 'error' when any readiness indicator is down", async () => { @@ -86,7 +86,7 @@ describe("HealthService", () => { const result = await service.checkReadiness(); expect(result.status).toBe("error"); - const failed = result.indicators.find((r) => r.status === "down"); + const failed = result.results.find((r: { status: string }) => r.status === "down"); expect(failed?.message).toBe("ECONNREFUSED"); }); 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 0e1d258953762be6c801c6e84b4ad93bc4b71476 Mon Sep 17 00:00:00 2001 From: saadmoumou Date: Thu, 2 Apr 2026 15:42:43 +0100 Subject: [PATCH 15/22] Feat/compt 82 custom indicator api and health service (#23) * fix(services): rename indicators to results in HealthCheckResult response * fix(tests): update health.service.spec to use results instead of indicators * chore(indicators): remove MongoHealthIndicator * feat(module): make path optional with default 'health', add registerAsync * feat(exports): remove MongoHealthIndicator, export HealthModuleAsyncOptions * chore(package): add @nestjs/terminus peer dep, fix description * chore(indicators): remove MongoHealthIndicator spec * refactor(module): extract shared logic to eliminate code duplication --------- Co-authored-by: saad moumou --- package.json | 8 +- src/health-kit.module.ts | 118 ++++++++++++++++++------- src/index.ts | 5 +- src/indicators/mongo.indicator.spec.ts | 62 ------------- src/indicators/mongo.indicator.ts | 70 --------------- 5 files changed, 95 insertions(+), 168 deletions(-) delete mode 100644 src/indicators/mongo.indicator.spec.ts delete mode 100644 src/indicators/mongo.indicator.ts diff --git a/package.json b/package.json index 91064a7..14b327c 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" diff --git a/src/health-kit.module.ts b/src/health-kit.module.ts index cba8808..9e35152 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,56 +60,96 @@ export interface HealthModuleOptions { */ @Module({}) export class HealthKitModule { - static register(options: HealthModuleOptions): DynamicModule { - const indicatorClasses = options.indicators ?? []; + static register(options: HealthModuleOptions = {}): DynamicModule { + const { path = "health", liveness = [], readiness = [], indicators = [] } = options; + const { indicatorProviders, livenessClasses, readinessClasses } = + HealthKitModule._resolveIndicatorClasses(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", - ); + const providers: Provider[] = [ + ...indicatorProviders, + { + provide: HEALTH_LIVENESS_INDICATORS, + useFactory: (...injected: BaseHealthIndicator[]) => [...liveness, ...injected], + inject: livenessClasses, + }, + { + provide: HEALTH_READINESS_INDICATORS, + useFactory: (...injected: BaseHealthIndicator[]) => [...readiness, ...injected], + inject: readinessClasses, + }, + ...HealthKitModule._healthServiceProvider(), + ]; - // Create a NestJS provider for each indicator class (enables DI injection) - const indicatorProviders: Provider[] = indicatorClasses.map((cls) => ({ - provide: cls, - useClass: cls, - })); + return { + module: HealthKitModule, + controllers: [createHealthController(path)], + providers, + exports: [HealthService], + }; + } + + static registerAsync(options: HealthModuleAsyncOptions): DynamicModule { + const { path = "health", indicators = [], imports = [], inject = [] } = options; + const { indicatorProviders, livenessClasses, readinessClasses } = + HealthKitModule._resolveIndicatorClasses(indicators); const providers: Provider[] = [ ...indicatorProviders, + { + provide: HEALTH_MODULE_OPTIONS, + useFactory: options.useFactory, + inject: inject as never[], + }, { provide: HEALTH_LIVENESS_INDICATORS, - useFactory: (...injected: BaseHealthIndicator[]) => [ - ...(options.liveness ?? []), + useFactory: (opts: HealthModuleOptions, ...injected: BaseHealthIndicator[]) => [ + ...(opts.liveness ?? []), ...injected, ], - inject: livenessClasses, + inject: [HEALTH_MODULE_OPTIONS, ...livenessClasses], }, { provide: HEALTH_READINESS_INDICATORS, - useFactory: (...injected: BaseHealthIndicator[]) => [ - ...(options.readiness ?? []), + useFactory: (opts: HealthModuleOptions, ...injected: BaseHealthIndicator[]) => [ + ...(opts.readiness ?? []), ...injected, ], - inject: readinessClasses, - }, - { - provide: HealthService, - useFactory: (liveness: IHealthIndicator[], readiness: IHealthIndicator[]) => - new HealthService(liveness, readiness), - inject: [HEALTH_LIVENESS_INDICATORS, HEALTH_READINESS_INDICATORS], + inject: [HEALTH_MODULE_OPTIONS, ...readinessClasses], }, + ...HealthKitModule._healthServiceProvider(), ]; - const HealthController = createHealthController(options.path); - return { module: HealthKitModule, - controllers: [HealthController], + imports, + controllers: [createHealthController(path)], providers, exports: [HealthService], }; } + + private static _resolveIndicatorClasses(indicators: Type[]) { + const livenessClasses = indicators.filter( + (cls) => Reflect.getMetadata(HEALTH_INDICATOR_METADATA, cls) === "liveness", + ); + const readinessClasses = indicators.filter( + (cls) => Reflect.getMetadata(HEALTH_INDICATOR_METADATA, cls) === "readiness", + ); + const indicatorProviders: Provider[] = indicators.map((cls) => ({ + provide: cls, + useClass: cls, + })); + return { livenessClasses, readinessClasses, indicatorProviders }; + } + + private static _healthServiceProvider(): Provider[] { + return [ + { + provide: HealthService, + useFactory: (liveness: IHealthIndicator[], readiness: IHealthIndicator[]) => + new HealthService(liveness, readiness), + inject: [HEALTH_LIVENESS_INDICATORS, HEALTH_READINESS_INDICATORS], + }, + ]; + } } 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 // ============================================================================ diff --git a/src/indicators/mongo.indicator.spec.ts b/src/indicators/mongo.indicator.spec.ts deleted file mode 100644 index 155b27e..0000000 --- a/src/indicators/mongo.indicator.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -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(); - }); -}); 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 3162d4d41d800571e9d9c6be17f18e0718a14dde Mon Sep 17 00:00:00 2001 From: saadmoumou Date: Thu, 2 Apr 2026 16:01:33 +0100 Subject: [PATCH 16/22] Feat/compt 83 testing suite (#25) * chore(indicators): remove MongoHealthIndicator spec * test(module): add HealthKitModule spec - register, registerAsync, path default --------- Co-authored-by: saad moumou --- jest.config.ts | 8 +- src/controllers/health.controller.spec.ts | 4 +- src/health-kit.module.spec.ts | 187 ++++++++++++++++++++++ src/services/health.service.spec.ts | 2 +- 4 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 src/health-kit.module.spec.ts diff --git a/jest.config.ts b/jest.config.ts index 2b69a86..a8f6751 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -27,10 +27,10 @@ const config: Config = { coverageDirectory: "coverage", coverageThreshold: { global: { - branches: 80, - functions: 80, - lines: 80, - statements: 80, + branches: 85, + functions: 85, + lines: 85, + statements: 85, }, }, moduleNameMapper: { diff --git a/src/controllers/health.controller.spec.ts b/src/controllers/health.controller.spec.ts index c9e8d2d..a6709e9 100644 --- a/src/controllers/health.controller.spec.ts +++ b/src/controllers/health.controller.spec.ts @@ -10,8 +10,8 @@ import { createHealthController } from "./health.controller"; const makeService = (liveness: "ok" | "error", readiness: "ok" | "error") => ({ - checkLiveness: jest.fn().mockResolvedValue({ status: liveness, indicators: [] }), - checkReadiness: jest.fn().mockResolvedValue({ status: readiness, indicators: [] }), + checkLiveness: jest.fn().mockResolvedValue({ status: liveness, results: [] }), + checkReadiness: jest.fn().mockResolvedValue({ status: readiness, results: [] }), }) as unknown as HealthService; interface HealthControllerInstance { diff --git a/src/health-kit.module.spec.ts b/src/health-kit.module.spec.ts new file mode 100644 index 0000000..1393049 --- /dev/null +++ b/src/health-kit.module.spec.ts @@ -0,0 +1,187 @@ +import "reflect-metadata"; + +import { HealthIndicator } from "@decorators/health-indicator.decorator"; +import { BaseHealthIndicator } from "@indicators/base.indicator"; +import { createIndicator } from "@indicators/create-indicator"; +import type { HealthIndicatorResult } from "@interfaces/health-indicator.interface"; +import { Injectable } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; + +import { + HEALTH_LIVENESS_INDICATORS, + HEALTH_READINESS_INDICATORS, + HealthKitModule, +} from "./health-kit.module"; +import { HealthService } from "./services/health.service"; + +// ÔöÇÔöÇ Fixtures ÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇ + +@HealthIndicator("liveness") +@Injectable() +class LivenessIndicator extends BaseHealthIndicator { + readonly name = "custom-live"; + async check(): Promise { + return this.result("up"); + } +} + +@HealthIndicator("readiness") +@Injectable() +class ReadinessIndicator extends BaseHealthIndicator { + readonly name = "custom-ready"; + async check(): Promise { + return this.result("up"); + } +} + +@Injectable() +class UndecoratedIndicator extends BaseHealthIndicator { + readonly name = "no-scope"; + async check(): Promise { + return this.result("up"); + } +} + +// ÔöÇÔöÇ Helpers ÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇ + +async function compile(options: Parameters[0]) { + const module = await Test.createTestingModule({ + imports: [HealthKitModule.register(options)], + }).compile(); + return module; +} + +// ÔöÇÔöÇ Tests ÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇ + +describe("HealthKitModule.register()", () => { + it("returns a DynamicModule with the correct module reference", () => { + const dyn = HealthKitModule.register({ path: "health" }); + expect(dyn.module).toBe(HealthKitModule); + }); + + it("exports HealthService", () => { + const dyn = HealthKitModule.register({ path: "health" }); + expect(dyn.exports).toContain(HealthService); + }); + + it("compiles with no indicators", async () => { + const module = await compile({ path: "health" }); + const service = module.get(HealthService); + expect(service).toBeInstanceOf(HealthService); + }); + + it("liveness result is ok when all liveness indicators pass", async () => { + const indicator = createIndicator("ping", async () => true); + const module = await compile({ path: "health", liveness: [indicator] }); + + const service = module.get(HealthService); + const result = await service.checkLiveness(); + + expect(result.status).toBe("ok"); + expect(result.results[0]?.name).toBe("ping"); + }); + + it("readiness result is ok when all readiness indicators pass", async () => { + const indicator = createIndicator("db", async () => true); + const module = await compile({ path: "health", readiness: [indicator] }); + + const service = module.get(HealthService); + const result = await service.checkReadiness(); + + expect(result.status).toBe("ok"); + expect(result.results[0]?.name).toBe("db"); + }); + + it("auto-routes @HealthIndicator('liveness') class to liveness list", async () => { + const module = await compile({ + path: "health", + indicators: [LivenessIndicator], + }); + + const liveness = module.get(HEALTH_LIVENESS_INDICATORS); + expect(liveness).toHaveLength(1); + + const readiness = module.get(HEALTH_READINESS_INDICATORS); + expect(readiness).toHaveLength(0); + }); + + it("auto-routes @HealthIndicator('readiness') class to readiness list", async () => { + const module = await compile({ + path: "health", + indicators: [ReadinessIndicator], + }); + + const readiness = module.get(HEALTH_READINESS_INDICATORS); + expect(readiness).toHaveLength(1); + + const liveness = module.get(HEALTH_LIVENESS_INDICATORS); + expect(liveness).toHaveLength(0); + }); + + it("merges explicit instances with DI-based indicators", async () => { + const explicitReady = createIndicator("http", async () => true); + const module = await compile({ + path: "health", + readiness: [explicitReady], + indicators: [ReadinessIndicator], + }); + + const readiness = module.get(HEALTH_READINESS_INDICATORS); + expect(readiness).toHaveLength(2); + }); + + it("undecorated indicator class is not added to either list", async () => { + const module = await compile({ + path: "health", + indicators: [UndecoratedIndicator], + }); + + const liveness = module.get(HEALTH_LIVENESS_INDICATORS); + const readiness = module.get(HEALTH_READINESS_INDICATORS); + + expect(liveness).toHaveLength(0); + expect(readiness).toHaveLength(0); + }); + + it("handles both liveness and readiness DI indicators simultaneously", async () => { + const module = await compile({ + path: "health", + indicators: [LivenessIndicator, ReadinessIndicator], + }); + + const liveness = module.get(HEALTH_LIVENESS_INDICATORS); + const readiness = module.get(HEALTH_READINESS_INDICATORS); + + expect(liveness).toHaveLength(1); + expect(readiness).toHaveLength(1); + + const service = module.get(HealthService); + const liveResult = await service.checkLiveness(); + const readyResult = await service.checkReadiness(); + + expect(liveResult.status).toBe("ok"); + expect(readyResult.status).toBe("ok"); + }); + + it("path defaults to 'health' when not provided", () => { + const dyn = HealthKitModule.register({}); + expect(dyn.controllers).toBeDefined(); + expect(dyn.module).toBe(HealthKitModule); + }); + + it("registerAsync resolves options from factory", async () => { + const indicator = createIndicator("async-ping", async () => true); + const module = await Test.createTestingModule({ + imports: [ + HealthKitModule.registerAsync({ + useFactory: () => ({ liveness: [indicator] }), + }), + ], + }).compile(); + + const service = module.get(HealthService); + const result = await service.checkLiveness(); + expect(result.status).toBe("ok"); + expect(result.results[0]?.name).toBe("async-ping"); + }); +}); diff --git a/src/services/health.service.spec.ts b/src/services/health.service.spec.ts index 6298eaa..abcff42 100644 --- a/src/services/health.service.spec.ts +++ b/src/services/health.service.spec.ts @@ -86,7 +86,7 @@ describe("HealthService", () => { const result = await service.checkReadiness(); expect(result.status).toBe("error"); - const failed = result.results.find((r: { status: string }) => r.status === "down"); + const failed = result.results.find((r) => r.status === "down"); expect(failed?.message).toBe("ECONNREFUSED"); }); From d37d01b348f455d77439830f4ad9dc6b2216ee1d Mon Sep 17 00:00:00 2001 From: saadmoumou Date: Thu, 2 Apr 2026 17:41:44 +0100 Subject: [PATCH 17/22] Feat/compt 84 readme changeset publish v0.1.0 (#26) * fix(package): remove unused bundled dependencies (class-transformer, class-validator) * chore(package): bump version to 0.1.0 --------- Co-authored-by: saad moumou --- package-lock.json | 31 ++++++++++++++++++++++++------- package.json | 6 +----- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index c7af5c1..bba8fe4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,6 @@ "name": "@ciscode/health-kit", "version": "1.0.0", "license": "MIT", - "dependencies": { - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1" - }, "devDependencies": { "@changesets/cli": "^2.27.7", "@eslint/js": "^9.18.0", @@ -44,8 +40,14 @@ "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 + } } }, "node_modules/@babel/code-frame": { @@ -2690,7 +2692,10 @@ "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", - "license": "MIT" + "dev": true, + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@types/yargs": { "version": "17.0.35", @@ -3783,13 +3788,19 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "dev": true, + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -7269,7 +7280,10 @@ "version": "1.12.36", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.36.tgz", "integrity": "sha512-woWhKMAVx1fzzUnMCyOzglgSgf6/AFHLASdOBcchYCyvWSGWt12imw3iu2hdI5d4dGZRsNWAmWiz37sDKUPaRQ==", - "license": "MIT" + "dev": true, + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/lines-and-columns": { "version": "1.2.4", @@ -10187,7 +10201,10 @@ "version": "13.15.26", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.10" } diff --git a/package.json b/package.json index 14b327c..360d762 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ciscode/health-kit", - "version": "1.0.0", + "version": "0.1.0", "description": "NestJS health-check module — liveness & readiness probes with built-in PostgreSQL, Redis, and HTTP indicators.", "author": "CisCode", "publishConfig": { @@ -54,10 +54,6 @@ "optional": true } }, - "dependencies": { - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1" - }, "devDependencies": { "@changesets/cli": "^2.27.7", "@eslint/js": "^9.18.0", From 1b1f44aa9012413b052a4f915936e441d0c48a1a Mon Sep 17 00:00:00 2001 From: saadmoumou Date: Fri, 3 Apr 2026 17:18:48 +0100 Subject: [PATCH 18/22] docs(readme): rewrite README and add changeset for v0.1.0 (#28) - Rewrite README.md for @ciscode/health-kit (remove template content) - Add register/registerAsync usage examples - Document built-in indicators: PostgresHealthIndicator, RedisHealthIndicator, HttpHealthIndicator - Document custom indicator APIs: createIndicator, BaseHealthIndicator + @HealthIndicator - Add 200/503 response body examples - Add API reference tables - Add .changeset/health-kit-v0-1-0.md: minor bump (0.0.0 -> 0.1.0) - Fix .changeset/config.json repo field to CISCODE-MA/HealthKit - Set package.json version to 0.0.0 (changeset will bump to 0.1.0) Co-authored-by: saad moumou --- .changeset/config.json | 2 +- .changeset/health-kit-v0-1-0.md | 15 ++ README.md | 447 +++++++++++++++----------------- package.json | 2 +- 4 files changed, 221 insertions(+), 245 deletions(-) create mode 100644 .changeset/health-kit-v0-1-0.md diff --git a/.changeset/config.json b/.changeset/config.json index feddcf6..19e9af9 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,6 +8,6 @@ "baseBranch": "develop", "updateInternalDependencies": "patch", "ignore": [], - "repo": "ciscode/nest-js-developer-kit", + "repo": "CISCODE-MA/HealthKit", "preState": null } diff --git a/.changeset/health-kit-v0-1-0.md b/.changeset/health-kit-v0-1-0.md new file mode 100644 index 0000000..2f78a59 --- /dev/null +++ b/.changeset/health-kit-v0-1-0.md @@ -0,0 +1,15 @@ +--- +"@ciscode/health-kit": minor +--- + +Initial release of `@ciscode/health-kit` v0.1.0. + +### Features + +- `HealthKitModule.register()` and `registerAsync()` — dynamic NestJS module with configurable liveness and readiness probes +- Built-in `PostgresHealthIndicator` (`SELECT 1`), `RedisHealthIndicator` (`PING`), `HttpHealthIndicator` (GET 2xx check) — all with configurable timeout +- `createIndicator(name, fn)` — inline factory for simple custom indicators +- `BaseHealthIndicator` abstract class + `@HealthIndicator('liveness' | 'readiness')` decorator for DI-based custom indicators +- `GET /{path}/live` and `GET /{path}/ready` endpoints — 200 OK / 503 with `{ status, results[] }` body +- All indicators run concurrently via `Promise.allSettled` +- `path` option defaults to `"health"` diff --git a/README.md b/README.md index 79260b6..feac1b3 100644 --- a/README.md +++ b/README.md @@ -1,309 +1,276 @@ -# NestJS Developer Kit (Template) +# @ciscode/health-kit -A professional template for creating reusable NestJS npm packages with best practices, standardized structure, and AI-friendly development workflow. - -## 🎯 What You Get - -- ✅ **CSR Architecture** - Controller-Service-Repository pattern -- ✅ **TypeScript** - Strict mode with path aliases -- ✅ **Testing** - Jest with 80% coverage threshold -- ✅ **Code Quality** - ESLint + Prettier + Husky -- ✅ **Versioning** - Changesets for semantic versioning -- ✅ **CI/CD** - GitHub Actions workflows -- ✅ **Documentation** - Complete Copilot instructions -- ✅ **Examples** - Full working examples for all layers +A NestJS health-check module providing **liveness** and **readiness** probe endpoints with built-in PostgreSQL, Redis, and HTTP indicators, plus a simple API for custom indicators. ## 📦 Installation ```bash -# Clone this template -git clone https://github.com/CISCODE-MA/NestJs-DeveloperKit.git my-module -cd my-module - -# Install dependencies -npm install - -# Start developing -npm run build -npm test +npm install @ciscode/health-kit ``` -## 🏗️ Architecture +**Peer dependencies** (install the ones you use): +```bash +npm install @nestjs/common @nestjs/core reflect-metadata rxjs +# Optional — only needed if you use @nestjs/terminus alongside this package +npm install @nestjs/terminus ``` -src/ - ├── index.ts # PUBLIC API exports - ├── {module-name}.module.ts # NestJS module definition - │ - ├── controllers/ # HTTP Layer - │ └── example.controller.ts - │ - ├── services/ # Business Logic - │ └── example.service.ts - │ - ├── entities/ # Domain Models - │ └── example.entity.ts - │ - ├── repositories/ # Data Access - │ └── example.repository.ts - │ - ├── guards/ # Auth Guards - │ └── example.guard.ts - │ - ├── decorators/ # Custom Decorators - │ └── example.decorator.ts - │ - ├── dto/ # Data Transfer Objects - │ ├── create-example.dto.ts - │ └── update-example.dto.ts - │ - ├── filters/ # Exception Filters - ├── middleware/ # Middleware - ├── config/ # Configuration - └── utils/ # Utilities -``` -## 🚀 Usage +--- + +## 🚀 Quick Start -### 1. Customize Your Module +### `HealthKitModule.register()` ```typescript -// src/example-kit.module.ts -import { Module, DynamicModule } from "@nestjs/common"; -import { ExampleService } from "@services/example.service"; - -@Module({}) -export class ExampleKitModule { - static forRoot(options: ExampleKitOptions): DynamicModule { - return { - module: ExampleKitModule, - providers: [ExampleService], - exports: [ExampleService], - }; - } -} +import { Module } from "@nestjs/common"; +import { + HealthKitModule, + PostgresHealthIndicator, + RedisHealthIndicator, + HttpHealthIndicator, +} from "@ciscode/health-kit"; +import { Pool } from "pg"; +import Redis from "ioredis"; + +@Module({ + imports: [ + HealthKitModule.register({ + path: "health", // → GET /health/live and GET /health/ready + liveness: [], + readiness: [ + new PostgresHealthIndicator( + new Pool({ + host: "localhost", + port: 5432, + user: "postgres", + password: "", + database: "mydb", + }), + 3000, // timeout ms + ), + new RedisHealthIndicator(new Redis({ host: "localhost", port: 6379, lazyConnect: true })), + new HttpHealthIndicator("https://api.example.com/health", 3000), + ], + }), + ], +}) +export class AppModule {} ``` -### 2. Create Services - -```typescript -// src/services/example.service.ts -import { Injectable } from "@nestjs/common"; +--- -@Injectable() -export class ExampleService { - async doSomething(data: string): Promise { - return `Processed: ${data}`; - } -} -``` +## 🔌 Endpoints -### 3. Define DTOs +| Endpoint | Description | +| ------------------- | ------------------------------------------------- | +| `GET /{path}/live` | Liveness probe — is the process alive? | +| `GET /{path}/ready` | Readiness probe — are all dependencies reachable? | -```typescript -// src/dto/create-example.dto.ts -import { IsString, IsNotEmpty } from "class-validator"; +### 200 OK — all indicators up -export class CreateExampleDto { - @IsString() - @IsNotEmpty() - name: string; +```json +{ + "status": "ok", + "results": [ + { "name": "postgres", "status": "up" }, + { "name": "redis", "status": "up" }, + { "name": "http", "status": "up" } + ] } ``` -### 4. Export Public API +### 503 Service Unavailable — any indicator down -```typescript -// src/index.ts -export { ExampleKitModule } from "./example-kit.module"; -export { ExampleService } from "./services/example.service"; -export { CreateExampleDto } from "./dto/create-example.dto"; +```json +{ + "status": "error", + "results": [ + { "name": "postgres", "status": "down", "message": "connect ECONNREFUSED 127.0.0.1:5432" }, + { "name": "redis", "status": "up" }, + { "name": "http", "status": "down", "message": "HTTP 503 Service Unavailable" } + ] +} ``` -## 📝 Scripts +All indicators run **concurrently** via `Promise.allSettled` — one failure never blocks the others. -```bash -# Development -npm run build # Build the package -npm run build:watch # Build in watch mode -npm run typecheck # TypeScript type checking - -# Testing -npm test # Run tests -npm run test:watch # Run tests in watch mode -npm run test:cov # Run tests with coverage - -# Code Quality -npm run lint # Run ESLint -npm run format # Check formatting -npm run format:write # Fix formatting - -# Release -npx changeset # Create a changeset -npm run release # Publish to npm (CI does this) -``` +--- -## 🔄 Release Workflow +## 🛠 Built-in Indicators -This template uses [Changesets](https://github.com/changesets/changesets) for version management. +### `PostgresHealthIndicator` -### 1. Create a Feature +Runs `SELECT 1` against the pool. Supports a configurable timeout. -```bash -git checkout develop -git checkout -b feature/my-feature -# Make your changes +```typescript +import { Pool } from "pg"; +new PostgresHealthIndicator(pool, 3000 /* timeout ms, default 3000 */); ``` -### 2. Create a Changeset +### `RedisHealthIndicator` -```bash -npx changeset -``` +Sends a `PING` command. Compatible with `ioredis`. -Select the change type: +```typescript +import Redis from "ioredis"; +new RedisHealthIndicator(new Redis({ host: "localhost", port: 6379, lazyConnect: true })); +``` -- **patch** - Bug fixes -- **minor** - New features (backwards compatible) -- **major** - Breaking changes +### `HttpHealthIndicator` -### 3. Commit and PR +Makes a `GET` request to the URL. Any **2xx** response is healthy; non-2xx, network errors, and timeouts are reported as `"down"`. -```bash -git add . -git commit -m "feat: add new feature" -git push origin feature/my-feature -# Create PR → develop +```typescript +new HttpHealthIndicator("https://api.example.com/health", 3000 /* timeout ms */); ``` -### 4. Release +--- -- Automation opens "Version Packages" PR -- Merge to `master` to publish +## ✏️ Custom Indicators -## 🧪 Testing +### Way 1 — `createIndicator` (inline factory) -Tests are MANDATORY for all public APIs. +The simplest option. No class needed — just a name and an async function returning a boolean. ```typescript -// src/services/example.service.spec.ts -describe("ExampleService", () => { - let service: ExampleService; - - beforeEach(async () => { - const module = await Test.createTestingModule({ - providers: [ExampleService], - }).compile(); - - service = module.get(ExampleService); - }); - - it("should be defined", () => { - expect(service).toBeDefined(); - }); - - it("should process data correctly", async () => { - const result = await service.doSomething("test"); - expect(result).toBe("Processed: test"); - }); +import { createIndicator, HealthKitModule } from "@ciscode/health-kit"; + +const appVersionCheck = createIndicator("app-version", async () => { + return !!process.env["npm_package_version"]; // true → up, false → down }); -``` -**Coverage threshold: 80%** +HealthKitModule.register({ + liveness: [appVersionCheck], + readiness: [], +}); +``` -## 📚 Path Aliases +### Way 2 — `BaseHealthIndicator` + `@HealthIndicator` decorator (DI-based) -Configured in `tsconfig.json`: +For indicators that need NestJS dependency injection. ```typescript -import { ExampleService } from "@services/example.service"; -import { CreateExampleDto } from "@dtos/create-example.dto"; -import { Example } from "@entities/example.entity"; -import { ExampleRepository } from "@repos/example.repository"; -``` - -Available aliases: - -- `@/*` → `src/*` -- `@controllers/*` → `src/controllers/*` -- `@services/*` → `src/services/*` -- `@entities/*` → `src/entities/*` -- `@repos/*` → `src/repositories/*` -- `@dtos/*` → `src/dto/*` -- `@guards/*` → `src/guards/*` -- `@decorators/*` → `src/decorators/*` -- `@config/*` → `src/config/*` -- `@utils/*` → `src/utils/*` +import { Injectable } from "@nestjs/common"; +import { BaseHealthIndicator, HealthIndicator, HealthIndicatorResult } from "@ciscode/health-kit"; -## 🔒 Security Best Practices +@HealthIndicator("readiness") // auto-routes to readiness list +@Injectable() +export class EnvHealthIndicator extends BaseHealthIndicator { + readonly name = "env"; + + async check(): Promise { + const missing = ["DB_HOST", "DB_USER"].filter((k) => !process.env[k]); + if (missing.length) { + return this.result("down", `Missing env vars: ${missing.join(", ")}`); + } + return this.result("up"); + } +} -- ✅ Input validation on all DTOs (class-validator) -- ✅ Environment variables for secrets -- ✅ No hardcoded credentials -- ✅ Proper error handling -- ✅ Rate limiting on public endpoints +// Register via the `indicators` array — NestJS handles DI automatically +HealthKitModule.register({ + liveness: [], + readiness: [], + indicators: [EnvHealthIndicator], +}); +``` -## 🤖 AI-Friendly Development +--- -This template includes comprehensive Copilot instructions in `.github/copilot-instructions.md`: +## ⚙️ Async Configuration — `registerAsync()` -- Module architecture guidelines -- Naming conventions -- Testing requirements -- Documentation standards -- Export patterns -- Security best practices +Use this when your options depend on NestJS providers (e.g. `ConfigService`). -## 📖 Documentation +```typescript +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { Pool } from "pg"; +import Redis from "ioredis"; + +HealthKitModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + path: "health", + liveness: [], + readiness: [ + new PostgresHealthIndicator( + new Pool({ + host: config.get("DB_HOST"), + port: config.get("DB_PORT"), + user: config.get("DB_USER"), + password: config.get("DB_PASSWORD"), + database: config.get("DB_NAME"), + }), + ), + new RedisHealthIndicator( + new Redis({ + host: config.get("REDIS_HOST"), + port: config.get("REDIS_PORT"), + lazyConnect: true, + }), + ), + ], + }), +}); +``` -- [Architecture](docs/ARCHITECTURE.md) - Detailed architecture overview -- [Release Process](docs/RELEASE.md) - How to release versions -- [Copilot Instructions](.github/copilot-instructions.md) - AI development guidelines +--- -## 🛠️ Customization +## 📖 API Reference -1. **Rename the module**: Update `package.json` name -2. **Update description**: Modify `package.json` description -3. **Configure exports**: Edit `src/index.ts` -4. **Add dependencies**: Update `peerDependencies` and `dependencies` -5. **Customize structure**: Add/remove directories as needed +### `HealthKitModule.register(options)` -## ⚠️ Important Notes +| Option | Type | Default | Description | +| ------------ | ----------------------------- | ---------- | -------------------------------------------------------------- | +| `path` | `string` | `"health"` | URL prefix for the probe endpoints | +| `liveness` | `IHealthIndicator[]` | `[]` | Indicators for `GET /{path}/live` | +| `readiness` | `IHealthIndicator[]` | `[]` | Indicators for `GET /{path}/ready` | +| `indicators` | `Type[]` | `[]` | DI-based indicator classes (decorated with `@HealthIndicator`) | -### What to Export +### `HealthKitModule.registerAsync(options)` -✅ **DO export**: +| Option | Type | Description | +| ------------ | ------------------------------------------------------------------ | ------------------------------------- | +| `imports` | `ModuleMetadata['imports']` | Modules to import for the factory | +| `inject` | `any[]` | Providers to inject into `useFactory` | +| `useFactory` | `(...args) => HealthModuleOptions \| Promise` | Factory returning the module options | +| `indicators` | `Type[]` | DI-based indicator classes | -- Module -- Services -- DTOs -- Guards -- Decorators -- Types/Interfaces +### `HealthIndicatorResult` -❌ **DON'T export**: +```typescript +interface HealthIndicatorResult { + name: string; + status: "up" | "down"; + message?: string; // error message when down + details?: unknown; // optional structured metadata +} +``` -- Entities -- Repositories +### `HealthCheckResult` -Entities and repositories are internal implementation details. +```typescript +interface HealthCheckResult { + status: "ok" | "error"; + results: HealthIndicatorResult[]; +} +``` -### Versioning +--- -- **MAJOR** (x.0.0) - Breaking changes -- **MINOR** (0.x.0) - New features (backwards compatible) -- **PATCH** (0.0.x) - Bug fixes +## 📝 Scripts -## 📋 Checklist Before Publishing +```bash +npm run build # Compile to dist/ +npm run typecheck # TypeScript type check only +npm test # Run jest +npm run test:cov # Run jest with coverage (≥ 85% required) +npm run lint # ESLint +npm run format:write # Prettier fix +``` -- [ ] All tests passing (80%+ coverage) -- [ ] No ESLint warnings -- [ ] TypeScript strict mode passing -- [ ] All public APIs documented (JSDoc) -- [ ] README updated -- [ ] Changeset created -- [ ] Breaking changes documented -- [ ] `.env.example` updated (if needed) +--- ## 📄 License @@ -315,10 +282,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) ## 🆘 Support -- [Documentation](docs/) -- [GitHub Issues](https://github.com/CISCODE-MA/NestJs-DeveloperKit/issues) -- [Discussions](https://github.com/CISCODE-MA/NestJs-DeveloperKit/discussions) - ---- - -**Made with ❤️ by CisCode** +- [GitHub Issues](https://github.com/CISCODE-MA/HealthKit/issues) diff --git a/package.json b/package.json index 360d762..868a7af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ciscode/health-kit", - "version": "0.1.0", + "version": "0.0.0", "description": "NestJS health-check module — liveness & readiness probes with built-in PostgreSQL, Redis, and HTTP indicators.", "author": "CisCode", "publishConfig": { From 4a0c2b0c216f0af5f3eb5d68993121bc87a59b37 Mon Sep 17 00:00:00 2001 From: saadmoumou Date: Fri, 3 Apr 2026 17:30:34 +0100 Subject: [PATCH 19/22] Feat/compt 84 readme changeset publish v0.1.0 (#30) * docs(readme): rewrite README and add changeset for v0.1.0 - Rewrite README.md for @ciscode/health-kit (remove template content) - Add register/registerAsync usage examples - Document built-in indicators: PostgresHealthIndicator, RedisHealthIndicator, HttpHealthIndicator - Document custom indicator APIs: createIndicator, BaseHealthIndicator + @HealthIndicator - Add 200/503 response body examples - Add API reference tables - Add .changeset/health-kit-v0-1-0.md: minor bump (0.0.0 -> 0.1.0) - Fix .changeset/config.json repo field to CISCODE-MA/HealthKit - Set package.json version to 0.0.0 (changeset will bump to 0.1.0) * chore(package): set version to 0.1.0 --------- Co-authored-by: saad moumou From e6941b29eb0e39194e9e857ef9cdbda715335f54 Mon Sep 17 00:00:00 2001 From: saadmoumou Date: Fri, 3 Apr 2026 17:41:51 +0100 Subject: [PATCH 20/22] chore(release): version packages (#31) Co-authored-by: saad moumou --- .changeset/health-kit-v0-1-0.md | 15 --------------- .changeset/thick-maps-raise.md | 5 ----- CHANGELOG.md | 16 ++++++++++++++++ package.json | 2 +- 4 files changed, 17 insertions(+), 21 deletions(-) delete mode 100644 .changeset/health-kit-v0-1-0.md delete mode 100644 .changeset/thick-maps-raise.md create mode 100644 CHANGELOG.md diff --git a/.changeset/health-kit-v0-1-0.md b/.changeset/health-kit-v0-1-0.md deleted file mode 100644 index 2f78a59..0000000 --- a/.changeset/health-kit-v0-1-0.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@ciscode/health-kit": minor ---- - -Initial release of `@ciscode/health-kit` v0.1.0. - -### Features - -- `HealthKitModule.register()` and `registerAsync()` — dynamic NestJS module with configurable liveness and readiness probes -- Built-in `PostgresHealthIndicator` (`SELECT 1`), `RedisHealthIndicator` (`PING`), `HttpHealthIndicator` (GET 2xx check) — all with configurable timeout -- `createIndicator(name, fn)` — inline factory for simple custom indicators -- `BaseHealthIndicator` abstract class + `@HealthIndicator('liveness' | 'readiness')` decorator for DI-based custom indicators -- `GET /{path}/live` and `GET /{path}/ready` endpoints — 200 OK / 503 with `{ status, results[] }` body -- All indicators run concurrently via `Promise.allSettled` -- `path` option defaults to `"health"` diff --git a/.changeset/thick-maps-raise.md b/.changeset/thick-maps-raise.md deleted file mode 100644 index 0b3a593..0000000 --- a/.changeset/thick-maps-raise.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@ciscode/nestjs-developerkit": patch ---- - -Patch 1, testing Changeset Automation diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f1af102 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# @ciscode/health-kit + +## 0.1.0 + +### Minor Changes + +- 1b1f44a: Initial release of `@ciscode/health-kit` v0.1.0. + + ### Features + - `HealthKitModule.register()` and `registerAsync()` — dynamic NestJS module with configurable liveness and readiness probes + - Built-in `PostgresHealthIndicator` (`SELECT 1`), `RedisHealthIndicator` (`PING`), `HttpHealthIndicator` (GET 2xx check) — all with configurable timeout + - `createIndicator(name, fn)` — inline factory for simple custom indicators + - `BaseHealthIndicator` abstract class + `@HealthIndicator('liveness' | 'readiness')` decorator for DI-based custom indicators + - `GET /{path}/live` and `GET /{path}/ready` endpoints — 200 OK / 503 with `{ status, results[] }` body + - All indicators run concurrently via `Promise.allSettled` + - `path` option defaults to `"health"` diff --git a/package.json b/package.json index 868a7af..360d762 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ciscode/health-kit", - "version": "0.0.0", + "version": "0.1.0", "description": "NestJS health-check module — liveness & readiness probes with built-in PostgreSQL, Redis, and HTTP indicators.", "author": "CisCode", "publishConfig": { From 3af194e38d581aa088268e82ab2df781a992cc4e Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Mon, 6 Apr 2026 09:05:18 +0100 Subject: [PATCH 21/22] ci: update release check workflow --- .github/workflows/release-check.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 3ad9d5c..6e98768 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -10,7 +10,6 @@ concurrency: jobs: ci: - name: release checks runs-on: ubuntu-latest permissions: From 40692ef7a32aadee12efc410828e9b222d533770 Mon Sep 17 00:00:00 2001 From: Zaiidmo Date: Tue, 7 Apr 2026 09:44:26 +0100 Subject: [PATCH 22/22] ops: updated release check jobs --- .github/workflows/release-check.yml | 155 ++++++++++++++++++++++++---- 1 file changed, 133 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 6e98768..ac965df 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -8,36 +8,39 @@ concurrency: group: ci-release-${{ github.ref }} cancel-in-progress: true +env: + SONAR_HOST_URL: "https://sonarcloud.io" + SONAR_ORGANIZATION: "ciscode" + SONAR_PROJECT_KEY: "CISCODE-MA_HealthKit" + NODE_VERSION: "22" + +# ─── Job 1: Static checks (fast feedback, runs in parallel with test) ────────── jobs: - ci: + quality: + name: Quality Checks runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read - statuses: write - timeout-minutes: 25 - - # Config stays in the workflow file (token stays in repo secrets) - env: - SONAR_HOST_URL: "https://sonarcloud.io" - SONAR_ORGANIZATION: "ciscode" - SONAR_PROJECT_KEY: "CISCODE-MA_HealthKit" steps: - name: Checkout uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v4 with: - node-version: "22" + node-version: ${{ env.NODE_VERSION }} cache: "npm" - name: Install run: npm ci + - name: Security Audit + # Only fail on high/critical — moderate noise in dev deps is expected + run: npm audit --production --audit-level=high + - name: Format run: npm run format @@ -47,12 +50,94 @@ jobs: - name: Lint run: npm run lint + # ─── Job 2: Tests + Coverage (artifact passed to Sonar) ──────────────────────── + test: + name: Test & Coverage + runs-on: ubuntu-latest + timeout-minutes: 15 + + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + + - name: Install + run: npm ci + - name: Test (with coverage) run: npm run test:cov + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ + retention-days: 1 + + # ─── Job 3: Build ────────────────────────────────────────────────────────────── + build: + name: Build + runs-on: ubuntu-latest + needs: [quality, test] + timeout-minutes: 10 + + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + + - name: Install + run: npm ci + - name: Build run: npm run build + # ─── Job 4: SonarCloud (depends on test for coverage data) ───────────────────── + sonar: + name: SonarCloud Analysis + runs-on: ubuntu-latest + needs: [test] + timeout-minutes: 15 + + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Full history required for accurate blame & new code detection + fetch-depth: 0 + + - name: Download coverage report + uses: actions/download-artifact@v4 + with: + name: coverage-report + path: coverage/ + + - name: Cache SonarCloud packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: sonar-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: sonar-${{ runner.os }}- + - name: SonarCloud Scan uses: SonarSource/sonarqube-scan-action@v6 env: @@ -64,17 +149,41 @@ jobs: -Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }} -Dsonar.sources=src -Dsonar.tests=test + -Dsonar.test.inclusions=**/*.spec.ts,**/*.test.ts + -Dsonar.exclusions=**/node_modules/**,**/dist/**,**/coverage/**,**/*.d.ts + -Dsonar.coverage.exclusions=**/*.spec.ts,**/*.test.ts,**/index.ts -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info + -Dsonar.typescript.tsconfigPath=tsconfig.json + -Dsonar.qualitygate.wait=true + -Dsonar.qualitygate.timeout=300 - - name: SonarCloud Quality Gate - uses: SonarSource/sonarqube-quality-gate-action@v1 - timeout-minutes: 10 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }} + # ─── Job 5: Final status report (always runs) ────────────────────────────────── + report: + name: Report CI Status + runs-on: ubuntu-latest + needs: [quality, test, build, sonar] + # Run even if upstream jobs failed + if: always() + timeout-minutes: 5 + + permissions: + contents: read + statuses: write - - name: Report CI status - if: always() + steps: + - name: Resolve overall result + id: result + run: | + results="${{ needs.quality.result }} ${{ needs.test.result }} ${{ needs.build.result }} ${{ needs.sonar.result }}" + if echo "$results" | grep -qE "failure|cancelled"; then + echo "state=failure" >> $GITHUB_OUTPUT + echo "desc=One or more CI checks failed" >> $GITHUB_OUTPUT + else + echo "state=success" >> $GITHUB_OUTPUT + echo "desc=All CI checks passed" >> $GITHUB_OUTPUT + fi + + - name: Post commit status uses: actions/github-script@v7 with: script: | @@ -82,6 +191,8 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, sha: context.sha, - state: '${{ job.status }}' === 'success' ? 'success' : 'failure', - description: 'CI checks completed' + state: '${{ steps.result.outputs.state }}', + context: 'CI / Release Check', + description: '${{ steps.result.outputs.desc }}', + target_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` })