Initial commit
This commit is contained in:
46
src/server/bootstrap.ts
Normal file
46
src/server/bootstrap.ts
Normal 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
51
src/server/config.ts
Normal 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
12
src/server/dev.ts
Normal 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
45
src/server/helpers.ts
Normal 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
12
src/server/main.ts
Normal 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
63
src/server/middleware.ts
Normal 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() };
|
||||
}
|
||||
7
src/server/routes/health.ts
Normal file
7
src/server/routes/health.ts
Normal 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
38
src/server/server.ts
Normal 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
60
src/server/static.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user