refactor: 迁移 Bun fullstack 架构
This commit is contained in:
102
scripts/build.ts
102
scripts/build.ts
@@ -1,30 +1,10 @@
|
||||
import { $ } from "bun";
|
||||
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, relative, sep } from "node:path";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node: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/dial-server", 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));
|
||||
const entrypoint = fileURLToPath(new URL("../src/server/main.ts", import.meta.url));
|
||||
|
||||
await rm(buildDir, { force: true, recursive: 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({
|
||||
@@ -40,86 +20,14 @@ const result = await Bun.build({
|
||||
autoloadDotenv: true,
|
||||
outfile: executablePath,
|
||||
},
|
||||
entrypoints: [generatedEntryPath],
|
||||
entrypoints: [entrypoint],
|
||||
minify: true,
|
||||
sourcemap: "linked",
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
await rm(executablePath, { force: true });
|
||||
throw new Error("Bun executable 构建失败");
|
||||
console.error("构建失败:", result.logs);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Built executable: ${executablePath}`);
|
||||
|
||||
await rm(buildDir, { force: true, recursive: true });
|
||||
|
||||
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().sort((left, right) => normalize(left).localeCompare(normalize(right)));
|
||||
}
|
||||
|
||||
function normalize(path: string): string {
|
||||
return path.split(sep).join("/");
|
||||
}
|
||||
|
||||
function toImportPath(path: string): string {
|
||||
const rel = normalize(relative(buildDir, path));
|
||||
return rel.startsWith(".") ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
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 { bootstrap } from "../src/server/bootstrap";
|
||||
import { readRuntimeConfig } from "../src/server/config";
|
||||
import { staticAssets } from "./static-assets";
|
||||
|
||||
async function main() {
|
||||
const { configPath } = readRuntimeConfig();
|
||||
await bootstrap({ configPath, mode: "production", staticAssets });
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error("启动失败:", error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
interface ChildProcessInfo {
|
||||
name: string;
|
||||
process: Bun.Subprocess;
|
||||
}
|
||||
|
||||
const configPath = process.argv[2];
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
BACKEND_PORT: process.env["PORT"] ?? "3000",
|
||||
};
|
||||
|
||||
const children: ChildProcessInfo[] = [
|
||||
{
|
||||
name: "server",
|
||||
process: Bun.spawn(["bun", "run", "dev:server", ...(configPath ? [configPath] : [])], {
|
||||
env,
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "web",
|
||||
process: Bun.spawn(["bun", "run", "dev:web"], {
|
||||
env,
|
||||
stderr: "inherit",
|
||||
stdout: "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) => ({ code: await child.process.exited, name: child.name })),
|
||||
);
|
||||
|
||||
stopChildren();
|
||||
|
||||
if (firstExit.code !== 0) {
|
||||
console.error(`${firstExit.name} exited with code ${firstExit.code}`);
|
||||
process.exit(firstExit.code ?? 1);
|
||||
}
|
||||
168
scripts/smoke.ts
168
scripts/smoke.ts
@@ -1,168 +0,0 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { access } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { HealthResponse, SummaryResponse } from "../src/shared/api";
|
||||
|
||||
const executablePath = process.argv[2] ?? fileURLToPath(new URL("../dist/dial-server", import.meta.url));
|
||||
|
||||
await assertExecutableExists(executablePath);
|
||||
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "dial-smoke-"));
|
||||
const configPath = join(tempDir, "probes.yaml");
|
||||
|
||||
const port = getFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`server:
|
||||
port: ${port}
|
||||
targets:
|
||||
- name: "httpbin"
|
||||
type: http
|
||||
http:
|
||||
url: "https://httpbin.org/get"
|
||||
interval: "5m"
|
||||
timeout: "15s"
|
||||
expect:
|
||||
status: [200]
|
||||
`,
|
||||
);
|
||||
const app = Bun.spawn([executablePath, configPath], {
|
||||
env: { ...process.env },
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
});
|
||||
const stdout = readStream(app.stdout);
|
||||
const stderr = readStream(app.stderr);
|
||||
|
||||
try {
|
||||
await waitForServer(`${baseUrl}/health`);
|
||||
|
||||
const { body: health, response: healthResponse } = await expectJson<HealthResponse>(`${baseUrl}/health`, 200);
|
||||
assert(health.ok === true, "健康检查响应缺少 ok=true");
|
||||
assertSecurityHeaders(healthResponse, "/health");
|
||||
|
||||
const { body: summary } = await expectJson<SummaryResponse>(`${baseUrl}/api/summary`, 200);
|
||||
assert(summary.total === 1, "总览统计: total 应为 1");
|
||||
assertSecurityHeaders(await fetch(`${baseUrl}/api/summary`), "/api/summary");
|
||||
|
||||
const { body: targets } = await expectJson<unknown[]>(`${baseUrl}/api/targets`, 200);
|
||||
assert(Array.isArray(targets), "/api/targets 应返回数组");
|
||||
assert(targets.length === 1, "/api/targets 应有 1 个目标");
|
||||
assert((targets[0] as { name: string }).name === "httpbin", "目标名称应为 httpbin");
|
||||
|
||||
const missingApi = await fetch(`${baseUrl}/api/not-found`);
|
||||
assert(missingApi.status === 404, "未知 API 应返回 404");
|
||||
|
||||
const missingTarget = await fetch(`${baseUrl}/api/targets/99999/history`);
|
||||
assert(missingTarget.status === 404, "不存在的目标应返回 404");
|
||||
|
||||
const { body: rootHtml, response: rootResponse } = await expectText(`${baseUrl}/`, 200);
|
||||
assert(rootHtml.includes("DiAL"), "前端根页面缺少标题");
|
||||
assert(rootResponse.headers.get("cache-control") === "no-cache", "前端根页面应使用 no-cache");
|
||||
|
||||
const { body: fallbackHtml } = await expectText(`${baseUrl}/dashboard`, 200);
|
||||
assert(fallbackHtml.includes("DiAL"), "SPA fallback 未返回前端入口页面");
|
||||
|
||||
const assetPath = /(?:src|href)="(\/assets\/[^"]+)"/.exec(rootHtml)?.[1];
|
||||
assert(assetPath !== undefined, "前端入口页面未引用 /assets/* 资源");
|
||||
|
||||
const asset = await fetch(`${baseUrl}${assetPath}`);
|
||||
assert(asset.status === 200, `静态资源 ${assetPath} 未返回 200`);
|
||||
|
||||
const missingAsset = await expectText(`${baseUrl}/assets/not-found.js`, 404);
|
||||
assert(!missingAsset.body.includes("DiAL"), "未知静态资源不应返回前端入口页面");
|
||||
|
||||
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}`, { cause: error });
|
||||
} finally {
|
||||
app.kill();
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertExecutableExists(path: string) {
|
||||
try {
|
||||
await access(path);
|
||||
} catch (error) {
|
||||
throw new Error(`找不到 executable: ${path},请先运行 bun run build`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function assertSecurityHeaders(response: Response, label: string) {
|
||||
assert(response.headers.get("x-content-type-options") === "nosniff", `${label} 缺少 nosniff 安全头`);
|
||||
assert(
|
||||
response.headers.get("referrer-policy") === "strict-origin-when-cross-origin",
|
||||
`${label} 缺少 Referrer-Policy 安全头`,
|
||||
);
|
||||
}
|
||||
|
||||
async function expectJson<T = unknown>(url: string, status: number): Promise<{ body: T; response: Response }> {
|
||||
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 { body: (await response.json()) as T, response };
|
||||
}
|
||||
|
||||
async function expectText(url: string, status: number): Promise<{ body: string; response: Response }> {
|
||||
const response = await fetch(url);
|
||||
|
||||
assert(response.status === status, `${url} 应返回 ${status},实际为 ${response.status}`);
|
||||
|
||||
return { body: await response.text(), response };
|
||||
}
|
||||
|
||||
function getFreePort(): number {
|
||||
const server = Bun.serve({
|
||||
fetch: () => new Response("ok"),
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
});
|
||||
const port = server.port;
|
||||
|
||||
void server.stop(true);
|
||||
|
||||
if (port === undefined) {
|
||||
throw new Error("无法分配 smoke test 端口");
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
async function readStream(stream: null | ReadableStream<Uint8Array>): Promise<string> {
|
||||
if (!stream) return "";
|
||||
|
||||
return new Response(stream).text();
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user