openstatusHQ/health

Tree-shakable health endpoints for JS servers. Zero-dependency core, adapters for Hono, Elysia, Express and Next.js, probes for your databases and APIs. Deno, Node, Bun, edge.

6

stars

18

commits

TypeScript

primary language

Sep 12, 2026

updated

www.openstatus.dev
openstatus
uptime
uptime-monitoring

README

@openstatus/health

main JSR npm MIT

Tree-shakable health endpoints for JavaScript servers. A dependency-free core runs probes against your dependencies and renders ok | degraded | unhealthy; thin adapters mount it as GET /health on your framework; thin probe packages know how to ping one dependency each.

Runs on Deno, Node ≥ 22, Bun and edge runtimes. Published to JSR and npm. Built and used in production by openstatus, the open-source uptime monitoring and status page platform.

{
  "status": "degraded",
  "checkedAt": "2026-09-11T12:00:00.000Z",
  "latencyMs": 41,
  "checks": [
    { "name": "database", "status": "ok",      "critical": true,  "latencyMs": 3 },
    { "name": "redis",    "status": "skipped", "critical": false, "latencyMs": 0 },
    { "name": "tinybird", "status": "timeout", "critical": false, "latencyMs": 5000, "error": "timed out after 5000ms" }
  ]
}

Packages

Core

PackageJSRnpmDescription
@openstatus/healthJSRnpmProbe runner, caching, response rendering, Fetch-API handler

Server adapters

PackageJSRnpmDescription
@openstatus/health-honoJSRnpmHono adapter
@openstatus/health-elysiaJSRnpmElysia adapter
@openstatus/health-expressJSRnpmExpress 4 / 5 adapter
@openstatus/health-nextJSRnpmNext.js App Router adapter
@openstatus/health-tanstack-startJSRnpmTanStack Start adapter

Providers

PackageJSRnpmDescription
@openstatus/health-drizzleJSRnpmDrizzle ORM select 1 probe
@openstatus/health-supabaseJSRnpmSupabase connection-pressure probe
@openstatus/health-tinybirdJSRnpmTinybird reachability probe
@openstatus/health-tursoJSRnpmTurso libSQL select 1 probe (@libsql/client)
@openstatus/health-turso-serverlessJSRnpmTurso select 1 probe over the serverless driver (@tursodatabase/serverless)
@openstatus/health-unkeyJSRnpmUnkey liveness probe
@openstatus/health-upstashJSRnpmUpstash Redis PING probe over REST

Hosting

PackageJSRnpmDescription
@openstatus/health-flyJSRnpmFly.io region, machine and deployment
@openstatus/health-koyebJSRnpmKoyeb region, instance and deployment
@openstatus/health-railwayJSRnpmRailway region, replica, environment and deployment
@openstatus/health-vercelJSRnpmVercel region, environment and deployment
@openstatus/health-cloudflareJSRnpmCloudflare Workers colo and version metadata

Each package is its own concern with its own peer dependencies: importing @openstatus/health-hono never pulls Express, and importing @openstatus/health-unkey never pulls Drizzle. CI bundles a one-line consumer of every package and fails if any other framework or client library lands in the output.

Quick start

Install the core plus one adapter and the probes you need:

deno add jsr:@openstatus/health jsr:@openstatus/health-hono jsr:@openstatus/health-turso
npm install @openstatus/health @openstatus/health-hono @openstatus/health-turso

Every adapter exports the same two functions. healthRoute(options) is the batteries-included form: it mounts GET and HEAD on options.path (default /health). healthHandler(options) is the primitive underneath — a plain handler for that framework — for when you want to pick the path, stack your own middleware in front, or register it the way you register everything else.

Hono

import { Hono } from "hono";
import { healthHandler, healthRoute } from "@openstatus/health-hono";
import { tursoProbe } from "@openstatus/health-turso";
import { unkeyProbe } from "@openstatus/health-unkey";

const app = new Hono();

app.route("/", healthRoute({
  probes: [tursoProbe({ client }), unkeyProbe()],
  extend: (_report, c) => ({ requestId: c.get("requestId") }),
}));

// or, on a route of your own:
app.on(["GET", "HEAD"], "/health", healthHandler({ probes: [unkeyProbe()] }));

Elysia

import { Elysia } from "elysia";
import { healthRoute } from "@openstatus/health-elysia";
import { tinybirdProbe } from "@openstatus/health-tinybird";

new Elysia().use(healthRoute({ probes: [tinybirdProbe()] })).listen(3000);

Express

import express from "express";
import { healthRoute } from "@openstatus/health-express";
import { drizzleProbe } from "@openstatus/health-drizzle";

const app = express();
app.use(healthRoute({ probes: [drizzleProbe({ db })] }));

Next.js (App Router)

// app/health/route.ts
import { healthRoute } from "@openstatus/health-next";
import { supabaseProbe } from "@openstatus/health-supabase";

// required: keeps Next.js from statically caching the route
export const dynamic = "force-dynamic";

export const { GET, HEAD } = healthRoute({ probes: [supabaseProbe({ client })] });

TanStack Start

// src/routes/api/health.ts
import { createFileRoute } from "@tanstack/react-router";
import { healthRoute } from "@openstatus/health-tanstack-start";
import { supabaseProbe } from "@openstatus/health-supabase";

export const Route = createFileRoute("/api/health")({
  server: { handlers: healthRoute({ probes: [supabaseProbe({ client })] }) },
});

Anything with a Fetch API (Deno.serve, Bun.serve, Workers)

import { createHealthHandler } from "@openstatus/health";

Deno.serve(createHealthHandler({ path: "/health", probes: [/* ... */] }));

Options

Every entry point takes the same options — probes (or a shared check), path, cacheMs, staleMs, timeoutMs, deadlineMs, exposeChecks, unhealthyStatusCode, degradedStatusCode, extend, formatError, onReport, onError — documented once in packages/health.

Aggregation: a failing or timed-out critical probe makes the report unhealthy; a failing non-critical probe makes it degraded; skipped probes never affect it. Errors are masked as "failed" unless you opt in with formatError: "message".

Liveness, readiness, public and internal

Liveness is "the process answers"; readiness is "the process can serve". Mount the same adapter twice — an empty probe list is always ok:

app.route("/", healthRoute({ path: "/livez", probes: [] }));
app.route("/", healthRoute({ path: "/readyz", probes, deadlineMs: 800, cacheFailuresMs: 0 }));

deadlineMs caps the whole round so a hung dependency cannot outlast a Kubernetes probe's timeoutSeconds; cacheFailuresMs: 0 lets the next poll see a recovery immediately. Set staleMs to keep answering from the last report while a refresh runs in the background.

One /health can serve the load balancer and your on-call engineer: exposeChecks takes a function of the request, and extend output is only rendered when checks are exposed. If you would rather serve two routes, build the check once and share it so the probes run once per cache window:

import { createHealthCheck } from "@openstatus/health";

const check = createHealthCheck({ probes, cacheMs: 5000, onReport: log });

app.route("/", healthRoute({ check, exposeChecks: false }));
app.route("/", healthRoute({ check, path: "/_health", extend: flyExtend() }));
// or, one route:
app.route("/", healthRoute({
  check,
  exposeChecks: (c) => c.req.header("x-health-token") === env.HEALTH_TOKEN,
  extend: flyExtend(),
}));

check.invalidate() drops the cache — call it after a reconnect or a config reload.

Probes

ProbeDefault nameCriticalChecks
tinybirdProbe({ baseUrl? })tinybirdnoGET {baseUrl}/v0/health
unkeyProbe({ baseUrl? })unkeynoGET {baseUrl}/v2/liveness
tursoProbe({ client })databaseyesclient.execute("select 1") on a Turso libSQL client
tursoServerlessProbe({ connection })databaseyesconnection.get("select 1") on a Turso serverless Connection
drizzleProbe({ db })databaseyesdb.execute(sql\select 1`)ordb.run(...)`
supabaseProbe({ client, maxConnectionPercent? })supabasenorpc("health_connection_pressure") ≤ threshold
upstashProbe({ url, token })redisnoGET {url}/ping with the REST token

Every probe factory accepts name, critical, timeoutMs and skip overrides. Probes take a client instance or a base URL — they never read process.env themselves.

Writing your own probe

A probe is a plain object. Resolve for healthy, reject or throw for failed, and honour the AbortSignal so a timeout actually cancels the work:

import { httpProbe, probe } from "@openstatus/health";

const queue = probe({
  name: "queue",
  critical: true,
  timeoutMs: 1000,
  skip: () => !env.QUEUE_URL,
  run: async (signal, ctx) => {
    const res = await fetch(`${env.QUEUE_URL}/depth`, { signal });
    if (!res.ok) throw new Error(`${ctx.name} answered ${res.status}`);
    const { depth } = await res.json();
    if (depth > 10_000) throw new Error(`queue depth ${depth}`);
  },
});

const docs = httpProbe({ name: "docs", url: "https://docs.example.com", method: "HEAD" });

skip runs on every request, may be async, and reports the check as skipped without running it — use it for optional dependencies that are not configured in every environment. ctx carries the probe's name, critical flag and effective timeoutMs.

@openstatus/health/testing exports fakeFetch, hangFetch and ready-made okProbe / failingProbe / hangingProbe fixtures for testing probes and adapters of your own.

Server metadata

The hosting packages answer a different question from the probes: not "is the database up" but "which replica is telling me that". Each reads its platform's own environment — or, on Workers, the request — and renders it under server through the same extend hook:

import { healthRoute } from "@openstatus/health-hono";
import { flyExtend } from "@openstatus/health-fly";

app.route("/", healthRoute({ probes, extend: flyExtend() }));
{
  "status": "ok",
  "checkedAt": "2026-09-11T12:00:00.000Z",
  "latencyMs": 41,
  "checks": [{ "name": "database", "status": "ok", "critical": true, "latencyMs": 3 }],
  "server": {
    "platform": "fly",
    "region": "ams",
    "instanceId": "148e21ebd47089",
    "service": "openstatus-api",
    "version": "registry.fly.io/openstatus-api:deployment-01H9RK9EYO9PGNBYAKGXSHV0PH",
    "primaryRegion": "cdg"
  }
}

platform, region, instanceId, service, version and environment mean the same thing on every platform; anything else is named as that platform names it. A field is absent rather than guessed when the platform has no equivalent — Vercel exposes no instance identity, so there is no instanceId there. Values are passed through exactly as the platform sets them, so region is ams on Fly and DFW on Cloudflare.

Each package also exports the data on its own — flyServer(), vercelServer() — so you can compose it with your own fields, or chain platforms if one build deploys to several. extend may return anything JSON.stringify accepts; the report's own fields always take precedence over keys of the same name:

extend: (_report, c) => ({
  server: flyServer() ?? vercelServer(),
  requestId: c.get("requestId"),
}),

Off-platform they return undefined and nothing is rendered, so the same build runs unchanged on your laptop. extend follows exposeChecks: when the checks are hidden, so is everything extend adds.

Development

deno task check            # type-check, lint, fmt, version consistency
deno task test             # node:test suites under Deno
deno task build            # tsdown -> dist/ for every package
deno task test:node        # the same suites under Node against dist/
deno task check:treeshake  # no package bundles another framework/client
deno task test-all         # all of the above

See AGENTS.md for conventions and RELEASING.md for the release checklist.

About openstatus

openstatus monitors endpoints from regions around the world and turns the results into status pages and alerts. These packages are the /health endpoints behind openstatus's own services, extracted so any JavaScript server can expose one — and so a monitor has something more useful to poll than 200 OK. Point an openstatus monitor at the endpoint and assert on status in the body to be alerted on degraded before it becomes unhealthy.

Source: github.com/openstatusHQ/health. Issues and PRs welcome.

License

MIT

Contributors

thibaultleouay

18 commits

openstatusHQ/health

Tree-shakable health endpoints for JS servers. Zero-dependency core, adapters for Hono, Elysia, Express and Next.js, probes for your databases and APIs. Deno, Node, Bun, edge.

6

stars

18

commits

TypeScript

primary language

Sep 12, 2026

updated

www.openstatus.dev
openstatus
uptime
uptime-monitoring

README

@openstatus/health

main JSR npm MIT

Tree-shakable health endpoints for JavaScript servers. A dependency-free core runs probes against your dependencies and renders ok | degraded | unhealthy; thin adapters mount it as GET /health on your framework; thin probe packages know how to ping one dependency each.

Runs on Deno, Node ≥ 22, Bun and edge runtimes. Published to JSR and npm. Built and used in production by openstatus, the open-source uptime monitoring and status page platform.

{
  "status": "degraded",
  "checkedAt": "2026-09-11T12:00:00.000Z",
  "latencyMs": 41,
  "checks": [
    { "name": "database", "status": "ok",      "critical": true,  "latencyMs": 3 },
    { "name": "redis",    "status": "skipped", "critical": false, "latencyMs": 0 },
    { "name": "tinybird", "status": "timeout", "critical": false, "latencyMs": 5000, "error": "timed out after 5000ms" }
  ]
}

Packages

Core

PackageJSRnpmDescription
@openstatus/healthJSRnpmProbe runner, caching, response rendering, Fetch-API handler

Server adapters

PackageJSRnpmDescription
@openstatus/health-honoJSRnpmHono adapter
@openstatus/health-elysiaJSRnpmElysia adapter
@openstatus/health-expressJSRnpmExpress 4 / 5 adapter
@openstatus/health-nextJSRnpmNext.js App Router adapter
@openstatus/health-tanstack-startJSRnpmTanStack Start adapter

Providers

PackageJSRnpmDescription
@openstatus/health-drizzleJSRnpmDrizzle ORM select 1 probe
@openstatus/health-supabaseJSRnpmSupabase connection-pressure probe
@openstatus/health-tinybirdJSRnpmTinybird reachability probe
@openstatus/health-tursoJSRnpmTurso libSQL select 1 probe (@libsql/client)
@openstatus/health-turso-serverlessJSRnpmTurso select 1 probe over the serverless driver (@tursodatabase/serverless)
@openstatus/health-unkeyJSRnpmUnkey liveness probe
@openstatus/health-upstashJSRnpmUpstash Redis PING probe over REST

Hosting

PackageJSRnpmDescription
@openstatus/health-flyJSRnpmFly.io region, machine and deployment
@openstatus/health-koyebJSRnpmKoyeb region, instance and deployment
@openstatus/health-railwayJSRnpmRailway region, replica, environment and deployment
@openstatus/health-vercelJSRnpmVercel region, environment and deployment
@openstatus/health-cloudflareJSRnpmCloudflare Workers colo and version metadata

Each package is its own concern with its own peer dependencies: importing @openstatus/health-hono never pulls Express, and importing @openstatus/health-unkey never pulls Drizzle. CI bundles a one-line consumer of every package and fails if any other framework or client library lands in the output.

Quick start

Install the core plus one adapter and the probes you need:

deno add jsr:@openstatus/health jsr:@openstatus/health-hono jsr:@openstatus/health-turso
npm install @openstatus/health @openstatus/health-hono @openstatus/health-turso

Every adapter exports the same two functions. healthRoute(options) is the batteries-included form: it mounts GET and HEAD on options.path (default /health). healthHandler(options) is the primitive underneath — a plain handler for that framework — for when you want to pick the path, stack your own middleware in front, or register it the way you register everything else.

Hono

import { Hono } from "hono";
import { healthHandler, healthRoute } from "@openstatus/health-hono";
import { tursoProbe } from "@openstatus/health-turso";
import { unkeyProbe } from "@openstatus/health-unkey";

const app = new Hono();

app.route("/", healthRoute({
  probes: [tursoProbe({ client }), unkeyProbe()],
  extend: (_report, c) => ({ requestId: c.get("requestId") }),
}));

// or, on a route of your own:
app.on(["GET", "HEAD"], "/health", healthHandler({ probes: [unkeyProbe()] }));

Elysia

import { Elysia } from "elysia";
import { healthRoute } from "@openstatus/health-elysia";
import { tinybirdProbe } from "@openstatus/health-tinybird";

new Elysia().use(healthRoute({ probes: [tinybirdProbe()] })).listen(3000);

Express

import express from "express";
import { healthRoute } from "@openstatus/health-express";
import { drizzleProbe } from "@openstatus/health-drizzle";

const app = express();
app.use(healthRoute({ probes: [drizzleProbe({ db })] }));

Next.js (App Router)

// app/health/route.ts
import { healthRoute } from "@openstatus/health-next";
import { supabaseProbe } from "@openstatus/health-supabase";

// required: keeps Next.js from statically caching the route
export const dynamic = "force-dynamic";

export const { GET, HEAD } = healthRoute({ probes: [supabaseProbe({ client })] });

TanStack Start

// src/routes/api/health.ts
import { createFileRoute } from "@tanstack/react-router";
import { healthRoute } from "@openstatus/health-tanstack-start";
import { supabaseProbe } from "@openstatus/health-supabase";

export const Route = createFileRoute("/api/health")({
  server: { handlers: healthRoute({ probes: [supabaseProbe({ client })] }) },
});

Anything with a Fetch API (Deno.serve, Bun.serve, Workers)

import { createHealthHandler } from "@openstatus/health";

Deno.serve(createHealthHandler({ path: "/health", probes: [/* ... */] }));

Options

Every entry point takes the same options — probes (or a shared check), path, cacheMs, staleMs, timeoutMs, deadlineMs, exposeChecks, unhealthyStatusCode, degradedStatusCode, extend, formatError, onReport, onError — documented once in packages/health.

Aggregation: a failing or timed-out critical probe makes the report unhealthy; a failing non-critical probe makes it degraded; skipped probes never affect it. Errors are masked as "failed" unless you opt in with formatError: "message".

Liveness, readiness, public and internal

Liveness is "the process answers"; readiness is "the process can serve". Mount the same adapter twice — an empty probe list is always ok:

app.route("/", healthRoute({ path: "/livez", probes: [] }));
app.route("/", healthRoute({ path: "/readyz", probes, deadlineMs: 800, cacheFailuresMs: 0 }));

deadlineMs caps the whole round so a hung dependency cannot outlast a Kubernetes probe's timeoutSeconds; cacheFailuresMs: 0 lets the next poll see a recovery immediately. Set staleMs to keep answering from the last report while a refresh runs in the background.

One /health can serve the load balancer and your on-call engineer: exposeChecks takes a function of the request, and extend output is only rendered when checks are exposed. If you would rather serve two routes, build the check once and share it so the probes run once per cache window:

import { createHealthCheck } from "@openstatus/health";

const check = createHealthCheck({ probes, cacheMs: 5000, onReport: log });

app.route("/", healthRoute({ check, exposeChecks: false }));
app.route("/", healthRoute({ check, path: "/_health", extend: flyExtend() }));
// or, one route:
app.route("/", healthRoute({
  check,
  exposeChecks: (c) => c.req.header("x-health-token") === env.HEALTH_TOKEN,
  extend: flyExtend(),
}));

check.invalidate() drops the cache — call it after a reconnect or a config reload.

Probes

ProbeDefault nameCriticalChecks
tinybirdProbe({ baseUrl? })tinybirdnoGET {baseUrl}/v0/health
unkeyProbe({ baseUrl? })unkeynoGET {baseUrl}/v2/liveness
tursoProbe({ client })databaseyesclient.execute("select 1") on a Turso libSQL client
tursoServerlessProbe({ connection })databaseyesconnection.get("select 1") on a Turso serverless Connection
drizzleProbe({ db })databaseyesdb.execute(sql\select 1`)ordb.run(...)`
supabaseProbe({ client, maxConnectionPercent? })supabasenorpc("health_connection_pressure") ≤ threshold
upstashProbe({ url, token })redisnoGET {url}/ping with the REST token

Every probe factory accepts name, critical, timeoutMs and skip overrides. Probes take a client instance or a base URL — they never read process.env themselves.

Writing your own probe

A probe is a plain object. Resolve for healthy, reject or throw for failed, and honour the AbortSignal so a timeout actually cancels the work:

import { httpProbe, probe } from "@openstatus/health";

const queue = probe({
  name: "queue",
  critical: true,
  timeoutMs: 1000,
  skip: () => !env.QUEUE_URL,
  run: async (signal, ctx) => {
    const res = await fetch(`${env.QUEUE_URL}/depth`, { signal });
    if (!res.ok) throw new Error(`${ctx.name} answered ${res.status}`);
    const { depth } = await res.json();
    if (depth > 10_000) throw new Error(`queue depth ${depth}`);
  },
});

const docs = httpProbe({ name: "docs", url: "https://docs.example.com", method: "HEAD" });

skip runs on every request, may be async, and reports the check as skipped without running it — use it for optional dependencies that are not configured in every environment. ctx carries the probe's name, critical flag and effective timeoutMs.

@openstatus/health/testing exports fakeFetch, hangFetch and ready-made okProbe / failingProbe / hangingProbe fixtures for testing probes and adapters of your own.

Server metadata

The hosting packages answer a different question from the probes: not "is the database up" but "which replica is telling me that". Each reads its platform's own environment — or, on Workers, the request — and renders it under server through the same extend hook:

import { healthRoute } from "@openstatus/health-hono";
import { flyExtend } from "@openstatus/health-fly";

app.route("/", healthRoute({ probes, extend: flyExtend() }));
{
  "status": "ok",
  "checkedAt": "2026-09-11T12:00:00.000Z",
  "latencyMs": 41,
  "checks": [{ "name": "database", "status": "ok", "critical": true, "latencyMs": 3 }],
  "server": {
    "platform": "fly",
    "region": "ams",
    "instanceId": "148e21ebd47089",
    "service": "openstatus-api",
    "version": "registry.fly.io/openstatus-api:deployment-01H9RK9EYO9PGNBYAKGXSHV0PH",
    "primaryRegion": "cdg"
  }
}

platform, region, instanceId, service, version and environment mean the same thing on every platform; anything else is named as that platform names it. A field is absent rather than guessed when the platform has no equivalent — Vercel exposes no instance identity, so there is no instanceId there. Values are passed through exactly as the platform sets them, so region is ams on Fly and DFW on Cloudflare.

Each package also exports the data on its own — flyServer(), vercelServer() — so you can compose it with your own fields, or chain platforms if one build deploys to several. extend may return anything JSON.stringify accepts; the report's own fields always take precedence over keys of the same name:

extend: (_report, c) => ({
  server: flyServer() ?? vercelServer(),
  requestId: c.get("requestId"),
}),

Off-platform they return undefined and nothing is rendered, so the same build runs unchanged on your laptop. extend follows exposeChecks: when the checks are hidden, so is everything extend adds.

Development

deno task check            # type-check, lint, fmt, version consistency
deno task test             # node:test suites under Deno
deno task build            # tsdown -> dist/ for every package
deno task test:node        # the same suites under Node against dist/
deno task check:treeshake  # no package bundles another framework/client
deno task test-all         # all of the above

See AGENTS.md for conventions and RELEASING.md for the release checklist.

About openstatus

openstatus monitors endpoints from regions around the world and turns the results into status pages and alerts. These packages are the /health endpoints behind openstatus's own services, extracted so any JavaScript server can expose one — and so a monitor has something more useful to poll than 200 OK. Point an openstatus monitor at the endpoint and assert on status in the body to be alerted on degraded before it becomes unhealthy.

Source: github.com/openstatusHQ/health. Issues and PRs welcome.

License

MIT

Contributors

thibaultleouay

18 commits

Languages

TypeScript

100.0%