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/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/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..2279f0b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @CISCODE-MA/devops 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 2475a6c..8016885 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,44 +1,82 @@ name: Publish to NPM on: - # push: - # tags: - # - "v*.*.*" + push: + 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 1a05af2..ac965df 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -3,48 +3,44 @@ 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 }} 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: - name: release checks + quality: + name: Quality Checks runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 10 - # 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_LoggingKit" + permissions: + contents: read 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 @@ -54,30 +50,149 @@ 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 - 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.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 - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }} - 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 + + 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: | + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + 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}` + }) 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/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/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/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..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: { @@ -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/package-lock.json b/package-lock.json index 8698ff7..bba8fe4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,16 @@ { - "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": { - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1" - }, "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", @@ -43,9 +40,14 @@ "peerDependencies": { "@nestjs/common": "^10 || ^11", "@nestjs/core": "^10 || ^11", - "@nestjs/platform-express": "^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 54f5ef5..360d762 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.1.0", + "description": "NestJS health-check module β€” liveness & readiness probes with built-in PostgreSQL, Redis, and HTTP indicators.", "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": [ @@ -45,13 +45,14 @@ "peerDependencies": { "@nestjs/common": "^10 || ^11", "@nestjs/core": "^10 || ^11", - "@nestjs/platform-express": "^10 || ^11", + "@nestjs/terminus": "^10 || ^11", "reflect-metadata": "^0.2.2", "rxjs": "^7" }, - "dependencies": { - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1" + "peerDependenciesMeta": { + "@nestjs/terminus": { + "optional": true + } }, "devDependencies": { "@changesets/cli": "^2.27.7", diff --git a/src/controllers/health.controller.spec.ts b/src/controllers/health.controller.spec.ts new file mode 100644 index 0000000..a6709e9 --- /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, results: [] }), + checkReadiness: jest.fn().mockResolvedValue({ status: readiness, results: [] }), + }) 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/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.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/health-kit.module.ts b/src/health-kit.module.ts new file mode 100644 index 0000000..9e35152 --- /dev/null +++ b/src/health-kit.module.ts @@ -0,0 +1,155 @@ +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, Type } 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"; +const HEALTH_MODULE_OPTIONS = "HEALTH_MODULE_OPTIONS"; + +export interface HealthModuleOptions { + /** 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`. */ + 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[]; +} + +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. + * + * @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 { path = "health", liveness = [], readiness = [], indicators = [] } = options; + const { indicatorProviders, livenessClasses, readinessClasses } = + HealthKitModule._resolveIndicatorClasses(indicators); + + 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(), + ]; + + 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: (opts: HealthModuleOptions, ...injected: BaseHealthIndicator[]) => [ + ...(opts.liveness ?? []), + ...injected, + ], + inject: [HEALTH_MODULE_OPTIONS, ...livenessClasses], + }, + { + provide: HEALTH_READINESS_INDICATORS, + useFactory: (opts: HealthModuleOptions, ...injected: BaseHealthIndicator[]) => [ + ...(opts.readiness ?? []), + ...injected, + ], + inject: [HEALTH_MODULE_OPTIONS, ...readinessClasses], + }, + ...HealthKitModule._healthServiceProvider(), + ]; + + return { + module: HealthKitModule, + 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 3026198..a317a1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,41 +3,49 @@ 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, HealthModuleAsyncOptions } 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"; + +export { RedisHealthIndicator } from "./indicators/redis.indicator"; +export type { RedisClient } from "./indicators/redis.indicator"; + +export { HttpHealthIndicator } from "./indicators/http.indicator"; // ============================================================================ -// GUARDS (For Route Protection) +// CUSTOM INDICATOR API // ============================================================================ -// Export guards so consumers can use them in their apps -export { ExampleGuard } from "./guards/example.guard"; +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"; // ============================================================================ -// 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/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]); + }, + }; +} 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..2c7fe5d --- /dev/null +++ b/src/interfaces/health-indicator.interface.ts @@ -0,0 +1,37 @@ +/** + * 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; + /** Optional structured metadata (e.g. response time, version). */ + details?: Record; +} + +/** + * 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/src/services/health.service.spec.ts b/src/services/health.service.spec.ts new file mode 100644 index 0000000..abcff42 --- /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.results).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.results).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.results).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.results.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..723190c --- /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"; + results: 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", 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" 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"],