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:
49
apps/web/app/api/health/route.test.ts
Normal file
49
apps/web/app/api/health/route.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { GET } from './route';
|
||||
|
||||
// Mock the database module
|
||||
vi.mock('@skillhub/db', () => ({
|
||||
createDb: () => ({
|
||||
execute: vi.fn().mockResolvedValue([{ '?column?': 1 }]),
|
||||
}),
|
||||
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }),
|
||||
isMeilisearchHealthy: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
describe('GET /api/health', () => {
|
||||
it('should return status 200', async () => {
|
||||
const response = await GET();
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return health status', async () => {
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
expect(['healthy', 'degraded', 'unhealthy']).toContain(data.status);
|
||||
});
|
||||
|
||||
it('should return current timestamp', async () => {
|
||||
const before = new Date().toISOString();
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
const after = new Date().toISOString();
|
||||
|
||||
expect(data.timestamp).toBeDefined();
|
||||
expect(new Date(data.timestamp).getTime()).toBeGreaterThanOrEqual(new Date(before).getTime());
|
||||
expect(new Date(data.timestamp).getTime()).toBeLessThanOrEqual(new Date(after).getTime());
|
||||
});
|
||||
|
||||
it('should return version', async () => {
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
expect(data.version).toBe('0.1.0');
|
||||
});
|
||||
|
||||
it('should return services status', async () => {
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
expect(data.services).toBeDefined();
|
||||
expect(data.services.database).toBeDefined();
|
||||
expect(data.services.meilisearch).toBeDefined();
|
||||
});
|
||||
});
|
||||
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