# Deno 2.9.7 + TypeScript 7.0.2 切换准入验证记录 - 日期:2026-09-27;宿主:Linux x86_64,Docker 可用;Node v24.15.0 仍在机器上,用来做对照。 - 代码:`spikes/deno/`,是独立的 Deno workspace,不属于 pnpm workspace。原始输出在 `spikes/deno/logs/*.txt`。 - PG:`docker run -d --name umt-deno-pg -p 55435:5432 -e POSTGRES_PASSWORD=spike postgres:16-alpine`,验证结束后已删除。 - 结论见 `docs/adr/ADR-004-deno-runtime.md`。 ## 0. 安装与版本 ```text $ curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s v2.9.7 Deno was installed successfully to /usr/local/bin/deno $ deno --version deno 2.9.7 (stable, release, x86_64-unknown-linux-gnu) v8 15.0.245.2-rusty typescript 6.0.3 ``` **注意:Deno 2.9.7 内置的 TypeScript 是 6.0.3,不是 7。** `deno check`、`deno test`、`deno compile` 做类型检查时都用 6.0.3。TS 7.0.2 只能从 npm 装(`npm:typescript@7.0.2`),见 §g。 ## 1. 结论表 | 项 | 结论 | 要点 | |---|---|---| | workspace + `nodeModulesDir: "auto"` + `npm:` imports + `deno install` | 通过 | 12 秒装完;生成的 `deno.lock` 是 v5 格式。有安装脚本的包(protobufjs、@google/genai、msw)默认被跳过,和 pnpm `allowBuilds` 的效果一样 | | a. openworkflow 0.10.1 + postgres.js | 通过 | 迁移建表、step.run/sleep/waitForSignal、幂等键、kill -9 后被另一个 worker 接管、双 worker 40 个 run 无重复,语义和 Node 下一致 | | b. pi-agent-core / pi-ai 0.87.1 | 通过 | fake provider 事件流、白名单(`bash` 被拒)、abort、SIGKILL 后 JSONL 恢复("结果未知",不重复提交)都跑通。`NodeExecutionEnv` 在 Deno 下可以用 | | c1. Hono 4 + `Deno.serve` + SSE | 通过 | SSE 头和分帧正确;SIGTERM 触发 `AbortController` 后,即使有 SSE 长连接挂着,服务也在约 0.2 秒内退出 | | c2. Kysely 0.29 + pg 8 / postgres.js | 两种都通过,**推荐 pg** | 项目真实的 12 个迁移在 Deno 下全部成功。postgres.js 方言对 `JSON.stringify` 过的 jsonb 会双重编码,所以业务库继续用 pg;OpenWorkflow 自己带 postgres.js | | d. pino 10 | 通过 | 自定义 destination、formatters/hooks 脱敏、`pino.transport`(worker_threads)、pino-pretty 都能用,**不用换**。LogTape 作为备选也验证通过 | | e. vitest 5 | 通过(需两处调整) | `deno run -A npm:vitest run` 能跑 node:http、child_process kill、fs 临时目录、statfs。真实仓库各包:server 15/15,contracts、db、domain、testkit 和 Node 一样;tasks 用干净 PG 跑 15/15;agent 有 1 例失败,原因是 import 没写扩展名 | | e'. `deno test` + `@std/expect` | 通过 | 只改 2 行 import 就能跑同一套用例 | | f. 前端 Vite 7 + React Router 7 | 通过 | `deno task build`(执行 package.json 里的 script)能构建,`vite dev` / `react-router dev` 在 5181 端口 curl 200;token 脚本 `deno run -A` 能生成也能检查。全程没有调用 node(用假 node 放在 PATH 最前面验证过) | | g. TypeScript 7.0.2 | 通过(需变通) | 可执行入口是 `npm:typescript@7.0.2/tsc`,**包里没有 `tsgo`**。真实仓库 8 个 tsconfig 在 TS7 下都是 0 错误;web 要删掉 `baseUrl`(TS7 已移除这个选项)。tsc 解析不了 `jsr:`,Deno 全局类型要靠 `deno types` 提供 | | h. `deno compile` / 镜像 | 通过(需变通) | 默认打包会把整个 node_modules 塞进去,二进制 440 MB;加 `--node-modules-dir=none --exclude-unused-npm` 后是 107 MB。`denoland/deno:2.9.7` 有 amd64 和 arm64 两个平台。单容器镜像 260 MB,API、SSE、SPA 回退和 SIGTERM 退出都验证过 | ## 2. 分项记录 ### workspace 结构 ```text spikes/deno/ deno.json workspace: packages/x, packages/contracts, apps/server, web;nodeModulesDir: auto;imports 用 npm:/jsr: packages/x/ deno.json 成员(name/exports),模拟 packages/* packages/contracts/ 仓库 contracts 的快照;同时有 deno.json 和精简的 package.json apps/server/ deno.json 成员:ow/、pi/、http.ts、db.ts、log-*.ts、node-compat.ts、test/ web/ apps/web 的只读复制;package.json 成员 tsconfig.tsc.json 只给 TS 7 用(不能叫 tsconfig.json,见 §g) Dockerfile 单容器验证 ``` **成员被 Vite/vitest/tsc 解析的前提:** `package.json` 成员会被 `deno install` 软链到 `node_modules/@umt/*`,所以 Vite、vitest、tsc 这些走 node 解析的工具都能找到它。只有 `deno.json` 的成员(比如 `@spike/x`)**不会**被链接,这些工具找不到。已验证同一个目录可以同时放 `deno.json` 和 `package.json`:Deno 的 name/exports 以 deno.json 为准,链接照样生成。 ### a. OpenWorkflow ```text $ deno run -A src/ow/cli.ts tables biz.side_effect_ledger / openworkflow.openworkflow_migrations / step_attempts / workflow_runs / workflow_signals # 正常路径:signal 发出后 run 完成,步骤依次是 submit→signal-wait→copy→register→sleep→cleanup;用同一个 idempotencyKey 再投递,返回的是同一个 run # kill -9:copy-file(6s)执行中 kill -9 worker A,启动 worker B,约 36 秒后完成(租约过期接管) 1fdbe2ec copy-file A start 1fdbe2ec copy-file B start / end → register B → cleanup B (attempts=4) # 双 worker:40 个 burst run {"total":"40","runs":"40","a":"24","b":"16"} # SIGTERM:Deno.addSignalListener → worker.stop() → "[A] worker stopped gracefully" ``` 改动只有三处:`process.on("SIGTERM")` 换成 `Deno.addSignalListener`,`node:fs` 写 pidfile 换成 `Deno.writeTextFileSync`,其余文件在顶部补 `import process from "node:process"`。其实不改也能跑,因为 Deno 2 对 npm/Node 代码提供了全局 `process`,`process.on` 也验证可用,见 §h。 ### b. Pi ```text $ deno run -A src/pi/fake-run.ts [tool_end] inventory__query isError=false → {"inLibrary":false} [tool_end] download__submit isError=false → {"accepted":true,"idempotencyKey":"dl:Dune:1080p"} [tool_end] bash isError=true -> "Tool \"bash\" is unavailable" [run_end] status=completed fake calls: 3 $ SUBMIT_DELAY_MS=2000 deno run -A src/pi/abort-resume.ts crash # 进程自己 SIGKILL $ deno run -A src/pi/abort-resume.ts resume open operations at attach: [{"lane":"main","kind":"run",...}] [tool_end] (recovery) download__submit "[Tool execution was interrupted ... external outcome is unknown.]" SUBMITTED in this process [] ← 没有重复提交 ``` ### c. Hono + Deno.serve + SSE,Kysely - `Deno.serve({ port, signal })` 直接接 `app.fetch`;`streamSSE` 输出 `event/data/id`。SIGTERM 时 abort signal,`server.finished` 会 resolve。 - 挂着 SSE 长连接时关服:不管应用层有没有主动关 SSE,进程都在约 0.21 秒内退出(`logs/hono-shutdown.txt`)。Node 那边原来需要 `closeAllConnections()`,这里不需要。 - 静态托管:用 `hono/deno` 的 `serveStatic({ root })`,SPA 回退要写成 `serveStatic({ root, path: "index.html" })`。**写绝对路径 `path: "/app/public/index.html"` 会 404**。 - Kysely 0.29 的迁移 API 要从 `kysely/migration` 导入,import map 里需要加 `"kysely/": "npm:/kysely@^0.29.6/"`。 ```text pg migrate: ["0001_init:Success","0002_index:Success"] rows: string:1/Dune/{"q":"1080p"}/true 50 并发/池 4: 158ms postgresjs migrate: 同上 rows: string:1/Dune/"{\"q\":\"1080p\"}"/true ← jsonb 被双重编码 170ms # 真实迁移(只读引用 packages/db/migrations): pg migrate: ["0001_init:Success", … ,"0012_organization:Success"] error: null ``` ### d. 日志 ```text {"level":30,...,"token":"[REDACTED]","url":"https://x/rss?passkey=[REDACTED]&a=1","msg":"fetch https://x?api_key=[REDACTED]"} {"level":40,...,"component":"worker","password":"p","err":{},"msg":"child"} ← 见下方"顺带发现" {"level":30,...,"secret":"[REDACTED]","msg":"via pino.transport (worker_threads)"} [18:04:42] INFO (987284): via pino-pretty transport ``` **顺带发现(和运行时无关,Node 下复现结果相同):** 现有 `apps/server/src/logger.ts` 的脱敏有两个漏洞:① `logger.child({ password })` 的 bindings 不经过 `formatters.log`,需要再加一个 `formatters.bindings`;② `redact()` 把 `Error` 当普通对象处理,结果 `err` 变成 `{}`,应该先按 `err.message/stack` 序列化,再做脱敏。 LogTape(npm 版 `@logtape/logtape`)写一个 redacting sink 包装约 5 行,输出 JSON Lines,也可用。**结论:保留 pino。** ### e. 测试 ```text $ deno run -A npm:vitest@5.0.2 run # spike 自带用例 FAIL: Cannot find package '@spike/x' ← deno.json 成员不在 node_modules 里,Vite 解析不到 # vitest.config.ts 加上 resolve.alias 之后,连跑 3 次都是 5/5 通过(约 0.7s) $ cd apps/server && deno run -A --no-lock npm:vitest run # 真实仓库,只读 Test Files 4 passed (4) Tests 15 passed (15) ``` 真实各包在 Deno 和 Node 下的对照(`logs/vitest-real-packages.txt`): | 包 | Deno | Node | 说明 | |---|---|---|---| | contracts / db / domain / testkit | 361 / 11 / 122 / 1 全过 | 同 | | | adapters | 108 过 1 败 | 108 过 1 败 | 两边一样,是在改代码导致的,和运行时无关 | | tasks | 在共享的开发 PG 上不稳 | 在共享的开发 PG 上也不稳 | 换成干净的 PG(55435)后 Deno 下 **15/15**,包括子进程 kill -9 的恢复用例 | | agent | 26 过 1 败 | 27 全过 | 子进程用 `process.execPath` 启动,在 Deno 下这个路径就是 deno,原生加载 `contracts/src/index.ts` 时碰到 `export * from "./agent"` 这种不带扩展名的 import | `deno test` 的写法(`logs/deno-test.txt`):只需把 ```ts import { afterAll, beforeAll, describe, expect, it } from "vitest"; ``` 换成 ```ts import { afterAll, beforeAll, describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; ``` `ok | 1 passed (6 steps)`。资源泄漏检查(sanitizer)也没有报错。要注意的差异:没有 `vi.fn/vi.mock`(改用 `@std/testing/mock` 的 `spy/stub`);没有 `describe.skipIf`(改成 `it({ ignore: cond })`);`toMatchObject`、`toEqual` 等常用 matcher 在 `@std/expect` 里都有。 ### f. 前端 ```text $ deno run -A npm:react-router build error: ... react-router/package.json did not have a bin property ← CLI 在 @react-router/dev 包里 $ deno run -A npm:@react-router/dev@7.12.0/react-router build → ✓ built in 690ms,SPA Mode: Generated build/client/index.html $ deno task build (执行 package.json 的 "react-router build";PATH 最前面放了会 exit 99 的假 node,构建仍然成功) $ deno run -A npm:vite@7 dev --port 5181 --strictPort GET / -> 200 228202B UHD 媒体自动化控制台;GET /app/root.tsx -> 200 text/javascript $ deno run -A scripts/gen-design-tokens.mjs [--check] → 生成 / "登记表是最新的",exit 0;check-design-tokens.sh 通过 ``` `deno task` 运行 `node_modules/.bin/*` 时用的是 Deno,不会去调系统的 node。 ### g. TypeScript 7.0.2 - 包结构:`typescript@7.0.2` 的 `bin` 只有 `{ tsc }`,`lib/tsc.js` 只是个壳,真正执行的是平台包 `@typescript/typescript-linux-x64/lib/tsc`(Go 原生二进制)。Deno 下没有 `process.execve`,会走 `execFileSync` 分支,exit code 能正确透传。 - 命令:`deno run -A npm:typescript@7.0.2/tsc --noEmit -p tsconfig.tsc.json`。 - 结果对照(`logs/typecheck-compare.txt`、`logs/tsc7-real-repo.txt`、`logs/tsc7-web.txt`): | | tsc 7.0.2 | deno check(TS 6.0.3) | |---|---|---| | spike 代码 | 0 错误,1.5s(约 12s CPU,多核并行) | 0 错误,2.6s | | 同一批严格错误(noUncheckedIndexedAccess 等) | 报出,结果一致 | 报出,结果一致 | | `jsr:` 包(如 `@std/expect`) | **解析不了**(TS2307) | 能解析 | | `Deno.*` 全局 | 需要 `deno types > types/deno.d.ts` 加进 include | 内置 | | 真实仓库 7 个包 + server(现有 tsconfig) | **全部 0 错误** | 不带扩展名的 import 未解决前:contracts 16、domain 7、db 3、tasks 9、agent 11、adapters 6、server 8 个解析错误;加 `--sloppy-imports` 后全部 0 错误 | | web(React Router typegen 之后) | 删掉 `baseUrl` 后 0 错误(TS5102: Option 'baseUrl' has been removed) | 不适用 | - **坑:** 只要 workspace 根目录有 `tsconfig.json`,`deno check` 就会读它的 `compilerOptions.lib`。如果里面写的是 `["ES2023","DOM"]`,Deno 会报找不到 `Deno` 名字,也不认识 `import.meta.main`。所以给 TS7 用的配置要换个文件名(`tsconfig.tsc.json`),或者写到各包目录里,不要放在根目录叫 `tsconfig.json`。 - 不带扩展名的 import 可以自动修:`deno lint --sloppy-imports --fix --rules-include=no-sloppy-imports `,验证过能改成 `./a.ts` 和 `./dir/index.ts`(`logs/sloppy-fix.txt`)。仓库 src+test 里大约有 400 行这样的 import。 ### h. 容器与 Node 专有 API ```text deno compile -A worker.ts → 440 MB(把整个 node_modules 443M 都嵌进去了,包括前端依赖和 TS 原生二进制) deno compile --bundle → 141 MB deno compile --node-modules-dir=none --exclude-unused-npm → 107 MB 三种都能正常消费 OW 任务并优雅停止 $ docker manifest inspect denoland/deno:2.9.7 → OCI index,linux/amd64 + linux/arm64(Debian 13 trixie,默认 root 用户) $ docker build(spikes/deno/Dockerfile:deno install → web build → deno compile → debian:trixie-slim) image size 260MB;health 200;/ 200 text/html;/settings(SPA 回退)200;assets js 200;SSE 3 个事件;docker stop(SIGTERM)0.31s ``` 第一版 Dockerfile 用的是 `deno run --cached-only` 加 `USER deno`,结果失败:`File exists (os error 17), symlink '../../packages/contracts' -> '/app/node_modules/@umt/contracts'`。原因是 `nodeModulesDir: auto` 在运行时还会尝试重建 node_modules 链接,而且镜像有 813 MB。**生产镜像应改用 compile 出的二进制;如果坚持用 `deno run`,就加 `--node-modules-dir=manual`,并在构建阶段用 root 装好依赖。** Node API 探测(`src/node-compat.ts` → `logs/node-compat.txt`):`node:util parseArgs`、`fs.statfs`、`os.*`、`process.memoryUsage`、`AsyncLocalStorage`、`node:crypto`、`worker_threads`(`new Worker(url, {type:"module"})`)、`child_process.spawn` 加 SIGTERM、`process.on("SIGTERM")` 全部正常;`process.execPath` 是 `/usr/local/bin/deno`。 ## 3. 清理 `umt-deno-pg`、`umt-deno-app` 两个容器和 `umt-deno-spike` 镜像已删除;compile 产物、`web/build`、会话目录已删除;5181、5199、58080、55435 端口都没有监听进程。`spikes/deno/node_modules`(443 MB)被 .gitignore 忽略,需要时执行 `deno install` 重建。