Health & Readiness
Container orchestrators need two different signals: is the process alive at all, and is it ready to handle
traffic? @heximon/health discovers your HealthIndicator classes, aggregates them into an injectable
HealthRegistry, and leaves the HTTP surface entirely to you. You expose the probes by injecting
HealthRegistry into your own controller — giving you full control over paths, status codes, and response
shapes with ordinary HTTP code.
Install and register the plugin
pnpm add @heximon/health
npm install @heximon/health
yarn add @heximon/health
bun add @heximon/health
Add HealthPlugin alongside the HTTP plugin in your heximon.config.ts config file:
import heximon from "@heximon/build/vite";
import { defineConfig } from "vite-plus";
export default defineConfig({ plugins: [heximon()] });
import { defineHeximonConfig } from "@heximon/build";
import { HttpPlugin } from "@heximon/http/compiler";
import { HealthPlugin } from "@heximon/health/compiler";
export default defineHeximonConfig({
plugins: [new HttpPlugin(), new HealthPlugin()],
});
The app-owned controller
@heximon/health mounts no routes. It discovers your indicators and provides the injectable
HealthRegistry as a core provider. You expose the probes by injecting the registry into a plain
controller listed under http: { controllers }.
The controller from the flagship example shows the full pattern:
import { type AggregatedHealthResult, HealthRegistry } from "@heximon/health";
import type { Controller, Get } from "@heximon/http";
/**
* The app-owned HTTP surface for the health probes. `@heximon/health` mounts nothing itself — it only
* provides the injectable {@link HealthRegistry}. This controller injects the registry and exposes the
* two conventional endpoints, owning their paths, status codes, and response shapes:
*
* - `GET /health` — liveness: responds `200 { status: "up" }` immediately. No indicator is run and no
* boot promise is awaited, so it is safe for a container liveness probe that fires before the app is
* ready.
* - `GET /ready` — readiness: runs every registered indicator via {@link HealthRegistry.checkAll}, then
* reports `200` when up or degraded and `503` when any indicator is down.
*/
export class HealthController implements Controller {
/** @param health - The health registry, resolved by class identity (a core provider). */
public constructor(private readonly health: HealthRegistry) {}
/**
* `GET /health` — liveness. Returns immediately without running any indicator.
*
* @returns A constant `{ status: "up" }` payload.
*/
public live(_action: Get<"/health">): { status: string } {
return { status: "up" };
}
/**
* `GET /ready` — readiness. Aggregates every indicator and maps the overall status to an HTTP code.
*
* @param action - The request action (used to set a `503` status when the aggregate is down).
* @returns The aggregated health result (overall status + per-indicator breakdown).
*/
public async ready(action: Get<"/ready">): Promise<AggregatedHealthResult> {
const result = await this.health.checkAll();
if (result.status === "down") {
action.response.status = 503;
}
return result;
}
}
The app owns the paths, verbs, status codes, and response shape — rename the endpoints, put readiness behind auth, or fold the probes into an existing controller, all with plain HTTP code.
GET /health returns immediately even if the app is still
booting — that is intentional. A liveness probe that fires during a slow boot must not trigger a pod
restart; the readiness probe is the correct gate for "is the app done booting and healthy enough to
receive traffic." These two probes intentionally answer different questions.Write a health indicator
Implement HealthIndicator — declare both id and check(). The id is the key your indicator's
result appears under in the aggregated checks map — define it as an explicit source string so it
stays meaningful in production (unlike a class name, it is not rewritten by a minifier). (extends HealthIndicator binds identically — use it when you want your own base-class hierarchy on top of an
indicator.)
| Value | HTTP status from your controller | Meaning |
|---|---|---|
HealthStatus.Up | 200 | The subsystem is healthy. |
HealthStatus.Degraded | 200 | Partial degradation — still serving. |
HealthStatus.Down | 503 | The subsystem is unavailable. |
Degraded still returns 200 because a degraded system is operational and should receive traffic. Down
returns 503 so an orchestrator's readiness gate stops routing to it.
Here's an illustrative custom indicator — one that always reports Up, useful as the shape to copy when
wrapping a subsystem that has no shipped indicator:
import { HealthStatus, type HealthIndicator, type HealthStatusValue } from "@heximon/health";
/** A minimal custom indicator that always reports `Up` — the shape to copy for your own subsystem. */
export class AlwaysUpIndicator implements HealthIndicator {
/** The key this indicator's result appears under in the aggregated `checks` map. */
public readonly id: string = "always-up";
/**
* Always returns `Up` — this indicator represents a subsystem that is always healthy in this example.
*
* @returns A resolved `Up` status.
*/
public async check(): Promise<HealthStatusValue> {
return HealthStatus.Up;
}
}
Register indicators and the controller
List indicator classes under health: { indicators } and the controller under http: { controllers } in
the same module. The compiler discovers all listed indicators and wires them into HealthRegistry. This
is the flagship app's actual health module, listing the two shipped indicators directly — no local
wrapper:
import { Module } from "@heximon/runtime";
import { DatabaseHealthIndicator } from "@heximon/health/database";
import { StorageHealthIndicator } from "@heximon/health/storage";
import { Storage } from "@heximon/kv";
import { Database } from "@heximon/persistence";
import { HealthController } from "./health.controller";
/**
* The health-and-readiness module — the app-owned `/health` (liveness) + `/ready` (readiness) HTTP surface plus
* the two probes it aggregates. `@heximon/health` mounts no routes itself; it only provides the injectable
* `HealthRegistry`. This module lists the {@link HealthController} (which injects that registry and owns the
* endpoint paths/status codes) and the two shipped indicators under `health: { indicators }`:
*
* - {@link DatabaseHealthIndicator} — pings the pool via an empty transaction (injects the abstract `Database`).
* - {@link StorageHealthIndicator} — round-trips a sentinel key (injects the abstract `Storage`).
*
* Both indicators inject abstract platform-seam ports the consuming app supplies, so this module surfaces
* `Database` and `Storage` as required dependencies the host routes.
*/
export class HealthModule extends Module({
requires: [Database, Storage],
http: { controllers: [HealthController] },
health: { indicators: [DatabaseHealthIndicator, StorageHealthIndicator] },
}) {}
A module mixing in your own indicator (like AlwaysUpIndicator above) alongside a shipped one just adds
it to the same indicators array — health: { indicators: [AlwaysUpIndicator, DatabaseHealthIndicator] }.
Using the built-in DatabaseHealthIndicator
The built-in DatabaseHealthIndicator exercises the active Database by running an empty transaction — a
dialect-agnostic pool ping. It ships with id = "database". Import it from the @heximon/health/database
subpath (not the package root): it injects @heximon/persistence's Database, so persistence is an
optional peer dependency that only an app using this probe pulls in.
List it directly under
health: { indicators } — no local wrapper. The compiler reads the shipped indicator's type declarations,
recovers its (Database, Context) constructor, and wires both from your DI graph.
Your Database typically reaches the abstract Database token only through its dialect base classes
(class Database extends DrizzleLibSQLDatabase → … → @heximon/persistence). That's fine: the compiler
follows the cross-package extends/implements chain to bind your Database as the indicator's
dependency — even when the provider lives in a different module you import.
Customizing a shipped indicator
Direct listing is the default. To add behavior around a shipped probe — extra logging, a degraded
threshold, combining two checks — write a thin local class that implements HealthIndicator and delegates
to a constructed instance. The local class owns its own implements HealthIndicator declaration (its own
DI identity), defines its own id, and customizes check():
import { Context } from "@heximon/runtime";
import type { HealthIndicator, HealthStatusValue } from "@heximon/health";
import { DatabaseHealthIndicator } from "@heximon/health/database";
import { Database } from "../database/database";
export class AppDatabaseHealthIndicator implements HealthIndicator {
public readonly id: string = "database";
/** The shipped indicator this class delegates the actual database ping to. */
private readonly delegate: DatabaseHealthIndicator;
public constructor(database: Database, context: Context) {
this.delegate = new DatabaseHealthIndicator(database, context);
}
public async check(): Promise<HealthStatusValue> {
// Add your own logic around the shipped probe here.
return this.delegate.check();
}
}
List the local class instead of (or alongside) the shipped one. Because its constructor reads from app
source, its Database dependency is resolved the same way — by class identity.
Inject HealthRegistry directly
HealthRegistry is always emitted as a core provider — even with zero indicators — so it is injectable
without guarding. Call checkAll() to run all indicators programmatically, useful for admin dashboards or
pre-flight checks:
export class StatusService {
public constructor(private readonly healthRegistry: HealthRegistry) {}
public async getStatus() {
const result = await this.healthRegistry.checkAll();
// result.status — "up" | "degraded" | "down"
// result.checks — { [indicatorId]: { status, message? } }
return result;
}
}
checkAll() accepts an optional timeout argument (a TimeSpan; default: 5 seconds). A hanging or throwing indicator
is treated as Down after the timeout — a permanently-hung check should not block traffic routing
indefinitely.
Probe responses
A /ready response — with an AlwaysUpIndicator (id "always-up") and a DatabaseHealthIndicator
(id "database") registered — looks like:
curl http://localhost:3000/health
# { "status": "up" }
curl http://localhost:3000/ready
# {
# "status": "up",
# "checks": {
# "always-up": { "status": "up" },
# "database": { "status": "up" }
# }
# }
The checks keys are each indicator's id — an explicit source string that, unlike the class name,
survives minification and stays meaningful in a production probe response.
Zero-indicator behavior
When no indicators are registered, HealthRegistry is still provided (always reports Up with an empty
checks map). No HTTP routes are contributed — the health module you import provides only the registry;
your controller decides whether to mount probe endpoints at all.
Using the built-in StorageHealthIndicator
The built-in StorageHealthIndicator probes any Storage backend with a lightweight write + read
round-trip. It writes a sentinel key (TTL 60 s, so it expires automatically) and reads it back via
has() — a backend-agnostic connectivity check that exercises the real read/write path and catches
misconfigured credentials, closed connections, and network failures. It ships with id = "storage".
Import it from the @heximon/health/storage subpath, not the package root: it injects
@heximon/kv's Storage, so @heximon/kv is an optional peer dependency that only an app
using this probe pulls in. List it directly under health: { indicators } — no local wrapper:
import { Module } from "@heximon/runtime";
import { StorageHealthIndicator } from "@heximon/health/storage";
import { KvModule } from "../kv/kv.module";
export class HealthModule extends Module({
imports: [KvModule],
health: {
indicators: [StorageHealthIndicator],
},
}) {}
The indicator's Storage dependency is satisfied by any concrete Storage subclass your module
provides — MemoryStorage, RedisStorage, UpstashStorage, CloudflareKvStorage, or a custom
adapter. The probe is backend-agnostic: the same indicator works across all Storage backends
without any per-backend configuration.
See also
- Flagship Health / Readiness
— the built-in
DatabaseHealthIndicator+StorageHealthIndicator, an app-ownedHealthController, and an in-process test that verifies both the liveness and readiness probes. - Packages overview — where
@heximon/healthsits in the stack. - Database — the
Databasetoken thatDatabaseHealthIndicatorexercises. - Key-value storage — the
Storagetoken thatStorageHealthIndicatorexercises.
Blob Storage
The abstract BlobStore DI token for streamed bodies and byte ranges, the memory/filesystem/R2/S3 drivers, and the SignedUrlBlobStore capability for direct-to-storage signed upload/download URLs.
Notifications
Multi-channel notifications behind one Message / MessageTransport contract — email (Resend, unemail), SMS (Twilio), push (OneSignal, FCM, Web Push), TemplateRenderer, SuppressionService, and the NotificationDispatcher.