1
0

feat: 搭建前后端可执行程序示例

This commit is contained in:
2026-05-09 12:25:39 +08:00
commit 5b412c624d
27 changed files with 1860 additions and 0 deletions

111
src/server/app.ts Normal file
View File

@@ -0,0 +1,111 @@
import type { ApiErrorResponse, DemoResponse, HealthResponse, RuntimeMode } from "../shared/api";
export interface StaticAssets {
indexHtml: Blob;
files: Record<string, Blob>;
}
export interface AppOptions {
mode: RuntimeMode;
staticAssets?: StaticAssets;
}
export function createFetchHandler(options: AppOptions) {
return (request: Request): Response => {
const url = new URL(request.url);
if (url.pathname === "/health") {
return Response.json(createHealthResponse());
}
if (url.pathname === "/api/demo") {
return Response.json(createDemoResponse(options.mode));
}
if (url.pathname.startsWith("/api/")) {
return Response.json(createApiError("API route not found", 404), { status: 404 });
}
if (options.staticAssets) {
return serveStaticAsset(url.pathname, options.staticAssets);
}
return new Response("开发期请通过 Vite 前端地址访问页面。", {
status: 404,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
};
}
function createDemoResponse(mode: RuntimeMode): DemoResponse {
return {
message: "Bun 后端已通过 /api/demo 连接到 React 前端。",
runtime: {
mode,
bunVersion: Bun.version,
platform: process.platform,
arch: process.arch,
timestamp: new Date().toISOString(),
},
};
}
function createHealthResponse(): HealthResponse {
return {
ok: true,
service: "gateway-checker",
timestamp: new Date().toISOString(),
};
}
function createApiError(error: string, status: number): ApiErrorResponse {
return { error, status };
}
function serveStaticAsset(pathname: string, staticAssets: StaticAssets): Response {
if (pathname === "/") {
return htmlResponse(staticAssets.indexHtml);
}
const asset = staticAssets.files[pathname];
if (asset) {
return new Response(asset, {
headers: {
"Content-Type": contentTypeFor(pathname),
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
if (pathname.startsWith("/assets/") || hasFileExtension(pathname)) {
return new Response("Not Found", { status: 404 });
}
return htmlResponse(staticAssets.indexHtml);
}
function htmlResponse(indexHtml: Blob): Response {
return new Response(indexHtml, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-cache",
},
});
}
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";
}

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

@@ -0,0 +1,39 @@
export interface RuntimeConfig {
host: string;
port: number;
}
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000;
export function readRuntimeConfig(
argv: string[] = process.argv.slice(2),
env: Record<string, string | undefined> = Bun.env,
): RuntimeConfig {
const host = readOption(argv, "host") ?? env.HOST ?? DEFAULT_HOST;
const portValue = readOption(argv, "port") ?? env.PORT ?? String(DEFAULT_PORT);
const port = Number(portValue);
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`无效端口: ${portValue}`);
}
return { host, port };
}
function readOption(argv: string[], name: string): string | undefined {
const prefix = `--${name}=`;
const inline = argv.find((value) => value.startsWith(prefix));
if (inline) {
return inline.slice(prefix.length);
}
const index = argv.indexOf(`--${name}`);
if (index >= 0) {
return argv[index + 1];
}
return undefined;
}

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

@@ -0,0 +1,7 @@
import { readRuntimeConfig } from "./config";
import { startServer } from "./server";
startServer({
config: readRuntimeConfig(),
mode: "development",
});

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

@@ -0,0 +1,25 @@
import type { RuntimeMode } from "../shared/api";
import { createFetchHandler, type StaticAssets } from "./app";
import { readRuntimeConfig, type RuntimeConfig } from "./config";
export interface StartServerOptions {
config?: RuntimeConfig;
mode: RuntimeMode;
staticAssets?: StaticAssets;
}
export function startServer(options: StartServerOptions) {
const config = options.config ?? readRuntimeConfig();
const server = Bun.serve({
hostname: config.host,
port: config.port,
fetch: createFetchHandler({
mode: options.mode,
staticAssets: options.staticAssets,
}),
});
console.log(`Gateway Checker listening on ${server.url}`);
return server;
}

23
src/shared/api.ts Normal file
View File

@@ -0,0 +1,23 @@
export type RuntimeMode = "development" | "production" | "test";
export interface DemoResponse {
message: string;
runtime: {
mode: RuntimeMode;
bunVersion: string;
platform: string;
arch: string;
timestamp: string;
};
}
export interface HealthResponse {
ok: true;
service: "gateway-checker";
timestamp: string;
}
export interface ApiErrorResponse {
error: string;
status: number;
}

91
src/web/app.tsx Normal file
View File

@@ -0,0 +1,91 @@
import { useEffect, useState } from "react";
import type { DemoResponse } from "../shared/api";
type DemoState =
| { status: "loading" }
| { status: "success"; data: DemoResponse }
| { status: "error"; message: string };
export function App() {
const [demoState, setDemoState] = useState<DemoState>({ status: "loading" });
useEffect(() => {
const abortController = new AbortController();
async function loadDemo() {
try {
const response = await fetch("/api/demo", { signal: abortController.signal });
if (!response.ok) {
throw new Error(`请求失败: ${response.status}`);
}
const data = (await response.json()) as DemoResponse;
setDemoState({ status: "success", data });
} catch (error) {
if (abortController.signal.aborted) return;
setDemoState({
status: "error",
message: error instanceof Error ? error.message : "未知错误",
});
}
}
void loadDemo();
return () => abortController.abort();
}, []);
return (
<main className="shell">
<section className="hero" aria-labelledby="page-title">
<p className="eyebrow">Vite + React + Bun</p>
<h1 id="page-title">Gateway Checker Demo</h1>
<p className="summary">Bun API </p>
</section>
<section className="card" aria-live="polite">
<div className="card-header">
<span className="status-dot" data-state={demoState.status} />
<h2></h2>
</div>
{demoState.status === "loading" ? <p> /api/demo...</p> : null}
{demoState.status === "error" ? (
<div className="error">
<strong></strong>
<p>{demoState.message}</p>
</div>
) : null}
{demoState.status === "success" ? (
<div className="result">
<p className="message">{demoState.data.message}</p>
<dl>
<div>
<dt></dt>
<dd>{demoState.data.runtime.mode}</dd>
</div>
<div>
<dt>Bun </dt>
<dd>{demoState.data.runtime.bunVersion}</dd>
</div>
<div>
<dt></dt>
<dd>
{demoState.data.runtime.platform}/{demoState.data.runtime.arch}
</dd>
</div>
<div>
<dt></dt>
<dd>{demoState.data.runtime.timestamp}</dd>
</div>
</dl>
</div>
) : null}
</section>
</main>
);
}

13
src/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Gateway Checker Vite React Bun executable demo" />
<title>Gateway Checker Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

16
src/web/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app";
import "./styles.css";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("找不到前端挂载节点 #root");
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
);

169
src/web/styles.css Normal file
View File

@@ -0,0 +1,169 @@
:root {
color: #102033;
background: #edf3f8;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
}
.shell {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 460px);
gap: 32px;
align-items: center;
min-height: 100vh;
padding: 56px;
background:
radial-gradient(circle at top left, rgba(55, 125, 255, 0.18), transparent 34rem),
linear-gradient(135deg, #f8fbff 0%, #e3edf7 100%);
}
.hero,
.card {
border: 1px solid rgba(49, 83, 126, 0.14);
border-radius: 28px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 24px 80px rgba(34, 57, 91, 0.16);
backdrop-filter: blur(18px);
}
.hero {
padding: 48px;
}
.eyebrow {
margin: 0 0 18px;
color: #356dd2;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
max-width: 760px;
margin-bottom: 20px;
font-size: clamp(3rem, 8vw, 7rem);
line-height: 0.9;
letter-spacing: -0.08em;
}
.summary {
max-width: 620px;
margin-bottom: 0;
color: #42546c;
font-size: 1.2rem;
line-height: 1.8;
}
.card {
padding: 32px;
}
.card-header {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 24px;
}
.card-header h2 {
margin: 0;
font-size: 1.25rem;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 999px;
background: #f5a524;
box-shadow: 0 0 0 8px rgba(245, 165, 36, 0.14);
}
.status-dot[data-state="success"] {
background: #1fbf75;
box-shadow: 0 0 0 8px rgba(31, 191, 117, 0.14);
}
.status-dot[data-state="error"] {
background: #e5484d;
box-shadow: 0 0 0 8px rgba(229, 72, 77, 0.14);
}
.error {
padding: 16px;
border: 1px solid rgba(229, 72, 77, 0.25);
border-radius: 18px;
color: #9f2228;
background: rgba(255, 240, 240, 0.8);
}
.error p,
.message {
margin-bottom: 0;
}
.result {
display: grid;
gap: 24px;
}
.message {
color: #1c3f73;
font-size: 1.05rem;
font-weight: 700;
line-height: 1.6;
}
dl {
display: grid;
gap: 12px;
margin: 0;
}
dl div {
display: grid;
gap: 4px;
padding: 14px 16px;
border-radius: 16px;
background: rgba(236, 243, 252, 0.74);
}
dt {
color: #61728a;
font-size: 0.78rem;
}
dd {
margin: 0;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
@media (max-width: 860px) {
.shell {
grid-template-columns: 1fr;
padding: 24px;
}
.hero,
.card {
padding: 28px;
border-radius: 22px;
}
}