- root deno.json workspace (nodeModulesDir auto), npm versions registered once in imports, deno.lock; remove pnpm-lock/pnpm-workspace/root package.json; members keep minimal package.json (name/type/exports) - tasks: dev, typecheck (tsc 7.0.2 per member + deno check), lint/fmt (biome, useImportExtensions), test (vitest via deno), check:tokens, build (web + deno compile), ci, db:migrate/reset, gen:types - tsconfig base moved to config/ (no root tsconfig.json); deno types -> types/deno.d.ts; web drops baseUrl - server: Deno.serve entry with graceful shutdown, --role migrate|preflight|probe, WEB_ROOT static SPA hosting; drop @hono/node-server, tsx, esbuild build.mjs - logger: redact child() bindings and serialize err by message/stack; sync stdout destination; tests - relative imports get explicit extensions (sites/mp-sync paths left for follow-up) - crash-recovery tests spawn `deno run -A`; tasks tests use unique per-file schemas - single-container Dockerfile + entrypoint (migrate -> preflight -> api+worker watchdog), docker-compose.yml - docs: README, runbook, ADR-001 superseded, plan §1 validation row Co-Authored-By: Claude Fable 5.1 <[email protected]>
122 lines
3.9 KiB
TypeScript
122 lines
3.9 KiB
TypeScript
import { parseArgs } from "node:util";
|
|
import { createDb, migrateToLatest } from "@umt/db";
|
|
import denoJson from "../deno.json" with { type: "json" };
|
|
import { loadConfig } from "./config.ts";
|
|
import { createApp } from "./http/app.ts";
|
|
import { EventBus } from "./http/sse.ts";
|
|
import { createLogger } from "./logger.ts";
|
|
import { runPreflight } from "./preflight.ts";
|
|
import { startWorker } from "./worker/index.ts";
|
|
|
|
// api/worker/all 是常驻角色;migrate/preflight/probe 是容器入口脚本用的一次性子命令
|
|
const Roles = ["api", "worker", "all", "migrate", "preflight", "probe"] as const;
|
|
type Role = (typeof Roles)[number];
|
|
|
|
const { values } = parseArgs({ args: Deno.args, options: { role: { type: "string", default: "all" } }, strict: true });
|
|
const role = values.role as Role;
|
|
if (!Roles.includes(role)) {
|
|
console.error(`--role 必须是 ${Roles.join("|")}`);
|
|
Deno.exit(2);
|
|
}
|
|
|
|
// 健康探测:只打本机回环端口,不需要完整配置(HEALTHCHECK 用,镜像里不装 curl)
|
|
if (role === "probe") {
|
|
const port = Deno.env.get("PORT") ?? "4380";
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, { signal: AbortSignal.timeout(3000) });
|
|
const body = (await res.json()) as { data?: { status?: string } };
|
|
Deno.exit(res.ok && body.data?.status === "ok" ? 0 : 1);
|
|
} catch {
|
|
Deno.exit(1);
|
|
}
|
|
}
|
|
|
|
let config: ReturnType<typeof loadConfig>;
|
|
try {
|
|
config = loadConfig();
|
|
} catch (err) {
|
|
console.error((err as Error).message);
|
|
Deno.exit(2);
|
|
}
|
|
|
|
const logger = createLogger({
|
|
level: config.LOG_LEVEL,
|
|
pretty: config.NODE_ENV === "development" && Deno.stdout.isTerminal(),
|
|
});
|
|
const db = createDb({ connectionString: config.DATABASE_URL });
|
|
|
|
if (role === "migrate" || role === "preflight") {
|
|
let code = 0;
|
|
try {
|
|
if (role === "migrate") {
|
|
const { results } = await migrateToLatest(db);
|
|
for (const r of results ?? []) logger.info({ migration: r.migrationName, status: r.status }, "migration");
|
|
logger.info({ applied: results?.length ?? 0 }, "迁移完成");
|
|
} else {
|
|
const problems = await runPreflight({ config, db });
|
|
for (const p of problems) logger.error({ check: p.check }, p.message);
|
|
if (problems.length) code = 1;
|
|
else logger.info("启动自检通过");
|
|
}
|
|
} catch (err) {
|
|
logger.error({ err }, `${role} 失败`);
|
|
code = 1;
|
|
} finally {
|
|
await db.destroy();
|
|
}
|
|
Deno.exit(code);
|
|
}
|
|
|
|
const bus = new EventBus();
|
|
const stops: Array<() => Promise<void>> = [];
|
|
|
|
if (role === "api" || role === "all") {
|
|
const app = createApp({
|
|
config,
|
|
logger,
|
|
bus,
|
|
db,
|
|
role,
|
|
version: denoJson.version,
|
|
...(config.WEB_ROOT ? { webRoot: config.WEB_ROOT } : {}),
|
|
});
|
|
const ac = new AbortController();
|
|
const server = Deno.serve(
|
|
{
|
|
port: config.PORT,
|
|
hostname: config.HOST,
|
|
signal: ac.signal,
|
|
onListen: ({ port }) =>
|
|
logger.info(
|
|
{ port, role, webRoot: config.WEB_ROOT ?? null },
|
|
`API 已启动 http://localhost:${port}/api/v1/health`,
|
|
),
|
|
},
|
|
app.fetch,
|
|
);
|
|
stops.push(async () => {
|
|
// abort 后 Deno.serve 会断开 SSE 等长连接,finished 在所有连接关闭后 resolve
|
|
ac.abort();
|
|
await server.finished;
|
|
});
|
|
}
|
|
if (role === "worker" || role === "all") {
|
|
const worker = startWorker({ logger, heartbeatMs: config.WORKER_HEARTBEAT_MS, bus });
|
|
stops.push(worker.stop);
|
|
}
|
|
|
|
let stopping = false;
|
|
async function shutdown(signal: Deno.Signal) {
|
|
if (stopping) return;
|
|
stopping = true;
|
|
logger.info({ signal }, "正在退出");
|
|
// 兜底:10 秒内没退干净就强退,避免容器 stop 超时被 SIGKILL
|
|
const force = setTimeout(() => Deno.exit(1), 10_000);
|
|
await Promise.allSettled(stops.map((s) => s()));
|
|
await db.destroy();
|
|
clearTimeout(force);
|
|
Deno.exit(0);
|
|
}
|
|
Deno.addSignalListener("SIGINT", () => void shutdown("SIGINT"));
|
|
Deno.addSignalListener("SIGTERM", () => void shutdown("SIGTERM"));
|