dorfteich/apps/collab/src/health.ts
Claude Opus 4.8 8316c617d2
All checks were successful
CD / Build and push images (push) Successful in 2m36s
CI / Lint, typecheck, test (push) Successful in 1m50s
CI / Auth e2e pack (push) Successful in 1m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Add collaboration server skeleton (Hocuspocus) with health, container, and CI/CD (#33)
Bootstrap apps/collab as a Hocuspocus WebSocket server (ADR 0003):
- pino JSON logging (service=collab) and shared Zod env validation
  (collabEnvSchema); structured connection open/close logs.
- /healthz endpoint (process liveness + PostgreSQL ping) served via the
  onRequest hook, matching the container-internal path and the proxied
  /collab/healthz path; any WebSocket handshake is accepted for now
  (authentication arrives with #34, persistence with #35).
- Dockerfile (ESM workspace build) and a compose service on the frontend
  and internal networks with a healthcheck; dev overlay service and a new
  COLLAB_PORT variable.
- CD builds, pushes, and promotes the collab image; CI builds it on PRs;
  the smoke suite asserts /collab/healthz through the reverse proxy.
- deployment.md/stages.md: proxy routing, per-stage COLLAB_PORT, checklist.

Closes #33

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:53:44 +02:00

65 lines
2.1 KiB
TypeScript

import type { HealthResponse } from '@dorfteich/shared';
/** Result of a single dependency probe. */
export interface DatabaseProbe {
ok: boolean;
detail?: string;
}
/**
* Liveness + dependency report for the collab service. Unlike the api, which
* splits pure liveness (`/healthz`) from readiness (`/readyz`), the collab
* skeleton only needs the one endpoint the compose healthcheck and pipeline
* smoke test hit, so `/healthz` also carries the database probe (issue #33).
*/
export interface CollabHealthReport {
status: 'ok' | 'unhealthy';
service: HealthResponse['service'];
version: string;
time: string;
checks: { name: 'database'; status: 'ok' | 'failed'; detail?: string }[];
}
/** Build the health payload and the HTTP status a monitor should see. */
export function buildHealthReport(
version: string,
database: DatabaseProbe,
): { httpStatus: 200 | 503; body: CollabHealthReport } {
const body: CollabHealthReport = {
status: database.ok ? 'ok' : 'unhealthy',
service: 'collab',
version,
time: new Date().toISOString(),
checks: [
{
name: 'database',
status: database.ok ? 'ok' : 'failed',
...(database.detail ? { detail: database.detail } : {}),
},
],
};
return { httpStatus: database.ok ? 200 : 503, body };
}
/**
* Whether a plain HTTP request is a health probe. Matches both the container-
* internal path (`/healthz`, used by the Docker healthcheck) and the path seen
* through the host reverse proxy, which routes `/collab*` to this service
* without stripping the prefix (deployment.md), i.e. `/collab/healthz`.
*/
export function isHealthRequest(method: string | undefined, url: string | undefined): boolean {
if (method !== undefined && method !== 'GET' && method !== 'HEAD') {
return false;
}
const pathname = pathnameOf(url);
return pathname === '/healthz' || pathname === '/collab/healthz';
}
function pathnameOf(url: string | undefined): string {
if (!url) {
return '';
}
// The base only matters to parse a path-only request-target; it is discarded.
return new URL(url, 'http://collab.internal').pathname.replace(/\/+$/, '') || '/';
}