healthzkit 0.0.4 → 0.1.0
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 +101 -20
- package/dist/index.d.mts +7 -1
- package/dist/index.mjs +1 -1
- package/package.json +27 -1
package/README.md
CHANGED
|
@@ -1,53 +1,132 @@
|
|
|
1
1
|
# healthzkit
|
|
2
2
|
|
|
3
|
-
Framework-agnostic **liveness** and **readiness** probes for Node.js
|
|
3
|
+
Framework-agnostic **liveness** and **readiness** probes for Node.js. 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
4
|
|
|
5
|
-
|
|
5
|
+
Docs: [healthzkit.dev](https://healthzkit.dev)
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install healthzkit
|
|
8
|
+
npm install healthzkit hono @healthzkit/postgres pg
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
The package is ESM-only
|
|
11
|
+
The package is ESM-only. Runtime: Node.js 22.12+ (CI). Other runtimes with `fetch` / `Response` work for the Fetch helper.
|
|
12
12
|
|
|
13
|
-
## Quick start
|
|
13
|
+
## Quick start (Hono)
|
|
14
14
|
|
|
15
15
|
```ts
|
|
16
|
-
import {
|
|
16
|
+
import { Hono } from "hono";
|
|
17
|
+
import { createHealthKit, toFetchResponse } from "healthzkit";
|
|
18
|
+
import { pgAdapter } from "@healthzkit/postgres/pg";
|
|
17
19
|
|
|
18
20
|
const kit = createHealthKit({
|
|
19
21
|
checks: [
|
|
22
|
+
{
|
|
23
|
+
name: "process",
|
|
24
|
+
type: ["liveness"],
|
|
25
|
+
adapter: { check: async () => ({ status: "ok" }) },
|
|
26
|
+
},
|
|
20
27
|
{
|
|
21
28
|
name: "db",
|
|
22
29
|
type: ["readiness"],
|
|
23
|
-
adapter: {
|
|
24
|
-
|
|
25
|
-
// ping your database, etc.
|
|
26
|
-
return { status: "ok" };
|
|
27
|
-
},
|
|
28
|
-
},
|
|
30
|
+
adapter: pgAdapter({ connectionString: process.env.DATABASE_URL! }),
|
|
31
|
+
schedule: { intervalMs: 30_000 },
|
|
29
32
|
},
|
|
33
|
+
],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
kit.start();
|
|
37
|
+
|
|
38
|
+
const app = new Hono();
|
|
39
|
+
app.get("/healthz/live", async () => toFetchResponse(await kit.handleLiveness()));
|
|
40
|
+
app.get("/healthz/ready", async () => toFetchResponse(await kit.handleReadiness()));
|
|
41
|
+
|
|
42
|
+
export default app;
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Point Kubernetes (or any orchestrator) at `GET /healthz/live` and `GET /healthz/ready`. A failed readiness check returns **503** by default so the instance is taken out of rotation.
|
|
46
|
+
|
|
47
|
+
## Fetch, Next.js, Bun, Workers
|
|
48
|
+
|
|
49
|
+
`createFetchHandler` mounts the default routes on any Fetch-API server:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { createFetchHandler, createHealthKit } from "healthzkit";
|
|
53
|
+
|
|
54
|
+
const kit = createHealthKit({
|
|
55
|
+
checks: [
|
|
56
|
+
{ name: "process", type: ["liveness"], adapter: { check: async () => ({ status: "ok" }) } },
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const handler = createFetchHandler(kit);
|
|
61
|
+
|
|
62
|
+
Deno.serve(handler); // also: Bun.serve({ fetch: handler })
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
GET and HEAD are served on the live/ready routes; other methods on those routes return **405** with `Allow: GET, HEAD`. Unknown paths return **404**.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// app/healthz/live/route.ts (Next.js App Router)
|
|
69
|
+
import { createHealthKit, toFetchResponse } from "healthzkit";
|
|
70
|
+
|
|
71
|
+
const kit = createHealthKit({
|
|
72
|
+
checks: [
|
|
30
73
|
{
|
|
31
74
|
name: "process",
|
|
32
|
-
type: ["liveness"],
|
|
75
|
+
type: ["liveness", "readiness"],
|
|
33
76
|
adapter: { check: async () => ({ status: "ok" }) },
|
|
34
77
|
},
|
|
35
78
|
],
|
|
36
79
|
});
|
|
37
80
|
|
|
38
|
-
|
|
81
|
+
export const dynamic = "force-dynamic";
|
|
82
|
+
export const GET = async () => toFetchResponse(await kit.handleLiveness());
|
|
83
|
+
export const HEAD = async () => toFetchResponse(await kit.handleLiveness(), "HEAD");
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Express
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import express from "express";
|
|
90
|
+
import { createHealthKit } from "healthzkit";
|
|
91
|
+
|
|
92
|
+
const kit = createHealthKit({
|
|
93
|
+
checks: [
|
|
94
|
+
{
|
|
95
|
+
name: "process",
|
|
96
|
+
type: ["liveness", "readiness"],
|
|
97
|
+
adapter: { check: async () => ({ status: "ok" }) },
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const app = express();
|
|
103
|
+
app.get("/healthz/live", async (_req, res) => {
|
|
104
|
+
const out = await kit.handleLiveness();
|
|
105
|
+
res.status(out.status).set(out.headers).send(out.body);
|
|
106
|
+
});
|
|
107
|
+
app.get("/healthz/ready", async (_req, res) => {
|
|
108
|
+
const out = await kit.handleReadiness();
|
|
109
|
+
res.status(out.status).set(out.headers).send(out.body);
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
More frameworks: [healthzkit.dev/guide/frameworks](https://healthzkit.dev/guide/frameworks).
|
|
114
|
+
|
|
115
|
+
## `handleRequest`
|
|
116
|
+
|
|
117
|
+
If you already have a router, pass the incoming path:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
39
120
|
const res = await kit.handleRequest({ path: "/healthz/ready", method: "GET" });
|
|
40
121
|
if (res) {
|
|
41
122
|
// res.status, res.headers, res.body
|
|
42
123
|
}
|
|
43
124
|
```
|
|
44
125
|
|
|
45
|
-
|
|
126
|
+
`handleRequest` returns `null` when the path is not a probe route, so you can try it first and fall through.
|
|
46
127
|
|
|
47
128
|
## Routes and `basePath`
|
|
48
129
|
|
|
49
|
-
By default, **healthzkit** expects:
|
|
50
|
-
|
|
51
130
|
| Path | Behavior |
|
|
52
131
|
| ------------------ | ----------------------------------------------- |
|
|
53
132
|
| `{basePath}/live` | Runs checks whose `type` includes `"liveness"` |
|
|
@@ -55,8 +134,6 @@ By default, **healthzkit** expects:
|
|
|
55
134
|
|
|
56
135
|
Default `basePath` is `/healthz`. Override with `basePath` in config (e.g. `/api/health` → `/api/health/live`).
|
|
57
136
|
|
|
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
137
|
## Checks and adapters
|
|
61
138
|
|
|
62
139
|
Each check is a `CheckConfig`:
|
|
@@ -77,9 +154,11 @@ Each check is a `CheckConfig`:
|
|
|
77
154
|
|
|
78
155
|
Thrown errors from `adapter.check()` are treated as **`fail`** with the error message captured when `exposeError` is true.
|
|
79
156
|
|
|
157
|
+
Official adapters (Postgres, Redis, Kafka, S3, Prisma, Drizzle, and more) live in [`@healthzkit/*` packages](https://healthzkit.dev).
|
|
158
|
+
|
|
80
159
|
## Scheduling
|
|
81
160
|
|
|
82
|
-
For expensive checks (database, external APIs),
|
|
161
|
+
For expensive checks (database, external APIs), run them on a timer and serve probes from cache:
|
|
83
162
|
|
|
84
163
|
```ts
|
|
85
164
|
const kit = createHealthKit({
|
|
@@ -144,6 +223,8 @@ Exported from `healthzkit`:
|
|
|
144
223
|
|
|
145
224
|
- `createHealthKit(config)` → `HealthKit`
|
|
146
225
|
- `HealthKit`: `start()`, `stop()`, `handleRequest(req)`, `handleLiveness()`, `handleReadiness()`
|
|
226
|
+
- `toFetchResponse(res, method?)` → Fetch `Response` (`HEAD` omits the body)
|
|
227
|
+
- `createFetchHandler(kit)` → `(request: Request) => Promise<Response>`
|
|
147
228
|
|
|
148
229
|
**Types**
|
|
149
230
|
|
package/dist/index.d.mts
CHANGED
|
@@ -92,10 +92,16 @@ declare class HealthKit {
|
|
|
92
92
|
stop(): void;
|
|
93
93
|
handleLiveness(): Promise<AgnosticResponse>;
|
|
94
94
|
handleReadiness(): Promise<AgnosticResponse>;
|
|
95
|
+
/** True for `{basePath}/live` and `{basePath}/ready`. Does not run checks. */
|
|
96
|
+
matchHealthPath(path: string): boolean;
|
|
95
97
|
handleRequest(req: AgnosticRequest): Promise<AgnosticResponse | null>;
|
|
96
98
|
private runForType;
|
|
97
99
|
private resolveHttpStatus;
|
|
98
100
|
}
|
|
99
101
|
declare function createHealthKit(config: HealthkitConfig): HealthKit;
|
|
100
102
|
//#endregion
|
|
101
|
-
|
|
103
|
+
//#region src/http.d.ts
|
|
104
|
+
declare function toFetchResponse(res: AgnosticResponse, method?: string): Response;
|
|
105
|
+
declare function createFetchHandler(kit: HealthKit): (request: Request) => Promise<Response>;
|
|
106
|
+
//#endregion
|
|
107
|
+
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, createFetchHandler, createHealthKit, toFetchResponse };
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
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})}}}
|
|
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})}}};const l=`/healthz`;var u=class{config;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`)}matchHealthPath(e){let t=this.config.basePath??l;return e===`${t}/live`||e===`${t}/ready`}async handleRequest(e){let t=this.config.basePath??l,{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 d(e){return new u(e)}function f(e,t=`GET`){let n=t.toUpperCase()===`HEAD`?null:e.body;return new Response(n,{status:e.status,headers:e.headers})}const p={allow:`GET, HEAD`};function m(e){return async t=>{let n=t.method.toUpperCase(),r=new URL(t.url).pathname;if(n!==`GET`&&n!==`HEAD`)return e.matchHealthPath(r)?new Response(null,{status:405,headers:p}):new Response(null,{status:404});let i=await e.handleRequest({path:r,method:n});return i?f(i,n):new Response(null,{status:404})}}export{u as HealthKit,m as createFetchHandler,d as createHealthKit,f as toFetchResponse};
|
package/package.json
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "healthzkit",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic Kubernetes liveness and readiness probes for Node.js. Parallel checks, status rollup, HTTP mapping, and optional background scheduling.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"express",
|
|
7
|
+
"health-check",
|
|
8
|
+
"healthcheck",
|
|
9
|
+
"healthz",
|
|
10
|
+
"healthzkit",
|
|
11
|
+
"hono",
|
|
12
|
+
"kubernetes",
|
|
13
|
+
"liveness",
|
|
14
|
+
"nextjs",
|
|
15
|
+
"nodejs",
|
|
16
|
+
"probe",
|
|
17
|
+
"readiness"
|
|
18
|
+
],
|
|
19
|
+
"homepage": "https://healthzkit.dev",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/alasti-company/healthzkit.dev/issues"
|
|
22
|
+
},
|
|
4
23
|
"license": "MIT",
|
|
24
|
+
"author": {
|
|
25
|
+
"name": "Alasti Company",
|
|
26
|
+
"url": "https://healthzkit.dev"
|
|
27
|
+
},
|
|
5
28
|
"repository": {
|
|
6
29
|
"type": "git",
|
|
7
30
|
"url": "https://github.com/alasti-company/healthzkit.dev",
|
|
@@ -26,6 +49,9 @@
|
|
|
26
49
|
"typescript": "^5",
|
|
27
50
|
"vite-plus": "0.3.0"
|
|
28
51
|
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=22.12.0"
|
|
54
|
+
},
|
|
29
55
|
"scripts": {
|
|
30
56
|
"build": "vp pack",
|
|
31
57
|
"dev": "vp pack --watch",
|