Open-source marketplace for AI Agent skills. Features: - Next.js 15 web app with i18n (en/fa) - CLI tool for skill installation (npx skillhub) - GitHub crawler/indexer with multi-strategy discovery - Security scanning for all indexed skills - Self-hostable with Docker Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
191 lines
5.6 KiB
TypeScript
191 lines
5.6 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import { createDb, sql, isMeilisearchHealthy } from '@skillhub/db';
|
|
import { isCacheAvailable } from '@/lib/cache';
|
|
|
|
interface ServiceStatus {
|
|
status: 'healthy' | 'unhealthy' | 'degraded';
|
|
latency?: number;
|
|
error?: string;
|
|
}
|
|
|
|
interface ReplicationStatus extends ServiceStatus {
|
|
isReplica?: boolean;
|
|
lagSeconds?: number;
|
|
}
|
|
|
|
interface GitHubStatus extends ServiceStatus {
|
|
// GitHub accessibility status (for OAuth on mirror)
|
|
}
|
|
|
|
interface HealthResponse {
|
|
status: 'healthy' | 'unhealthy' | 'degraded';
|
|
timestamp: string;
|
|
version: string;
|
|
isPrimary: boolean;
|
|
services: {
|
|
database: ServiceStatus;
|
|
meilisearch: ServiceStatus;
|
|
redis: ServiceStatus;
|
|
replication?: ReplicationStatus;
|
|
github?: GitHubStatus;
|
|
};
|
|
}
|
|
|
|
export async function GET() {
|
|
const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false';
|
|
|
|
const services: HealthResponse['services'] = {
|
|
database: { status: 'unhealthy' },
|
|
meilisearch: { status: 'unhealthy' },
|
|
redis: { status: 'unhealthy' },
|
|
};
|
|
|
|
// Check database connectivity
|
|
try {
|
|
const dbStart = Date.now();
|
|
const db = createDb();
|
|
await db.execute(sql`SELECT 1`);
|
|
services.database = {
|
|
status: 'healthy',
|
|
latency: Date.now() - dbStart,
|
|
};
|
|
} catch (error) {
|
|
// Don't expose DATABASE_URL or connection details in error messages
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
// Sanitize error message - remove any potential credentials
|
|
const sanitizedError = errorMessage
|
|
.replace(/postgresql:\/\/[^@]*@/gi, 'postgresql://***@')
|
|
.replace(/password[=:][^\s&]*/gi, 'password=***');
|
|
|
|
services.database = {
|
|
status: 'unhealthy',
|
|
error: process.env.DATABASE_URL ? sanitizedError : 'DATABASE_URL not configured',
|
|
};
|
|
}
|
|
|
|
// Check Meilisearch connectivity (optional service)
|
|
try {
|
|
const meiliStart = Date.now();
|
|
const meiliHealthy = await isMeilisearchHealthy();
|
|
if (meiliHealthy) {
|
|
services.meilisearch = {
|
|
status: 'healthy',
|
|
latency: Date.now() - meiliStart,
|
|
};
|
|
} else {
|
|
services.meilisearch = {
|
|
status: 'degraded',
|
|
error: 'Meilisearch not responding or not configured',
|
|
};
|
|
}
|
|
} catch (error) {
|
|
services.meilisearch = {
|
|
status: 'degraded',
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
};
|
|
}
|
|
|
|
// Check Redis connectivity (optional service for caching and rate limiting)
|
|
try {
|
|
const redisStart = Date.now();
|
|
const redisHealthy = await isCacheAvailable();
|
|
if (redisHealthy) {
|
|
services.redis = {
|
|
status: 'healthy',
|
|
latency: Date.now() - redisStart,
|
|
};
|
|
} else {
|
|
services.redis = {
|
|
status: 'degraded',
|
|
error: 'Redis not responding or not configured',
|
|
};
|
|
}
|
|
} catch (error) {
|
|
services.redis = {
|
|
status: 'degraded',
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
};
|
|
}
|
|
|
|
// Mirror server only: Check PostgreSQL replication status
|
|
if (!isPrimary) {
|
|
try {
|
|
const db = createDb();
|
|
// drizzle-orm/postgres-js returns results directly as an array
|
|
const lagResult = await db.execute<{ is_replica: boolean; lag_seconds: number | null }>(sql`
|
|
SELECT
|
|
pg_is_in_recovery() as is_replica,
|
|
EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))::int as lag_seconds
|
|
`);
|
|
|
|
const row = lagResult[0];
|
|
const isReplica = row?.is_replica ?? false;
|
|
const lagSeconds = row?.lag_seconds ?? 0;
|
|
|
|
// Healthy if lag is under 5 minutes (300 seconds)
|
|
services.replication = {
|
|
status: isReplica && lagSeconds < 300 ? 'healthy' : 'degraded',
|
|
isReplica,
|
|
lagSeconds,
|
|
latency: undefined,
|
|
};
|
|
} catch (error) {
|
|
services.replication = {
|
|
status: 'degraded',
|
|
error: error instanceof Error ? error.message : 'Failed to check replication status',
|
|
isReplica: false,
|
|
lagSeconds: undefined,
|
|
};
|
|
}
|
|
|
|
// Mirror server only: Check GitHub accessibility (for OAuth)
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
|
|
const githubStart = Date.now();
|
|
const githubCheck = await fetch('https://github.com', {
|
|
method: 'HEAD',
|
|
signal: controller.signal,
|
|
});
|
|
clearTimeout(timeoutId);
|
|
|
|
services.github = {
|
|
status: githubCheck.ok ? 'healthy' : 'degraded',
|
|
latency: Date.now() - githubStart,
|
|
};
|
|
} catch {
|
|
// GitHub likely filtered/blocked in Iran
|
|
services.github = {
|
|
status: 'degraded',
|
|
error: 'GitHub unreachable (may be filtered)',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Determine overall status
|
|
let overallStatus: 'healthy' | 'unhealthy' | 'degraded' = 'healthy';
|
|
|
|
// Database is critical - if it's down, the whole system is unhealthy
|
|
if (services.database.status === 'unhealthy') {
|
|
overallStatus = 'unhealthy';
|
|
}
|
|
// Meilisearch and Redis are optional - if either is down, system is degraded but still functional
|
|
else if (services.meilisearch.status !== 'healthy' || services.redis.status !== 'healthy') {
|
|
overallStatus = 'degraded';
|
|
}
|
|
|
|
const response: HealthResponse = {
|
|
status: overallStatus,
|
|
timestamp: new Date().toISOString(),
|
|
version: process.env.npm_package_version || '0.1.0',
|
|
isPrimary,
|
|
services,
|
|
};
|
|
|
|
// Return appropriate HTTP status code for load balancers/orchestrators
|
|
// 200 = healthy, 503 = unhealthy (critical service down)
|
|
const statusCode = overallStatus === 'unhealthy' ? 503 : 200;
|
|
return NextResponse.json(response, { status: statusCode });
|
|
}
|