Files
sofa/packages/core/src/system-health.ts
T
jakeandClaude Opus 4.6 a326c968b7 Migrate web app from Next.js to Vite + TanStack Router SPA (#6)
* Convert to Turborepo monorepo with shared API contract package

Restructure the repository as a monorepo in preparation for adding
future clients (mobile app, CLI). Extract the oRPC contract and Zod
schemas into `@sofa/api` (packages/api/) as a JIT internal package,
and relocate the Next.js app to `@sofa/web` (apps/web/).

- Add Turborepo with Bun workspaces for task orchestration and caching
- Extract `contract.ts` and `schemas.ts` into `@sofa/api` package
- Move all app code, configs, tests, and migrations to `apps/web/`
- Update 17 import paths from `@/lib/orpc/schemas` to `@sofa/api/schemas`
- Add `outputFileTracingRoot` and `transpilePackages` to next.config.ts
- Rewrite Dockerfile with `turbo prune --docker` for efficient builds
- Update CI workflows to use `turbo run` for lint/check-types/test
- Update CLAUDE.md with monorepo structure and commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Extract standalone Hono API server and split shared packages

Separate all server-side concerns from the Next.js frontend into a new
`apps/server/` Hono app and dedicated shared packages, making `@sofa/web`
a frontend-only app with no direct DB or service access.

- Add `@sofa/server` (`apps/server/`) — Hono API on port 3001 hosting
  oRPC procedures, Better Auth, cron jobs, and non-RPC routes
- Add `@sofa/core` (`packages/core/`) — All 15 business logic services
  moved from `apps/web/lib/services/`; tests moved to `packages/core/test/`
- Add `@sofa/db` (`packages/db/`) — DB client, schema, migrations,
  constants, and logger extracted from `apps/web/lib/db/` and `lib/`
- Add `@sofa/tmdb` (`packages/tmdb/`) — TMDB client and image helpers
  moved from `apps/web/lib/tmdb/`
- Add `@sofa/auth` (`packages/auth/`) — Better Auth server config moved
  from `apps/web/lib/auth/`
- Move oRPC procedures, handler, router, middleware to `apps/server/src/orpc/`
- Move Hono route handlers (avatars, backups, images, lists, webhooks,
  health) to `apps/server/src/routes/`; delete equivalent Next.js API routes
- Strip `apps/web` to frontend-only: no DB imports, no service imports,
  all data via oRPC client calls to the API server
- Add `entrypoint.sh` to start API server, wait for health, then Next.js
- Update `next.config.ts` rewrites to proxy `/rpc/*` and `/api/*` to
  `INTERNAL_API_URL` (default `http://localhost:3001`)
- Update Dockerfile and CLAUDE.md for the new structure

* Migrate web app from Next.js to Vite + TanStack Router SPA and add workspace catalog

Replace Next.js with a pure Vite SPA using TanStack Router for file-based routing,
removing all SSR complexity. The API server (Hono) now serves both API routes and
SPA static files in production, simplifying Docker to a single-process container.

Key changes:
- Vite 7 + @tanstack/react-router with file-based routing via plugin
- Route guards via beforeLoad + authClient.getSession() (replaces server-side auth)
- Route loaders with queryClient.ensureQueryData() (replaces SSR data fetching)
- Self-hosted fonts via @fontsource (replaces next/font/google)
- Tailwind v4 via @tailwindcss/vite (replaces @tailwindcss/postcss)
- Single oRPC client (removed SSR client and server-side session helper)
- Hono serves SPA static files in production (single port 3000)
- Single-process Dockerfile (removed entrypoint.sh)
- Bun workspace catalog for centralized dependency version management

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Extract @sofa/logger and @sofa/config shared packages

- Add `@sofa/logger` (`packages/logger/`) — standalone logger package
  extracted from `@sofa/db/logger`; update all imports across server,
  core, auth, db, and tmdb packages
- Add `@sofa/config` (`packages/config/`) — standalone config/constants
  package extracted from `@sofa/db/constants`; exports `DATA_DIR`,
  `DATABASE_URL`, `CACHE_DIR`, `AVATAR_DIR`, `BACKUP_DIR`
- Move `.env.example` from `apps/web/` to repo root; update server dev
  scripts to load it via `--env-file=../../.env`
- Move image serving from `/api/images` to `/images`; add `serveStatic`
  fast path in `index.ts` for cached files before falling back to the
  TMDB fetch route; add `/images` proxy to Vite dev config
- Fix `Sparkline` component: replace `ResponsiveContainer` with
  `ResizeObserver` to avoid SSR/hydration issues with recharts
- Replace `VITE_SERVER_URL` env var with `window.location.origin` in
  the oRPC client (always same-origin in both dev and production)

* Fix asset caching, SPA 404 fallback, and DATA_DIR resolution

- Add `Cache-Control: immutable` header for hashed `/assets/*` files;
  return 404 for missing asset paths instead of falling back to
  `index.html` (prevents serving stale chunks after deploy)
- Wrap `query.invalidate` in an arrow function in the oRPC QueryClient
  error handler to avoid illegal invocation errors
- Resolve `DATA_DIR` to an absolute path via `path.resolve()` so
  relative paths work regardless of the process working directory

* Migrate @sofa/logger to pino for structured logging

- Replace custom logger implementation in `packages/logger/` with pino
  + pino-pretty; add both as workspace catalog dependencies
- Add `pino` and `pino-pretty` to the workspace catalog in `package.json`
- Fix `log.error()` calls in oRPC and OpenAPI handlers to pass the
  error directly instead of wrapping it in `{ error }` to match pino's
  serializer expectations

* Rename discoverProcedure/statsProcedure exports to discover/stats

* Add TanStackDevtools unified panel and VS Code workspace config

- Replace separate Router/Query devtools with unified `TanStackDevtools`
  from `@tanstack/react-devtools` + `@tanstack/devtools-vite` plugin
- Wrap app in `<StrictMode>` in `main.tsx`
- Add `.vscode/settings.json` (Biome formatter, format-on-save, readonly
  `routeTree.gen.ts`) and `.vscode/extensions.json` (recommended extensions)

* Move test DB helpers to @sofa/db/test-utils and add root bunfig.toml

Extract in-memory SQLite setup and fixture helpers (insertUser, insertTitle,
etc.) from packages/core/test/sqlite.ts into packages/db/src/test-utils.ts
so DB test utilities live alongside the schema they depend on. Use
import.meta.dir for CWD-independent migration path resolution.

Add root bunfig.toml so `bun test` works from the repo root in addition
to `bun run test` (turbo).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix devtools plugin order and whitespace-only TMDB token check

Move devtools() to first position in Vite plugins array per TanStack
docs, and trim TMDB_API_READ_ACCESS_TOKEN before boolean coercion so
whitespace-only values are treated as unconfigured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:50:34 -04:00

345 lines
9.6 KiB
TypeScript

import { access, constants, readdir } from "node:fs/promises";
import path from "node:path";
import {
CACHE_DIR,
DATA_DIR,
DATABASE_URL,
TMDB_API_BASE_URL,
} from "@sofa/config";
import { db } from "@sofa/db/client";
import { count, desc, eq } from "@sofa/db/helpers";
import { cronRuns, episodes, titles, user } from "@sofa/db/schema";
import { listBackups } from "./backup";
import { imageCacheEnabled } from "./image-cache";
import { getSetting } from "./settings";
import { isUpdateCheckEnabled } from "./update-check";
export interface SystemHealthData {
database: {
dbSizeBytes: number;
walSizeBytes: number;
titleCount: number;
episodeCount: number;
userCount: number;
};
tmdb: {
connected: boolean;
tokenValid: boolean;
tokenConfigured: boolean;
responseTimeMs: number | null;
error: string | null;
};
jobs: {
jobName: string;
cronPattern: string | null;
nextRunAt: string | null;
lastRunAt: string | null;
lastDurationMs: number | null;
lastStatus: "running" | "success" | "error" | null;
lastError: string | null;
isCurrentlyRunning: boolean;
disabled: boolean;
}[];
imageCache: {
enabled: boolean;
totalSizeBytes: number;
imageCount: number;
categories: Record<string, { count: number; sizeBytes: number }>;
};
backups: {
lastBackupAt: string | null;
lastBackupAgeHours: number | null;
backupCount: number;
totalSizeBytes: number;
};
environment: {
dataDir: string;
dataDirWritable: boolean;
envVars: { name: string; value: string | null }[];
};
checkedAt: string;
}
const JOB_NAMES = [
"nightlyRefreshLibrary",
"refreshAvailability",
"refreshRecommendations",
"refreshTvChildren",
"cacheImages",
"scheduledBackup",
"updateCheck",
];
function getDatabaseHealth(): SystemHealthData["database"] {
let dbSizeBytes = 0;
let walSizeBytes = 0;
try {
dbSizeBytes = Bun.file(DATABASE_URL).size;
} catch {}
try {
walSizeBytes = Bun.file(`${DATABASE_URL}-wal`).size;
} catch {}
const [titleCount] = db.select({ count: count() }).from(titles).all();
const [episodeCount] = db.select({ count: count() }).from(episodes).all();
const [userCount] = db.select({ count: count() }).from(user).all();
return {
dbSizeBytes,
walSizeBytes,
titleCount: titleCount.count,
episodeCount: episodeCount.count,
userCount: userCount.count,
};
}
async function getTmdbHealth(): Promise<SystemHealthData["tmdb"]> {
const token = process.env.TMDB_API_READ_ACCESS_TOKEN;
if (!token) {
return {
connected: false,
tokenValid: false,
tokenConfigured: false,
responseTimeMs: null,
error: null,
};
}
try {
const start = performance.now();
const res = await fetch(`${TMDB_API_BASE_URL}/configuration`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
signal: AbortSignal.timeout(5000),
});
const responseTimeMs = Math.round(performance.now() - start);
if (res.ok) {
return {
connected: true,
tokenValid: true,
tokenConfigured: true,
responseTimeMs,
error: null,
};
}
return {
connected: true,
tokenValid: false,
tokenConfigured: true,
responseTimeMs,
error: `HTTP ${res.status}`,
};
} catch (err) {
return {
connected: false,
tokenValid: false,
tokenConfigured: true,
responseTimeMs: null,
error: err instanceof Error ? err.message : String(err),
};
}
}
type JobSchedule = {
jobName: string;
pattern: string;
nextRunAt: string | null;
};
let _getJobSchedules: (() => JobSchedule[]) | null = null;
/** Register the job schedule provider (called by the API server on startup). */
export function registerJobScheduleProvider(fn: () => JobSchedule[]) {
_getJobSchedules = fn;
}
function getJobsHealth(): SystemHealthData["jobs"] {
const schedules = _getJobSchedules?.() ?? [];
const scheduleMap = new Map(schedules.map((s) => [s.jobName, s]));
// Fetch only the latest cron run per job (index-optimized LIMIT 1 each)
const latestByJob = new Map<string, typeof cronRuns.$inferSelect>();
for (const jobName of JOB_NAMES) {
const latest = db
.select()
.from(cronRuns)
.where(eq(cronRuns.jobName, jobName))
.orderBy(desc(cronRuns.startedAt))
.limit(1)
.get();
if (latest) latestByJob.set(jobName, latest);
}
return JOB_NAMES.map((jobName) => {
const latest = latestByJob.get(jobName);
const isCurrentlyRunning = latest?.status === "running";
const schedule = scheduleMap.get(jobName);
// Prefer the in-memory durationMs column; fall back to timestamp diff
let lastDurationMs: number | null = latest?.durationMs ?? null;
if (lastDurationMs === null && latest?.finishedAt && latest.startedAt) {
lastDurationMs = latest.finishedAt.getTime() - latest.startedAt.getTime();
}
const disabled =
(jobName === "scheduledBackup" &&
getSetting("scheduledBackups") !== "true") ||
(jobName === "updateCheck" && !isUpdateCheckEnabled());
return {
jobName,
cronPattern: disabled ? null : (schedule?.pattern ?? null),
nextRunAt: disabled ? null : (schedule?.nextRunAt ?? null),
lastRunAt:
latest?.finishedAt?.toISOString() ??
latest?.startedAt?.toISOString() ??
null,
lastDurationMs,
lastStatus: (latest?.status as "running" | "success" | "error") ?? null,
lastError: latest?.errorMessage ?? null,
isCurrentlyRunning,
disabled,
};
});
}
async function getImageCacheHealth(): Promise<SystemHealthData["imageCache"]> {
const enabled = imageCacheEnabled();
if (!enabled) {
return { enabled: false, totalSizeBytes: 0, imageCount: 0, categories: {} };
}
const categoryNames = ["posters", "backdrops", "stills", "logos"];
const categories: Record<string, { count: number; sizeBytes: number }> = {};
let totalSizeBytes = 0;
let imageCount = 0;
await Promise.all(
categoryNames.map(async (category) => {
const dir = path.join(CACHE_DIR, category);
try {
const files = await readdir(dir);
const sizes = await Promise.all(
files.map((file) => Bun.file(path.join(dir, file)).size),
);
const sizeBytes = sizes.reduce((sum, s) => sum + s, 0);
categories[category] = { count: files.length, sizeBytes };
totalSizeBytes += sizeBytes;
imageCount += files.length;
} catch {
categories[category] = { count: 0, sizeBytes: 0 };
}
}),
);
return { enabled: true, totalSizeBytes, imageCount, categories };
}
async function getBackupsHealth(): Promise<SystemHealthData["backups"]> {
try {
const backups = await listBackups();
const totalSizeBytes = backups.reduce((sum, b) => sum + b.sizeBytes, 0);
const lastBackup = backups[0] ?? null;
const lastBackupAt = lastBackup?.createdAt ?? null;
const lastBackupAgeHours = lastBackupAt
? Math.round(
(Date.now() - new Date(lastBackupAt).getTime()) / (1000 * 60 * 60),
)
: null;
return {
lastBackupAt,
lastBackupAgeHours,
backupCount: backups.length,
totalSizeBytes,
};
} catch {
return {
lastBackupAt: null,
lastBackupAgeHours: null,
backupCount: 0,
totalSizeBytes: 0,
};
}
}
async function getEnvironmentHealth(): Promise<
SystemHealthData["environment"]
> {
const resolvedDataDir = path.resolve(DATA_DIR);
let dataDirWritable = false;
try {
await access(resolvedDataDir, constants.W_OK);
dataDirWritable = true;
} catch {}
const redact = (val: string | undefined): string | null => {
if (!val) return null;
if (val.length <= 8) return "••••••••";
return `${val.slice(0, 4)}${"•".repeat(Math.min(val.length - 4, 24))}`;
};
const env = (name: string) => process.env[name] ?? null;
return {
dataDir: resolvedDataDir,
dataDirWritable,
envVars: [
// Core
{ name: "DATA_DIR", value: resolvedDataDir },
{ name: "LOG_LEVEL", value: env("LOG_LEVEL") ?? "info" },
// Auth
{ name: "BETTER_AUTH_URL", value: env("BETTER_AUTH_URL") },
{
name: "BETTER_AUTH_SECRET",
value: redact(env("BETTER_AUTH_SECRET") ?? undefined),
},
// TMDB
{
name: "TMDB_API_READ_ACCESS_TOKEN",
value: redact(env("TMDB_API_READ_ACCESS_TOKEN") ?? undefined),
},
{ name: "TMDB_API_BASE_URL", value: env("TMDB_API_BASE_URL") },
{ name: "TMDB_IMAGE_BASE_URL", value: env("TMDB_IMAGE_BASE_URL") },
// Image cache
{
name: "IMAGE_CACHE_ENABLED",
value: env("IMAGE_CACHE_ENABLED") ?? "true",
},
// OIDC
{ name: "OIDC_ISSUER_URL", value: env("OIDC_ISSUER_URL") },
{ name: "OIDC_CLIENT_ID", value: env("OIDC_CLIENT_ID") },
{
name: "OIDC_CLIENT_SECRET",
value: redact(env("OIDC_CLIENT_SECRET") ?? undefined),
},
{ name: "OIDC_PROVIDER_NAME", value: env("OIDC_PROVIDER_NAME") },
{ name: "OIDC_AUTO_REGISTER", value: env("OIDC_AUTO_REGISTER") },
{ name: "DISABLE_PASSWORD_LOGIN", value: env("DISABLE_PASSWORD_LOGIN") },
],
};
}
export async function getSystemHealth(): Promise<SystemHealthData> {
const [tmdb, imageCache, backups, environment] = await Promise.all([
getTmdbHealth(),
getImageCacheHealth(),
getBackupsHealth(),
getEnvironmentHealth(),
]);
return {
database: getDatabaseHealth(),
tmdb,
jobs: getJobsHealth(),
imageCache,
backups,
environment,
checkedAt: new Date().toISOString(),
};
}