docs(spikes): Deno 2.9.7 + TS 7 compatibility spike and ADR-004

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 18:24:08 +08:00
1 parent fe3613ee8f
commit cf85481d95
147 files changed
+15213

No files matched your search

+196
View File
@@ -0,0 +1,196 @@
# 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:[email protected]`),见 §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:[email protected]/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:[email protected] 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/[email protected]/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 <title>UHD 媒体自动化控制台</title>;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
- 包结构:`[email protected]` 的 `bin` 只有 `{ tsc }`,`lib/tsc.js` 只是个壳,真正执行的是平台包 `@typescript/typescript-linux-x64/lib/tsc`(Go 原生二进制)。Deno 下没有 `process.execve`,会走 `execFileSync` 分支,exit code 能正确透传。
- 命令:`deno run -A npm:[email protected]/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 <dir>`,验证过能改成 `./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` 重建。