@sandsoftwaresolutions/health 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SandSoftwareSolutions
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,55 @@
1
- # Temporary Holding Version
1
+ # @sandsoftwaresolutions/health
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/SandSoftwareSolutions/ss-health/actions/workflows/ci.yml/badge.svg)](https://github.com/SandSoftwareSolutions/ss-health/actions/workflows/ci.yml)
4
+
5
+ Health checks for a service, the way a load balancer, Docker or Kubernetes wants them: a **live** endpoint that only says the process runs, and a **ready** endpoint that checks your database and friends. No dependencies.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @sandsoftwaresolutions/health
11
+ ```
12
+
13
+ Works with `import` and `require()`, and ships TypeScript types for both.
14
+
15
+ ## Usage
16
+
17
+ ```js
18
+ import { createServer } from "node:http";
19
+ import { createHealth } from "@sandsoftwaresolutions/health";
20
+
21
+ const health = createHealth({
22
+ checks: {
23
+ database: () => db.prepare("SELECT 1").get(),
24
+ disk: () => freeSpaceMb() > 100, // return false to fail
25
+ mail: { check: pingSmtp, critical: false, timeoutMs: 5000 } // may fail without taking the service down
26
+ },
27
+ timeoutMs: 2000,
28
+ cacheMs: 1000
29
+ });
30
+
31
+ createServer((req, res) => {
32
+ if (req.url === "/live") return health.live(req, res);
33
+ if (req.url === "/ready") return health.ready(req, res);
34
+ /* ... */
35
+ }).listen(3000);
36
+ ```
37
+
38
+ `GET /ready` answers with
39
+
40
+ ```json
41
+ { "status": "ok", "checks": { "database": { "status": "up", "ms": 1, "critical": true } }, "at": "2026-10-17T10:00:00.000Z" }
42
+ ```
43
+
44
+ ## How it behaves
45
+
46
+ - **ok / degraded / down.** A failing *critical* check (the default) makes the service `down` (HTTP 503). A failing non-critical one only makes it `degraded` (still HTTP 200), so a broken mail server does not get your app restarted.
47
+ - **A check passes** when it returns without throwing and does not return `false`. It fails when it throws, returns `false`, or takes longer than its timeout.
48
+ - **Caching.** Results are cached for `cacheMs` and simultaneous requests share one run, so a busy load balancer cannot hammer your database.
49
+ - **A hung check is not piled up.** A check that is still running from an earlier probe is not called again: it is reported `down` ("the previous call of this check is still running") until it comes back. Otherwise every probe would add one more hung call to your database. Each check also gets an `AbortSignal` (`check(signal)`) that aborts when it times out, so a check can cancel its own work. Give a check its own timeout if it can hang for ever, because until it settles it stays `down`.
50
+ - **Timeouts** must be a number above 0 (`RangeError` otherwise); a very large one is cut to 2^31-1 ms. A clock that steps back does not freeze the cache.
51
+ - **Live is not ready.** `/live` never runs checks. If your database is slow, you want traffic held back (not ready) and not a restart (not live).
52
+ - **No leaks.** Error messages are left out unless you set `verbose: true`, because they can contain addresses and passwords. Use `verbose` for private networks only.
53
+ - Responses are `no-store`. The check names, their status and timing are in the answer: put `/ready` where only your orchestrator can reach it, or name checks so that they reveal little. If the run itself breaks, `/ready` answers `503 {"status":"down"}`.
54
+
55
+ Pairs well with `@sandsoftwaresolutions/graceful`: after shutdown starts, answer 503 on `/ready`.
package/dist/index.cjs ADDED
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ // setTimeout fires after 1 ms for anything above this, which would report a slow check as timed out at once.
3
+ const MAX_TIMER_MS = 2 ** 31 - 1;
4
+ const TIMED_OUT = Symbol("timed out");
5
+
6
+ /**
7
+ * A health check for a service. `checks` maps a name to a function (or `{ check, timeoutMs, critical }`) that throws or returns
8
+ * false when something is wrong; it gets an AbortSignal that aborts on timeout. A check is not called again while an earlier
9
+ * call of it is still running. A failing critical check (the default) makes the service "down"; a failing non-critical one
10
+ * only "degraded". Results are cached for `cacheMs` and concurrent calls share one run, so a busy load balancer cannot
11
+ * hammer your database. Error details are only included with `verbose: true`.
12
+ */
13
+ function createHealth({ checks = {}, timeoutMs = 2000, cacheMs = 1000, verbose = false, now = Date.now } = {}) {
14
+ const checkTimeout = (value, where) => {
15
+ if (typeof value !== "number" || Number.isNaN(value) || value <= 0) throw new RangeError(`createHealth: ${where} must be a number of milliseconds above 0`);
16
+ return Math.min(value, MAX_TIMER_MS);
17
+ };
18
+ const globalTimeout = checkTimeout(timeoutMs, "timeoutMs");
19
+ if (typeof cacheMs !== "number" || Number.isNaN(cacheMs) || cacheMs < 0) throw new RangeError("createHealth: cacheMs must be a number of milliseconds, 0 or more");
20
+ const entries = Object.entries(checks).map(([name, spec]) => {
21
+ const def = typeof spec === "function" ? { check: spec } : spec;
22
+ if (typeof def?.check !== "function") throw new TypeError(`createHealth: check "${name}" must be a function`);
23
+ if (def.critical !== undefined && typeof def.critical !== "boolean") throw new TypeError(`createHealth: critical of check "${name}" must be true or false`);
24
+ return [name, { check: def.check, timeoutMs: def.timeoutMs === undefined ? globalTimeout : checkTimeout(def.timeoutMs, `timeoutMs of check "${name}"`), critical: def.critical ?? true, inFlight: false }];
25
+ });
26
+ let cached = null;
27
+ let running = null;
28
+
29
+ async function runOne(name, def) {
30
+ const started = now();
31
+ let state = "up";
32
+ let error;
33
+ if (def.inFlight) {
34
+ // The earlier call has not come back yet. Calling again would add one more hung call (and connection) per probe.
35
+ state = "down";
36
+ error = "the previous call of this check is still running";
37
+ } else {
38
+ const controller = new AbortController();
39
+ let timer;
40
+ const timedOut = new Promise((resolve) => {
41
+ timer = setTimeout(resolve, def.timeoutMs, TIMED_OUT);
42
+ timer.unref?.();
43
+ });
44
+ def.inFlight = true;
45
+ const call = Promise.resolve().then(() => def.check(controller.signal));
46
+ const settled = () => { def.inFlight = false; };
47
+ call.then(settled, settled);
48
+ try {
49
+ const outcome = await Promise.race([call, timedOut]);
50
+ if (outcome === TIMED_OUT) {
51
+ state = "down";
52
+ error = `timed out after ${def.timeoutMs} ms`;
53
+ controller.abort(new Error(error));
54
+ } else if (outcome === false) {
55
+ state = "down";
56
+ error = "check returned false";
57
+ }
58
+ } catch (e) {
59
+ state = "down";
60
+ error = e?.message ?? String(e);
61
+ } finally {
62
+ clearTimeout(timer);
63
+ }
64
+ }
65
+ const result = { status: state, ms: Math.max(0, now() - started), critical: def.critical };
66
+ if (error && verbose) result.error = error;
67
+ return [name, result];
68
+ }
69
+
70
+ async function execute() {
71
+ const results = Object.fromEntries(await Promise.all(entries.map(([name, def]) => runOne(name, def))));
72
+ const failed = Object.values(results).filter((r) => r.status === "down");
73
+ const status = failed.some((r) => r.critical) ? "down" : failed.length > 0 ? "degraded" : "ok";
74
+ return { status, checks: results, at: new Date(now()).toISOString() };
75
+ }
76
+
77
+ /** Runs the checks (or returns the cached result). */
78
+ async function run() {
79
+ const age = cached ? now() - cached.time : -1; // a clock that stepped back (negative age) does not keep the cache alive
80
+ if (cached && age >= 0 && age < cacheMs) return cached.report;
81
+ running ??= execute().then(
82
+ (report) => {
83
+ cached = { time: now(), report };
84
+ running = null;
85
+ return report;
86
+ },
87
+ (error) => {
88
+ running = null;
89
+ throw error;
90
+ }
91
+ );
92
+ return running;
93
+ }
94
+
95
+ const send = (res, code, body) => {
96
+ res.writeHead(code, { "content-type": "application/json", "cache-control": "no-store" });
97
+ res.end(JSON.stringify(body));
98
+ };
99
+
100
+ return {
101
+ run,
102
+ /** Request handler for `/ready`: 200 for ok and degraded, 503 for down (also when the run itself breaks). */
103
+ async ready(_req, res) {
104
+ let report;
105
+ try {
106
+ report = await run();
107
+ } catch {
108
+ if (!res.headersSent) send(res, 503, { status: "down" });
109
+ return;
110
+ }
111
+ send(res, report.status === "down" ? 503 : 200, report);
112
+ },
113
+ /** Request handler for `/live`: 200 as long as the process can answer. No checks, so a slow database never restarts the app. */
114
+ live(_req, res) {
115
+ send(res, 200, { status: "ok" });
116
+ }
117
+ };
118
+ }
119
+
120
+ exports.createHealth = createHealth;
@@ -0,0 +1,41 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+
3
+ /** Gets a signal that aborts when the check times out. */
4
+ export type CheckFn = (signal: AbortSignal) => unknown | Promise<unknown>;
5
+ export interface CheckSpec {
6
+ check: CheckFn;
7
+ /** Default: the global timeoutMs. */
8
+ timeoutMs?: number;
9
+ /** A failing critical check makes the service "down" (default true); otherwise "degraded". */
10
+ critical?: boolean;
11
+ }
12
+
13
+ export interface CheckResult {
14
+ status: "up" | "down";
15
+ ms: number;
16
+ critical: boolean;
17
+ /** Only with `verbose: true`. */
18
+ error?: string;
19
+ }
20
+
21
+ export interface HealthReport {
22
+ status: "ok" | "degraded" | "down";
23
+ checks: Record<string, CheckResult>;
24
+ at: string;
25
+ }
26
+
27
+ export interface HealthOptions {
28
+ checks?: Record<string, CheckFn | CheckSpec>;
29
+ timeoutMs?: number;
30
+ cacheMs?: number;
31
+ verbose?: boolean;
32
+ now?: () => number;
33
+ }
34
+
35
+ export interface Health {
36
+ run(): Promise<HealthReport>;
37
+ ready(req: IncomingMessage, res: ServerResponse): Promise<void>;
38
+ live(req: IncomingMessage, res: ServerResponse): void;
39
+ }
40
+
41
+ export function createHealth(options?: HealthOptions): Health;
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@sandsoftwaresolutions/health",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Live and ready health endpoints with timeouts, caching and degraded state, for Docker and load balancers.",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "types": "./src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": {
11
+ "types": "./src/index.d.ts",
12
+ "default": "./src/index.js"
13
+ },
14
+ "require": {
15
+ "types": "./dist/index.d.cts",
16
+ "default": "./dist/index.cjs"
17
+ }
18
+ }
19
+ },
20
+ "files": [
21
+ "src",
22
+ "dist",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "sideEffects": false,
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "scripts": {
31
+ "test": "node --test && node scripts/build-cjs.mjs --check",
32
+ "pack:check": "npm pack --dry-run",
33
+ "build": "node scripts/build-cjs.mjs",
34
+ "prepack": "node scripts/build-cjs.mjs"
35
+ },
36
+ "keywords": [
37
+ "health",
38
+ "readiness",
39
+ "liveness",
40
+ "docker"
41
+ ],
42
+ "license": "MIT",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/SandSoftwareSolutions/ss-health.git"
46
+ },
47
+ "bugs": {
48
+ "url": "https://github.com/SandSoftwareSolutions/ss-health/issues"
49
+ },
50
+ "homepage": "https://github.com/SandSoftwareSolutions/ss-health#readme",
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
54
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+
3
+ /** Gets a signal that aborts when the check times out. */
4
+ export type CheckFn = (signal: AbortSignal) => unknown | Promise<unknown>;
5
+ export interface CheckSpec {
6
+ check: CheckFn;
7
+ /** Default: the global timeoutMs. */
8
+ timeoutMs?: number;
9
+ /** A failing critical check makes the service "down" (default true); otherwise "degraded". */
10
+ critical?: boolean;
11
+ }
12
+
13
+ export interface CheckResult {
14
+ status: "up" | "down";
15
+ ms: number;
16
+ critical: boolean;
17
+ /** Only with `verbose: true`. */
18
+ error?: string;
19
+ }
20
+
21
+ export interface HealthReport {
22
+ status: "ok" | "degraded" | "down";
23
+ checks: Record<string, CheckResult>;
24
+ at: string;
25
+ }
26
+
27
+ export interface HealthOptions {
28
+ checks?: Record<string, CheckFn | CheckSpec>;
29
+ timeoutMs?: number;
30
+ cacheMs?: number;
31
+ verbose?: boolean;
32
+ now?: () => number;
33
+ }
34
+
35
+ export interface Health {
36
+ run(): Promise<HealthReport>;
37
+ ready(req: IncomingMessage, res: ServerResponse): Promise<void>;
38
+ live(req: IncomingMessage, res: ServerResponse): void;
39
+ }
40
+
41
+ export function createHealth(options?: HealthOptions): Health;
package/src/index.js ADDED
@@ -0,0 +1,117 @@
1
+ // setTimeout fires after 1 ms for anything above this, which would report a slow check as timed out at once.
2
+ const MAX_TIMER_MS = 2 ** 31 - 1;
3
+ const TIMED_OUT = Symbol("timed out");
4
+
5
+ /**
6
+ * A health check for a service. `checks` maps a name to a function (or `{ check, timeoutMs, critical }`) that throws or returns
7
+ * false when something is wrong; it gets an AbortSignal that aborts on timeout. A check is not called again while an earlier
8
+ * call of it is still running. A failing critical check (the default) makes the service "down"; a failing non-critical one
9
+ * only "degraded". Results are cached for `cacheMs` and concurrent calls share one run, so a busy load balancer cannot
10
+ * hammer your database. Error details are only included with `verbose: true`.
11
+ */
12
+ export function createHealth({ checks = {}, timeoutMs = 2000, cacheMs = 1000, verbose = false, now = Date.now } = {}) {
13
+ const checkTimeout = (value, where) => {
14
+ if (typeof value !== "number" || Number.isNaN(value) || value <= 0) throw new RangeError(`createHealth: ${where} must be a number of milliseconds above 0`);
15
+ return Math.min(value, MAX_TIMER_MS);
16
+ };
17
+ const globalTimeout = checkTimeout(timeoutMs, "timeoutMs");
18
+ if (typeof cacheMs !== "number" || Number.isNaN(cacheMs) || cacheMs < 0) throw new RangeError("createHealth: cacheMs must be a number of milliseconds, 0 or more");
19
+ const entries = Object.entries(checks).map(([name, spec]) => {
20
+ const def = typeof spec === "function" ? { check: spec } : spec;
21
+ if (typeof def?.check !== "function") throw new TypeError(`createHealth: check "${name}" must be a function`);
22
+ if (def.critical !== undefined && typeof def.critical !== "boolean") throw new TypeError(`createHealth: critical of check "${name}" must be true or false`);
23
+ return [name, { check: def.check, timeoutMs: def.timeoutMs === undefined ? globalTimeout : checkTimeout(def.timeoutMs, `timeoutMs of check "${name}"`), critical: def.critical ?? true, inFlight: false }];
24
+ });
25
+ let cached = null;
26
+ let running = null;
27
+
28
+ async function runOne(name, def) {
29
+ const started = now();
30
+ let state = "up";
31
+ let error;
32
+ if (def.inFlight) {
33
+ // The earlier call has not come back yet. Calling again would add one more hung call (and connection) per probe.
34
+ state = "down";
35
+ error = "the previous call of this check is still running";
36
+ } else {
37
+ const controller = new AbortController();
38
+ let timer;
39
+ const timedOut = new Promise((resolve) => {
40
+ timer = setTimeout(resolve, def.timeoutMs, TIMED_OUT);
41
+ timer.unref?.();
42
+ });
43
+ def.inFlight = true;
44
+ const call = Promise.resolve().then(() => def.check(controller.signal));
45
+ const settled = () => { def.inFlight = false; };
46
+ call.then(settled, settled);
47
+ try {
48
+ const outcome = await Promise.race([call, timedOut]);
49
+ if (outcome === TIMED_OUT) {
50
+ state = "down";
51
+ error = `timed out after ${def.timeoutMs} ms`;
52
+ controller.abort(new Error(error));
53
+ } else if (outcome === false) {
54
+ state = "down";
55
+ error = "check returned false";
56
+ }
57
+ } catch (e) {
58
+ state = "down";
59
+ error = e?.message ?? String(e);
60
+ } finally {
61
+ clearTimeout(timer);
62
+ }
63
+ }
64
+ const result = { status: state, ms: Math.max(0, now() - started), critical: def.critical };
65
+ if (error && verbose) result.error = error;
66
+ return [name, result];
67
+ }
68
+
69
+ async function execute() {
70
+ const results = Object.fromEntries(await Promise.all(entries.map(([name, def]) => runOne(name, def))));
71
+ const failed = Object.values(results).filter((r) => r.status === "down");
72
+ const status = failed.some((r) => r.critical) ? "down" : failed.length > 0 ? "degraded" : "ok";
73
+ return { status, checks: results, at: new Date(now()).toISOString() };
74
+ }
75
+
76
+ /** Runs the checks (or returns the cached result). */
77
+ async function run() {
78
+ const age = cached ? now() - cached.time : -1; // a clock that stepped back (negative age) does not keep the cache alive
79
+ if (cached && age >= 0 && age < cacheMs) return cached.report;
80
+ running ??= execute().then(
81
+ (report) => {
82
+ cached = { time: now(), report };
83
+ running = null;
84
+ return report;
85
+ },
86
+ (error) => {
87
+ running = null;
88
+ throw error;
89
+ }
90
+ );
91
+ return running;
92
+ }
93
+
94
+ const send = (res, code, body) => {
95
+ res.writeHead(code, { "content-type": "application/json", "cache-control": "no-store" });
96
+ res.end(JSON.stringify(body));
97
+ };
98
+
99
+ return {
100
+ run,
101
+ /** Request handler for `/ready`: 200 for ok and degraded, 503 for down (also when the run itself breaks). */
102
+ async ready(_req, res) {
103
+ let report;
104
+ try {
105
+ report = await run();
106
+ } catch {
107
+ if (!res.headersSent) send(res, 503, { status: "down" });
108
+ return;
109
+ }
110
+ send(res, report.status === "down" ? 503 : 200, report);
111
+ },
112
+ /** Request handler for `/live`: 200 as long as the process can answer. No checks, so a slow database never restarts the app. */
113
+ live(_req, res) {
114
+ send(res, 200, { status: "ok" });
115
+ }
116
+ };
117
+ }