healthzkit 0.0.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 ADDED
@@ -0,0 +1,166 @@
1
+ # healthzkit
2
+
3
+ Framework-agnostic **liveness** and **readiness** probes for Node.js (and similar runtimes). You define checks as small adapters; **healthzkit** runs them in parallel, rolls up overall status, maps that to HTTP status and a JSON or plain-text body, and optionally **schedules** checks in the background so probes can read cached results instead of hitting dependencies on every request.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install healthzkit
9
+ ```
10
+
11
+ The package is ESM-only (`"type": "module"`). The published entry is `./dist/index.mjs` (see `package.json` `exports`).
12
+
13
+ ## Quick start
14
+
15
+ ```ts
16
+ import { createHealthKit } from "healthzkit";
17
+
18
+ const kit = createHealthKit({
19
+ checks: [
20
+ {
21
+ name: "db",
22
+ type: ["readiness"],
23
+ adapter: {
24
+ check: async () => {
25
+ // ping your database, etc.
26
+ return { status: "ok" };
27
+ },
28
+ },
29
+ },
30
+ {
31
+ name: "process",
32
+ type: ["liveness"],
33
+ adapter: { check: async () => ({ status: "ok" }) },
34
+ },
35
+ ],
36
+ });
37
+
38
+ // Wire into your HTTP server: path + method from the incoming request
39
+ const res = await kit.handleRequest({ path: "/healthz/ready", method: "GET" });
40
+ if (res) {
41
+ // res.status, res.headers, res.body
42
+ }
43
+ ```
44
+
45
+ Call `kit.handleLiveness()` or `kit.handleReadiness()` directly if you already route those endpoints yourself.
46
+
47
+ ## Routes and `basePath`
48
+
49
+ By default, **healthzkit** expects:
50
+
51
+ | Path | Behavior |
52
+ | ------------------ | ----------------------------------------------- |
53
+ | `{basePath}/live` | Runs checks whose `type` includes `"liveness"` |
54
+ | `{basePath}/ready` | Runs checks whose `type` includes `"readiness"` |
55
+
56
+ Default `basePath` is `/healthz`. Override with `basePath` in config (e.g. `/api/health` → `/api/health/live`).
57
+
58
+ `handleRequest(req)` returns `null` if `req.path` does not match either route, so you can try it first and fall through to your app.
59
+
60
+ ## Checks and adapters
61
+
62
+ Each check is a `CheckConfig`:
63
+
64
+ - **`name`** — Key in the response `checks` object.
65
+ - **`type`** — One or both of `"liveness"` and `"readiness"`. Only checks that include the probe type run for that probe. If none match, the response is still **200** with an empty `checks` object.
66
+ - **`adapter`** — Must implement `HealthAdapter`: `check(): Promise<AdapterResult>`.
67
+ - **`timeout`** — Per-check timeout in ms. Default is **5000**, unless overridden by `defaults.timeout`.
68
+ - **`schedule`** — Optional `{ intervalMs }`. When `kit.start()` has been called, the adapter runs on that interval and results are **cached** (see [Scheduling](#scheduling)).
69
+ - **`onFail`** — Optional `httpStatus`, `treatAs` (e.g. map `"fail"` to `"degraded"` for rollup and body while adjusting HTTP status rules).
70
+ - **`onDegraded`** — Optional on `CheckConfig` in the type definition only; degraded HTTP status is set via **`defaults.onDegraded`** (see [HTTP status](#http-status)).
71
+
72
+ `AdapterResult`:
73
+
74
+ - **`status`**: `"ok" | "degraded" | "fail"`.
75
+ - **`error`**: Optional `Error` or string (included in the serialized check unless `output.exposeError` is `false`).
76
+ - **`metadata`**: Optional object merged into the check result.
77
+
78
+ Thrown errors from `adapter.check()` are treated as **`fail`** with the error message captured when `exposeError` is true.
79
+
80
+ ## Scheduling
81
+
82
+ For expensive checks (database, external APIs), you can run them on a timer and serve probes from cache:
83
+
84
+ ```ts
85
+ const kit = createHealthKit({
86
+ checks: [
87
+ {
88
+ name: "db",
89
+ type: ["readiness"],
90
+ adapter: { check: async () => ({ status: "ok" }) },
91
+ schedule: { intervalMs: 30_000 },
92
+ },
93
+ ],
94
+ });
95
+
96
+ kit.start(); // starts background intervals for checks that define schedule
97
+ // ... on shutdown:
98
+ kit.stop();
99
+ ```
100
+
101
+ - **`start()`** is idempotent (second call is a no-op).
102
+ - **`stop()`** clears intervals and the cache.
103
+ - When a cached result is used, that check’s **`latency`** is **0** and **`cachedAt`** is an ISO timestamp on the check result.
104
+
105
+ Scheduled runs use **`adapter.check()`** directly (no per-request timeout wrapper in the scheduler). Timeouts still apply when there is **no** cache entry and the probe executes the check on demand.
106
+
107
+ Timers use `unref` when available so they do not keep the process alive by themselves.
108
+
109
+ ## Rollup status
110
+
111
+ Overall `HealthResponse.status` is computed from all check results for that probe:
112
+
113
+ 1. Any **`fail`** → **`fail`** (unless remapped by `onFail.treatAs` on that check).
114
+ 2. Else any **`degraded`** → **`degraded`**.
115
+ 3. Else **`ok`**.
116
+
117
+ Override with `rollup.computeStatus(results)` for custom rules.
118
+
119
+ ## HTTP status
120
+
121
+ Response **`status`** (HTTP code) is derived from the rolled-up health status and your config:
122
+
123
+ - Any check with **`status === "fail"`** and **`onFail.httpStatus`** set → that value is returned (first matching check in config order wins among failed checks with a custom status).
124
+ - Else if rollup is **`fail`**: `defaults.onFail.httpStatus` or **503**.
125
+ - Else if rollup is **`degraded`**: `defaults.onDegraded.httpStatus` or **200**.
126
+ - Else **200**.
127
+
128
+ Note: `onFail.treatAs` changes the **check** status used for rollup and JSON/text body; combine with `onFail.httpStatus` / defaults if you need a specific HTTP code.
129
+
130
+ ## Output
131
+
132
+ `output` on the root config:
133
+
134
+ - **`format`**: `"json"` (default) or `"text"`.
135
+ - JSON: `Content-Type: application/json`, body is `JSON.stringify` of `HealthResponse`.
136
+ - Text: `Content-Type: text/plain`, human-readable lines (`status:`, then each check with latency and optional error).
137
+ - **`exposeError`**: Default **true**. If **false**, the `error` field is omitted from each check in the payload.
138
+
139
+ ## Types (public API)
140
+
141
+ Exported from `healthzkit`:
142
+
143
+ **Runtime**
144
+
145
+ - `createHealthKit(config)` → `HealthKit`
146
+ - `HealthKit`: `start()`, `stop()`, `handleRequest(req)`, `handleLiveness()`, `handleReadiness()`
147
+
148
+ **Types**
149
+
150
+ - `HealthkitConfig`, `CheckConfig`, `HealthAdapter`, `AdapterResult`, `CheckResult`, `HealthResponse`
151
+ - `CheckStatus`, `CheckType`, `RollupConfig`, `OutputConfig`, `DefaultsConfig`
152
+ - `AgnosticRequest`, `AgnosticResponse`
153
+
154
+ `AgnosticResponse` is `{ status: number; headers: Record<string, string>; body: string }` so you can map it to Express, Fastify, `fetch` `Response`, etc.
155
+
156
+ ## Development (this repo)
157
+
158
+ From the package directory:
159
+
160
+ ```bash
161
+ vp install
162
+ vp test
163
+ vp pack
164
+ ```
165
+
166
+ See the repo root `AGENTS.md` for Vite+ / `vp` conventions.
@@ -0,0 +1,101 @@
1
+ //#region src/types.d.ts
2
+ type CheckStatus = "ok" | "degraded" | "fail";
3
+ type CheckType = "liveness" | "readiness";
4
+ type OutputFormat = "json" | "text";
5
+ interface AdapterResult {
6
+ status: CheckStatus;
7
+ error?: Error | string;
8
+ metadata?: Record<string, unknown>;
9
+ }
10
+ interface HealthAdapter {
11
+ check(): Promise<AdapterResult>;
12
+ }
13
+ interface CheckConfig {
14
+ name: string;
15
+ type: CheckType[];
16
+ adapter: HealthAdapter;
17
+ timeout?: number;
18
+ schedule?: {
19
+ intervalMs: number;
20
+ };
21
+ onFail?: {
22
+ httpStatus?: number;
23
+ treatAs?: CheckStatus;
24
+ };
25
+ onDegraded?: {
26
+ httpStatus?: number;
27
+ };
28
+ }
29
+ interface CheckResult {
30
+ status: CheckStatus;
31
+ latency: number;
32
+ error?: string;
33
+ metadata?: Record<string, unknown>;
34
+ cachedAt?: string;
35
+ }
36
+ interface HealthResponse {
37
+ status: CheckStatus;
38
+ timestamp: string;
39
+ checks: Record<string, CheckResult>;
40
+ }
41
+ interface RollupConfig {
42
+ /**
43
+ * Custom function to determine top-level status from all check results.
44
+ * Defaults to:
45
+ * * any fail -> fail
46
+ * * any degraded -> degraded
47
+ * * else ok
48
+ */
49
+ computeStatus?: (results: Record<string, CheckResult>) => CheckStatus;
50
+ }
51
+ interface OutputConfig {
52
+ format?: OutputFormat;
53
+ /**
54
+ * Expose error messages in the response.
55
+ * Default: true
56
+ */
57
+ exposeError?: boolean;
58
+ }
59
+ interface DefaultsConfig {
60
+ onFail?: {
61
+ httpStatus?: number;
62
+ };
63
+ onDegraded?: {
64
+ httpStatus?: number;
65
+ };
66
+ timeout?: number;
67
+ }
68
+ interface HealthkitConfig {
69
+ checks: CheckConfig[];
70
+ basePath?: string;
71
+ rollup?: RollupConfig;
72
+ output?: OutputConfig;
73
+ defaults?: DefaultsConfig;
74
+ }
75
+ interface AgnosticRequest {
76
+ path: string;
77
+ method?: string;
78
+ }
79
+ interface AgnosticResponse {
80
+ status: number;
81
+ headers: Record<string, string>;
82
+ body: string;
83
+ }
84
+ //#endregion
85
+ //#region src/healthkit.d.ts
86
+ declare class HealthKit {
87
+ private config;
88
+ private scheduler;
89
+ private started;
90
+ constructor(config: HealthkitConfig);
91
+ start(): void;
92
+ stop(): void;
93
+ handleLiveness(): Promise<AgnosticResponse>;
94
+ handleReadiness(): Promise<AgnosticResponse>;
95
+ handleRequest(req: AgnosticRequest): Promise<AgnosticResponse | null>;
96
+ private runForType;
97
+ private resolveHttpStatus;
98
+ }
99
+ declare function createHealthKit(config: HealthkitConfig): HealthKit;
100
+ //#endregion
101
+ export { type AdapterResult, type AgnosticRequest, type AgnosticResponse, type CheckConfig, type CheckResult, type CheckStatus, type CheckType, type DefaultsConfig, type HealthAdapter, HealthKit, type HealthResponse, type HealthkitConfig, type OutputConfig, type RollupConfig, createHealthKit };
package/dist/index.mjs ADDED
@@ -0,0 +1,2 @@
1
+ function e(e){return JSON.stringify(e)}function t(e){let t=[`status: ${e.status}`];for(let[n,r]of Object.entries(e.checks)){let e=`${n}: ${r.status} (${r.latency}ms)`;r.error&&(e+=` - ${r.error}`),t.push(e)}return t.join(`
2
+ `)}function n(n,r=`json`){switch(r){case`text`:return{body:t(n),contentType:`text/plain`};default:return{body:e(n),contentType:`application/json`}}}const r=e=>{let t=Object.values(e).map(e=>e.status);return t.includes(`fail`)?`fail`:t.includes(`degraded`)?`degraded`:`ok`};function i(e,t){return(t?.computeStatus??r)(e)}async function a(e,t,n){let r,i=new Promise((e,i)=>{r=setTimeout(()=>i(Error(`Check "${n}" timed out after ${t}ms`)),t)});try{return await Promise.race([e,i])}finally{clearTimeout(r)}}async function o(e,t){let n=Date.now();try{return{result:await a(e.adapter.check(),t,e.name),latency:Date.now()-n}}catch(e){return{result:{status:`fail`,error:e instanceof Error?e:Error(String(e))},latency:Date.now()-n}}}async function s(e,t,n=5e3,r=!0){let i=await Promise.all(e.map(async e=>{let i=e.timeout??n,a=t.getCache(e.name),s,c,l;a?(s=a.result,c=0,l=a.cachedAt.toISOString()):{result:s,latency:c}=await o(e,i);let u=s.status===`fail`&&e.onFail?.treatAs?e.onFail.treatAs:s.status,d=s.error instanceof Error?s.error.message:s.error,f={status:u,latency:c,...s.metadata&&{metadata:s.metadata},...l&&{cachedAt:l},...r&&d&&{error:d}};return[e.name,f]}));return Object.fromEntries(i)}var c=class{timers=new Map;cache=new Map;start(e){for(let t of e){if(!t.schedule||this.timers.has(t.name))continue;this.runAndCache(t);let e=setInterval(()=>this.runAndCache(t),t.schedule.intervalMs);e.unref&&e.unref(),this.timers.set(t.name,e)}}stop(){for(let e of this.timers.values())clearInterval(e);this.timers.clear(),this.cache.clear()}getCache(e){return this.cache.get(e)}async runAndCache(e){try{let t=await e.adapter.check();this.cache.set(e.name,{result:t,cachedAt:new Date})}catch(t){this.cache.set(e.name,{result:{status:`fail`,error:t instanceof Error?t:Error(String(t))},cachedAt:new Date})}}},l=class{scheduler;started=!1;constructor(e){this.config=e,this.scheduler=new c}start(){this.started||=(this.scheduler.start(this.config.checks),!0)}stop(){this.scheduler.stop(),this.started=!1}async handleLiveness(){return this.runForType(`liveness`)}async handleReadiness(){return this.runForType(`readiness`)}async handleRequest(e){let t=this.config.basePath??`/healthz`,{path:n}=e;return n===`${t}/live`?this.handleLiveness():n===`${t}/ready`?this.handleReadiness():null}async runForType(e){let t=this.config.checks.filter(t=>t.type.includes(e)),{output:r,defaults:a,rollup:o}=this.config,c=r?.exposeError??!0,l=a?.timeout,u=await s(t,this.scheduler,l,c),d=i(u,o),{body:f,contentType:p}=n({status:d,timestamp:new Date().toISOString(),checks:u},r?.format);return{status:this.resolveHttpStatus(d,t,u),headers:{"Content-Type":p},body:f}}resolveHttpStatus(e,t,n){let{defaults:r}=this.config;for(let e of t)if(n[e.name]?.status===`fail`&&e.onFail?.httpStatus!==void 0)return e.onFail.httpStatus;return e===`fail`?r?.onFail?.httpStatus??503:e===`degraded`?r?.onDegraded?.httpStatus??200:200}};function u(e){return new l(e)}export{l as HealthKit,u as createHealthKit};
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "healthzkit",
3
+ "version": "0.0.1",
4
+ "files": [
5
+ "dist"
6
+ ],
7
+ "type": "module",
8
+ "exports": {
9
+ ".": "./dist/index.mjs",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "devDependencies": {
16
+ "@types/node": "^25.5.0",
17
+ "@typescript/native-preview": "7.0.0-dev.20260328.1",
18
+ "bumpp": "^11.0.1",
19
+ "tsdown": "^0.21.10",
20
+ "typescript": "^6.0.2",
21
+ "vite-plus": "^0.1.14"
22
+ },
23
+ "scripts": {
24
+ "build": "vp pack",
25
+ "dev": "vp pack --watch",
26
+ "test": "vp test",
27
+ "check": "vp check"
28
+ }
29
+ }