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 +166 -0
- package/dist/index.d.mts +101 -0
- package/dist/index.mjs +2 -0
- package/package.json +29 -0
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.
|
package/dist/index.d.mts
ADDED
|
@@ -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
|
+
}
|