- 新增 body 分组校验:contains、regex、json(JSONPath)、css(CSS选择器)、xpath - 新增操作符系统:equals、contains、match、empty、exists、gte、lte、gt、lt - 新增 headers 响应头校验 - 引入 cheerio、xpath、@xmldom/xmldom 依赖 - BREAKING: expect.bodyContains 迁移至 expect.body.contains
301 lines
8.6 KiB
TypeScript
301 lines
8.6 KiB
TypeScript
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<string, Blob>;
|
|
}
|
|
|
|
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";
|
|
}
|