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 CHANGED
@@ -1,53 +1,132 @@
1
1
  # healthzkit
2
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.
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
- ## Install
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 (`"type": "module"`). The published entry is `./dist/index.mjs` (see `package.json` `exports`).
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 { createHealthKit } from "healthzkit";
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
- check: async () => {
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
- // Wire into your HTTP server: path + method from the incoming request
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
- Call `kit.handleLiveness()` or `kit.handleReadiness()` directly if you already route those endpoints yourself.
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), you can run them on a timer and serve probes from cache:
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
- 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 };
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})}}},l=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`)}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};
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.4",
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",