chore(deno): migrate toolchain from Node 24 + pnpm to Deno 2.9.7 + TypeScript 7.0.2 (W1.5)
- 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]>
This commit is contained in:
1 parent
d9f2af2f3c
commit
304feea141
226 files changed
+6601
-8620
No files matched your search
+82
-27
@@ -1,21 +1,34 @@
|
||||
import { parseArgs } from "node:util";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { createDb } from "@umt/db";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
import { loadConfig } from "./config";
|
||||
import { createApp } from "./http/app";
|
||||
import { EventBus } from "./http/sse";
|
||||
import { createLogger } from "./logger";
|
||||
import { startWorker } from "./worker";
|
||||
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";
|
||||
|
||||
const Roles = ["api", "worker", "all"] as const;
|
||||
// api/worker/all 是常驻角色;migrate/preflight/probe 是容器入口脚本用的一次性子命令
|
||||
const Roles = ["api", "worker", "all", "migrate", "preflight", "probe"] as const;
|
||||
type Role = (typeof Roles)[number];
|
||||
|
||||
const { values } = parseArgs({ options: { role: { type: "string", default: "all" } }, strict: true });
|
||||
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("|")}`);
|
||||
process.exit(2);
|
||||
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>;
|
||||
@@ -23,30 +36,69 @@ try {
|
||||
config = loadConfig();
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exit(2);
|
||||
Deno.exit(2);
|
||||
}
|
||||
|
||||
const logger = createLogger({
|
||||
level: config.LOG_LEVEL,
|
||||
pretty: config.NODE_ENV === "development" && process.stdout.isTTY,
|
||||
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: pkg.version });
|
||||
const server = serve({ fetch: app.fetch, port: config.PORT }, (info) => {
|
||||
logger.info({ port: info.port, role }, `API 已启动 http://localhost:${info.port}/api/v1/health`);
|
||||
const app = createApp({
|
||||
config,
|
||||
logger,
|
||||
bus,
|
||||
db,
|
||||
role,
|
||||
version: denoJson.version,
|
||||
...(config.WEB_ROOT ? { webRoot: config.WEB_ROOT } : {}),
|
||||
});
|
||||
stops.push(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
// SSE 长连接不会自行结束,强制关闭
|
||||
(server as unknown as { closeAllConnections?: () => void }).closeAllConnections?.();
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
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 });
|
||||
@@ -54,13 +106,16 @@ if (role === "worker" || role === "all") {
|
||||
}
|
||||
|
||||
let stopping = false;
|
||||
async function shutdown(signal: string) {
|
||||
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();
|
||||
process.exit(0);
|
||||
clearTimeout(force);
|
||||
Deno.exit(0);
|
||||
}
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
Deno.addSignalListener("SIGINT", () => void shutdown("SIGINT"));
|
||||
Deno.addSignalListener("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
Reference in new issue
Block a user