Initial release v1.0.0
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>
This commit is contained in:
190
apps/web/app/api/health/route.ts
Normal file
190
apps/web/app/api/health/route.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user