@astroscope/health 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 ADDED
@@ -0,0 +1,283 @@
1
+ # @astroscope/health
2
+
3
+ > **Note:** This package is in active development. APIs may change between versions.
4
+
5
+ Kubernetes-style health check endpoints for Astro SSR. Provides `/livez`, `/readyz`, `/startupz`, and `/healthz` probes on a separate HTTP server.
6
+
7
+ ## Examples
8
+
9
+ See the [demo/health](../../demo/health) directory for a working example.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install @astroscope/health
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ This package is designed to work with [@astroscope/boot](../boot) for lifecycle management.
20
+
21
+ ```ts
22
+ // src/boot.ts
23
+ import type { BootContext } from "@astroscope/boot";
24
+ import { checks, probes, server } from "@astroscope/health";
25
+
26
+ export async function onStartup({ dev, host, port }: BootContext) {
27
+ // start health server on a separate port
28
+ server.start({ port: 9090 });
29
+
30
+ // enable liveness immediately
31
+ probes.livez.enable();
32
+
33
+ // initialize your app...
34
+ await connectToDatabase();
35
+
36
+ // register health checks
37
+ checks.register("database", () => db.ping());
38
+
39
+ // enable startup probe (initialization complete)
40
+ probes.startupz.enable();
41
+
42
+ // enable readiness probe (ready for traffic)
43
+ probes.readyz.enable();
44
+ }
45
+
46
+ export async function onShutdown() {
47
+ // disable readiness first (stop receiving traffic)
48
+ probes.readyz.disable();
49
+
50
+ await disconnectFromDatabase();
51
+
52
+ // stop health server
53
+ await server.stop();
54
+ }
55
+ ```
56
+
57
+ ## Probes
58
+
59
+ ### `/livez` — Liveness Probe
60
+
61
+ Indicates if the process is running. If this fails, Kubernetes will restart the container.
62
+
63
+ ```ts
64
+ probes.livez.enable(); // returns 200 OK
65
+ probes.livez.disable(); // returns 503 Service Unavailable
66
+ ```
67
+
68
+ ### `/startupz` — Startup Probe
69
+
70
+ Indicates if the application has finished initializing. Kubernetes waits for this before sending traffic or checking liveness.
71
+
72
+ ```ts
73
+ probes.startupz.enable();
74
+ probes.startupz.disable();
75
+ ```
76
+
77
+ ### `/readyz` — Readiness Probe
78
+
79
+ Indicates if the application is ready to receive traffic. When disabled or when required health checks fail, Kubernetes removes the pod from load balancer rotation.
80
+
81
+ ```ts
82
+ probes.readyz.enable();
83
+ probes.readyz.disable();
84
+ ```
85
+
86
+ The readiness probe automatically runs all non-optional health checks and returns 503 if any fail.
87
+
88
+ ### `/healthz` — Health Status
89
+
90
+ Returns detailed JSON status of all probes and health checks. Useful for debugging and dashboards.
91
+
92
+ ```json
93
+ {
94
+ "status": "healthy",
95
+ "probes": {
96
+ "livez": true,
97
+ "startupz": true,
98
+ "readyz": true
99
+ },
100
+ "checks": {
101
+ "database": {
102
+ "status": "healthy",
103
+ "latency": 12
104
+ },
105
+ "redis": {
106
+ "status": "unhealthy",
107
+ "latency": 5003,
108
+ "error": "check \"redis\" timed out after 5000ms"
109
+ }
110
+ }
111
+ }
112
+ ```
113
+
114
+ Status values:
115
+ - `healthy` — all checks pass
116
+ - `degraded` — optional checks failing, required checks pass
117
+ - `unhealthy` — required checks failing
118
+
119
+ ## Health Checks
120
+
121
+ Register health checks to verify dependencies are working:
122
+
123
+ ```ts
124
+ import { checks } from "@astroscope/health";
125
+
126
+ // return result (recommended for boolean checks)
127
+ checks.register("database", () => ({
128
+ status: db.isConnected() ? "healthy" : "unhealthy",
129
+ error: db.isConnected() ? undefined : "connection lost",
130
+ }));
131
+
132
+ // throw-based (classic pattern)
133
+ checks.register("cache", async () => {
134
+ await redis.ping(); // throws if fails
135
+ });
136
+
137
+ // with options
138
+ checks.register({
139
+ name: "external-api",
140
+ check: () => fetch("https://api.example.com/health").then(() => {}),
141
+ optional: true, // doesn't affect /readyz, only /healthz status
142
+ timeout: 10000, // custom timeout (default: 5000ms)
143
+ });
144
+ ```
145
+
146
+ The check function can either:
147
+ - Return `HealthCheckResult` with status and optional error
148
+ - Return `void` (completing without error = healthy)
149
+ - Throw an error (= unhealthy with error message)
150
+
151
+ ### Unregistering Checks
152
+
153
+ `register()` returns an unregister function:
154
+
155
+ ```ts
156
+ const unregister = checks.register({
157
+ name: "database",
158
+ check: () => db.ping(),
159
+ });
160
+
161
+ // later...
162
+ unregister();
163
+ ```
164
+
165
+ ## Server Options
166
+
167
+ ```ts
168
+ server.start({
169
+ host: "0.0.0.0", // default: "localhost"
170
+ port: 9090, // default: 9090
171
+ });
172
+ ```
173
+
174
+ ## Kubernetes Configuration
175
+
176
+ ```yaml
177
+ apiVersion: v1
178
+ kind: Pod
179
+ spec:
180
+ containers:
181
+ - name: app
182
+ ports:
183
+ - containerPort: 4321 # astro
184
+ - containerPort: 9090 # health
185
+ livenessProbe:
186
+ httpGet:
187
+ path: /livez
188
+ port: 9090
189
+ initialDelaySeconds: 0
190
+ periodSeconds: 10
191
+ startupProbe:
192
+ httpGet:
193
+ path: /startupz
194
+ port: 9090
195
+ failureThreshold: 30
196
+ periodSeconds: 2
197
+ readinessProbe:
198
+ httpGet:
199
+ path: /readyz
200
+ port: 9090
201
+ periodSeconds: 5
202
+ ```
203
+
204
+ ## API Reference
205
+
206
+ ### Server
207
+
208
+ ```ts
209
+ import { server } from "@astroscope/health";
210
+
211
+ server.start(options?: HealthServerOptions): void;
212
+ server.stop(): Promise<void>;
213
+ ```
214
+
215
+ ### Probes
216
+
217
+ ```ts
218
+ import { probes } from "@astroscope/health";
219
+
220
+ probes.livez.enable(): void;
221
+ probes.livez.disable(): void;
222
+ probes.livez.get(): Promise<HealthProbeResult>;
223
+ probes.livez.response(): Promise<Response>;
224
+
225
+ // same for startupz, readyz
226
+
227
+ probes.healthz.get(): Promise<HealthzResult>;
228
+ probes.healthz.response(): Promise<Response>;
229
+ ```
230
+
231
+ ### Checks
232
+
233
+ ```ts
234
+ import { checks } from "@astroscope/health";
235
+
236
+ checks.register(name: string, check: CheckFn): () => void;
237
+ checks.register(check: HealthCheck): () => void;
238
+
239
+ // CheckFn = () => Promise<HealthCheckResult | void> | HealthCheckResult | void
240
+ checks.getChecks(): HealthCheck[];
241
+ checks.runAll(): Promise<Record<string, HealthCheckResult>>;
242
+ checks.runRequired(): Promise<boolean>;
243
+ ```
244
+
245
+ ## Types
246
+
247
+ ```ts
248
+ interface HealthCheck {
249
+ name: string;
250
+ check: () => Promise<HealthCheckResult | void> | HealthCheckResult | void;
251
+ optional?: boolean; // default: false
252
+ timeout?: number; // default: 5000
253
+ }
254
+
255
+ interface HealthCheckResult {
256
+ status: "healthy" | "unhealthy";
257
+ latency?: number;
258
+ error?: string;
259
+ }
260
+
261
+ interface HealthProbeResult {
262
+ passing: boolean;
263
+ }
264
+
265
+ interface HealthzResult {
266
+ status: "healthy" | "degraded" | "unhealthy";
267
+ probes: {
268
+ livez: boolean;
269
+ startupz: boolean;
270
+ readyz: boolean;
271
+ };
272
+ checks: Record<string, HealthCheckResult>;
273
+ }
274
+
275
+ interface HealthServerOptions {
276
+ host?: string; // default: "localhost"
277
+ port?: number; // default: 9090
278
+ }
279
+ ```
280
+
281
+ ## License
282
+
283
+ MIT
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Server configuration options.
3
+ */
4
+ interface HealthServerOptions {
5
+ /**
6
+ * Host to bind the health server to.
7
+ * @default 'localhost'
8
+ */
9
+ host?: string | undefined;
10
+ /**
11
+ * Port to bind the health server to.
12
+ * @default 9090
13
+ */
14
+ port?: number | undefined;
15
+ }
16
+ /**
17
+ * Health check definition.
18
+ */
19
+ interface HealthCheck {
20
+ /**
21
+ * Unique name for this health check.
22
+ */
23
+ name: string;
24
+ /**
25
+ * Function that performs the health check.
26
+ * Return HealthCheckResult or throw an error to indicate status.
27
+ * Returning void or completing without error means healthy.
28
+ */
29
+ check: () => Promise<HealthCheckResult | void> | HealthCheckResult | void;
30
+ /**
31
+ * Whether this check is optional.
32
+ * Optional checks don't affect the /readyz endpoint.
33
+ * @default false
34
+ */
35
+ optional?: boolean | undefined;
36
+ /**
37
+ * Maximum time in ms for the check to complete.
38
+ * @default 5000
39
+ */
40
+ timeout?: number | undefined;
41
+ }
42
+ /**
43
+ * Result of a single health check.
44
+ */
45
+ interface HealthCheckResult {
46
+ status: 'healthy' | 'unhealthy';
47
+ latency?: number | undefined;
48
+ error?: string | undefined;
49
+ }
50
+ /**
51
+ * Result of a probe query.
52
+ */
53
+ interface HealthProbeResult {
54
+ passing: boolean;
55
+ }
56
+ /**
57
+ * Full health status including all checks.
58
+ */
59
+ interface HealthzResult {
60
+ status: 'healthy' | 'degraded' | 'unhealthy';
61
+ probes: {
62
+ livez: boolean;
63
+ startupz: boolean;
64
+ readyz: boolean;
65
+ };
66
+ checks: Record<string, HealthCheckResult>;
67
+ }
68
+ /**
69
+ * Single probe interface.
70
+ */
71
+ interface HealthProbe {
72
+ /**
73
+ * Enable this probe (will return 200 when called).
74
+ */
75
+ enable(): void;
76
+ /**
77
+ * Disable this probe (will return 503 when called).
78
+ */
79
+ disable(): void;
80
+ /**
81
+ * Get the current probe result.
82
+ */
83
+ get(): Promise<HealthProbeResult>;
84
+ /**
85
+ * Get a Response object for this probe.
86
+ */
87
+ response(): Promise<Response>;
88
+ }
89
+ /**
90
+ * Healthz probe interface (always returns data, used for debugging).
91
+ */
92
+ interface HealthzProbe {
93
+ /**
94
+ * Get the full health status.
95
+ */
96
+ get(): Promise<HealthzResult>;
97
+ /**
98
+ * Get a Response object with JSON health data.
99
+ */
100
+ response(): Promise<Response>;
101
+ }
102
+
103
+ /**
104
+ * Manages the HTTP server for health endpoints.
105
+ */
106
+ declare class HealthServer {
107
+ private instance;
108
+ /**
109
+ * Start the health check HTTP server.
110
+ */
111
+ start(options?: HealthServerOptions): void;
112
+ /**
113
+ * Stop the health check HTTP server.
114
+ */
115
+ stop(): Promise<void>;
116
+ private handleRequest;
117
+ }
118
+ declare const server: HealthServer;
119
+
120
+ /**
121
+ * Manages probe state and provides probe endpoints.
122
+ */
123
+ declare class HealthProbes {
124
+ private state;
125
+ private createProbeResponse;
126
+ readonly livez: HealthProbe;
127
+ readonly startupz: HealthProbe;
128
+ readonly readyz: HealthProbe;
129
+ readonly healthz: HealthzProbe;
130
+ }
131
+ declare const probes: HealthProbes;
132
+
133
+ /**
134
+ * Manages health check registration and execution.
135
+ */
136
+ declare class HealthChecks {
137
+ private readonly registered;
138
+ /**
139
+ * Register a health check.
140
+ * Returns an unregister function.
141
+ */
142
+ register(name: string, check: () => Promise<HealthCheckResult | void> | HealthCheckResult | void): () => void;
143
+ register(check: HealthCheck): () => void;
144
+ /**
145
+ * Run a single check with timeout.
146
+ */
147
+ private run;
148
+ /**
149
+ * Run all registered health checks.
150
+ */
151
+ runAll(): Promise<Record<string, HealthCheckResult>>;
152
+ /**
153
+ * Run only required checks and return whether they all pass.
154
+ */
155
+ runRequired(): Promise<boolean>;
156
+ /**
157
+ * Get all registered checks.
158
+ */
159
+ getChecks(): HealthCheck[];
160
+ }
161
+ declare const checks: HealthChecks;
162
+
163
+ export { type HealthCheck, type HealthCheckResult, HealthChecks, type HealthProbe, type HealthProbeResult, HealthProbes, HealthServer, type HealthServerOptions, type HealthzProbe, type HealthzResult, checks, probes, server };
package/dist/index.js ADDED
@@ -0,0 +1,236 @@
1
+ // src/server.ts
2
+ import { createServer } from "http";
3
+
4
+ // src/checks.ts
5
+ var DEFAULT_TIMEOUT = 5e3;
6
+ var HealthChecks = class {
7
+ registered = /* @__PURE__ */ new Map();
8
+ register(nameOrCheck, checkFn) {
9
+ const check = typeof nameOrCheck === "string" ? { name: nameOrCheck, check: checkFn } : nameOrCheck;
10
+ if (this.registered.has(check.name)) {
11
+ console.warn(`[health] overwriting existing check "${check.name}"`);
12
+ }
13
+ this.registered.set(check.name, check);
14
+ return () => {
15
+ this.registered.delete(check.name);
16
+ };
17
+ }
18
+ /**
19
+ * Run a single check with timeout.
20
+ */
21
+ async run(check) {
22
+ const timeout = check.timeout ?? DEFAULT_TIMEOUT;
23
+ const start = performance.now();
24
+ try {
25
+ const result = await Promise.race([
26
+ Promise.resolve(check.check()),
27
+ new Promise((_, reject) => {
28
+ setTimeout(() => reject(new Error(`check "${check.name}" timed out after ${timeout}ms`)), timeout);
29
+ })
30
+ ]);
31
+ const latency = Math.round(performance.now() - start);
32
+ if (result) {
33
+ return { ...result, latency };
34
+ }
35
+ return { status: "healthy", latency };
36
+ } catch (error) {
37
+ const latency = Math.round(performance.now() - start);
38
+ const errorMessage = error instanceof Error ? error.message : String(error);
39
+ return { status: "unhealthy", latency, error: errorMessage };
40
+ }
41
+ }
42
+ /**
43
+ * Run all registered health checks.
44
+ */
45
+ async runAll() {
46
+ const results = {};
47
+ const checks2 = [...this.registered.values()];
48
+ const checkPromises = checks2.map(async (check) => {
49
+ const result = await this.run(check);
50
+ results[check.name] = result;
51
+ });
52
+ await Promise.all(checkPromises);
53
+ return results;
54
+ }
55
+ /**
56
+ * Run only required checks and return whether they all pass.
57
+ */
58
+ async runRequired() {
59
+ const requiredChecks = [...this.registered.values()].filter((check) => !check.optional);
60
+ if (requiredChecks.length === 0) {
61
+ return true;
62
+ }
63
+ const checkPromises = requiredChecks.map(async (check) => {
64
+ const result = await this.run(check);
65
+ return result.status === "healthy";
66
+ });
67
+ const results = await Promise.all(checkPromises);
68
+ return results.every(Boolean);
69
+ }
70
+ /**
71
+ * Get all registered checks.
72
+ */
73
+ getChecks() {
74
+ return [...this.registered.values()];
75
+ }
76
+ };
77
+ var checks = new HealthChecks();
78
+
79
+ // src/probes.ts
80
+ var HealthProbes = class {
81
+ state = {
82
+ livez: false,
83
+ startupz: false,
84
+ readyz: false
85
+ };
86
+ createProbeResponse(result) {
87
+ return new Response(result.passing ? "OK" : "Service Unavailable", {
88
+ status: result.passing ? 200 : 503,
89
+ headers: { "Content-Type": "text/plain" }
90
+ });
91
+ }
92
+ livez = {
93
+ enable: () => {
94
+ this.state.livez = true;
95
+ },
96
+ disable: () => {
97
+ this.state.livez = false;
98
+ },
99
+ get: async () => {
100
+ return { passing: this.state.livez };
101
+ },
102
+ response: async () => {
103
+ return this.createProbeResponse(await this.livez.get());
104
+ }
105
+ };
106
+ startupz = {
107
+ enable: () => {
108
+ this.state.startupz = true;
109
+ },
110
+ disable: () => {
111
+ this.state.startupz = false;
112
+ },
113
+ get: async () => {
114
+ return { passing: this.state.startupz };
115
+ },
116
+ response: async () => {
117
+ return this.createProbeResponse(await this.startupz.get());
118
+ }
119
+ };
120
+ readyz = {
121
+ enable: () => {
122
+ this.state.readyz = true;
123
+ },
124
+ disable: () => {
125
+ this.state.readyz = false;
126
+ },
127
+ get: async () => {
128
+ return { passing: this.state.readyz ? await checks.runRequired() : false };
129
+ },
130
+ response: async () => {
131
+ return this.createProbeResponse(await this.readyz.get());
132
+ }
133
+ };
134
+ healthz = {
135
+ get: async () => {
136
+ const checkResults = await checks.runAll();
137
+ const registered = checks.getChecks();
138
+ const hasUnhealthy = Object.values(checkResults).some((c) => c.status === "unhealthy");
139
+ const hasRequiredUnhealthy = registered.filter((check) => !check.optional).some((check) => checkResults[check.name]?.status !== "healthy");
140
+ let status = "healthy";
141
+ if (hasRequiredUnhealthy) {
142
+ status = "unhealthy";
143
+ } else if (hasUnhealthy) {
144
+ status = "degraded";
145
+ }
146
+ return {
147
+ status,
148
+ probes: { livez: this.state.livez, startupz: this.state.startupz, readyz: this.state.readyz },
149
+ checks: checkResults
150
+ };
151
+ },
152
+ response: async () => {
153
+ const result = await this.healthz.get();
154
+ return new Response(JSON.stringify(result, null, 2), {
155
+ status: 200,
156
+ headers: { "Content-Type": "application/json" }
157
+ });
158
+ }
159
+ };
160
+ };
161
+ var probes = new HealthProbes();
162
+
163
+ // src/server.ts
164
+ var HealthServer = class {
165
+ instance = null;
166
+ /**
167
+ * Start the health check HTTP server.
168
+ */
169
+ start(options = {}) {
170
+ if (this.instance) {
171
+ return;
172
+ }
173
+ const host = options.host ?? "localhost";
174
+ const port = options.port ?? 9090;
175
+ const instance = createServer(async (req, res) => {
176
+ try {
177
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
178
+ const response = await this.handleRequest(url.pathname);
179
+ res.statusCode = response.status;
180
+ for (const [key, value] of response.headers.entries()) {
181
+ res.setHeader(key, value);
182
+ }
183
+ const body = await response.text();
184
+ res.end(body);
185
+ } catch {
186
+ res.statusCode = 500;
187
+ res.setHeader("Content-Type", "text/plain");
188
+ res.end("Internal Server Error");
189
+ }
190
+ });
191
+ instance.listen(port, host);
192
+ this.instance = instance;
193
+ }
194
+ /**
195
+ * Stop the health check HTTP server.
196
+ */
197
+ stop() {
198
+ return new Promise((resolve, reject) => {
199
+ if (!this.instance) {
200
+ resolve();
201
+ return;
202
+ }
203
+ this.instance.close((err) => {
204
+ if (err) {
205
+ reject(err);
206
+ } else {
207
+ this.instance = null;
208
+ resolve();
209
+ }
210
+ });
211
+ });
212
+ }
213
+ async handleRequest(pathname) {
214
+ switch (pathname) {
215
+ case "/livez":
216
+ return probes.livez.response();
217
+ case "/startupz":
218
+ return probes.startupz.response();
219
+ case "/readyz":
220
+ return probes.readyz.response();
221
+ case "/healthz":
222
+ return probes.healthz.response();
223
+ default:
224
+ return new Response("Not Found", { status: 404 });
225
+ }
226
+ }
227
+ };
228
+ var server = new HealthServer();
229
+ export {
230
+ HealthChecks,
231
+ HealthProbes,
232
+ HealthServer,
233
+ checks,
234
+ probes,
235
+ server
236
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@astroscope/health",
3
+ "version": "0.1.0",
4
+ "description": "Health check endpoints for Astro — livez, readyz, startupz probes",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "sideEffects": false,
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/smnbbrv/astroscope.git",
24
+ "directory": "packages/health"
25
+ },
26
+ "keywords": [
27
+ "astro",
28
+ "astro-integration",
29
+ "health",
30
+ "healthcheck",
31
+ "probes",
32
+ "readiness",
33
+ "liveness",
34
+ "kubernetes",
35
+ "k8s"
36
+ ],
37
+ "author": "smnbbrv",
38
+ "license": "MIT",
39
+ "bugs": {
40
+ "url": "https://github.com/smnbbrv/astroscope/issues"
41
+ },
42
+ "homepage": "https://github.com/smnbbrv/astroscope/tree/main/packages/health#readme",
43
+ "scripts": {
44
+ "build": "tsup src/index.ts --format esm --dts",
45
+ "typecheck": "tsc --noEmit",
46
+ "lint": "eslint 'src/**/*.ts'",
47
+ "lint:fix": "eslint 'src/**/*.ts' --fix"
48
+ },
49
+ "devDependencies": {
50
+ "tsup": "^8.5.1",
51
+ "typescript": "^5.9.3"
52
+ }
53
+ }