import type { ApiErrorResponse, CheckResult, HealthResponse, RuntimeMode, SummaryResponse, TargetStatus, TrendPoint, } from "../shared/api"; import type { StoredCheckResult } from "./checker/types"; import type { ProbeStore } from "./checker/store"; export interface StaticAssets { indexHtml: Blob; files: Record; } export interface AppOptions { mode: RuntimeMode; staticAssets?: StaticAssets; store?: ProbeStore; } export function createFetchHandler(options: AppOptions) { return (request: Request): Response => { const url = new URL(request.url); if (url.pathname === "/health") { if (!allowsGetHead(request.method)) { return methodNotAllowedResponse(["GET", "HEAD"], options.mode); } return jsonResponse(createHealthResponse(), { method: request.method, mode: options.mode }); } if (url.pathname.startsWith("/api/") && options.store) { return handleApiRoute(url, request, options.store, options.mode); } if (url.pathname.startsWith("/api/")) { return jsonResponse(createApiError("Service not ready", 503), { method: request.method, mode: options.mode, status: 503, }); } if (options.staticAssets) { return serveStaticAsset(url.pathname, options.staticAssets, options.mode); } return new Response("开发期请通过 Vite 前端地址访问页面。", { status: 404, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); }; } function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: RuntimeMode): Response { const { method } = request; if (!allowsGetHead(method)) { return methodNotAllowedResponse(["GET", "HEAD"], mode); } if (url.pathname === "/api/summary") { return jsonResponse(createSummaryResponse(store), { method, mode }); } if (url.pathname === "/api/targets") { return jsonResponse(createTargetsResponse(store), { method, mode }); } const historyMatch = url.pathname.match(/^\/api\/targets\/([^/]+)\/history$/); if (historyMatch) { return handleHistory(historyMatch[1]!, url, method, store, mode); } const trendMatch = url.pathname.match(/^\/api\/targets\/([^/]+)\/trend$/); if (trendMatch) { return handleTrend(trendMatch[1]!, url, method, store, mode); } return jsonResponse(createApiError("API route not found", 404), { method, mode, status: 404 }); } function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response { const id = Number(idStr); if (!Number.isInteger(id) || id <= 0) { return jsonResponse(createApiError("Invalid target ID", 400), { method, mode, status: 400 }); } const target = store.getTargetById(id); if (!target) { return jsonResponse(createApiError("Target not found", 404), { method, mode, status: 404 }); } const limitParam = url.searchParams.get("limit"); let limit = 20; if (limitParam !== null) { limit = Number(limitParam); if (!Number.isInteger(limit) || limit <= 0) { return jsonResponse(createApiError("Invalid limit parameter", 400), { method, mode, status: 400 }); } } const rows = store.getHistory(id, limit); const results: CheckResult[] = rows.map(mapCheckResult); return jsonResponse(results, { method, mode }); } function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response { const id = Number(idStr); if (!Number.isInteger(id) || id <= 0) { return jsonResponse(createApiError("Invalid target ID", 400), { method, mode, status: 400 }); } const target = store.getTargetById(id); if (!target) { return jsonResponse(createApiError("Target not found", 404), { method, mode, status: 404 }); } const hoursParam = url.searchParams.get("hours"); let hours = 24; if (hoursParam !== null) { hours = Number(hoursParam); if (!Number.isInteger(hours) || hours <= 0) { return jsonResponse(createApiError("Invalid hours parameter", 400), { method, mode, status: 400 }); } } const trend: TrendPoint[] = store.getTrend(id, hours).map((row) => ({ hour: row.hour, avgLatencyMs: row.avgLatencyMs, availability: Math.round(row.availability * 100) / 100, totalChecks: row.totalChecks, })); return jsonResponse(trend, { method, mode }); } function createSummaryResponse(store: ProbeStore): SummaryResponse { const summary = store.getSummary(); return { total: summary.total, up: summary.up, down: summary.down, avgLatencyMs: summary.avgLatencyMs, lastCheckTime: summary.lastCheckTime, }; } function createTargetsResponse(store: ProbeStore): TargetStatus[] { const targets = store.getTargets(); return targets.map((target) => { const latest = store.getLatestCheck(target.id); const stats = store.getTargetStats(target.id); return { id: target.id, name: target.name, url: target.url, method: target.method, interval: formatDuration(target.interval_ms), latestCheck: latest ? mapCheckResult(latest) : null, sparkline: store.getSparkline(target.id), stats: { totalChecks: stats.totalChecks, availability: stats.availability, avgLatencyMs: stats.avgLatencyMs, p99LatencyMs: stats.p99LatencyMs, }, }; }); } function mapCheckResult(row: StoredCheckResult): CheckResult { return { timestamp: row.timestamp, success: row.success === 1, statusCode: row.status_code, latencyMs: row.latency_ms, error: row.error, matched: row.matched === 1, }; } function formatDuration(ms: number): string { if (ms >= 60000 && ms % 60000 === 0) return `${ms / 60000}m`; if (ms >= 1000 && ms % 1000 === 0) return `${ms / 1000}s`; return `${ms}ms`; } function createHealthResponse(): HealthResponse { return { ok: true, service: "gateway-checker", timestamp: new Date().toISOString(), }; } function createApiError(error: string, status: number): ApiErrorResponse { return { error, status }; } function allowsGetHead(method: string): boolean { return method === "GET" || method === "HEAD"; } function methodNotAllowedResponse(allow: string[], mode: RuntimeMode): Response { return jsonResponse(createApiError("Method not allowed", 405), { mode, status: 405, headers: { Allow: allow.join(", ") }, }); } function jsonResponse( body: unknown, options: { method?: string; mode: RuntimeMode; status?: number; headers?: HeadersInit }, ): Response { const headers = createHeaders(options.mode, { "Content-Type": "application/json; charset=utf-8", ...options.headers, }); const responseBody = options.method === "HEAD" ? null : JSON.stringify(body); return new Response(responseBody, { status: options.status, headers, }); } function serveStaticAsset(pathname: string, staticAssets: StaticAssets, mode: RuntimeMode): Response { if (pathname === "/") { return htmlResponse(staticAssets.indexHtml, mode); } const asset = staticAssets.files[pathname]; if (asset) { return new Response(asset, { headers: createHeaders(mode, { "Content-Type": contentTypeFor(pathname), "Cache-Control": "public, max-age=31536000, immutable", }), }); } if (pathname.startsWith("/assets/") || hasFileExtension(pathname)) { return new Response("Not Found", { status: 404, headers: createHeaders(mode, { "Content-Type": "text/plain; charset=utf-8" }), }); } return htmlResponse(staticAssets.indexHtml, mode); } function htmlResponse(indexHtml: Blob, mode: RuntimeMode): Response { return new Response(indexHtml, { headers: createHeaders(mode, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache", }), }); } function createHeaders(mode: RuntimeMode, init: HeadersInit): Headers { const headers = new Headers(init); if (mode === "production") { headers.set("X-Content-Type-Options", "nosniff"); headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); } return headers; } function hasFileExtension(pathname: string): boolean { return /\/[^/]+\.[^/]+$/.test(pathname); } function contentTypeFor(pathname: string): string { if (pathname.endsWith(".js") || pathname.endsWith(".mjs")) return "text/javascript; charset=utf-8"; if (pathname.endsWith(".css")) return "text/css; charset=utf-8"; if (pathname.endsWith(".svg")) return "image/svg+xml"; if (pathname.endsWith(".json")) return "application/json; charset=utf-8"; if (pathname.endsWith(".png")) return "image/png"; if (pathname.endsWith(".jpg") || pathname.endsWith(".jpeg")) return "image/jpeg"; if (pathname.endsWith(".ico")) return "image/x-icon"; return "application/octet-stream"; }