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:
rootandClaude Fable 5.1 committed 2026-09-27 18:53:06 +08:00
1 parent d9f2af2f3c
commit 304feea141
226 files changed
+6601 -8620

No files matched your search

+5 -1
View File
@@ -5,6 +5,8 @@ const LogLevel = z.enum(["fatal", "error", "warn", "info", "debug", "trace", "si
export const ConfigSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().min(1).max(65535).default(4380),
/** 监听地址 */
HOST: z.string().min(1).default("0.0.0.0"),
DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, "必须是 postgres:// 连接串"),
SECRET_MASTER_KEY: z.string().min(16, "SECRET_MASTER_KEY 至少 16 个字符"),
LOG_LEVEL: LogLevel.default("info"),
@@ -12,11 +14,13 @@ export const ConfigSchema = z.object({
DEV_ADMIN_KEY: z.string().min(8).optional(),
/** worker 心跳间隔 */
WORKER_HEARTBEAT_MS: z.coerce.number().int().min(1000).default(30_000),
/** web 静态产物目录(单容器模式):设置后同端口托管 SPA,非 /api 路径回退 index.html */
WEB_ROOT: z.string().min(1).optional(),
});
export type Config = z.infer<typeof ConfigSchema>;
/** 从环境变量加载并校验配置;失败时抛出可读错误(不含变量值) */
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
export function loadConfig(env: Record<string, string | undefined> = Deno.env.toObject()): Config {
const parsed = ConfigSchema.safeParse(env);
if (!parsed.success) {
const lines = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`);
+13 -8
View File
@@ -5,14 +5,15 @@ import { cors } from "hono/cors";
import { HTTPException } from "hono/http-exception";
import { requestId } from "hono/request-id";
import type { Logger } from "pino";
import type { Config } from "../config";
import { isDev } from "../config";
import { redactString } from "../logger";
import { type ApiKeyStore, type AuthEnv, apiKeyAuth, kyselyApiKeyStore, requireScope } from "./auth";
import { ApiException, errorBody } from "./errors";
import { healthRoutes } from "./routes/health";
import { mountOpenApi } from "./routes/openapi";
import { type EventBus, sseRoutes } from "./sse";
import type { Config } from "../config.ts";
import { isDev } from "../config.ts";
import { redactString } from "../logger.ts";
import { type ApiKeyStore, type AuthEnv, apiKeyAuth, kyselyApiKeyStore, requireScope } from "./auth.ts";
import { ApiException, errorBody } from "./errors.ts";
import { healthRoutes } from "./routes/health.ts";
import { mountOpenApi } from "./routes/openapi.ts";
import { type EventBus, sseRoutes } from "./sse.ts";
import { mountWebRoot } from "./static.ts";
export interface AppDeps {
config: Pick<Config, "NODE_ENV" | "DEV_ADMIN_KEY">;
@@ -24,6 +25,8 @@ export interface AppDeps {
db?: Kysely<DB>;
/** 测试可注入;默认用 db 构造 */
apiKeyStore?: ApiKeyStore;
/** 单容器模式:托管 web 静态产物的目录 */
webRoot?: string;
}
export const API_PREFIX = "/api/v1";
@@ -109,6 +112,8 @@ export function createApp(deps: AppDeps) {
return c.json({ ok: true, data: { id: p.id, name: p.name, kind: p.kind, scopes: p.scopes } });
});
if (deps.webRoot) mountWebRoot(app, deps.webRoot);
return app;
}
export type App = ReturnType<typeof createApp>;
+1 -1
View File
@@ -2,7 +2,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
import type { ApiScope } from "@umt/contracts";
import type { DB, Kysely } from "@umt/db";
import type { MiddlewareHandler } from "hono";
import { ApiException } from "./errors";
import { ApiException } from "./errors.ts";
export interface Principal {
kind: "api_key" | "dev_admin";
+1 -1
View File
@@ -1,7 +1,7 @@
import { type EventEnvelope, EventType } from "@umt/contracts";
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { ApiException } from "./errors";
import { ApiException } from "./errors.ts";
type Listener = (e: EventEnvelope) => void;
+16
View File
@@ -0,0 +1,16 @@
import type { Hono } from "hono";
import { serveStatic } from "hono/deno";
/**
* 托管 React Router SPA 构建产物(build/client):存在的文件直接返回,
* 其余非 /api 的 GET 回退 index.html 交给前端路由。/api 下的未知路径仍走 JSON 404。
* 注意 hono/deno 的 serveStatic 要求 path 相对 root,写绝对路径会 404。
*/
// biome-ignore lint/suspicious/noExplicitAny: 适配任意 Env 的 Hono 实例
export function mountWebRoot(app: Hono<any>, root: string) {
const files = serveStatic({ root });
const index = serveStatic({ root, path: "index.html" });
const isApi = (path: string) => path === "/api" || path.startsWith("/api/");
app.use("*", (c, next) => (isApi(c.req.path) ? next() : files(c, next)));
app.get("*", (c, next) => (isApi(c.req.path) ? next() : index(c, next)));
}
+39 -3
View File
@@ -17,6 +17,7 @@ export function redact(value: unknown, depth = 0): unknown {
if (depth > 8) return value;
if (typeof value === "string") return redactString(value);
if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1));
if (value instanceof Error) return redactError(value, depth);
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
@@ -28,6 +29,20 @@ export function redact(value: unknown, depth = 0): unknown {
return value;
}
/**
* Error 的 message/stack 是不可枚举属性,按普通对象处理会变成 `{}`;
* 这里先按 pino 标准形状(type/message/stack + 自有字段 + cause)展开再脱敏。
*/
function redactError(err: Error, depth: number): Record<string, unknown> {
const out: Record<string, unknown> = { type: err.name, message: redactString(err.message) };
if (err.stack) out.stack = redactString(err.stack);
for (const [k, v] of Object.entries(err)) {
out[k] = SENSITIVE_KEY.test(k) && typeof v !== "number" && v != null ? REDACTED : redact(v, depth + 1);
}
if (err.cause !== undefined) out.cause = redact(err.cause, depth + 1);
return out;
}
export interface LoggerOptions {
level?: string;
pretty?: boolean;
@@ -41,7 +56,11 @@ export function createLogger(opts: LoggerOptions = {}): Logger {
formatters: {
// 所有结构化字段(含 err 序列化结果)统一过一遍脱敏
log: (obj) => redact(obj) as Record<string, unknown>,
// 根 logger 的 base 绑定;child() 的绑定 pino 会跳过这里,由 redactChildBindings 处理
bindings: (bindings) => redact(bindings) as Record<string, unknown>,
},
// err 已由 formatters.log 按 message/stack 展开并脱敏;默认 err 序列化器会把 type 改写成 "Object"
serializers: { err: (v: unknown) => v },
hooks: {
// msg 文本也脱敏
logMethod(args, method) {
@@ -50,9 +69,26 @@ export function createLogger(opts: LoggerOptions = {}): Logger {
},
},
};
if (opts.destination) return pino(options, opts.destination);
if (opts.destination) return redactChildBindings(pino(options, opts.destination));
if (opts.pretty) {
return pino({ ...options, transport: { target: "pino-pretty", options: { translateTime: "SYS:HH:MM:ss" } } });
return redactChildBindings(
pino({ ...options, transport: { target: "pino-pretty", options: { translateTime: "SYS:HH:MM:ss" } } }),
);
}
return pino(options);
// 同步写 stdout:进程随后 Deno.exit() 时(迁移/自检子命令、优雅退出)不丢尾部日志
return redactChildBindings(pino(options, pino.destination({ dest: 1, sync: true })));
}
/**
* pino 的 child() 会把 formatters.bindings 重置为恒等函数,绑定字段(如 `child({ password })`)
* 原样写进每一行。这里在根实例上覆盖 child:子孙 logger 经原型链继承,绑定先脱敏再交给 pino。
*/
function redactChildBindings(logger: Logger): Logger {
type ChildFn = (this: Logger, bindings: pino.Bindings, options?: pino.ChildLoggerOptions) => Logger;
const original = logger.child as unknown as ChildFn;
const child: ChildFn = function (bindings, options) {
return original.call(this, redact(bindings) as pino.Bindings, options);
};
logger.child = child as unknown as Logger["child"];
return logger;
}
+82 -27
View File
@@ -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"));
+45
View File
@@ -0,0 +1,45 @@
import { join } from "node:path";
import type { DB, Kysely } from "@umt/db";
import { sql } from "@umt/db";
import type { Config } from "./config.ts";
export interface PreflightProblem {
check: string;
message: string;
}
export interface PreflightDeps {
config: Config;
db: Kysely<DB>;
/** 测试可注入;默认取当前进程 uid(Windows 上为 null) */
uid?: number | null;
}
// 明显的占位符密钥:生产环境拒绝启动
const PLACEHOLDER = /change[-_]?me|replace[-_]?me|dev[-_]?only|example/i;
/** 启动自检:返回问题列表,空数组表示通过。不打印变量值。 */
export async function runPreflight({ config, db, uid = Deno.uid() }: PreflightDeps): Promise<PreflightProblem[]> {
const problems: PreflightProblem[] = [];
const prod = config.NODE_ENV === "production";
if (prod && PLACEHOLDER.test(config.SECRET_MASTER_KEY)) {
problems.push({ check: "secret", message: "SECRET_MASTER_KEY 仍是占位符,生产环境必须换成随机串" });
}
if (prod && uid === 0) {
problems.push({ check: "user", message: "生产环境不允许以 root 运行" });
}
try {
await sql`select 1`.execute(db);
} catch (err) {
problems.push({ check: "db", message: `数据库不可达: ${(err as Error).message}` });
}
if (config.WEB_ROOT) {
try {
await Deno.stat(join(config.WEB_ROOT, "index.html"));
} catch {
problems.push({ check: "web", message: `WEB_ROOT 下没有 index.html: ${config.WEB_ROOT}` });
}
}
return problems;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { EventType } from "@umt/contracts";
import type { Logger } from "pino";
import type { EventBus } from "../http/sse";
import type { EventBus } from "../http/sse.ts";
export interface WorkerDeps {
logger: Logger;