本文记录 Undici 8.11.2 适配仓颉(Cangjie)的完整过程。适配目标是得到一个可以被仓颉项目直接依赖的跨端 HTTP 三方库,并在本地终端中通过真实 Node.js 会话和本地 TCP、TLS、HTTP/2、WebSocket、SSE 服务验证原库公开能力。由于 Undici 本身是 Node.js HTTP 客户端,本次适配采用仓颉启动 Node.js 子进程的桥接方案,保留当前仓库
index.js的完整实现,同时让仓颉调用方获得稳定的类型化入口和可观察的运行结果。
项目地址: AtomGit/nutpi_cj/undici
上游仓库: nodejs/undici
开发工具: 华为云码道
一、背景
1.1 为什么适配 Undici
Undici 是 Node.js 生态中面向 HTTP/1.1 的底层客户端,当前版本同时提供 request、fetch、连接池、代理、HTTP/2、WebSocket、EventSource、Mock、缓存和拦截器等能力。JavaScript 版本依赖动态 options、Promise、ReadableStream、Buffer、Node.js socket 以及大量回调对象,而仓颉强调静态类型、显式异常和由语言标准库提供的进程与网络能力。直接逐行翻译 Undici 源码,无法保留这些运行时对象的身份和异步生命周期。
本项目没有把 Undici 的 Node.js 协议栈伪装成纯仓颉实现,而是把仓颉调用边界设计成 Undici、UndiciResult 和 UndiciError。仓颉负责配置会话、传输程序源码和参数、收集标准输出与标准错误;Node.js 会话加载当前仓库的 index.js,因此 request、fetch 和所有高级 API 都由正在适配的原库实际执行。
1.2 跨端三方库的适配边界
当前项目提供的是仓颉跨端包,不绑定某一个 UI 框架或操作系统。适配边界可以概括为:
| 边界 | 仓颉实现 |
|---|---|
| 包管理 | cjpm.toml、cjpm.lock、cjpm check 和静态库输出 |
| 调用入口 | Undici、run、runSession、request、fetch、exports 和 demonstrate |
| 进程传输 | std.process 启动 Node.js,程序通过标准输入传输,参数通过参数数组传输,不经过 shell |
| 原库加载 | 校验 package.json 为当前 Undici 仓库,并由本地 require 加载 index.js |
| HTTP 能力 | 在 Node.js 会话中真实执行 HTTP/1.1、HTTPS、HTTP/2、连接池、代理和请求 API |
| Web 能力 | 在 Node.js 会话中真实执行 WebSocket、WebSocketStream、EventSource、Headers、Response 和 FormData |
| 高级能力 | 在 Node.js 会话中真实执行 8 个拦截器、两种缓存存储、Mock、快照和 Cookie API |
| 错误模型 | UndiciError 保留退出码、标准错误和失败前已产生的标准输出 |
| 交付形态 | 仓颉静态库和 .cjo 接口文件,构建目录位于仓库外的 .cjpm-target |
| 验证方式 | cjpm test、独立 test/main.cj 和 example/example.cj 实际输出 |
这里没有把 Node.js 对象转换成仓颉对象,也没有宣称已经完成纯仓颉网络栈或鸿蒙原生 API 适配。目标平台需要提供兼容版本的仓颉 SDK、std.process 和 Node.js >= 22.19.0。只要满足这些条件,就可以使用相同的仓颉源码和 cjpm 配置构建并调用当前 Undici 仓库。
1.3 适配后的能力
当前仓颉桥接包公开 7 个入口,覆盖以下能力:
- 会话配置:
Undici(repository:, node:, timeoutSeconds:),指定原库目录、Node.js 可执行文件和会话时限; - 通用执行:
run(program, args:)执行可信 JavaScript 并返回标准输出,runSession(program, args:)同时返回 stdout 和 stderr; - 便捷请求:
request(url, options:)调用原库undici.request,返回状态、headers、trailers、UTF-8 body 和bodyBase64; - Fetch 调用:
fetch(url, options:)调用原库undici.fetch,返回状态、headers、最终 URL、重定向状态和正文; - API 核对:
exports()读取当前仓库实际导出的顶层名称,避免文档与源码漂移; - 完整演示:
demonstrate()启动本地测试服务,展示并断言 58/58 顶层导出、8 个拦截器和两种缓存存储; - 错误传递:
UndiciError让仓颉调用方读取 Node.js 退出码、stderr 和部分 stdout。
这些入口由仓颉源码真实调用,不把 Node.js 的高级能力改写成只打印断言通过的假示例。示例会打印请求体、响应体、连接 ID、缓存命中次数、重试次数、WebSocket 消息和 SSE 事件等结果。
二、实现路线图
text
第 1 阶段:盘点 API ── 对照 Undici 顶层导出,确定仓颉桥接边界和 Node.js 版本要求
第 2 阶段:建立 cjpm 工程 ── 固定包名、版本、源码目录和仓库外构建输出目录
第 3 阶段:类型映射 ── 用 Undici、UndiciResult 和 UndiciError 表达进程调用边界
第 4 阶段:实现桥接 ── 完成 stdin 程序传输、参数数组、输出收集、超时和原库加载
第 5 阶段:测试与核对 ── 验证参数安全、错误传递、双管道读取和顶层导出清单
第 6 阶段:终端演示 ── 启动本地协议服务,输出所有已验证功能和效果图
每个阶段都有可检查的产物:工程阶段得到 cjpm.toml,桥接阶段得到 undici_cangjie 静态库,测试阶段得到 6 项仓颉测试全部通过,最终示例则输出来自真实 Undici 会话的 49 组结果。
三、逐步实现过程
第 1 阶段:盘点原库 API 和仓颉工程边界
1.1 识别动态 API
Undici 的 JavaScript API 接受动态 options,并通过 dispatcher、handler、stream、Promise 和 Web API 对象组合请求生命周期。index.js 当前导出 58 个顶层名称,包含 Client、Pool、Agent、fetch、WebSocket、MockAgent、SnapshotAgent 和 EventSource 等不同类型。仓颉没有与这些 JavaScript 对象完全等价的跨进程对象模型,因此本适配把"执行原库"和"展示结果"作为桥接协议的核心。
仓颉侧使用 String 传递 JavaScript 程序,使用 Array<String> 传递参数;Node.js 侧通过 AsyncFunction 接收 undici、本地 require 和 args。这样复杂的回调、dispatcher、流和 signal 仍然可以在一个 Node.js 会话内保持原始对象身份,而仓颉侧只需要处理确定的文本输出和异常。
1.2 固定仓颉项目结构
以下目录树按照当前 Undici 项目的仓颉适配文件整理,只展示仓颉相关文件;原始 JavaScript、TypeScript、lib/、test/ 和原有构建文件保持原位置:
text
undici/
├── cjpm.toml
├── cjpm.lock
├── README.Cangjie.md
├── README.Cangjie_CN.md
├── src/
│ ├── undici.cj
│ ├── demonstration.cj
│ └── undici_test.cj
├── test/
│ └── main.cj
└── example/
├── cjpm.toml
├── cjpm.lock
└── example.cj
源码集中在 src/undici.cj,演示脚本位于 src/demonstration.cj,测试位于 src/undici_test.cj。test/main.cj 额外启动仓颉 TCP 服务,独立验证 request 和 fetch 两个便捷入口。示例工程位于 example/,其 src-dir 是当前目录,但 target-dir 放在仓库外的 ../../.cjpm-target/undici_example,不会被源码扫描。
第 2 阶段:创建 cjpm 工程
根目录 cjpm.toml 使用静态库输出类型,并将源码目录和构建目录固定下来:
toml
[package]
cjc-version = "1.1.3"
name = "undici_cangjie"
version = "8.11.2"
description = "Cangjie subprocess bridge to the original Undici implementation"
output-type = "static"
src-dir = "src"
target-dir = "../.cjpm-target/undici_cangjie"
示例工程使用本地路径依赖,并把构建目录放在 example/ 外部:
toml
[package]
cjc-version = "1.1.3"
name = "undici_example"
version = "1.0.0"
output-type = "executable"
src-dir = "."
target-dir = "../../.cjpm-target/undici_example"
[dependencies]
undici_cangjie = { path = ".." }
根项目从仓库目录计算 ../.cjpm-target/undici_cangjie,示例项目从 example/ 目录计算 ../../.cjpm-target/undici_example。两个目录都不在相应 src-dir 扫描范围内,运行 cjpm run 时不会出现把构建输出目录当作源码目录的警告,也不会把构建产物提交到仓库。
第 3 阶段:把 JavaScript 动态对象映射为仓颉类型
3.1 会话配置和结果
Undici 保存原库路径、Node.js 命令和超时时间:
cangjie
let library = Undici(
repository: "/workspace/undici",
node: "node",
timeoutSeconds: 60)
let output = library.request("http://127.0.0.1:8080/hello")
println(output)
request 和 fetch 适合可以用 JSON 描述的基础选项。返回值是 JSON 文本,便于仓颉调用方查看状态、headers、trailers、响应体和二进制 Base64。需要 callback、dispatcher、stream、AbortSignal 或 WebSocket 对象时,使用 runSession,让这些对象留在同一个 Node.js 会话内。
3.2 用 stdin 和参数数组表达动态输入
如果把 JavaScript 直接拼接到 shell 命令中,URL、header 或用户输入中的引号和 $() 都可能改变命令语义。本项目让仓颉把程序写入子进程 stdin,把参数放到 process.argv,Node.js 再按预期长度读取程序:
cangjie
let result = library.runSession(#"
const response = await undici.request(args[0], JSON.parse(args[1]));
console.log(JSON.stringify({
statusCode: response.statusCode,
body: await response.body.text()
}));
"#, args: ["http://127.0.0.1:8080/hello", "{}"])
println(result.stdout)
这里的参数不会经过 shell 展开。runSession 同时启动 stdout 和 stderr 读取协程,避免某一路输出过多时阻塞 Node.js。仓颉测试还使用 200000 字节的双管道输出验证这一点。
3.3 用 UndiciError 表示失败
Node.js 会话出现语法错误、版本错误、Undici 请求错误或超时时,仓颉侧统一收到 UndiciError:
cangjie
try {
library.run("throw new Error('request failed')")
} catch (error: UndiciError) {
println(error.exitCode)
println(error.output)
println(error.message)
}
exitCode 保存子进程退出码,output 保存失败前已经产生的 stdout,message 保存 stderr 或桥接失败原因。HTTP 的 4xx/5xx 是否抛出异常仍由原 Undici API 和调用方脚本决定,不会被仓颉层偷偷改写。
第 4 阶段:实现源码
4.1 文件 src/undici.cj
源码按桥接生命周期组织:首先规范化仓库路径,随后构造 Node.js 参数数组,启动 std.process.launch,并行读取 stdout 与 stderr;程序正文写入 stdInPipe,Node.js bootstrap 校验 package.json 的包名为 undici,使用 createRequire 加载当前仓库 index.js,最后执行仓颉传入的异步 JavaScript。会话结束后关闭全局 dispatcher,避免池连接阻止进程退出。
request 和 fetch 是两个小型便捷包装:它们在 Node.js 会话内读取完整响应正文,并把二进制内容同时保留为 UTF-8 和 bodyBase64。exports 用 Object.keys(undici).sort() 生成当前真实导出清单;demonstrate 则调用完整本地协议演示。
4.2 本地协议服务和完整展示
为了让示例可重复运行,src/demonstration.cj 中的 JavaScript 会在动态端口启动 HTTP、代理、SOCKS5、h2c、TLS、WebSocket 和 SSE 测试服务。每个调用都会输出带编号的 JSON 结果,并用 assert 检查真实返回值:
text
[01] request ── POST body、请求头、响应 JSON
[05] stream ── 收到 chunk-one|chunk-two
[20] RetryAgent ── attempts: 2,服务端第二次返回成功
[26] MemoryCacheStore ── serverRequests: 1
[31] Socks5ProxyAgent ── 通过 SOCKS5 返回真实 HTTP 正文
[33] MockAgent ── 201 响应、调用记录和 GET 筛选
[41] WebSocket ── 消息回显、Pong、1000 正常关闭
[46] EventSource ── data、lastEventId 和 origin
演示覆盖 HTTP/1.1、证书校验的 HTTPS、h2c、TLS HTTP/2、连接池和代理,还验证 FormData、Response、Cookie、MIME、CacheStorage、快照回放、超时和取消。输出不是预先写死的文本;正文、headers、重试次数、缓存请求次数和事件字段都来自实际库调用。
4.3 Node.js 版本和平台边界
Node.js bootstrap 会读取当前仓库的 package.json,拒绝加载错误目录,并检查 Node.js >= 22.19.0。Undici 的 SQLite cache store 使用 Node.js node:sqlite,示例运行建议使用 Node 24 或更新版本。仓颉部分依赖 std.process,所以本方案适合拥有 Node.js 和进程能力的主机环境。
这条边界必须写在适配说明中:仓颉库负责进程桥接和结果传输,HTTP/2、TLS、WebSocket、流和代理仍由 Node.js 原库执行。对于没有 Node.js 或不允许启动子进程的纯仓颉/鸿蒙环境,需要另行实现原生网络 adapter,不能直接把本包当成原生 Undici 替代品。
第 5 阶段:建立自动化测试
src/undici_test.cj 通过仓颉单元测试验证桥接本身,test/main.cj 则直接链接静态库并启动仓颉 TCP 服务,验证便捷请求接口。测试覆盖以下行为:
| 测试组 | 示例 |
|---|---|
| 参数传输 | 包含引号和 $() 文本的参数不经过 shell 解释 |
| 原库加载 | exports() 包含 Socks5ProxyAgent 和 WebSocketStream |
| 输出读取 | stdout 和 stderr 各 200000 字节时仍能完整收集 |
| 错误传递 | JavaScript 异常保留退出码、部分 stdout 和错误消息 |
| 输入校验 | 无效 JSON 会从 Node.js 返回语法错误 |
| 会话时限 | 未结束的 Promise 被 deadline 终止并返回 124 |
| 仓颉 TCP | request 和 fetch 都读取到本地仓颉服务器正文 |
| 完整示例 | 58/58 导出、8 个拦截器和两种 cache store 均实际验证 |
示例除了检查结果,还打印这些结果。运行成功时输出固定标记:
text
Coverage: 58/58 top-level exports, all 8 interceptors, both cache stores; 49 result displays.
第 6 阶段:核对 Undici 适配 API
示例在最后用当前加载到的对象反向核对公开导出和功能集合:
text
Top-level exports: 58/58
Interceptors: 8/8
Cache stores: 2/2
Result displays: 49
Missing demonstrations: 0
58 个顶层名称包括 dispatcher、pool、agent、fetch、Web API、连接 API、Mock、Snapshot、EventSource 和安装函数。8 个拦截器是 redirect、responseError、retry、dump、dns、cache、decompress 和 deduplicate;两种 cache store 是 MemoryCacheStore 和 SqliteCacheStore。这个核对说明文章中的能力清单与当前 index.js 一致,不代表已经穷尽 Undici 所有参数组合和上游测试。
四、完整验证步骤
以下命令均以仓库根目录 undici/ 为当前目录。先确认本机已经安装仓颉 SDK 和 Node.js:
bash
echo "$CANGJIE_HOME"
cjc --version
cjpm --version
node --version
然后检查并构建仓颉静态库:
bash
cjpm check
cjpm build
执行仓颉桥接单元测试:
bash
cjpm test --no-progress --no-color
编译并运行独立集成入口。--set-runtime-rpath 用于让 macOS 独立程序找到仓颉运行库:
bash
cjc test/main.cj --set-runtime-rpath \
--import-path ../.cjpm-target/undici_cangjie/release/undici_cangjie \
-L ../.cjpm-target/undici_cangjie/release/undici_cangjie \
-lundici_cangjie \
-o ../.cjpm-target/undici_cangjie/integration
../.cjpm-target/undici_cangjie/integration
再构建并运行独立示例工程:
bash
cd example
cjpm check
cjpm run
示例运行末尾会显示:
text
Coverage: 58/58 top-level exports, all 8 interceptors, both cache stores; 49 result displays.
cjpm run finished
每一组编号结果都来自 Undici 实际返回值。验证结束后可以执行 cjpm clean,并确认 .cjpm-target 等构建输出位于仓库外、没有加入 Git。运行 cjpm run 不应出现 there is no '.cj' file ... target 警告。
五、运行效果
下面的效果图来自 example/example.cj 的实际终端运行,包含 Undici Cangjie 桥接完成后的真实响应、错误码和最终覆盖标记。

截图中可以看到 EventSource 的 data、lastEventId 和 origin,Headers timeout 的 UND_ERR_HEADERS_TIMEOUT,fetch 取消产生的 AbortError,以及 install 后的 fetch 结果。终端底部的 Coverage: 58/58... 说明示例不仅断言通过,还完成了公开导出、拦截器、缓存存储和 49 组结果展示的核对。服务端、客户端和 Node.js 原库都由示例程序在本地控制,因此效果图是可重复的真实运行输出,不是模拟页面。
六、关键决策说明
6.1 为什么使用明确的桥接结果结构
仓颉适配的价值在于让调用方知道进程调用会返回什么。UndiciResult 明确区分 stdout 和 stderr,UndiciError 明确暴露退出码、失败前输出和错误消息。若把所有输出拼成一个字符串,调用方无法区分业务结果、Node.js warning 和错误诊断,也无法可靠地对失败进行处理。
6.2 为什么保留 Undici 原始 API 能力
Undici 的价值在于大量经过验证的 Node.js HTTP 细节和周边 API。桥接层保留原始 undici 对象,允许仓颉调用方在 runSession 内使用 Client、Pool、MockAgent、WebSocket、EventSource 和拦截器,而不是重新发明一套名称相似但行为不兼容的仓颉 API。对纯仓颉环境无法满足的 Node.js 依赖,文档明确写出前提,不把限制隐藏起来。
6.3 为什么把测试和示例分开
单元测试负责桥接边界和失败处理,适合自动化;独立集成入口负责仓颉 TCP 服务与便捷 API;示例工程负责启动多种本地协议服务并打印完整结果,适合终端观察和截图。三者使用同一个 undici_cangjie 包,因此效果图来自库的真实调用,测试又不会因为展示文本而失去失败定位能力。
七、兼容边界和遗留问题
JavaScript Undici 与仓颉的进程、对象和异步模型不同,以下限制需要在接入时提前了解:
- 当前版本是 Node.js 子进程桥接,不是纯仓颉 HTTP/HTTPS 实现;需要 Node.js >= 22.19.0 和允许启动子进程的运行环境。
request和fetch便捷入口通过 JSON 表达基础选项并缓冲响应;callback、dispatcher、stream、AbortSignal、WebSocket 等对象需要在runSession内使用。- JavaScript 对象身份、活动连接、流和回调不会跨进程变成仓颉对象;每个
run调用默认创建一个新的 Node.js 会话。 - Node.js 会话使用仓颉标准输入传输程序,参数通过参数数组传递,但
run的程序内容必须来自可信代码,桥接层不是 JavaScript 沙箱。 - HTTPS、HTTP/2、WebSocket、SSE、SQLite cache store、代理和压缩等能力依赖 Node.js 版本与本仓库已有实现,目标平台没有对应 Node.js 能力时不能使用这些示例。
- 示例中的 HTTP、TLS、SOCKS5、WebSocket 和 SSE 服务是有限的本地测试夹具,只验证文章列出的路径,不替代生产级协议服务器和安全审计。
- stdout、stderr 和便捷 API 的响应会暂存在内存中;大响应或长时间流式业务应在 Node.js 会话内部消费,并自行设计背压和生命周期管理。
- 适配覆盖公开导出和功能类别,不代表覆盖 Undici 的每一种 option、错误组合、RFC 边界或完整上游测试矩阵。
这些是适配边界,不是构建错误。只要 cjpm check、cjpm build、cjpm test 成功,独立集成程序读取到仓颉 TCP 服务正文,示例输出实际结果并出现 Coverage: 58/58...,就说明当前版本完成了本地编译和行为验收。
八、接入方式
在另一个仓颉项目中,可以将本仓库作为本地依赖:
toml
[dependencies]
undici_cangjie = { path = "../undici" }
仓颉调用方可以先使用便捷入口:
cangjie
import undici_cangjie.*
main(): Int64 {
let client = Undici(repository: "../undici", timeoutSeconds: 60)
let response = client.request(
"http://127.0.0.1:8080/hello",
options: "{\"headers\":{\"accept\":\"application/json\"}}")
println(response)
return 0
}
需要原库完整 API 时,在 runSession 内保持相关 JavaScript 对象:
cangjie
let result = client.runSession(#"
const agent = new undici.Agent({connections: 2, pipelining: 2});
try {
const response = await undici.request(args[0], {dispatcher: agent});
console.log(JSON.stringify({
status: response.statusCode,
body: await response.body.text()
}));
} finally {
await agent.close();
}
"#, args: ["http://127.0.0.1:8080/hello"])
println(result.stdout)
业务工程统一由 cjpm 构建,Node.js 路径可以通过 Undici(node:) 指定。跨端项目可以复用同一套仓颉调用和结果处理;具体的纯仓颉网络、鸿蒙系统能力、权限、线程和 UI 适配,需要由宿主平台另行实现。
九、总结
本次适配完成了 Undici 到仓颉调用边界的迁移:仓颉侧使用 Undici 管理 Node.js 会话,使用 UndiciResult 区分 stdout/stderr,使用 UndiciError 传递退出码和错误诊断;Node.js 会话加载当前 AtomGit 仓库的 index.js,实际执行 Undici 8.11.2 的完整公开 API。
验证链路包括 cjpm check、cjpm build、6 项仓颉桥接测试、仓颉 TCP 服务集成测试和本地多协议终端演示。最终效果图来自真实的 example/example.cj,能够直观看到 EventSource、超时、取消、install 和最终覆盖结果,而不是只看到断言通过。示例共验证 58/58 顶层导出、8 个拦截器、两种缓存存储并展示 49 组实际结果。
对于暂时无法脱离 Node.js 的对象生命周期和协议实现,项目通过明确的桥接接口、版本检查、超时、错误输出和文档边界保持行为可解释。后续如果目标平台需要纯仓颉实现,可以沿着同一套测试结果逐项替换 Node.js 能力,但应继续以真实请求和可观察输出作为验收标准。
适配过程 FAQ
以下问题来自仓颉工程创建、
cjpm依赖、与 HarmonyOS 设备联调的实际流程。
Q1:cjpm 依赖解析失败或拉取超时?
- 检查
cjpm.toml中的依赖坐标与版本号; - 配置可用的仓颉包仓库镜像或代理;
- 删除
target、cjpm.lock后重新执行cjpm update/cjpm build。
Q2:编译报找不到仓颉 SDK 或 cjc 版本不匹配?
- 设置
CANGJIE_HOME指向仓颉 SDK 根目录; - 确认
cjc --version、cjpm --version与工程要求的版本一致; - 清理构建缓存后重新生成。
Q3:与 HarmonyOS 设备联调时安装失败?
- 用
hdc list targets确认设备在线; - 确认 HAP 已签名,
bundleName与签名 profile 一致; - 设备需加入调试 profile 的
deviceIds。
Q4:动态库或 FFI 符号找不到?
- 检查导出符号名称、ABI 与打包路径;
- 确认
.so已随 HAP 一起打包到libs/<abi>/; - 用
llvm-readelf -d检查依赖库是否齐全。
Q5:测试用例在设备上失败,但本机通过?
- 对比设备与主机的架构、SDK 版本和环境变量;
- 用
hdc hilog抓取运行日志; - 优先复现最小用例,确认是否为平台差异或时序问题。