Heximon Logo
Capabilities

Health & Readiness

Liveness and readiness probes with HealthIndicator, HealthRegistry, and an app-owned controller — DatabaseHealthIndicator, per-indicator timeout, id-keyed results.

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

Add HealthPlugin alongside the HTTP plugin in your heximon.config.ts config file:

vite.config.ts
import heximon from "@heximon/build/vite";
import { defineConfig } from "vite-plus";

export default defineConfig({ plugins: [heximon()] });
heximon.config.ts
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:

src/health/health.controller.ts
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.

Liveness never awaits the boot promise.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.)

ValueHTTP status from your controllerMeaning
HealthStatus.Up200The subsystem is healthy.
HealthStatus.Degraded200Partial degradation — still serving.
HealthStatus.Down503The 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:

src/health/always-up-indicator.ts
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:

packages/app/src/health/health.module.ts
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():

src/health/database-indicator.ts
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:

terminal
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:

src/health/health.module.ts
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-owned HealthController, and an in-process test that verifies both the liveness and readiness probes.
  • Packages overview — where @heximon/health sits in the stack.
  • Database — the Database token that DatabaseHealthIndicator exercises.
  • Key-value storage — the Storage token that StorageHealthIndicator exercises.
Copyright © 2026