Skip to content

Repository files navigation

@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

Package JSR npm Description
@openstatus/health JSR npm Probe runner, caching, response rendering, Fetch-API handler

Server adapters

Package JSR npm Description
@openstatus/health-hono JSR npm Hono adapter
@openstatus/health-elysia JSR npm Elysia adapter
@openstatus/health-express JSR npm Express 4 / 5 adapter
@openstatus/health-next JSR npm Next.js App Router adapter
@openstatus/health-tanstack-start JSR npm TanStack Start adapter

Providers

Package JSR npm Description
@openstatus/health-drizzle JSR npm Drizzle ORM select 1 probe
@openstatus/health-supabase JSR npm Supabase connection-pressure probe
@openstatus/health-tinybird JSR npm Tinybird reachability probe
@openstatus/health-turso JSR npm Turso libSQL select 1 probe (@libsql/client)
@openstatus/health-turso-serverless JSR npm Turso select 1 probe over the serverless driver (@tursodatabase/serverless)
@openstatus/health-unkey JSR npm Unkey liveness probe
@openstatus/health-upstash JSR npm Upstash Redis PING probe over REST

Hosting

Package JSR npm Description
@openstatus/health-fly JSR npm Fly.io region, machine and deployment
@openstatus/health-koyeb JSR npm Koyeb region, instance and deployment
@openstatus/health-railway JSR npm Railway region, replica, environment and deployment
@openstatus/health-vercel JSR npm Vercel region, environment and deployment
@openstatus/health-cloudflare JSR npm Cloudflare 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

Probe Default name Critical Checks
tinybirdProbe({ baseUrl? }) tinybird no GET {baseUrl}/v0/health
unkeyProbe({ baseUrl? }) unkey no GET {baseUrl}/v2/liveness
tursoProbe({ client }) database yes client.execute("select 1") on a Turso libSQL client
tursoServerlessProbe({ connection }) database yes connection.get("select 1") on a Turso serverless Connection
drizzleProbe({ db }) database yes db.execute(sql\select 1`)ordb.run(...)`
supabaseProbe({ client, maxConnectionPercent? }) supabase no rpc("health_connection_pressure") ≤ threshold
upstashProbe({ url, token }) redis no GET {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

About

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.

Topics

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages