From 304feea141024f22089ab74388a5140699cc74b0 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 27 Sep 2026 18:53:06 +0800 Subject: [PATCH] chore(deno): migrate toolchain from Node 24 + pnpm to Deno 2.9.7 + TypeScript 7.0.2 (W1.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .dockerignore | 16 + .gitignore | 1 + Dockerfile | 31 + README.md | 18 +- apps/server/README.md | 2 +- apps/server/deno.json | 12 + apps/server/package.json | 25 +- apps/server/scripts/build.mjs | 42 - apps/server/src/config.ts | 6 +- apps/server/src/http/app.ts | 21 +- apps/server/src/http/auth.ts | 2 +- apps/server/src/http/sse.ts | 2 +- apps/server/src/http/static.ts | 16 + apps/server/src/logger.ts | 42 +- apps/server/src/main.ts | 109 +- apps/server/src/preflight.ts | 45 + apps/server/src/worker/index.ts | 2 +- apps/server/test/auth.test.ts | 4 +- apps/server/test/health.test.ts | 2 +- apps/server/test/helpers.ts | 8 +- apps/server/test/logger.test.ts | 38 +- apps/server/test/preflight.test.ts | 47 + apps/server/test/sse.test.ts | 4 +- apps/server/test/static.test.ts | 49 + apps/server/tsconfig.json | 2 +- apps/web/app/api/health.ts | 2 +- .../app/components/console/api-key-dialog.tsx | 8 +- .../app/components/console/empty-state.tsx | 2 +- apps/web/app/components/console/nav.test.ts | 2 +- .../app/components/console/page-header.tsx | 2 +- .../app/components/console/status-badge.tsx | 2 +- .../console/workspace-placeholder.tsx | 6 +- apps/web/app/components/prism-leak.tsx | 2 +- apps/web/app/components/theme-toggle.tsx | 4 +- apps/web/app/components/ui/alert-dialog.tsx | 4 +- apps/web/app/components/ui/avatar.tsx | 2 +- apps/web/app/components/ui/badge.tsx | 2 +- apps/web/app/components/ui/breadcrumb.tsx | 2 +- apps/web/app/components/ui/button.tsx | 2 +- apps/web/app/components/ui/calendar.tsx | 2 +- apps/web/app/components/ui/card.tsx | 2 +- apps/web/app/components/ui/dialog.tsx | 4 +- apps/web/app/components/ui/input.tsx | 2 +- apps/web/app/components/ui/label.tsx | 2 +- apps/web/app/components/ui/pagination.tsx | 2 +- apps/web/app/components/ui/popover.tsx | 2 +- apps/web/app/components/ui/select.tsx | 2 +- apps/web/app/components/ui/separator.tsx | 2 +- apps/web/app/components/ui/sheet.tsx | 2 +- apps/web/app/components/ui/slider.tsx | 2 +- apps/web/app/components/ui/switch.tsx | 2 +- apps/web/app/lib/design-tokens.generated.ts | 2 +- apps/web/app/lib/utils.ts | 2 +- apps/web/app/root.tsx | 2 +- apps/web/app/routes/console/agent.tsx | 8 +- apps/web/app/routes/console/home.tsx | 10 +- apps/web/app/routes/console/identity.tsx | 2 +- apps/web/app/routes/console/inbox.tsx | 2 +- apps/web/app/routes/console/layout.tsx | 14 +- apps/web/app/routes/console/library.tsx | 2 +- apps/web/app/routes/console/organize.tsx | 2 +- apps/web/app/routes/console/preferences.tsx | 2 +- apps/web/app/routes/console/settings.tsx | 2 +- apps/web/app/routes/console/sites.tsx | 2 +- apps/web/app/routes/console/sources.tsx | 2 +- apps/web/app/routes/console/subscriptions.tsx | 2 +- apps/web/app/routes/console/tasks.tsx | 2 +- apps/web/app/routes/not-found.tsx | 4 +- apps/web/package.json | 6 +- apps/web/scripts/gen-design-tokens.mjs | 8 +- apps/web/tsconfig.json | 1 - biome.json | 51 +- .../tsconfig.base.json | 5 +- deno.json | 77 + deno.lock | 5437 +++++++++++ docker-compose.dev.yml | 2 +- docker-compose.yml | 52 + docker/entrypoint.sh | 37 + docs/adr/ADR-001-runtime-node-not-deno.md | 4 +- docs/design/README.md | 4 +- docs/design/design-tokens.md | 6 +- docs/design/interface-materials.md | 2 +- docs/plan/00-first-version-plan.md | 2 +- docs/runbook/dev.md | 55 +- package.json | 31 - packages/adapters/deno.json | 9 + packages/adapters/package.json | 14 - packages/adapters/src/emby/emby.test.ts | 2 +- packages/adapters/src/emby/index.ts | 2 +- packages/adapters/src/fs/copy.ts | 2 +- packages/adapters/src/fs/ffprobe.ts | 2 +- packages/adapters/src/fs/fs.test.ts | 2 +- packages/adapters/src/fs/index.ts | 12 +- packages/adapters/src/fs/move.ts | 4 +- packages/adapters/src/fs/scan.ts | 4 +- packages/adapters/src/fs/stat.ts | 2 +- packages/adapters/src/index.ts | 12 +- packages/adapters/src/qbittorrent/client.ts | 6 +- packages/adapters/src/qbittorrent/index.ts | 10 +- .../src/qbittorrent/qbittorrent.test.ts | 4 +- packages/adapters/src/rss/cursor.ts | 2 +- packages/adapters/src/rss/fetch.ts | 6 +- packages/adapters/src/rss/index.ts | 12 +- packages/adapters/src/rss/parse.ts | 4 +- packages/adapters/src/rss/rss.test.ts | 2 +- packages/adapters/src/tmdb/client.ts | 2 +- packages/adapters/src/tmdb/index.ts | 6 +- packages/adapters/src/tmdb/match.ts | 2 +- packages/adapters/src/tmdb/tmdb.test.ts | 2 +- packages/adapters/test/index.test.ts | 2 +- packages/adapters/tsconfig.json | 2 +- packages/agent/deno.json | 9 + packages/agent/package.json | 12 - packages/agent/src/capability/registry.ts | 8 +- packages/agent/src/index.ts | 22 +- packages/agent/src/pi/fake.ts | 8 +- packages/agent/src/pi/model.ts | 2 +- packages/agent/src/pi/runtime.ts | 18 +- packages/agent/src/runtime/port.ts | 4 +- packages/agent/test/fixtures/crash-run.ts | 6 +- packages/agent/test/fixtures/crash-script.ts | 2 +- packages/agent/test/helpers.ts | 2 +- packages/agent/test/redact.test.ts | 2 +- packages/agent/test/registry.test.ts | 4 +- packages/agent/test/runtime.test.ts | 18 +- packages/agent/test/settings.test.ts | 4 +- packages/agent/tsconfig.json | 2 +- packages/contracts/deno.json | 9 + packages/contracts/package.json | 8 - packages/contracts/src/agent.ts | 2 +- packages/contracts/src/api.ts | 26 +- packages/contracts/src/assets.ts | 4 +- packages/contracts/src/capabilities-io.ts | 20 +- packages/contracts/src/capability.ts | 2 +- packages/contracts/src/content.ts | 2 +- packages/contracts/src/events.ts | 10 +- packages/contracts/src/execution.ts | 4 +- packages/contracts/src/identity.ts | 2 +- packages/contracts/src/index.ts | 32 +- packages/contracts/src/instances.ts | 2 +- packages/contracts/src/ops.ts | 2 +- packages/contracts/src/organization.ts | 4 +- packages/contracts/src/policy.ts | 2 +- packages/contracts/src/settings.ts | 2 +- packages/contracts/src/sources.ts | 2 +- packages/contracts/test/common.test.ts | 2 +- packages/contracts/test/db-sync.test.ts | 2 +- packages/contracts/test/schemas.test.ts | 2 +- packages/contracts/tsconfig.json | 2 +- packages/db/README.md | 2 +- packages/db/deno.json | 12 + packages/db/package.json | 15 - packages/db/scripts/gen-types-lib.ts | 4 +- packages/db/scripts/gen-types.ts | 6 +- packages/db/scripts/migrate.ts | 4 +- packages/db/src/client.ts | 2 +- packages/db/src/index.ts | 6 +- packages/db/src/types.ts | 2 +- packages/db/test/migrator.test.ts | 2 +- packages/db/test/schema.test.ts | 4 +- packages/db/tsconfig.json | 7 +- packages/domain/README.md | 2 +- packages/domain/deno.json | 9 + packages/domain/package.json | 5 - packages/domain/src/acquisition/index.ts | 8 +- packages/domain/src/acquisition/priority.ts | 2 +- packages/domain/src/identity/index.ts | 6 +- packages/domain/src/identity/match.ts | 4 +- packages/domain/src/identity/normalize.ts | 4 +- packages/domain/src/index.ts | 14 +- packages/domain/src/organize/index.ts | 4 +- packages/domain/src/organize/plan.ts | 12 +- packages/domain/src/path/binding.ts | 4 +- packages/domain/src/path/index.ts | 10 +- packages/domain/src/path/template.ts | 4 +- packages/domain/src/release-parser/index.ts | 14 +- packages/domain/src/release-parser/parse.ts | 8 +- packages/domain/src/release-parser/path.ts | 4 +- packages/domain/src/slot/completion.ts | 4 +- packages/domain/src/slot/evaluate.ts | 12 +- packages/domain/src/slot/index.ts | 8 +- packages/domain/src/slot/types.ts | 2 +- packages/domain/test/acquisition.test.ts | 2 +- packages/domain/test/download-state.test.ts | 2 +- packages/domain/test/identity.test.ts | 2 +- packages/domain/test/index.test.ts | 2 +- packages/domain/test/organize.test.ts | 2 +- packages/domain/test/path.test.ts | 2 +- packages/domain/test/release-parser.test.ts | 2 +- packages/domain/test/slot.test.ts | 2 +- packages/domain/tsconfig.json | 2 +- packages/tasks/README.md | 2 +- packages/tasks/deno.json | 9 + packages/tasks/package.json | 16 - packages/tasks/src/definitions.ts | 2 +- packages/tasks/src/index.ts | 23 +- packages/tasks/src/op-ledger.ts | 4 +- packages/tasks/src/openworkflow-engine.ts | 8 +- packages/tasks/src/outbox-relay.ts | 4 +- packages/tasks/src/registry.ts | 4 +- packages/tasks/src/types.ts | 2 +- packages/tasks/test/crash-recovery.test.ts | 6 +- packages/tasks/test/engine.test.ts | 6 +- packages/tasks/test/fixtures/tasks.ts | 4 +- packages/tasks/test/fixtures/worker.ts | 4 +- packages/tasks/test/helpers.ts | 11 +- packages/tasks/test/op-ledger.test.ts | 4 +- packages/tasks/test/outbox.test.ts | 6 +- packages/tasks/test/registry.test.ts | 2 +- packages/tasks/tsconfig.json | 2 +- packages/testkit/deno.json | 9 + packages/testkit/package.json | 5 - packages/testkit/src/emby/index.ts | 4 +- packages/testkit/src/emby/mock-emby.ts | 4 +- packages/testkit/src/fs/index.ts | 2 +- packages/testkit/src/index.ts | 12 +- packages/testkit/src/qbittorrent/index.ts | 4 +- packages/testkit/src/qbittorrent/mock-qb.ts | 4 +- packages/testkit/src/rss/index.ts | 2 +- packages/testkit/src/rss/mock-rss.ts | 2 +- packages/testkit/src/tmdb/index.ts | 6 +- packages/testkit/src/tmdb/mock-tmdb.ts | 4 +- packages/testkit/test/index.test.ts | 2 +- packages/testkit/tsconfig.json | 2 +- pnpm-lock.yaml | 7954 ----------------- pnpm-workspace.yaml | 11 - 226 files changed, 6601 insertions(+), 8620 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 apps/server/deno.json delete mode 100644 apps/server/scripts/build.mjs create mode 100644 apps/server/src/http/static.ts create mode 100644 apps/server/src/preflight.ts create mode 100644 apps/server/test/preflight.test.ts create mode 100644 apps/server/test/static.test.ts rename tsconfig.base.json => config/tsconfig.base.json (76%) create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 docker-compose.yml create mode 100755 docker/entrypoint.sh delete mode 100644 package.json create mode 100644 packages/adapters/deno.json create mode 100644 packages/agent/deno.json create mode 100644 packages/contracts/deno.json create mode 100644 packages/db/deno.json create mode 100644 packages/domain/deno.json create mode 100644 packages/tasks/deno.json create mode 100644 packages/testkit/deno.json delete mode 100644 pnpm-lock.yaml delete mode 100644 pnpm-workspace.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..37d45fd6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# 构建上下文只需要源码与锁文件;依赖在镜像内由 deno install --frozen 按 deno.lock 重装 +.git +**/node_modules +**/dist +**/build +**/.react-router +**/coverage +**/*.log +types +spikes +docs +.env +.env.* +!.env.example +*.md +!apps/**/README.md diff --git a/.gitignore b/.gitignore index 9bf301d4..3cacbd29 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ coverage/ *.log .DS_Store .vite/ +types/deno.d.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..366018b0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# 单容器镜像:一个 deno compile 出的 umt-server 二进制,同时承担 API(含 web 静态 SPA)与 worker。 +# PostgreSQL 不在本镜像内,见 docker-compose.yml。 +# 构建:docker build -t uhd-media-tool . 运行时版本锁定 Deno 2.9.7(ADR-004) + +FROM denoland/deno:2.9.7 AS build +WORKDIR /src +COPY . . +RUN deno install --frozen +ARG APP_VERSION="" +ENV APP_VERSION=${APP_VERSION} +RUN deno task check:tokens && cd apps/web && deno task build +# --node-modules-dir=none + --exclude-unused-npm:只嵌入 server 依赖图可达的 npm 包(约 110 MB,而不是整个 node_modules) +# 权限在编译时固定:--allow-read 只放 /app;不给 --allow-run / --allow-write +RUN cd apps/server && deno task compile + +FROM debian:trixie-slim +# 不装额外包:Deno 自带 Mozilla 根证书(外呼 HTTPS 不依赖 ca-certificates),健康探测用二进制自身的 fetch +RUN useradd --system --uid 10001 --home-dir /app --shell /usr/sbin/nologin umt +WORKDIR /app +COPY --from=build --chown=root:root /src/dist/umt-server /app/umt-server +COPY --from=build --chown=root:root /src/apps/web/build/client /app/public +COPY --chown=root:root --chmod=0755 docker/entrypoint.sh /app/entrypoint.sh +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=4380 \ + WEB_ROOT=/app/public +USER umt +EXPOSE 4380 +# 用二进制自带的 fetch 打本机回环端口,不装 curl +HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 CMD ["/app/umt-server", "--role", "probe"] +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.md b/README.md index b0bdf7a5..3e902cba 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,19 @@ ## 快速启动 +需要 Deno 2.9.7(锁定版本:`curl -fsSL https://deno.land/install.sh | sh -s v2.9.7`)和 Docker。不需要 Node.js / pnpm。 + ```sh cp .env.example .env -pnpm db:up # postgres:16-alpine,端口 55433 -pnpm install -pnpm db:migrate -pnpm dev # API http://localhost:4380 控制台 http://localhost:5180 +deno task db:up # postgres:16-alpine,端口 55433 +deno install # 按 deno.lock 安装依赖到 node_modules +deno task db:migrate +deno task dev # API http://localhost:4380 控制台 http://localhost:5180 ``` -提交前:`pnpm run ci`(typecheck + lint + test + check:tokens + build)。注意不要用 `pnpm ci`,它在 pnpm 11 是内置的 clean-install。 +提交前:`deno task ci`(typecheck:tsc 7.0.2 + deno check → biome → vitest → check:tokens → web 构建 + server 编译)。 + +生产/试运行:`docker compose up -d --build`(单容器 app + postgres,见 [docs/runbook/dev.md](docs/runbook/dev.md#docker-单容器部署))。 更多见 [docs/runbook/dev.md](docs/runbook/dev.md)。 @@ -32,6 +36,8 @@ pnpm dev # API http://localhost:4380 控制台 http://localhost:5180 | `docs/adr` | 架构决策记录 | | `docs/design` | 设计系统规范 | | `docs/runbook` | 运行手册 | +| `config/` | tsc 7 用的 tsconfig 基座(根目录不放 tsconfig.json,否则 `deno check` 丢 Deno 全局) | +| `docker/` | 容器入口脚本 | | `spikes/` | 独立兼容性验证(不属于 workspace) | -技术选型:Node.js 24 + pnpm workspace + TypeScript 5.9 strict(偏离 REQ 的 Deno 方向,原因见 [ADR-001](docs/adr/ADR-001-runtime-node-not-deno.md))。 +技术选型:Deno 2.9.7(锁定)+ TypeScript 7.0.2 strict,deno.json workspace(`nodeModulesDir: auto`,npm 依赖版本统一登记在根 `deno.json` 的 `imports`,`deno.lock` 锁定)。前端 Vite 7 + React Router 7 由 Deno 执行。决策见 [ADR-004](docs/adr/ADR-004-deno-runtime.md)(取代 ADR-001)。 diff --git a/apps/server/README.md b/apps/server/README.md index bebcc579..75620324 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -1,3 +1,3 @@ # @umt/server -Node 服务进程:Hono HTTP API(前缀 `/api/v1`,OpenAPI 见 `/api/openapi.json`、`/api/docs`)、SSE 事件流与 worker 入口。`--role api|worker|all` 决定本进程承担哪部分,默认 `all` 单进程运行。业务规则不写在这里,只做编排、鉴权、审计与协议适配。 +服务进程(Deno):Hono HTTP API(前缀 `/api/v1`,OpenAPI 见 `/api/openapi.json`、`/api/docs`)、SSE 事件流与 worker 入口。`--role api|worker|all` 决定本进程承担哪部分,默认 `all` 单进程运行。业务规则不写在这里,只做编排、鉴权、审计与协议适配。 diff --git a/apps/server/deno.json b/apps/server/deno.json new file mode 100644 index 00000000..3b8154cb --- /dev/null +++ b/apps/server/deno.json @@ -0,0 +1,12 @@ +{ + "name": "@umt/server", + "version": "0.0.0", + "exports": { ".": "./src/main.ts" }, + "tasks": { + "dev": "deno run -A --watch --env-file=../../.env src/main.ts", + "start": "deno run --allow-net --allow-env --allow-sys --allow-read --env-file=../../.env src/main.ts", + "compile": "deno compile --frozen --node-modules-dir=none --exclude-unused-npm --allow-net --allow-env --allow-sys --allow-read=/app --include ../../packages/db/migrations --output ../../dist/umt-server src/main.ts", + "test": "deno run -A npm:vitest run", + "typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json" + } +} diff --git a/apps/server/package.json b/apps/server/package.json index 742603dd..a19bb6fa 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,29 +1,8 @@ { "name": "@umt/server", - "version": "0.0.0", "private": true, "type": "module", - "scripts": { - "dev": "tsx watch --env-file-if-exists=../../.env src/main.ts", - "start": "node --env-file-if-exists=../../.env dist/main.js", - "build": "node scripts/build.mjs", - "typecheck": "tsc -p tsconfig.json", - "test": "vitest run" - }, - "dependencies": { - "@hono/node-server": "^2.1.1", - "@hono/swagger-ui": "^0.6.1", - "@hono/zod-openapi": "^1.6.3", - "@umt/contracts": "workspace:*", - "@umt/db": "workspace:*", - "hono": "^4.13.9", - "kysely": "^0.29.6", - "pg": "^8.23.0", - "pino": "^10.3.1", - "zod": "^4.3.6" - }, - "devDependencies": { - "esbuild": "^0.28.2", - "pino-pretty": "^13.1.3" + "exports": { + ".": "./src/main.ts" } } diff --git a/apps/server/scripts/build.mjs b/apps/server/scripts/build.mjs deleted file mode 100644 index 6eed8a5b..00000000 --- a/apps/server/scripts/build.mjs +++ /dev/null @@ -1,42 +0,0 @@ -// 打包 server:工作区包(@umt/*)以源码内联,npm 依赖保持 external 运行时从 node_modules 加载。 -// external 依赖必须在本包 package.json 的 dependencies 里声明(工作区包的运行时依赖也要在此重复声明), -// 否则运行时解析不到,构建直接报错。 -import { readFileSync } from "node:fs"; -import { builtinModules } from "node:module"; -import { build } from "esbuild"; - -const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); -const deps = new Set(Object.keys(pkg.dependencies ?? {})); -const builtins = new Set(builtinModules); -const pkgName = (p) => (p.startsWith("@") ? p.split("/").slice(0, 2).join("/") : p.split("/")[0]); - -await build({ - entryPoints: ["src/main.ts"], - outfile: "dist/main.js", - bundle: true, - platform: "node", - target: "node24", - format: "esm", - sourcemap: true, - logLevel: "info", - plugins: [ - { - name: "external-npm", - setup(b) { - b.onResolve({ filter: /^[^./]/ }, (args) => { - if (args.path.startsWith("node:") || builtins.has(args.path)) return { path: args.path, external: true }; - const name = pkgName(args.path); - if (name.startsWith("@umt/")) return undefined; - if (!deps.has(name)) { - return { - errors: [ - { text: `运行时依赖 ${name} 未在 apps/server/package.json dependencies 中声明(来自 ${args.importer})` }, - ], - }; - } - return { path: args.path, external: true }; - }); - }, - }, - ], -}); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 18954c7b..25425db1 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -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; /** 从环境变量加载并校验配置;失败时抛出可读错误(不含变量值) */ -export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { +export function loadConfig(env: Record = Deno.env.toObject()): Config { const parsed = ConfigSchema.safeParse(env); if (!parsed.success) { const lines = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`); diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts index 026285f4..7f7f8ab7 100644 --- a/apps/server/src/http/app.ts +++ b/apps/server/src/http/app.ts @@ -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; @@ -24,6 +25,8 @@ export interface AppDeps { db?: Kysely; /** 测试可注入;默认用 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; diff --git a/apps/server/src/http/auth.ts b/apps/server/src/http/auth.ts index b34fa1ff..b41f25ca 100644 --- a/apps/server/src/http/auth.ts +++ b/apps/server/src/http/auth.ts @@ -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"; diff --git a/apps/server/src/http/sse.ts b/apps/server/src/http/sse.ts index af7a7d96..bffed1d1 100644 --- a/apps/server/src/http/sse.ts +++ b/apps/server/src/http/sse.ts @@ -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; diff --git a/apps/server/src/http/static.ts b/apps/server/src/http/static.ts new file mode 100644 index 00000000..18e553e9 --- /dev/null +++ b/apps/server/src/http/static.ts @@ -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, 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))); +} diff --git a/apps/server/src/logger.ts b/apps/server/src/logger.ts index ab861838..91083b03 100644 --- a/apps/server/src/logger.ts +++ b/apps/server/src/logger.ts @@ -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 = {}; 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 { + const out: Record = { 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, + // 根 logger 的 base 绑定;child() 的绑定 pino 会跳过这里,由 redactChildBindings 处理 + bindings: (bindings) => redact(bindings) as Record, }, + // 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; } diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 18c35a24..b1f421a3 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -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; @@ -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> = []; 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")); diff --git a/apps/server/src/preflight.ts b/apps/server/src/preflight.ts new file mode 100644 index 00000000..7dd3feda --- /dev/null +++ b/apps/server/src/preflight.ts @@ -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; + /** 测试可注入;默认取当前进程 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 { + 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; +} diff --git a/apps/server/src/worker/index.ts b/apps/server/src/worker/index.ts index 584d4571..ebcfda4c 100644 --- a/apps/server/src/worker/index.ts +++ b/apps/server/src/worker/index.ts @@ -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; diff --git a/apps/server/test/auth.test.ts b/apps/server/test/auth.test.ts index 049a5770..3c2fac1f 100644 --- a/apps/server/test/auth.test.ts +++ b/apps/server/test/auth.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { hasScope } from "../src/http/auth"; -import { DEV_KEY, json, memoryStore, testApp } from "./helpers"; +import { hasScope } from "../src/http/auth.ts"; +import { DEV_KEY, json, memoryStore, testApp } from "./helpers.ts"; const store = memoryStore({ "key-read": { id: "k1", scopes: ["read"] }, diff --git a/apps/server/test/health.test.ts b/apps/server/test/health.test.ts index fa091d73..f737d650 100644 --- a/apps/server/test/health.test.ts +++ b/apps/server/test/health.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { json, testApp } from "./helpers"; +import { json, testApp } from "./helpers.ts"; describe("GET /api/v1/health", () => { it("免鉴权返回 ok 信封", async () => { diff --git a/apps/server/test/helpers.ts b/apps/server/test/helpers.ts index 2260b476..4bf4305f 100644 --- a/apps/server/test/helpers.ts +++ b/apps/server/test/helpers.ts @@ -1,8 +1,8 @@ import { Writable } from "node:stream"; -import { createApp } from "../src/http/app"; -import { type ApiKeyStore, hashApiKey } from "../src/http/auth"; -import { EventBus } from "../src/http/sse"; -import { createLogger } from "../src/logger"; +import { createApp } from "../src/http/app.ts"; +import { type ApiKeyStore, hashApiKey } from "../src/http/auth.ts"; +import { EventBus } from "../src/http/sse.ts"; +import { createLogger } from "../src/logger.ts"; export const DEV_KEY = "dev-admin-test-key"; diff --git a/apps/server/test/logger.test.ts b/apps/server/test/logger.test.ts index 6c507a25..893124fb 100644 --- a/apps/server/test/logger.test.ts +++ b/apps/server/test/logger.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { redact, redactString } from "../src/logger"; -import { memoryLogger } from "./helpers"; +import { redact, redactString } from "../src/logger.ts"; +import { memoryLogger } from "./helpers.ts"; describe("日志脱敏", () => { it("URL query 中的 passkey/token 被替换", () => { @@ -26,3 +26,37 @@ describe("日志脱敏", () => { for (const s of ["SECRET1", "SECRET2", "SECRET3", "SECRET4"]) expect(out).not.toContain(s); }); }); + +describe("日志脱敏:child 绑定与 Error 序列化", () => { + it("child() 绑定的敏感字段被脱敏", () => { + const { logger, lines } = memoryLogger(); + logger.child({ component: "worker", password: "SECRET5", url: "https://x/?passkey=SECRET6" }).warn("child"); + const out = lines.join(""); + expect(out).not.toContain("SECRET5"); + expect(out).not.toContain("SECRET6"); + const line = JSON.parse(lines.at(-1)!); + expect(line.component).toBe("worker"); + expect(line.password).toBe("[REDACTED]"); + }); + + it("err 按 message/stack 序列化而不是 {},且内容脱敏", () => { + const { logger, lines } = memoryLogger(); + const err = Object.assign(new Error("GET https://x/dl?passkey=SECRET7 failed", { cause: new Error("inner") }), { + code: "E_FETCH", + token: "SECRET8", + }); + logger.error({ err }, "boom"); + const line = JSON.parse(lines.at(-1)!); + expect(line.err.type).toBe("Error"); + expect(line.err.message).toBe("GET https://x/dl?passkey=[REDACTED] failed"); + expect(line.err.stack).toContain("Error: GET https://x/dl?passkey=[REDACTED] failed"); + expect(line.err.code).toBe("E_FETCH"); + expect(line.err.token).toBe("[REDACTED]"); + expect(line.err.cause.message).toBe("inner"); + expect(lines.join("")).not.toMatch(/SECRET7|SECRET8/); + }); + + it("redact() 直接处理 Error", () => { + expect(redact(new Error("x?token=abc"))).toMatchObject({ type: "Error", message: "x?token=[REDACTED]" }); + }); +}); diff --git a/apps/server/test/preflight.test.ts b/apps/server/test/preflight.test.ts new file mode 100644 index 00000000..86aef0c9 --- /dev/null +++ b/apps/server/test/preflight.test.ts @@ -0,0 +1,47 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { DB, Kysely } from "@umt/db"; +import { afterAll, describe, expect, it } from "vitest"; +import { type Config, ConfigSchema } from "../src/config.ts"; +import { runPreflight } from "../src/preflight.ts"; + +const dir = mkdtempSync(join(tmpdir(), "umt-pre-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +// 最小假 Kysely:只需要 sql`select 1`.execute(db) 走得通或失败 +const fakeDb = (ok: boolean) => + ({ + getExecutor: () => ({ + transformQuery: (n: unknown) => n, + compileQuery: (n: unknown) => ({ sql: "select 1", parameters: [], query: n }), + executeQuery: async () => { + if (!ok) throw new Error("connect ECONNREFUSED"); + return { rows: [{ "?column?": 1 }] }; + }, + }), + }) as unknown as Kysely; + +const cfg = (over: Record): Config => + ConfigSchema.parse({ DATABASE_URL: "postgres://x/y", SECRET_MASTER_KEY: "a-real-random-secret-value-123", ...over }); + +describe("启动自检", () => { + it("开发环境、DB 可达 → 通过", async () => { + expect(await runPreflight({ config: cfg({}), db: fakeDb(true), uid: 0 })).toEqual([]); + }); + + it("生产环境:占位符密钥、root、DB 不可达、WEB_ROOT 缺 index.html 都报出", async () => { + const problems = await runPreflight({ + config: cfg({ NODE_ENV: "production", SECRET_MASTER_KEY: "dev-only-change-me-dev-only", WEB_ROOT: dir }), + db: fakeDb(false), + uid: 0, + }); + expect(problems.map((p) => p.check).sort()).toEqual(["db", "secret", "user", "web"]); + }); + + it("WEB_ROOT 有 index.html、非 root → 通过", async () => { + writeFileSync(join(dir, "index.html"), ""); + const config = cfg({ NODE_ENV: "production", WEB_ROOT: dir }); + expect(await runPreflight({ config, db: fakeDb(true), uid: 1000 })).toEqual([]); + }); +}); diff --git a/apps/server/test/sse.test.ts b/apps/server/test/sse.test.ts index 85073e92..87c42f7c 100644 --- a/apps/server/test/sse.test.ts +++ b/apps/server/test/sse.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { EventBus } from "../src/http/sse"; -import { DEV_KEY, testApp } from "./helpers"; +import { EventBus } from "../src/http/sse.ts"; +import { DEV_KEY, testApp } from "./helpers.ts"; describe("EventBus", () => { it("序号自增并按 after 补读,淘汰后标记 gap", () => { diff --git a/apps/server/test/static.test.ts b/apps/server/test/static.test.ts new file mode 100644 index 00000000..883eed07 --- /dev/null +++ b/apps/server/test/static.test.ts @@ -0,0 +1,49 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { createApp } from "../src/http/app.ts"; +import { EventBus } from "../src/http/sse.ts"; +import { json, memoryLogger } from "./helpers.ts"; + +const root = mkdtempSync(join(tmpdir(), "umt-web-")); +mkdirSync(join(root, "assets")); +writeFileSync(join(root, "index.html"), "SPA"); +writeFileSync(join(root, "assets", "app.js"), "console.log(1)"); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +function app() { + return createApp({ + config: { NODE_ENV: "production" }, + logger: memoryLogger().logger, + bus: new EventBus(5), + role: "all", + version: "0.0.0-test", + webRoot: root, + }); +} + +describe("单容器静态托管(WEB_ROOT)", () => { + it("存在的文件直接返回", async () => { + const res = await app().request("/assets/app.js"); + expect(res.status).toBe(200); + expect(await res.text()).toBe("console.log(1)"); + }); + + it("首页与前端路由回退 index.html", async () => { + for (const path of ["/", "/settings", "/library/123"]) { + const res = await app().request(path); + expect(res.status, path).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(await res.text()).toContain("SPA"); + } + }); + + it("/api 下的未知路径不回退,仍是 JSON", async () => { + const health = await app().request("/api/v1/health"); + expect((await json(health)).ok).toBe(true); + const res = await app().request("/api/nope"); + expect(res.status).toBe(404); + expect((await json(res)).error.code).toBe("not_found"); + }); +}); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 7680f997..ca7f692c 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "../../tsconfig.base.json", + "extends": "../../config/tsconfig.base.json", "include": ["src", "test"] } diff --git a/apps/web/app/api/health.ts b/apps/web/app/api/health.ts index 9a7fa3f2..cd17f701 100644 --- a/apps/web/app/api/health.ts +++ b/apps/web/app/api/health.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import type { HealthStatus } from "@umt/contracts"; -import { apiData, TTL_SECOND } from "~/lib/api"; +import { apiData, TTL_SECOND } from "~/lib/api.ts"; /** 服务健康状态,15 秒轮询 */ export function useHealth() { diff --git a/apps/web/app/components/console/api-key-dialog.tsx b/apps/web/app/components/console/api-key-dialog.tsx index 70357ca6..2d13339e 100644 --- a/apps/web/app/components/console/api-key-dialog.tsx +++ b/apps/web/app/components/console/api-key-dialog.tsx @@ -1,7 +1,7 @@ import { KeyRound } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; -import { Button } from "~/components/ui/button"; +import { Button } from "~/components/ui/button.tsx"; import { Dialog, DialogContent, @@ -10,9 +10,9 @@ import { DialogHeader, DialogTitle, DialogTrigger, -} from "~/components/ui/dialog"; -import { Input } from "~/components/ui/input"; -import { clearAuth, getToken, setToken } from "~/lib/api"; +} from "~/components/ui/dialog.tsx"; +import { Input } from "~/components/ui/input.tsx"; +import { clearAuth, getToken, setToken } from "~/lib/api.ts"; /** 设置本机使用的 API Key(存 localStorage,首版单管理员) */ export function ApiKeyDialog() { diff --git a/apps/web/app/components/console/empty-state.tsx b/apps/web/app/components/console/empty-state.tsx index c196440d..169e960c 100644 --- a/apps/web/app/components/console/empty-state.tsx +++ b/apps/web/app/components/console/empty-state.tsx @@ -1,7 +1,7 @@ import type { LucideIcon } from "lucide-react"; import { Inbox } from "lucide-react"; import type { ReactNode } from "react"; -import { cn } from "~/lib/utils"; +import { cn } from "~/lib/utils.ts"; /** 空态:图标 + 标题 + 说明 + 可选操作。用于"尚无数据"与"功能建设中"占位 */ export function EmptyState({ diff --git a/apps/web/app/components/console/nav.test.ts b/apps/web/app/components/console/nav.test.ts index d9b33494..286e76d6 100644 --- a/apps/web/app/components/console/nav.test.ts +++ b/apps/web/app/components/console/nav.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { findNavItem, NAV_ITEMS } from "./nav"; +import { findNavItem, NAV_ITEMS } from "./nav.ts"; describe("控制台导航", () => { it("12 个工作区,路径唯一", () => { diff --git a/apps/web/app/components/console/page-header.tsx b/apps/web/app/components/console/page-header.tsx index 61db1d6e..10db00b2 100644 --- a/apps/web/app/components/console/page-header.tsx +++ b/apps/web/app/components/console/page-header.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { cn } from "~/lib/utils"; +import { cn } from "~/lib/utils.ts"; /** 控制台页面标题:标题 + 一句说明 + 右侧操作区 */ export function PageHeader({ diff --git a/apps/web/app/components/console/status-badge.tsx b/apps/web/app/components/console/status-badge.tsx index 9d3b5715..de251747 100644 --- a/apps/web/app/components/console/status-badge.tsx +++ b/apps/web/app/components/console/status-badge.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { cn } from "~/lib/utils"; +import { cn } from "~/lib/utils.ts"; export type StatusTone = "success" | "warning" | "destructive" | "neutral"; diff --git a/apps/web/app/components/console/workspace-placeholder.tsx b/apps/web/app/components/console/workspace-placeholder.tsx index 1a76fe03..5b68b1d7 100644 --- a/apps/web/app/components/console/workspace-placeholder.tsx +++ b/apps/web/app/components/console/workspace-placeholder.tsx @@ -1,8 +1,8 @@ import { Construction } from "lucide-react"; import { useLocation } from "react-router"; -import { EmptyState } from "./empty-state"; -import { findNavItem } from "./nav"; -import { PageHeader } from "./page-header"; +import { EmptyState } from "./empty-state.tsx"; +import { findNavItem } from "./nav.ts"; +import { PageHeader } from "./page-header.tsx"; /** 工作区占位页:标题与说明取自导航表,正文为"建设中"空态 */ export function WorkspacePlaceholder({ milestone = "W3" }: { milestone?: string }) { diff --git a/apps/web/app/components/prism-leak.tsx b/apps/web/app/components/prism-leak.tsx index 6e19cbd7..be2b8442 100644 --- a/apps/web/app/components/prism-leak.tsx +++ b/apps/web/app/components/prism-leak.tsx @@ -1,4 +1,4 @@ -import { cn } from "~/lib/utils"; +import { cn } from "~/lib/utils.ts"; /** * 全站背景上左上角那束淡淡的棱镜漏光(样式见 app.css 的 .prism-leak)。 diff --git a/apps/web/app/components/theme-toggle.tsx b/apps/web/app/components/theme-toggle.tsx index 7007f654..a5d5e0dd 100644 --- a/apps/web/app/components/theme-toggle.tsx +++ b/apps/web/app/components/theme-toggle.tsx @@ -3,8 +3,8 @@ import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; import { flushSync } from "react-dom"; -import { Button } from "~/components/ui/button"; -import { cn } from "~/lib/utils"; +import { Button } from "~/components/ui/button.tsx"; +import { cn } from "~/lib/utils.ts"; export function ThemeToggle({ className }: { className?: string }) { const [mounted, setMounted] = useState(false); diff --git a/apps/web/app/components/ui/alert-dialog.tsx b/apps/web/app/components/ui/alert-dialog.tsx index c33977e7..75520f03 100644 --- a/apps/web/app/components/ui/alert-dialog.tsx +++ b/apps/web/app/components/ui/alert-dialog.tsx @@ -1,7 +1,7 @@ import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; import type * as React from "react"; -import { Button } from "~/components/ui/button"; -import { cn } from "~/lib/utils"; +import { Button } from "~/components/ui/button.tsx"; +import { cn } from "~/lib/utils.ts"; function AlertDialog({ ...props }: React.ComponentProps) { return ; diff --git a/apps/web/app/components/ui/avatar.tsx b/apps/web/app/components/ui/avatar.tsx index b6c790d1..9474232c 100644 --- a/apps/web/app/components/ui/avatar.tsx +++ b/apps/web/app/components/ui/avatar.tsx @@ -3,7 +3,7 @@ import { Avatar as AvatarPrimitive } from "radix-ui"; import type * as React from "react"; -import { cn } from "~/lib/utils"; +import { cn } from "~/lib/utils.ts"; function Avatar({ className, diff --git a/apps/web/app/components/ui/badge.tsx b/apps/web/app/components/ui/badge.tsx index 6741bb20..207b5499 100644 --- a/apps/web/app/components/ui/badge.tsx +++ b/apps/web/app/components/ui/badge.tsx @@ -2,7 +2,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { Slot } from "radix-ui"; import type * as React from "react"; -import { cn } from "~/lib/utils"; +import { cn } from "~/lib/utils.ts"; const badgeVariants = cva( "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", diff --git a/apps/web/app/components/ui/breadcrumb.tsx b/apps/web/app/components/ui/breadcrumb.tsx index 68b1f1ae..7f41d968 100644 --- a/apps/web/app/components/ui/breadcrumb.tsx +++ b/apps/web/app/components/ui/breadcrumb.tsx @@ -2,7 +2,7 @@ import { ChevronRight, MoreHorizontal } from "lucide-react"; import { Slot } from "radix-ui"; import type * as React from "react"; -import { cn } from "~/lib/utils"; +import { cn } from "~/lib/utils.ts"; function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { return