Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@
"packages/drizzle-utils": "1.0.0",
"packages/openapi-generators": "0.2.0",
"packages/openapi-supertest": "0.2.0",
"packages/openapi-express-types": "0.2.0"
"packages/openapi-express-types": "0.2.0",
"packages/health-check": "0.0.1"
}
4 changes: 4 additions & 0 deletions .vscode/project.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,9 @@
"name": "drizzle-utils",
"path": "../packages/drizzle-utils",
},
{
"name": "health-check",
"path": "../packages/health-check",
},
],
}
56 changes: 56 additions & 0 deletions packages/health-check/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# @map-colonies/health-check

A wrapper around `@godaddy/terminus` to manage health checks (liveness/readiness) and graceful shutdown for Node.js applications. It abstracts the complexity of Terminus into a clean, object-oriented API while preserving the ability to pass advanced configuration when needed.

## Installation

```bash
npm install @map-colonies/health-check
```

## Usage

Whether your application is a REST API or a background worker, `HealthCheckManager` requires an `http.Server` to expose the Kubernetes probes.

```typescript
import http from 'http';
import { HealthCheckManager } from '@map-colonies/health-check';

// If this is a worker without an existing API, simply create a lightweight server:
// const server = http.createServer();
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end('Hello MapColonies!');
});

// Using a standard @map-colonies/js-logger
const logger = myLogger;

const healthCheckManager = new HealthCheckManager(server, logger, {
timeout: 5000,
signals: ['SIGINT', 'SIGTERM'],
});

// 1. Setup Liveness Check (e.g. memory limits)
healthCheckManager.registerLivenessCheck('memory', async () => {
const memUsage = process.memoryUsage();
if (memUsage.rss > 500 * 1024 * 1024) {
throw new Error('Memory usage exceeded 500MB');
}
});

// 2. Setup Readiness Check (e.g. Postgres or S3 connectivity)
healthCheckManager.registerReadinessCheck('database', async () => {
// DB ping logic
});

// 3. Setup Graceful Shutdown
healthCheckManager.registerShutdownHook('database', async () => {
logger.info('Closing database connection...');
// e.g., await connection.close();
});

server.listen(8080, () => {
logger.info('Server is listening on port 8080');
});
```
4 changes: 4 additions & 0 deletions packages/health-check/api-extractor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"extends": "../../api-extractor.json"
}
4 changes: 4 additions & 0 deletions packages/health-check/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import baseConfig from '@map-colonies/eslint-config/ts-base';
import { defineConfig } from 'eslint/config';

export default defineConfig(baseConfig, { ignores: ['vitest.config.ts'] });
35 changes: 35 additions & 0 deletions packages/health-check/etc/health-check.api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## API Report File for "@map-colonies/health-check"

> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).

```ts

import type { Logger } from '@map-colonies/js-logger';
import type { Server } from 'node:http';
import { TerminusOptions } from '@godaddy/terminus';

// @public (undocumented)
export type HealthCheckFunction = () => Promise<void>;

// @public (undocumented)
export class HealthCheckManager {
constructor(server: Server, logger: Logger, terminusOptions?: TerminusOptions);
// (undocumented)
getActiveRequestsCount(): number;
// (undocumented)
get isReady(): boolean;
// (undocumented)
registerLivenessCheck(name: string, check: HealthCheckFunction): void;
// (undocumented)
registerReadinessCheck(name: string, check: HealthCheckFunction): void;
// (undocumented)
registerShutdownHook(name: string, hook: HealthCheckFunction): void;
// (undocumented)
setReady(state: boolean): void;
// (undocumented)
waitUntilZeroActiveRequests(checkIntervalMs?: number): Promise<void>;
}

// (No @packageDocumentation comment for this package)

```
54 changes: 54 additions & 0 deletions packages/health-check/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"name": "@map-colonies/health-check",
"version": "0.0.1",
"description": "Health check manager and terminus wrapper for map colonies",
"main": "./dist/index.js",
"type": "commonjs",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js"
}
},
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"test": "vitest run",
"prebuild": "pnpm run clean",
"build": "tsc --project tsconfig.build.json",
"clean": "rimraf dist",
"prepack": "turbo run build",
"check-dist": "publint && attw --pack .",
"knip": "knip --directory ../.. --workspace packages/health-check",
"api": "api-extractor run --local --verbose",
"api:check": "api-extractor run --verbose"
},
"repository": "github:MapColonies/infra-packages",
"files": [
"dist/**/*"
],
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=24"
},
"dependencies": {
"@godaddy/terminus": "^4.12.1",
"@map-colonies/js-logger": "workspace:^"
},
"devDependencies": {
"@map-colonies/eslint-config": "workspace:^",
"@map-colonies/tsconfig": "workspace:^",
"@types/node": "catalog:",
"typescript": "catalog:",
"vitest-config": "workspace:^",
"vitest": "catalog:",
"eslint": "catalog:",
"@microsoft/api-extractor": "catalog:",
"publint": "catalog:",
"@arethetypeswrong/cli": "catalog:",
"rimraf": "catalog:"
}
}
130 changes: 130 additions & 0 deletions packages/health-check/src/healthCheckManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { Server } from 'node:http';
import { createTerminus, type TerminusOptions } from '@godaddy/terminus';
import type { Logger } from '@map-colonies/js-logger';

const CHECK_INTERVAL_MS = 1000;

/** @public */
export type HealthCheckFunction = () => Promise<void>;

/** @public */
export class HealthCheckManager {
private readonly livenessChecks: Record<string, HealthCheckFunction> = {};
private readonly readinessChecks: Record<string, HealthCheckFunction> = {};
private readonly shutdownHooks: Record<string, HealthCheckFunction> = {};
private readonly logger: Logger;
private ready = true;
private readonly server: Server;
private activeRequestsCount = 0;

public constructor(server: Server, logger: Logger, terminusOptions?: TerminusOptions) {
this.logger = logger;
this.server = server;

this.server.on('request', (req, res) => {
this.activeRequestsCount++;
res.on('close', () => {
this.activeRequestsCount--;
});
});

const defaultSignals = ['SIGINT', 'SIGTERM'];

this.registerReadinessCheck('default', async () => {
await Promise.resolve();
if (!this.ready) {
throw new Error('App is not ready');
}
});

const healthChecks: Record<string, () => Promise<void>> = {
'/liveness': async (): Promise<void> => {
await Promise.all(
Object.entries(this.livenessChecks).map(async ([name, check]) => {
try {
await check();
} catch (error) {
this.logger.error({ msg: `Liveness check failed: ${name}`, err: error });
throw error;
}
})
);
},
'/readiness': async (): Promise<void> => {
await Promise.all(
Object.entries(this.readinessChecks).map(async ([name, check]) => {
try {
await check();
} catch (error) {
this.logger.error({ msg: `Readiness check failed: ${name}`, err: error });
throw error;
Comment on lines +40 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DRY: /liveness and /readiness handlers are copy-pasted

}
})
);
},
};

const newTerminusOptions: TerminusOptions = {
signals: defaultSignals,
...terminusOptions,
healthChecks: {
...healthChecks,
...(terminusOptions?.healthChecks ?? {}),
},
onSignal: async (): Promise<void> => {
if (terminusOptions?.onSignal) {
await terminusOptions.onSignal();
}
await Promise.all(
Object.entries(this.shutdownHooks).map(async ([name, hook]) => {
try {
this.logger.info(`Running shutdown hook: ${name}`);
await hook();
} catch (err) {
this.logger.error({ msg: `Shutdown hook failed: ${name}`, err });
}
})
);
},
logger: (msg: string, error: Error): void => {
this.logger.error({ err: error, msg });
if (terminusOptions?.logger) {
terminusOptions.logger(msg, error);
}
},
};

createTerminus(this.server, newTerminusOptions);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Every new HealthCheckManager(...) in a process attaches new signal listeners with no way to remove them (no dispose()/close() method). Any test suite, or any code path that ends up constructing more than one manager (e.g. DI re-registration, hot-module-reload, or a bug that resolves the factory twice), leaks process listeners silently — Node will eventually warn MaxListenersExceededWarning.
  • This is also why the package's own test suite has to vi.mock('@godaddy/terminus') wholesale rather than testing against the real thing — a sign the constructor is doing too much for a unit boundary.

split wiring out of the constructor, and guard against accidental double-construction (note: @godaddy/terminus itself doesn't expose a way to unregister its signal handlers, so a full close() isn't possible without forking it — the pragmatic fix is to make "one per process" an enforced invariant instead of an assumption):

export class HealthCheckManager {
  private static instantiated = false;
  private readonly terminusOptions: TerminusOptions;

  public constructor(server: Server, logger: Logger, terminusOptions?: TerminusOptions) {
    if (HealthCheckManager.instantiated) {
      throw new Error('HealthCheckManager must only be constructed once per process');
    }
    HealthCheckManager.instantiated = true;

    this.logger = logger;
    this.server = server;
    this.server.on('request', (req, res) => { /* ...unchanged... */ });
    // build this.terminusOptions here, but do NOT call createTerminus yet
    this.terminusOptions = this.buildTerminusOptions(terminusOptions);
  }

  /** Wires the manager into terminus. Call once, right before `server.listen()`. */
  public attach(): void {
    createTerminus(this.server, this.terminusOptions);
  }
}

// caller:
const manager = new HealthCheckManager(server, logger, opts);
manager.registerReadinessCheck('db', dbCheck);
manager.attach(); // signal handlers registered here, at a well-defined point
server.listen(port);

nice to have also close()/teardown API.

}

public get isReady(): boolean {
return this.ready;
}

public setReady(state: boolean): void {
this.ready = state;
}

public getActiveRequestsCount(): number {
return this.activeRequestsCount;
}

public async waitUntilZeroActiveRequests(checkIntervalMs = CHECK_INTERVAL_MS): Promise<void> {
const sleep = async (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
while (this.getActiveRequestsCount() > 0) {
await sleep(checkIntervalMs);
Comment on lines +114 to +115

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potentially, it can loop forever if the pod is stuck (for example, jobnik)
Should we add some parameter of timeout for graceful closing?

}
}

public registerLivenessCheck(name: string, check: HealthCheckFunction): void {
this.livenessChecks[name] = check;
}

public registerReadinessCheck(name: string, check: HealthCheckFunction): void {
this.readinessChecks[name] = check;
}

public registerShutdownHook(name: string, hook: HealthCheckFunction): void {
this.shutdownHooks[name] = hook;
}
}
1 change: 1 addition & 0 deletions packages/health-check/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './healthCheckManager';
Loading
Loading