@sandsoftwaresolutions/health 0.1.0 → 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/README.md CHANGED
@@ -10,6 +10,8 @@ Health checks for a service, the way a load balancer, Docker or Kubernetes wants
10
10
  npm install @sandsoftwaresolutions/health
11
11
  ```
12
12
 
13
+ Works with `import` and `require()`, and ships TypeScript types for both.
14
+
13
15
  ## Usage
14
16
 
15
17
  ```js
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,16 +1,25 @@
1
1
  {
2
2
  "name": "@sandsoftwaresolutions/health",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Live and ready health endpoints with timeouts, caching and degraded state, for Docker and load balancers.",
5
5
  "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "types": "./src/index.d.ts",
6
8
  "exports": {
7
9
  ".": {
8
- "types": "./src/index.d.ts",
9
- "import": "./src/index.js"
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
+ }
10
18
  }
11
19
  },
12
20
  "files": [
13
21
  "src",
22
+ "dist",
14
23
  "README.md",
15
24
  "LICENSE"
16
25
  ],
@@ -19,8 +28,10 @@
19
28
  "node": ">=20"
20
29
  },
21
30
  "scripts": {
22
- "test": "node --test",
23
- "pack:check": "npm pack --dry-run"
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"
24
35
  },
25
36
  "keywords": [
26
37
  "health",