feat: 新增版本管理系统,重构 /health → /api/meta

This commit is contained in:
2026-05-24 23:32:19 +08:00
parent bc54f8352a
commit 7dc3a270ae
23 changed files with 450 additions and 111 deletions

View File

@@ -17,6 +17,7 @@ export interface BootstrapOptions {
configPath?: string;
mode: RuntimeMode;
staticAssets?: StartServerOptions["staticAssets"];
version?: string;
}
export async function bootstrap(options: BootstrapOptions, dependencies: BootstrapDependencies = {}): Promise<void> {
@@ -38,7 +39,7 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
onSignal("SIGINT", shutdown);
onSignal("SIGTERM", shutdown);
serve({ config, mode: options.mode, staticAssets: options.staticAssets });
serve({ config, mode: options.mode, staticAssets: options.staticAssets, version: options.version });
} catch (error) {
logError("启动失败:", error instanceof Error ? error.message : error);
process.exit(1);

View File

@@ -1,4 +1,4 @@
import type { ApiErrorResponse, HealthResponse, RuntimeMode } from "../shared/api";
import type { ApiErrorResponse, MetaResponse, RuntimeMode } from "../shared/api";
import { APP } from "../shared/app";
@@ -17,11 +17,12 @@ export function createHeaders(mode: RuntimeMode, init: HeadersInit): Headers {
return headers;
}
export function createHealthResponse(): HealthResponse {
export function createMetaResponse(version: string): MetaResponse {
return {
ok: true,
service: APP.name,
timestamp: new Date().toISOString(),
version,
};
}

View File

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

View File

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

View File

@@ -4,17 +4,24 @@ import type { StaticAssets } from "./static";
import { APP } from "../shared/app";
import { createApiError, jsonResponse } from "./helpers";
import { handleHealth } from "./routes/health";
import { handleMeta } from "./routes/meta";
import { serveStaticAsset } from "./static";
import { readAppVersion } from "./version";
export interface StartServerOptions {
config: ServerConfig;
mode: RuntimeMode;
staticAssets?: StaticAssets;
version?: string;
}
export function startServer(options: StartServerOptions) {
const { config, mode, staticAssets } = options;
const { config, mode, staticAssets, version } = options;
const resolveVersion = (): Promise<string> => {
if (version) return Promise.resolve(version);
return readAppVersion();
};
const server = Bun.serve({
fetch(req) {
@@ -27,8 +34,11 @@ export function startServer(options: StartServerOptions) {
port: config.port,
routes: {
"/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }),
"/health": {
GET: () => handleHealth(mode),
"/api/meta": {
GET: async () => {
const resolvedVersion = await resolveVersion();
return handleMeta(mode, resolvedVersion);
},
},
},
});

17
src/server/version.ts Normal file
View File

@@ -0,0 +1,17 @@
import { resolve } from "node:path";
import { validateVersion } from "../../scripts/bump-version-logic";
const PACKAGE_JSON_PATH = resolve(import.meta.dir, "..", "..", "package.json");
export async function readAppVersion(): Promise<string> {
const packageJson = (await Bun.file(PACKAGE_JSON_PATH).json()) as { version: string };
const version = packageJson.version;
if (typeof version !== "string") {
throw new Error("package.json does not have a valid version field");
}
validateVersion(version);
return version;
}

View File

@@ -3,10 +3,11 @@ export interface ApiErrorResponse {
status: number;
}
export interface HealthResponse {
export interface MetaResponse {
ok: true;
service: string;
timestamp: string;
version: string;
}
export type RuntimeMode = "development" | "production" | "test";

View File

@@ -3,5 +3,4 @@ export const APP = {
name: "my-app",
subtitle: "Bun 全栈应用",
title: "My App",
version: "0.1.0",
} as const;

View File

@@ -1,8 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { useLocation } from "react-router";
import { ChevronLeftIcon, ChevronRightIcon } from "tdesign-icons-react";
import { Button, Layout, RadioGroup } from "tdesign-react";
import type { MetaResponse } from "../shared/api";
import { APP } from "../shared/app";
import { Sidebar } from "./components/Sidebar";
import { useSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
@@ -22,6 +25,12 @@ export function App() {
const { preference: themePreference, setPreference: setThemePreference } = useThemePreference();
const { collapsed, toggleCollapsed } = useSidebarCollapsed();
const location = useLocation();
const { data: meta } = useQuery({
queryFn: fetchMeta,
queryKey: ["meta"],
refetchInterval: 30000,
staleTime: 5000,
});
useEffect(() => {
document.title = APP.title;
@@ -35,6 +44,7 @@ export function App() {
const currentPath = location.pathname;
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
const pageTitle = currentItem?.label ?? APP.title;
const versionDisplay = meta?.version ? `v${meta.version}` : null;
return (
<Layout className="app-layout">
@@ -44,6 +54,7 @@ export function App() {
{collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
</Button>
<span className="app-brand">{APP.title}</span>
{versionDisplay && <span className="app-version">{versionDisplay}</span>}
<span className="app-page-title">{pageTitle}</span>
</div>
<div className="app-header-right">
@@ -69,3 +80,9 @@ export function App() {
</Layout>
);
}
async function fetchMeta(): Promise<MetaResponse> {
const response = await fetch("/api/meta");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<MetaResponse>;
}

View File

@@ -1,14 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { Space } from "tdesign-react";
import type { HealthResponse } from "../../../shared/api";
import type { MetaResponse } from "../../../shared/api";
import { APP } from "../../../shared/app";
export function DashboardPage() {
const { data: health } = useQuery({
queryFn: fetchHealth,
queryKey: ["health"],
const { data: meta } = useQuery({
queryFn: fetchMeta,
queryKey: ["meta"],
refetchInterval: 30000,
staleTime: 5000,
});
@@ -16,14 +16,14 @@ export function DashboardPage() {
return (
<Space className="full-width-space" direction="vertical" size="large">
<h2>使 {APP.title}</h2>
<p> /health API </p>
{health && <pre className="health-response">{JSON.stringify(health, null, 2)}</pre>}
<p> /api/meta </p>
{meta && <pre className="meta-response">{JSON.stringify(meta, null, 2)}</pre>}
</Space>
);
}
async function fetchHealth(): Promise<HealthResponse> {
const response = await fetch("/health");
async function fetchMeta(): Promise<MetaResponse> {
const response = await fetch("/api/meta");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<HealthResponse>;
return response.json() as Promise<MetaResponse>;
}

View File

@@ -39,6 +39,14 @@
color: var(--td-text-color-primary);
font-size: calc(var(--td-font-size-title-large) + 6px);
font-weight: 700;
line-height: 64px;
}
.app-version {
color: var(--td-text-color-placeholder);
font-size: var(--td-font-size-body-small);
font-weight: 400;
line-height: 64px;
}
.app-page-title {
@@ -64,7 +72,7 @@
min-height: calc(100vh - 64px);
}
.health-response {
.meta-response {
background: var(--td-bg-color-component);
border-radius: var(--td-radius-default);
padding: var(--td-comp-paddingTB-l) var(--td-comp-paddingLR-l);