Initial commit

This commit is contained in:
2026-05-20 00:18:07 +08:00
commit e2bf594719
58 changed files with 5885 additions and 0 deletions

46
src/server/bootstrap.ts Normal file
View File

@@ -0,0 +1,46 @@
import type { RuntimeMode } from "../shared/api";
import type { ServerConfig } from "./config";
import type { StartServerOptions } from "./server";
import { loadServerConfig } from "./config";
import { startServer } from "./server";
export interface BootstrapDependencies {
loadConfig?: (configPath?: string) => Promise<ServerConfig>;
logError?: (...data: unknown[]) => void;
onSignal?: (signal: "SIGINT" | "SIGTERM", handler: () => void) => void;
startServer?: (options: StartServerOptions) => unknown;
}
export interface BootstrapOptions {
config?: ServerConfig;
configPath?: string;
mode: RuntimeMode;
staticAssets?: StartServerOptions["staticAssets"];
}
export async function bootstrap(options: BootstrapOptions, dependencies: BootstrapDependencies = {}): Promise<void> {
const load = dependencies.loadConfig ?? loadServerConfig;
const serve = dependencies.startServer ?? startServer;
const onSignal =
dependencies.onSignal ??
((signal: "SIGINT" | "SIGTERM", handler: () => void) => {
process.on(signal, handler);
});
const logError = dependencies.logError ?? console.error;
try {
const config = options.config ?? (await load(options.configPath));
const shutdown = () => {
process.exit(0);
};
onSignal("SIGINT", shutdown);
onSignal("SIGTERM", shutdown);
serve({ config, mode: options.mode, staticAssets: options.staticAssets });
} catch (error) {
logError("启动失败:", error instanceof Error ? error.message : error);
process.exit(1);
}
}

51
src/server/config.ts Normal file
View File

@@ -0,0 +1,51 @@
export interface ServerConfig {
host: string;
port: number;
}
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000;
interface YAMLConfigFile {
server?: YAMLServerBlock;
}
interface YAMLServerBlock {
host?: string;
port?: number;
}
export async function loadServerConfig(configPath?: string): Promise<ServerConfig> {
const fileConfig: { host?: string; port?: number } = {};
if (configPath) {
const file = Bun.file(configPath);
if (!(await file.exists())) {
throw new Error(`配置文件不存在: ${configPath}`);
}
const content = await file.text();
const parsed = Bun.YAML.parse(content) as YAMLConfigFile;
if (parsed.server) {
if (parsed.server.host !== undefined) fileConfig.host = parsed.server.host;
if (parsed.server.port !== undefined) fileConfig.port = parsed.server.port;
}
}
const envPortNum = parseInt(process.env["PORT"] ?? "", 10);
return {
host: process.env["HOST"] ?? fileConfig.host ?? DEFAULT_HOST,
port: !isNaN(envPortNum) ? envPortNum : (fileConfig.port ?? DEFAULT_PORT),
};
}
export function parseRuntimeArgs(argv: string[] = Bun.argv.slice(2)): { configPath?: string } {
if (argv.length === 0) return {};
const firstArg = argv[0];
if (firstArg === "--help" || firstArg === "-h") {
console.log("用法: {{app-name}} [config.yaml]");
console.log(" config.yaml 可选 YAML 配置文件路径(不存在时使用默认配置)");
process.exit(0);
}
return { configPath: firstArg };
}

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

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

45
src/server/helpers.ts Normal file
View File

@@ -0,0 +1,45 @@
import type { ApiErrorResponse, HealthResponse, RuntimeMode } from "../shared/api";
export function createApiError(error: string, status: number): ApiErrorResponse {
return { error, status };
}
export 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;
}
export function createHealthResponse(): HealthResponse {
return {
ok: true,
service: "{{app-name}}",
timestamp: new Date().toISOString(),
};
}
export 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`;
}
export function jsonResponse(
body: unknown,
options: { headers?: HeadersInit; mode: RuntimeMode; status?: number },
): Response {
const headers = createHeaders(options.mode, {
"Content-Type": "application/json; charset=utf-8",
...options.headers,
});
return new Response(JSON.stringify(body), {
headers,
status: options.status,
});
}

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

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

63
src/server/middleware.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { RuntimeMode } from "../shared/api";
import { createApiError, jsonResponse } from "./helpers";
const MAX_PAGE_SIZE = 200;
export function validateIdParam(idStr: string, mode: RuntimeMode): Response | { id: string } {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(idStr)) {
return jsonResponse(createApiError("Invalid ID parameter", 400), { mode, status: 400 });
}
return { id: idStr };
}
export function validatePagination(
pageParam: null | string,
pageSizeParam: null | string,
mode: RuntimeMode,
): Response | { page: number; pageSize: number } {
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), { mode, status: 400 });
}
}
if (pageSizeParam !== null) {
pageSize = Number(pageSizeParam);
if (!Number.isInteger(pageSize) || pageSize <= 0) {
return jsonResponse(createApiError("Invalid pageSize parameter", 400), { mode, status: 400 });
}
if (pageSize > MAX_PAGE_SIZE) {
return jsonResponse(createApiError(`pageSize must not exceed ${MAX_PAGE_SIZE}`, 400), { mode, status: 400 });
}
}
return { page, pageSize };
}
export function validateTimeRange(
from: null | string,
to: null | string,
mode: RuntimeMode,
): Response | { from: string; to: string } {
if (!from || !to) {
return jsonResponse(createApiError("from and to parameters are required", 400), { 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), { mode, status: 400 });
}
if (fromDate.getTime() > toDate.getTime()) {
return jsonResponse(createApiError("from must be earlier than to", 400), { mode, status: 400 });
}
return { from: fromDate.toISOString(), to: toDate.toISOString() };
}

View File

@@ -0,0 +1,7 @@
import type { RuntimeMode } from "../../shared/api";
import { createHealthResponse, jsonResponse } from "../helpers";
export function handleHealth(mode: RuntimeMode): Response {
return jsonResponse(createHealthResponse(), { mode });
}

38
src/server/server.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { RuntimeMode } from "../shared/api";
import type { ServerConfig } from "./config";
import type { StaticAssets } from "./static";
import { createApiError, jsonResponse } from "./helpers";
import { handleHealth } from "./routes/health";
import { serveStaticAsset } from "./static";
export interface StartServerOptions {
config: ServerConfig;
mode: RuntimeMode;
staticAssets?: StaticAssets;
}
export function startServer(options: StartServerOptions) {
const { config, mode, staticAssets } = options;
const server = Bun.serve({
fetch(req) {
if (staticAssets) {
return serveStaticAsset(new URL(req.url).pathname, staticAssets);
}
return new Response("Frontend is served by Vite dev server on :5173", { status: 404 });
},
hostname: config.host,
port: config.port,
routes: {
"/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }),
"/health": {
GET: () => handleHealth(mode),
},
},
});
console.log(`{{app-name}} listening on ${server.url}`);
return server;
}

60
src/server/static.ts Normal file
View File

@@ -0,0 +1,60 @@
export interface StaticAssets {
files: Record<string, Blob>;
indexHtml: Blob;
}
const CONTENT_TYPES: Record<string, string> = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".woff": "font/woff",
".woff2": "font/woff2",
};
export function contentTypeFor(path: string): string {
const dot = path.lastIndexOf(".");
if (dot === -1) return "application/octet-stream";
const ext = path.slice(dot);
return CONTENT_TYPES[ext] ?? "application/octet-stream";
}
export function hasFileExtension(path: string): boolean {
const lastSlash = path.lastIndexOf("/");
const segment = lastSlash === -1 ? path : path.slice(lastSlash + 1);
return segment.includes(".");
}
export function htmlResponse(html: Blob): Response {
return new Response(html, {
headers: {
"Cache-Control": "no-cache",
"Content-Type": "text/html; charset=utf-8",
},
});
}
export function serveStaticAsset(pathname: string, assets: StaticAssets): Response {
if (pathname === "/") {
return htmlResponse(assets.indexHtml);
}
const file = assets.files[pathname];
if (file) {
return new Response(file, {
headers: {
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": contentTypeFor(pathname),
},
});
}
if (hasFileExtension(pathname)) {
return new Response("Not found", { status: 404 });
}
return htmlResponse(assets.indexHtml);
}