frieren

~ / frieren

a self-hosted git server in one binary — everyone reads, only the owner writes

feat(web): health endpoint reporting backend reachability

6277ae45c4d43aa302ccd42c07ee6963c19fe9b3

justin06lee · Aug 18, 2026 18:20 (2h ago)

 README.md                   |  2 +-
 web/app/api/health/route.ts | 31 +++++++++++++++++++++++++++++++
 2 files changed, 32 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index ab3efe1..1d53132 100644
--- a/README.md
+++ b/README.md
@@ -107,7 +107,7 @@ It deploys anywhere Next.js runs; on Vercel, set two things on the project:
 - `FRIEREN_API_URL` — the public URL of your frieren backend (e.g. `https://git.example.com`)
 - `FRIEREN_CLONE_URL` — optional; shown in clone commands when it differs from the API URL
 
-Until the backend is reachable, the site renders a graceful "archive unreachable" state with setup instructions, and recovers on its own once the server answers.
+Until the backend is reachable, the site renders a graceful "archive unreachable" state with setup instructions, and recovers on its own once the server answers. `/api/health` on the deployed site reports whether it can reach the backend and how fast — the first place to look if the offline card ever shows.
 
 ## What it deliberately isn't
 
diff --git a/web/app/api/health/route.ts b/web/app/api/health/route.ts
new file mode 100644
index 0000000..da7819e
--- /dev/null
+++ b/web/app/api/health/route.ts
@@ -0,0 +1,31 @@
+import { apiBase } from "@/lib/api";
+
+// Reports whether this deployment can reach its backend — the first place to
+// look when the site shows the offline card.
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+  const base = apiBase();
+  if (!base) {
+    return Response.json({ configured: false, ok: false });
+  }
+  const started = Date.now();
+  try {
+    const res = await fetch(`${base}/api/repos`, { cache: "no-store" });
+    return Response.json({
+      configured: true,
+      ok: res.ok,
+      status: res.status,
+      ms: Date.now() - started,
+    });
+  } catch (e) {
+    const cause = e instanceof Error ? (e.cause as { code?: string } | undefined) : undefined;
+    return Response.json({
+      configured: true,
+      ok: false,
+      error: e instanceof Error ? e.message : String(e),
+      code: cause?.code ?? null,
+      ms: Date.now() - started,
+    });
+  }
+}