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

122
scripts/build.ts Normal file
View File

@@ -0,0 +1,122 @@
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
import { dirname, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { $ } from "bun";
const rootDir = fileURLToPath(new URL("../", import.meta.url));
const buildDir = fileURLToPath(new URL("../.build/", import.meta.url));
const webDistDir = fileURLToPath(new URL("../dist/web/", import.meta.url));
const executablePath = fileURLToPath(new URL("../dist/gateway-checker", import.meta.url));
const generatedAssetsPath = fileURLToPath(new URL("../.build/static-assets.ts", import.meta.url));
const generatedEntryPath = fileURLToPath(new URL("../.build/server-entry.ts", import.meta.url));
await rm(buildDir, { recursive: true, force: true });
await rm(executablePath, { force: true });
await mkdir(buildDir, { recursive: true });
await $`bunx --bun vite build`;
const files = await listFiles(webDistDir);
const indexPath = files.find((file) => normalize(relative(webDistDir, file)) === "index.html");
if (!indexPath) {
throw new Error("Vite build 未生成 dist/web/index.html");
}
const assetFiles = files.filter((file) => file !== indexPath);
await writeGeneratedAssets(indexPath, assetFiles);
await writeGeneratedEntry();
const target = process.env.BUN_TARGET ?? process.env.BUILD_TARGET;
const result = await Bun.build({
entrypoints: [generatedEntryPath],
compile: target
? {
target: target as Bun.Build.CompileTarget,
outfile: executablePath,
autoloadDotenv: true,
autoloadBunfig: true,
}
: {
outfile: executablePath,
autoloadDotenv: true,
autoloadBunfig: true,
},
minify: true,
sourcemap: "linked",
});
if (!result.success) {
await rm(executablePath, { force: true });
throw new Error("Bun executable 构建失败");
}
console.log(`Built executable: ${executablePath}`);
async function listFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const path = `${directory.replace(/\/$/, "")}/${entry.name}`;
if (entry.isDirectory()) {
return listFiles(path);
}
return [path];
}),
);
return files.flat();
}
async function writeGeneratedAssets(indexPath: string, assetFiles: string[]) {
const imports = [
`import type { StaticAssets } from "../src/server/app";`,
`import indexPath from "${toImportPath(indexPath)}" with { type: "file" };`,
...assetFiles.map(
(file, index) => `import asset${index}Path from "${toImportPath(file)}" with { type: "file" };`,
),
];
const assetEntries = assetFiles.map((file, index) => {
const urlPath = `/${normalize(relative(webDistDir, file))}`;
return ` ${JSON.stringify(urlPath)}: Bun.file(asset${index}Path),`;
});
const source = `${imports.join("\n")}
export const staticAssets: StaticAssets = {
indexHtml: Bun.file(indexPath),
files: {
${assetEntries.join("\n")}
},
};
`;
await mkdir(dirname(generatedAssetsPath), { recursive: true });
await writeFile(generatedAssetsPath, source);
}
async function writeGeneratedEntry() {
await writeFile(
generatedEntryPath,
`import { readRuntimeConfig } from "../src/server/config";
import { startServer } from "../src/server/server";
import { staticAssets } from "./static-assets";
startServer({
config: readRuntimeConfig(),
mode: "production",
staticAssets,
});
`,
);
}
function toImportPath(path: string): string {
const rel = normalize(relative(buildDir, path));
return rel.startsWith(".") ? rel : `./${rel}`;
}
function normalize(path: string): string {
return path.split(sep).join("/");
}

55
scripts/dev.ts Normal file
View File

@@ -0,0 +1,55 @@
interface ChildProcessInfo {
name: string;
process: Bun.Subprocess;
}
const env = {
...process.env,
BACKEND_PORT: process.env.BACKEND_PORT ?? process.env.PORT ?? "3000",
};
const children: ChildProcessInfo[] = [
{
name: "server",
process: Bun.spawn(["bun", "run", "dev:server"], {
env,
stdout: "inherit",
stderr: "inherit",
}),
},
{
name: "web",
process: Bun.spawn(["bun", "run", "dev:web"], {
env,
stdout: "inherit",
stderr: "inherit",
}),
},
];
const stopChildren = () => {
for (const child of children) {
child.process.kill();
}
};
process.on("SIGINT", () => {
stopChildren();
process.exit(130);
});
process.on("SIGTERM", () => {
stopChildren();
process.exit(143);
});
const firstExit = await Promise.race(
children.map(async (child) => ({ name: child.name, code: await child.process.exited })),
);
stopChildren();
if (firstExit.code !== 0) {
console.error(`${firstExit.name} exited with code ${firstExit.code}`);
process.exit(firstExit.code ?? 1);
}

130
scripts/smoke.ts Normal file
View File

@@ -0,0 +1,130 @@
import { access } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import type { DemoResponse, HealthResponse } from "../src/shared/api";
const executablePath = process.argv[2] ?? fileURLToPath(new URL("../dist/gateway-checker", import.meta.url));
await assertExecutableExists(executablePath);
const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const app = Bun.spawn([executablePath, "--host", "127.0.0.1", "--port", String(port)], {
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
HOST: "127.0.0.1",
PORT: String(port),
},
});
const stdout = readStream(app.stdout);
const stderr = readStream(app.stderr);
try {
await waitForServer(`${baseUrl}/health`);
const health = await expectJson<HealthResponse>(`${baseUrl}/health`, 200);
assert(health.ok === true, "健康检查响应缺少 ok=true");
const demo = await expectJson<DemoResponse>(`${baseUrl}/api/demo`, 200);
assert(demo.message.includes("/api/demo"), "demo 响应未包含预期 message");
const missingApi = await fetch(`${baseUrl}/api/not-found`);
assert(missingApi.status === 404, "未知 API 应返回 404");
assert(
missingApi.headers.get("content-type")?.includes("application/json") === true,
"未知 API 应返回 JSON",
);
const rootHtml = await expectText(`${baseUrl}/`, 200);
assert(rootHtml.includes("Gateway Checker Demo"), "前端根页面缺少 demo 标题");
const fallbackHtml = await expectText(`${baseUrl}/dashboard`, 200);
assert(fallbackHtml.includes("Gateway Checker Demo"), "SPA fallback 未返回前端入口页面");
const assetPath = rootHtml.match(/(?:src|href)="(\/assets\/[^"]+)"/)?.[1];
assert(assetPath !== undefined, "前端入口页面未引用 /assets/* 资源");
const asset = await fetch(`${baseUrl}${assetPath}`);
assert(asset.status === 200, `静态资源 ${assetPath} 未返回 200`);
console.log(`Smoke test passed: ${baseUrl}`);
} catch (error) {
app.kill();
const [out, err] = await Promise.all([stdout, stderr]);
const message = error instanceof Error ? error.message : String(error);
throw new Error(`executable smoke test 失败: ${message}\nstdout:\n${out}\nstderr:\n${err}`);
} finally {
app.kill();
}
async function assertExecutableExists(path: string) {
try {
await access(path);
} catch {
throw new Error(`找不到 executable: ${path},请先运行 bun run build`);
}
}
async function getFreePort(): Promise<number> {
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: () => new Response("ok"),
});
const port = server.port;
server.stop(true);
if (port === undefined) {
throw new Error("无法分配 smoke test 端口");
}
return port;
}
async function waitForServer(url: string) {
const deadline = Date.now() + 8_000;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
if (response.ok) return;
} catch {
await Bun.sleep(100);
}
}
throw new Error(`服务未在超时时间内启动: ${url}`);
}
async function expectJson<T>(url: string, status: number): Promise<T> {
const response = await fetch(url);
assert(response.status === status, `${url} 应返回 ${status},实际为 ${response.status}`);
assert(response.headers.get("content-type")?.includes("application/json") === true, `${url} 应返回 JSON`);
return (await response.json()) as T;
}
async function expectText(url: string, status: number): Promise<string> {
const response = await fetch(url);
assert(response.status === status, `${url} 应返回 ${status},实际为 ${response.status}`);
return response.text();
}
function assert(condition: boolean, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
async function readStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
if (!stream) return "";
return new Response(stream).text();
}