# ADR-004:运行时切换为 Deno 2.9.7(锁定)+ TypeScript 7.0.2 - 状态:提议(2026-09-27,Deno 准入 spike 完成后)。采纳后替代 ADR-001,ADR-001 的状态措辞由协调者修改。 - 关联:REQ §9、§10.2;plan §1;验证记录 spike `deno-2.9.7`(已删除,见 git `ceb66f8`);代码 `spikes/deno/`(已删除,见 git `ceb66f8`) ## 背景 用户要求运行时改成 Deno 最新稳定版并锁定版本,同时语言版本用 TypeScript 7.0.2;前端继续用 Vite 7 + React Router 7。ADR-001 当初选 Node 有三条理由:本机没有 Deno、依赖以 Node 为主要目标、工具链沿用 uhd_web。这次 spike 逐项核对了这三条:Deno 2.9.7 已装在 `/usr/local/bin/deno`;OpenWorkflow、Pi、Hono、Kysely/pg、pino、vitest、Vite/React Router 在 Deno 下的语义和 Node 一致;真实仓库 7 个包加 server 的测试和类型检查也都跑过。 ## 决定 1. **运行时:Deno 2.9.7**,本机、CI、镜像三处锁同一版本。镜像用 `denoland/deno:2.9.7`,已确认有 amd64 和 arm64。依赖统一用 `deno install` + `deno.lock` 管理,彻底去掉 pnpm。 2. **语言版本:TypeScript 7.0.2**,由 `npm:typescript@7.0.2/tsc` 提供。它是 Go 原生实现,入口名就叫 `tsc`,包里没有 `tsgo`。它是 CI 的**类型门禁**。另外加一道 `deno check`,覆盖 Deno 专有的解析:import map、`jsr:`、`Deno.*` 全局。这一道同时也是 `deno compile` 实际使用的检查,它内置的 TS 是 6.0.3。 3. **依赖目录 `nodeModulesDir: "auto"`。** Vite、React Router、vitest、tsc 7 都要从 node_modules 解析依赖,所以必须保留这个目录。 4. **workspace 成员同时放 `deno.json` 和精简的 `package.json`。** deno.json 负责 name/exports/tasks;package.json 只写 name/type/exports,作用是让 `deno install` 把成员软链到 `node_modules/@umt/*`,这样 Vite、vitest、tsc 7 不需要配置 alias 或 paths 就能解析。npm 依赖的版本**只在根 `deno.json` 的 `imports` 里登记一次**,相当于统一目录。 5. **不改动的部分:** Hono 4(入口换成 `Deno.serve`)、Kysely 0.29 + pg 8(业务库)、postgres.js(只给 OpenWorkflow 用)、pino 10、vitest 5(通过 `deno run -A npm:vitest` 运行)、biome(通过 `npm:@biomejs/biome` 运行)、Vite 7 + React Router 7。 6. **生产形态:单容器,一个 `deno compile` 二进制。** 它按 `--role api|worker|all` 运行 API 和 worker,并托管 web 静态产物。镜像基础是 `debian:trixie-slim`,spike 实测 260 MB。 ## 理由 - spike 的结论是通过或有变通,没有不通过的项。和 Node 的差异都能在工程层面处理:不带扩展名的 import、Kysely 迁移导入路径、TS7 移除了 `baseUrl`、compile 打包体积。 - 能拿回 Deno 的运行时权限模型,正好落实 ADR-001 列为代价的 REQ §10.2 要求。生产二进制在编译时就固定 `--allow-net/--allow-read=<白名单>/--allow-env/--allow-sys`,不给 `--allow-run`,除非后续确实需要 ffprobe 这类外部程序,那时按程序名列白名单。 - 测试和工具链继续沿用现有资产(vitest 用例、biome 规则、Vite 配置),迁移的改动主要是机械替换。 ## 代价 / 风险 - 类型检查分两套:TS 7.0.2 和 Deno 内置的 6.0.3。只要不用 TS7 独有语法就不会有分歧,spike 里两套结果一致。 - `jsr:` 包 tsc 7 解析不了。规则是:业务代码只用 npm 发布的包;`@std/*` 只能出现在 `deno check` 覆盖、tsc 排除的文件里,目前没有这类文件。 - compile 默认会把整个 node_modules 嵌进二进制(440 MB),必须加 `--node-modules-dir=none --exclude-unused-npm`。代码里如果有动态 `import()`,要用 `--include` 显式带上。 - 有安装脚本的依赖默认不执行脚本。`protobufjs`、`@google/genai`、`msw` 被跳过后都不影响使用,和原来 pnpm 的 `allowBuilds` 结果一致。以后新增这类依赖时,要用 `deno approve-scripts` 显式批准。 ## 迁移清单 ### 1. 根目录 新增 `deno.json`,示意如下,版本照搬现有锁定: ```jsonc { "workspace": ["./packages/*", "./apps/server", "./apps/web"], "nodeModulesDir": "auto", "imports": { "hono": "npm:hono@4.13.9", "hono/": "npm:/hono@4.13.9/", "@hono/zod-openapi": "npm:@hono/zod-openapi@1.6.3", "@hono/swagger-ui": "npm:@hono/swagger-ui@0.6.1", "kysely": "npm:kysely@0.29.6", "kysely/": "npm:/kysely@0.29.6/", "pg": "npm:pg@8.23.0", "@types/pg": "npm:@types/pg@8.23.1", "postgres": "npm:postgres@3.4.9", "openworkflow": "npm:openworkflow@0.10.1", "openworkflow/": "npm:/openworkflow@0.10.1/", "@earendil-works/pi-agent-core": "npm:@earendil-works/pi-agent-core@0.87.1", "@earendil-works/pi-agent-core/": "npm:/@earendil-works/pi-agent-core@0.87.1/", "@earendil-works/pi-ai": "npm:@earendil-works/pi-ai@0.87.1", "typebox": "npm:typebox@1.3.27", "zod": "npm:zod@4.3.6", "pino": "npm:pino@10.3.1", "pino-pretty": "npm:pino-pretty@13.1.3", "cheerio": "npm:cheerio@…", "fast-xml-parser": "npm:fast-xml-parser@…", "nunjucks": "npm:nunjucks@…", "@xmldom/xmldom": "npm:@xmldom/xmldom@…", "xpath": "npm:xpath@…", "@types/nunjucks": "npm:@types/nunjucks@…", "vitest": "npm:vitest@5.0.2", "typescript": "npm:typescript@7.0.2", "@types/node": "npm:@types/node@24", "@biomejs/biome": "npm:@biomejs/biome@2.5.14" }, "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "verbatimModuleSyntax": true }, "tasks": { "dev": { "dependencies": ["dev:server", "dev:web"] }, "dev:server": "cd apps/server && deno task dev", "dev:web": "cd apps/web && deno task dev", "db:up": "docker compose -f docker-compose.dev.yml up -d --wait", "db:migrate": "deno run -A --env-file=.env packages/db/scripts/migrate.ts latest", "db:reset": "deno run -A --env-file=.env packages/db/scripts/migrate.ts reset", "types": "deno types > types/deno.d.ts", "typecheck": "deno task types && deno task typecheck:tsc && deno task typecheck:deno", "typecheck:tsc": "for p in packages/* apps/server; do deno run -A npm:typescript@7.0.2/tsc --noEmit -p $p/tsconfig.json || exit 1; done && cd apps/web && deno run -A npm:@react-router/dev@7.12.0/react-router typegen && deno run -A npm:typescript@7.0.2/tsc -p tsconfig.json", "typecheck:deno": "deno check apps/server/src/main.ts packages/*/src/index.ts", "lint": "deno run -A npm:@biomejs/biome@2.5.14 check .", "format": "deno run -A npm:@biomejs/biome@2.5.14 check --write .", "test": "for p in packages/* apps/server apps/web; do (cd $p && deno run -A npm:vitest@5.0.2 run) || exit 1; done", "check:tokens": "cd apps/web && deno task check:tokens", "build": "cd apps/web && deno task build && cd ../server && deno task compile", "ci": "deno task typecheck && deno task lint && deno task test && deno task check:tokens && deno task build" } } ``` - lint/fmt:**保留 biome**(规则已调好,Deno 下验证可用),打开 `correctness/useImportExtensions` 来约束 import 必须带扩展名。`deno lint` 和 `deno fmt` 不纳入 CI,避免两套规则并存。 - 删除:`pnpm-workspace.yaml`、`pnpm-lock.yaml`、根 `package.json`、`tsx` 依赖、`apps/server/scripts/build.mjs`(esbuild,由 `deno compile` 取代)、`@hono/node-server`、`esbuild` devDependency。`.gitignore` 增加 `types/deno.d.ts`。 - `tsconfig.base.json`:保留(tsc 7 用),`types` 加一项 `"./types/deno.d.ts"`,或者只在 server 的配置里 include 它;其他选项在 TS7 下都已验证 0 错误。**不要**在根目录放 `tsconfig.json`,否则 `deno check` 会读它的 `lib`,导致 `Deno` 名字丢失。 - `docker-compose.dev.yml` 不变。 ### 2. 每个成员 `packages//deno.json`: ```json { "name": "@umt/", "version": "0.0.0", "exports": { ".": "./src/index.ts" } } ``` `packages//package.json` 精简为以下内容,去掉 dependencies、scripts、`workspace:*`: ```json { "name": "@umt/", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } } ``` - 各包的 `tsconfig.json`(extends base)保留,用于 tsc 7。`vitest.config.ts` 保留,不用改。 - `apps/server/deno.json`: - `tasks.dev`:`deno run -A --watch --env-file=../../.env src/main.ts` - `tasks.start`:`deno run --allow-net --allow-env --allow-sys --allow-read --env-file=../../.env src/main.ts` - `tasks.compile`:`deno compile --frozen --node-modules-dir=none --exclude-unused-npm --allow-net --allow-env --allow-sys --allow-read=/app --output ../../dist/umt-server src/main.ts` - `apps/web`:**保留 package.json**。它的 scripts 由 `deno task` 直接执行,会用 Deno 运行 `node_modules/.bin`,不依赖 node。改动两处:`gen:tokens` / `check:tokens` 里的 `node` 换成 `deno run -A`;`tsconfig.json` 删掉 `"baseUrl": "."`(TS5102),`paths` 保持 `"~/*": ["./app/*"]` 不变。`@umt/contracts` 的依赖写成 `"workspace:*"`,Deno 认这个写法。 ### 3. 代码改动 | 点 | 改法 | |---|---| | 不带扩展名的相对 import(src 和 test 共约 400 行) | 一次性执行 `deno lint --sloppy-imports --fix --rules-include=no-sloppy-imports packages apps/server`(已验证能自动改成 `./x.ts` / `./dir/index.ts`),之后由 biome `useImportExtensions` 守住 | | `apps/server/src/main.ts` | `@hono/node-server` 的 `serve` 换成 `Deno.serve({ port, hostname, signal: ac.signal }, app.fetch)`。关服时执行 `ac.abort(); await server.finished`,`closeAllConnections` 可以删掉。实测挂着 SSE 长连接也能在 0.2 秒内退出。`package.json with { type: "json" }` 读版本号的写法改成读 `deno.json` | | 静态托管(单容器) | `import { serveStatic } from "hono/deno"`。写法是 `app.use("/*", serveStatic({ root }))`,再加 `app.get("*", serveStatic({ root, path: "index.html" }))`。path 不能写绝对路径,否则 404 | | Kysely 迁移 | 已经在用 `kysely/migration`,不变。import map 需要有 `"kysely/"` 前缀映射 | | `packages/tasks/test/crash-recovery.test.ts` 和 agent 的崩溃恢复测试 | 原来是 `spawn(process.execPath, ["--import","tsx", WORKER])`,改成 `spawn(process.execPath, ["run","-A", WORKER])`,并去掉 tsx。现状在 Deno 下 tasks 已经 15/15;agent 失败的根因是不带扩展名的 import,做完上一行的修复就好 | | `packages/db/scripts/*.ts` | 由 `tsx --env-file-if-exists` 改成 `deno run -A --env-file=...`(Deno 的 `--env-file` 在文件不存在时只告警) | | logger | pino 保留。顺带修两个 Node 下也存在的脱敏漏洞:加 `formatters.bindings` 脱敏 child bindings;`redact()` 遇到 `Error` 先按 message/stack 序列化,否则 `err` 会变成 `{}` | ### 4. Node 专有 API 风险点(spike 中实测) | API | Deno 2.9.7 现状 | 建议 | |---|---|---| | `worker_threads`(pino transport、`new Worker(url,{type:"module"})`) | 可用 | 新代码可以直接用 Web Worker(`new Worker(url, { type: "module" })`) | | `process.on("SIGTERM")` / `process.exit` / `process.env` / `process.pid` | 可用,Deno 2 下 `process` 是全局对象 | 入口层统一改成 `Deno.addSignalListener`、`Deno.exit`、`Deno.env`,库代码保留 `node:process` 也行 | | `fs.statfs`(磁盘余量) | 可用,bsize/bavail 正确 | 不变 | | `child_process.spawn` / `kill(SIGKILL/SIGTERM)` | 可用 | 生产要 `--allow-run=<程序名>`。新代码可以用 `Deno.Command` | | `process.execPath` | 是 deno 本身 | 凡是"用当前运行时起子进程"的地方,参数要改成 `run -A `(见 §3) | | `AsyncLocalStorage`、`node:crypto`、`node:util parseArgs`、`os.*` | 可用 | 不变 | | `require` / `__dirname` | 仓库里只有 2 处 `__dirname`(脚本) | 改成 `import.meta.dirname` | ### 5. 测试 - **继续用 vitest 5**,运行方式是 `deno run -A npm:vitest@5.0.2 run`,用例不用改。成员有精简 package.json 之后,`@umt/*` 走 node_modules 链接就能解析,不需要 alias。 - 暂不切 `deno test`。如果以后要切,最小改动是:import 换成 `@std/testing/bdd` 的 describe/it/beforeAll/afterAll 加 `@std/expect` 的 expect;`vi.fn/vi.spyOn` 换成 `@std/testing/mock` 的 `spy/stub`;`describe.skipIf(c)` 换成 `describe({ name, ignore: c, fn })`;同时要接受资源泄漏检查(sanitizer)。 ### 6. Dockerfile(单容器,spike 已构建并验证) ```dockerfile FROM denoland/deno:2.9.7 AS build WORKDIR /app COPY . . RUN deno install --frozen RUN deno task check:tokens && cd apps/web && deno task build RUN cd apps/server && deno task compile # → /app/dist/umt-server FROM debian:trixie-slim RUN useradd -r -u 1000 umt WORKDIR /app COPY --from=build /app/dist/umt-server /app/umt-server COPY --from=build /app/apps/web/build/client /app/public ENV WEB_ROOT=/app/public HOST=0.0.0.0 PORT=4380 USER umt EXPOSE 4380 ENTRYPOINT ["/app/umt-server"] CMD ["--role", "all"] ``` - 不要用 `deno run` + `nodeModulesDir: auto` 当运行时入口。spike 实测运行时会尝试重建 node_modules 链接,报 EEXIST,镜像还有 813 MB。如果非要用 `deno run`,必须加 `--node-modules-dir=manual --cached-only`。 - 迁移作为同一个二进制的子命令(`--role migrate`),或者单独 compile 一个 `umt-migrate`,在部署时先执行。 ### 7. CI ```bash curl -fsSL https://deno.land/install.sh | sh -s v2.9.7 # 或 denoland/setup-deno,deno-version: 2.9.7 deno install --frozen deno task ci # typecheck(tsc 7.0.2 + deno check)→ biome → vitest → tokens → web build + compile ``` 需要 PG 的测试(tasks/db)在 CI 里单独起一个一次性 PG 服务,通过 `TASKS_TEST_DATABASE_URL` 注入。spike 显示,和开发者共用的 55433 库会互相干扰。 ## 预估改动面 - 新增 9 个 `deno.json`,改 8 个 `package.json`,新增根 `deno.json`,删除 pnpm 相关的 3 个文件和 `build.mjs`。 - 约 400 行相对 import 补扩展名,可自动修复。server `main.ts` 约 30 行。2 个崩溃恢复测试的 spawn 参数。db scripts 3 个。logger 约 15 行。web 的 tsconfig 和 package.json 各 1 到 2 行。 - 文档:README、plan §1、ADR-001 标记 superseded。 - 业务逻辑(domain/contracts/adapters/agent/tasks 的实现)**不用改**。