Files
UHD_Media_Tool/docs/spikes/deno-2.9.7.md
T
2026-09-27 18:24:08 +08:00

14 KiB

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. 安装与版本

$ 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 结构

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

$ 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

$ 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/"。
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. 日志

{"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. 测试

$ 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):只需把

import { afterAll, beforeAll, describe, expect, it } from "vitest";

换成

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. 前端

$ 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

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 重建。