1
0

refactor: 迁移 Bun fullstack 架构

This commit is contained in:
2026-05-14 00:23:37 +08:00
parent bcfac52112
commit 6e485cc991
36 changed files with 403 additions and 1081 deletions

View File

@@ -1,91 +0,0 @@
import type { RuntimeMode } from "../shared/api";
import type { ProbeStore } from "./checker/store";
import { createApiError, jsonResponse } from "./helpers";
import { guardGetHead } from "./middleware";
import { handleHealth } from "./routes/health";
import { handleHistory } from "./routes/history";
import { handleMeta } from "./routes/meta";
import { handleSummary } from "./routes/summary";
import { handleTargets } from "./routes/targets";
import { handleTrend } from "./routes/trend";
import { serveStaticAsset } from "./static";
export interface AppOptions {
mode: RuntimeMode;
staticAssets?: StaticAssets;
store?: ProbeStore;
}
export interface StaticAssets {
files: Record<string, Blob>;
indexHtml: Blob;
}
export function createFetchHandler(options: AppOptions) {
return (request: Request): Response => {
const url = new URL(request.url);
if (url.pathname === "/health") {
return handleHealth(request.method, options.mode);
}
if (url.pathname === "/api/meta") {
return handleMetaRoute(request, 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 前端地址访问页面。", {
headers: { "Content-Type": "text/plain; charset=utf-8" },
status: 404,
});
};
}
function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: RuntimeMode): Response {
const guardResult = guardGetHead(request.method, mode);
if (guardResult) return guardResult;
const method = request.method;
if (url.pathname === "/api/summary") {
return handleSummary(store, method, mode);
}
if (url.pathname === "/api/targets") {
return handleTargets(store, method, mode);
}
const historyMatch = /^\/api\/targets\/([^/]+)\/history$/.exec(url.pathname);
if (historyMatch) {
return handleHistory(historyMatch[1]!, url, method, store, mode);
}
const trendMatch = /^\/api\/targets\/([^/]+)\/trend$/.exec(url.pathname);
if (trendMatch) {
return handleTrend(trendMatch[1]!, url, method, store, mode);
}
return jsonResponse(createApiError("API route not found", 404), { method, mode, status: 404 });
}
function handleMetaRoute(request: Request, mode: RuntimeMode): Response {
const guardResult = guardGetHead(request.method, mode);
if (guardResult) return guardResult;
return handleMeta(request.method, mode);
}

View File

@@ -1,7 +1,6 @@
import { join } from "node:path";
import type { RuntimeMode } from "../shared/api";
import type { StaticAssets } from "./app";
import type { StartServerOptions } from "./server";
import { loadConfig, type ResolvedConfig } from "./checker/config-loader";
@@ -27,7 +26,6 @@ export interface BootstrapDependencies {
export interface BootstrapOptions {
configPath: string;
mode: RuntimeMode;
staticAssets?: StaticAssets;
}
type BootstrapEngine = Pick<ProbeEngine, "start" | "stop">;
@@ -71,7 +69,6 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
serve({
config: { host: config.host, port: config.port },
mode: options.mode,
staticAssets: options.staticAssets,
store,
});
} catch (error) {

View File

@@ -1,10 +1,6 @@
import type { ApiErrorResponse, CheckFailure, CheckResult, HealthResponse, RuntimeMode } from "../shared/api";
import type { StoredCheckResult } from "./checker/types";
export function allowsGetHead(method: string): boolean {
return method === "GET" || method === "HEAD";
}
export function createApiError(error: string, status: number): ApiErrorResponse {
return { error, status };
}
@@ -36,15 +32,14 @@ export function formatDuration(ms: number): string {
export function jsonResponse(
body: unknown,
options: { headers?: HeadersInit; method?: string; mode: RuntimeMode; status?: number },
options: { headers?: HeadersInit; mode: RuntimeMode; status?: number },
): 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, {
return new Response(JSON.stringify(body), {
headers,
status: options.status,
});
@@ -69,11 +64,3 @@ export function mapCheckResult(row: StoredCheckResult): CheckResult {
timestamp: row.timestamp,
};
}
export function methodNotAllowedResponse(allow: string[], mode: RuntimeMode): Response {
return jsonResponse(createApiError("Method not allowed", 405), {
headers: { Allow: allow.join(", ") },
mode,
status: 405,
});
}

12
src/server/main.ts Normal file
View File

@@ -0,0 +1,12 @@
import { bootstrap } from "./bootstrap";
import { readRuntimeConfig } from "./config";
async function main() {
const { configPath } = readRuntimeConfig();
await bootstrap({ configPath, mode: "production" });
}
void main().catch((error) => {
console.error("启动失败:", error instanceof Error ? error.message : error);
process.exit(1);
});

View File

@@ -1,16 +1,9 @@
import type { RuntimeMode } from "../shared/api";
import { allowsGetHead, createApiError, jsonResponse, methodNotAllowedResponse } from "./helpers";
import { createApiError, jsonResponse } from "./helpers";
const MAX_PAGE_SIZE = 200;
export function guardGetHead(method: string, mode: RuntimeMode): null | Response {
if (!allowsGetHead(method)) {
return methodNotAllowedResponse(["GET", "HEAD"], mode);
}
return null;
}
export function validatePagination(
pageParam: null | string,
pageSizeParam: null | string,

View File

@@ -1,11 +1,7 @@
import type { RuntimeMode } from "../../shared/api";
import { allowsGetHead, createHealthResponse, jsonResponse, methodNotAllowedResponse } from "../helpers";
import { createHealthResponse, jsonResponse } from "../helpers";
export function handleHealth(method: string, mode: RuntimeMode): Response {
if (!allowsGetHead(method)) {
return methodNotAllowedResponse(["GET", "HEAD"], mode);
}
return jsonResponse(createHealthResponse(), { method, mode });
export function handleHealth(mode: RuntimeMode): Response {
return jsonResponse(createHealthResponse(), { mode });
}

View File

@@ -4,13 +4,13 @@ import type { ProbeStore } from "../checker/store";
import { jsonResponse, mapCheckResult } from "../helpers";
import { validatePagination, validateTargetId, validateTimeRange } from "../middleware";
export function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
export function handleHistory(idStr: string, url: URL, store: ProbeStore, mode: RuntimeMode): Response {
const idResult = validateTargetId(idStr, mode);
if (idResult instanceof Response) return idResult;
const target = store.getTargetById(idResult.id);
if (!target) {
return jsonResponse({ error: "Target not found", status: 404 } as const, { method, mode, status: 404 });
return jsonResponse({ error: "Target not found", status: 404 } as const, { mode, status: 404 });
}
const timeResult = validateTimeRange(url.searchParams.get("from"), url.searchParams.get("to"), mode);
@@ -27,5 +27,5 @@ export function handleHistory(idStr: string, url: URL, method: string, store: Pr
total: result.total,
};
return jsonResponse(response, { method, mode });
return jsonResponse(response, { mode });
}

View File

@@ -3,10 +3,10 @@ import type { MetaResponse, RuntimeMode } from "../../shared/api";
import { checkerRegistry } from "../checker/runner";
import { jsonResponse } from "../helpers";
export function handleMeta(method: string, mode: RuntimeMode): Response {
export function handleMeta(mode: RuntimeMode): Response {
const response: MetaResponse = {
checkerTypes: checkerRegistry.supportedTypes,
};
return jsonResponse(response, { method, mode });
return jsonResponse(response, { mode });
}

View File

@@ -3,7 +3,7 @@ import type { ProbeStore } from "../checker/store";
import { jsonResponse } from "../helpers";
export function handleSummary(store: ProbeStore, method: string, mode: RuntimeMode): Response {
export function handleSummary(store: ProbeStore, mode: RuntimeMode): Response {
const summary = store.getSummary();
const response: SummaryResponse = {
down: summary.down,
@@ -12,5 +12,5 @@ export function handleSummary(store: ProbeStore, method: string, mode: RuntimeMo
up: summary.up,
};
return jsonResponse(response, { method, mode });
return jsonResponse(response, { mode });
}

View File

@@ -3,7 +3,7 @@ import type { ProbeStore } from "../checker/store";
import { formatDuration, jsonResponse, mapCheckResult } from "../helpers";
export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMode): Response {
export function handleTargets(store: ProbeStore, mode: RuntimeMode): Response {
const targets = store.getTargets();
const latestChecksMap = store.getLatestChecksMap();
const allStats = store.getAllTargetStats();
@@ -34,5 +34,5 @@ export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMo
};
});
return jsonResponse(result, { method, mode });
return jsonResponse(result, { mode });
}

View File

@@ -4,13 +4,13 @@ import type { ProbeStore } from "../checker/store";
import { jsonResponse } from "../helpers";
import { validateTargetId, validateTimeRange } from "../middleware";
export function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
export function handleTrend(idStr: string, url: URL, store: ProbeStore, mode: RuntimeMode): Response {
const idResult = validateTargetId(idStr, mode);
if (idResult instanceof Response) return idResult;
const target = store.getTargetById(idResult.id);
if (!target) {
return jsonResponse({ error: "Target not found", status: 404 } as const, { method, mode, status: 404 });
return jsonResponse({ error: "Target not found", status: 404 } as const, { mode, status: 404 });
}
const timeResult = validateTimeRange(url.searchParams.get("from"), url.searchParams.get("to"), mode);
@@ -23,5 +23,5 @@ export function handleTrend(idStr: string, url: URL, method: string, store: Prob
totalChecks: row.totalChecks,
}));
return jsonResponse(trend, { method, mode });
return jsonResponse(trend, { mode });
}

View File

@@ -1,27 +1,54 @@
import type { RuntimeMode } from "../shared/api";
import type { StaticAssets } from "./app";
import type { ProbeStore } from "./checker/store";
import type { RuntimeConfig } from "./config";
import { createFetchHandler } from "./app";
import homepage from "../web/index.html";
import { createApiError, jsonResponse } from "./helpers";
import { handleHealth } from "./routes/health";
import { handleHistory } from "./routes/history";
import { handleMeta } from "./routes/meta";
import { handleSummary } from "./routes/summary";
import { handleTargets } from "./routes/targets";
import { handleTrend } from "./routes/trend";
export interface StartServerOptions {
config: RuntimeConfig;
mode: RuntimeMode;
staticAssets?: StaticAssets;
store?: ProbeStore;
store: ProbeStore;
}
export function startServer(options: StartServerOptions) {
const { config, mode, staticAssets, store } = options;
const { config, mode, store } = options;
const server = Bun.serve({
fetch: createFetchHandler({
mode,
staticAssets,
store,
}),
development: mode === "development" ? { console: true, hmr: true } : false,
fetch() {
return new Response("Not found", { status: 404 });
},
hostname: config.host,
port: config.port,
routes: {
"/*": homepage,
"/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }),
"/api/meta": {
GET: () => handleMeta(mode),
},
"/api/summary": {
GET: () => handleSummary(store, mode),
},
"/api/targets": {
GET: () => handleTargets(store, mode),
},
"/api/targets/:id/history": {
GET: (req) => handleHistory(req.params.id, new URL(req.url), store, mode),
},
"/api/targets/:id/trend": {
GET: (req) => handleTrend(req.params.id, new URL(req.url), store, mode),
},
"/health": {
GET: () => handleHealth(mode),
},
},
});
console.log(`DiAL listening on ${server.url}`);

View File

@@ -1,55 +0,0 @@
import type { RuntimeMode } from "../shared/api";
import type { StaticAssets } from "./app";
import { createHeaders } from "./helpers";
export 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";
}
export function hasFileExtension(pathname: string): boolean {
return /\/[^/]+\.[^/]+$/.test(pathname);
}
export function htmlResponse(indexHtml: Blob, mode: RuntimeMode): Response {
return new Response(indexHtml, {
headers: createHeaders(mode, {
"Cache-Control": "no-cache",
"Content-Type": "text/html; charset=utf-8",
}),
});
}
export 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, {
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": contentTypeFor(pathname),
}),
});
}
if (pathname.startsWith("/assets/") || hasFileExtension(pathname)) {
return new Response("Not Found", {
headers: createHeaders(mode, { "Content-Type": "text/plain; charset=utf-8" }),
status: 404,
});
}
return htmlResponse(staticAssets.indexHtml, mode);
}