import type { ApiErrorResponse, CheckFailure, CheckResult, HealthResponse, HistoryResponse, 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 from = url.searchParams.get("from"); const to = url.searchParams.get("to"); if (!from || !to) { return jsonResponse(createApiError("from and to parameters are required", 400), { method, mode, status: 400 }); } const fromDate = new Date(from); const toDate = new Date(to); if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) { return jsonResponse(createApiError("Invalid from or to parameter format", 400), { method, mode, status: 400 }); } const pageParam = url.searchParams.get("page"); const pageSizeParam = url.searchParams.get("pageSize"); let page = 1; let pageSize = 20; if (pageParam !== null) { page = Number(pageParam); if (!Number.isInteger(page) || page <= 0) { return jsonResponse(createApiError("Invalid page parameter", 400), { method, mode, status: 400 }); } } if (pageSizeParam !== null) { pageSize = Number(pageSizeParam); if (!Number.isInteger(pageSize) || pageSize <= 0) { return jsonResponse(createApiError("Invalid pageSize parameter", 400), { method, mode, status: 400 }); } } const result = store.getHistory(id, from, to, page, pageSize); const response: HistoryResponse = { items: result.items.map(mapCheckResult), total: result.total, page: result.page, pageSize: result.pageSize, }; return jsonResponse(response, { 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 from = url.searchParams.get("from"); const to = url.searchParams.get("to"); if (!from || !to) { return jsonResponse(createApiError("from and to parameters are required", 400), { method, mode, status: 400 }); } const fromDate = new Date(from); const toDate = new Date(to); if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) { return jsonResponse(createApiError("Invalid from or to parameter format", 400), { method, mode, status: 400 }); } const trend: TrendPoint[] = store.getTrend(id, from, to).map((row) => ({ hour: row.hour, avgDurationMs: row.avgDurationMs, 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, 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); const recentSamples = store.getRecentSamples(target.id, 30); return { id: target.id, name: target.name, type: target.type, target: target.target, group: target.grp, interval: formatDuration(target.interval_ms), latestCheck: latest ? mapCheckResult(latest) : null, recentSamples: recentSamples.map((s) => ({ timestamp: s.timestamp, durationMs: s.duration_ms, up: s.matched === 1, })), stats: { totalChecks: stats.totalChecks, availability: stats.availability, }, }; }); } function mapCheckResult(row: StoredCheckResult): CheckResult { let failure: CheckFailure | null = null; if (row.failure) { try { failure = JSON.parse(row.failure) as CheckFailure; } catch { console.warn(`无法解析 failure 数据: target_id=${row.target_id}, timestamp=${row.timestamp}`); failure = null; } } return { timestamp: row.timestamp, matched: row.matched === 1, durationMs: row.duration_ms, statusDetail: row.status_detail, failure, }; } 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: "dial-server", 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"; }