Kilo 仓库 @opencode-ai/http-recorder 实战指南:用 Effect 4 把真实 HTTP/WebSocket 流量录制成确定性 JSON Cassette
【免费下载链接】kilocodeKilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/ki/kilocode
@opencode-ai/http-recorder是 Kilo 仓库(GitHub_Trending/ki/kilocode)中一个面向 Effect 4 生态的私有工作区包:它只暴露HttpRecorder.http(name, options?)与HttpRecorder.socket(name, options?)两个 API,就能把真实的外部 HTTP 请求与 WebSocket 帧“录一次、回放无数次”,测试结果由确定性 JSON cassette 驱动,而不是手写的 mock。读完本文,你将掌握如何用它对 provider 集成、重试、轮询、多步流程编写录制回放测试,如何配置脱敏与请求匹配规则,以及它在 recorder.ts、redactor.ts、cassette.ts 中的底层实现原理。
包定位与使用前提
这个包解决的核心痛点是:手写的 HTTP mock 往往“隐藏了太多真实请求形状”(丢失 header、URL 编码、body 序列化等细节),而录制回放(VCR 风格)把真实流量原样固化为可提交进版本库的 JSON cassette,让应用代码完全感知不到响应是活的还是回放的。
从 package.json 可以确认以下使用前提:
- 这是私有工作区包,仅在当前 monorepo 内可用,不对外发布独立版本(
files仅声明了dist、README.md、CHANGELOG.md、LICENSE,但实际源码以./src/index.ts直接导出)。 - 运行环境支持Node.js 22+ 与 Bun(
engines.node >= 22,测试脚本使用bun test),不支持浏览器、Worker 或 Deno。 - API 依赖Effect 4 beta(peer 依赖为
effect@4.0.0-beta.83,同时依赖@effect/platform-node与@effect/platform-node-shared的4.0.0-beta.83),其传输层模块(effect/unstable/http、effect/unstable/socket)仍不稳定,API 可能随上游变化。 - 由于 Effect
4.0.0-beta.74存在已知声明错误(缺少SchemaErrorTypeId),TypeScript 消费者需要在tsconfig.json中开启:
{ "compilerOptions": { "skipLibCheck": true } }快速开始:一次录制,永久回放
下面是从 README.md 继承的完整示例。它请求jsonplaceholder.typicode.com的/users/1,用Schema.Struct定义响应结构并解码:
import { assert, describe, it } from "@effect/vitest" import { Effect, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { HttpRecorder } from "@opencode-ai/http-recorder" const User = Schema.Struct({ id: Schema.Number, name: Schema.String, }) const getUser = Effect.gen(function* () { const http = yield* HttpClient.HttpClient const response = yield* http.execute(HttpClientRequest.get("https://jsonplaceholder.typicode.com/users/1")) return yield* Schema.decodeUnknownEffect(User)(yield* response.json) }) describe("getUser", () => { it.effect("loads a user", () => Effect.gen(function* () { const user = yield* getUser assert.strictEqual(user.id, 1) assert.strictEqual(user.name, "Leanne Graham") }).pipe(Effect.provide(HttpRecorder.http("users/get-one"))), ) })用 Vitest 运行测试:
bunx vitest run users.test.ts第一次本地运行会真实调用线上 API 并录制,生成:
test/fixtures/recordings/users/get-one.json后续运行则直接回放 cassette,不再触碰上游服务器。当环境变量CI=true时,缺失的 cassette 会直接导致测试失败,而不是去录制。
完整决策流程如下:
底层模式解析:record / replay / passthrough
这个流程在 recorder.ts 的resolveAutoMode中实现:
export const resolveAutoMode = (cassette, name) => Effect.gen(function* () { if (isCI()) return "replay" return (yield* cassette.exists(name)) ? "replay" : "record" })其中isCI()的判定逻辑是:process.env.CI存在且不等于空字符串、"false"、"0"即为 CI。也就是说:
CI=true时强制replay,缺失 cassette 会抛出CassetteNotFoundError(定义于 cassette.ts,message 为`Cassette "${name}" not found`);- 本地且 cassette 已存在时回放;
- 本地且 cassette 缺失时录制。
从源码结构看,模式枚举还预留了"passthrough"(直通不录制),但目前对外 API(HttpRecorder.http/HttpRecorder.socket)并未暴露强制指定模式的入口——这是从 recorder.ts 类型定义可以推断的设计预留。
http层本身由 effect.ts 组装:录制/回放层之上依次提供CassetteService.fileSystem({ directory })、FetchHttpClient.layer与NodeFileSystem.layer,因此应用拿到的仍然是一个标准的HttpClient,无需任何改动。
完整公开 API
整个包的公开 API 只有两个,定义在 index.ts:
HttpRecorder.http(name, options?) HttpRecorder.socket(name, options?)http:提供基于fetch的、带录制/回放能力的HttpClient(Layer.Layer<HttpClient.HttpClient>)。socket:在下方提供的标准 EffectSocket.Socket之上做装饰,返回Layer.Layer<Socket.Socket, never, Socket.Socket>,即需要再提供一层真实的 URL 绑定 socket 层。
同时 index.ts 的HttpRecorder命名空间还导出五个类型别名,便于用户引用:CassetteMetadata、RecorderOptions、RedactOptions、RequestMatcher、RequestSnapshot。
WebSocket 录制与回放
WebSocket cassette 保存的是客户端与服务端文本/二进制帧的有序转录(transcript)。回放严格遵循该时间线:先释放服务端帧,直到遇到下一条已录制的客户端帧,然后等待应用发送匹配的帧再继续。
完整示例(来自 README.md)使用NodeSocket.layerWebSocket连接wss://ws.postman-echo.com/raw:
import { assert, it } from "@effect/vitest" import { NodeSocket } from "@effect/platform-node" import { Effect, Layer } from "effect" import { Socket } from "effect/unstable/socket" import { HttpRecorder } from "@opencode-ai/http-recorder" const echo = Effect.gen(function* () { const socket = yield* Socket.Socket const write = yield* socket.writer yield* socket.runString( (message) => Effect.gen(function* () { assert.strictEqual(message, "hello") yield* write(new Socket.CloseEvent(1000)) }), { onOpen: write("hello") }, ) }) const recordedSocket = HttpRecorder.socket("echo/hello").pipe( Layer.provide( NodeSocket.layerWebSocket("wss://ws.postman-echo.com/raw", { closeCodeIsError: (code) => code !== 1000, }), ), ) it.effect("exchanges WebSocket frames", () => echo.pipe(Effect.provide(recordedSocket)))要点:
- WebSocket 的 URL 与协议由应用通过普通 Effect layer 注入,录制器只装饰 socket,不重复配置 URL——
HttpRecorder.socket(name)内部以{ url: "" }作为请求快照(见 socket.ts 的socket导出),匹配依据的是下层真实连接产生的 open 快照与帧序列。 - 多个端点或并发连接需要分别提供不同的 socket layer。
- 文本帧复用与 HTTP body 相同的 JSON 字段脱敏与 body 脱敏规则;二进制帧以 base64 无损存储(对应
encodeEvent中的Buffer.from(message).toString("base64"))。 - 回放时客户端与服务端帧的 kind(text/binary)必须匹配。
回放实现的关键机制
socket.ts 中回放驱动器(runReplay)用Ref<{ position, changed }>记录播放位置,配合Deferred实现“停在客户端帧处等待写入”的同步语义:驱动循环遇到server事件就推进位置并派发 handler;遇到client事件则Deferred.await(current.changed)挂起,直到应用的 writer 写入匹配帧后Deferred.succeed唤醒。此外:
- 关闭时若仍有未消费事件,会直接
Effect.die报错WebSocket closed with unconsumed events: used X of Y; - 同一 cassette 的并发 run 不被支持(
Concurrent runs of a recorded WebSocket are not supported); - 文本客户端帧默认按规范化 JSON比较(
compareClientMessagesAsJson默认开启),键序不影响匹配; - 录制侧在连接结束时(
onExit)才把完整事件数组追加写入 cassette,且只有Exit.isSuccess && opened && valid时才会落盘——因此失败的或被中断的实时连接不会被录制。
刷新一条 Cassette
刷新某个录制数据的标准做法是删除后再重跑(README 原文示例):
rm test/fixtures/recordings/users/get-one.json bun run test users.test.ts注意:包刻意不提供公开的覆盖写入(overwrite)模式。删除使“即将被刷新”的那组录制变得可见、可审查,避免在不知不觉中掩盖行为变化。
底层写入本身是安全的:在 cassette.ts 中,追加采用Semaphore串行化,并先写入目标文件 + crypto.randomUUID() + .tmp临时文件,再rename原子替换,ensuring中清理残留临时文件。
脱敏(Redaction):安全默认 + 分层扩展
包自带安全默认值:移除绝大多数 header,并对 header、URL、JSON body 中常见凭据做脱敏。可在构建 layer 时扩展默认规则:
HttpRecorder.http("anthropic/messages", { redact: { headers: ["x-project-token"], allowRequestHeaders: ["anthropic-version"], queryParameters: ["session-id"], jsonFields: ["user_id"], url: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}"), body: (body) => body.replaceAll(/usr_[a-z0-9]+/g, "usr_redacted"), }, })配置项一览
| Option | Purpose |
|---|---|
headers | 添加敏感 header 名,保留为[REDACTED]。 |
allowRequestHeaders | 额外保留非敏感请求 header(用于匹配)。 |
allowResponseHeaders | 额外保留非敏感响应 header(用于回放)。 |
queryParameters | 添加敏感 URL 查询参数名。 |
jsonFields | 在请求与响应中递归脱敏匹配的 JSON 键。 |
url | 在内置脱敏之后稳定 URL。 |
body | 在内置 JSON 脱敏之后稳定请求与响应 body。 |
内置默认规则(源码级)
redactor.ts 与 redaction.ts 中固化了几层默认值:
- 允许保留的请求 header(
DEFAULT_REQUEST_HEADERS):content-type、accept、openai-beta。 - 允许保留的响应 header(
DEFAULT_RESPONSE_HEADERS):content-type。 - 默认脱敏 header(
DEFAULT_REDACT_HEADERS):authorization、cookie、proxy-authorization、set-cookie、x-api-key、x-amz-security-token、x-goog-api-key。 - 默认脱敏查询参数(
DEFAULT_REDACT_QUERY):access_token、api-key、api_key、apikey、code、key、signature、sig、token、x-amz-credential、x-amz-security-token、x-amz-signature。 - 默认脱敏 JSON 字段(
DEFAULT_REDACT_JSON_FIELDS):access_token、api_key、apikey、client_secret、password、refresh_token、secret、token。匹配时字段名会先做归一化(去除非字母数字并转小写),因此user_id、userId、user-id都能命中同一个user_id规则。 - URL 中的
username/password也会被替换为[REDACTED];header 名一律小写化并按字典序排序后写入。
写入前的全量安全扫描
这是最值得注意的安全设计(redaction.ts 的secretFindings+ cassette.ts 的failIfUnsafe):在写入 cassette 之前,录制器会对整份 cassette扫描:
- 常见凭据格式正则:Bearer token、
sk-前缀 API key、Anthropicsk-ant-key、GoogleAIzakey、AWSAKIA/ASIAaccess key、GitHubghp/gho/ghu/ghs/ghrtoken、-----BEGIN ... PRIVATE KEY-----私钥; - 与凭据类环境变量(名称匹配
/API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN/i且值长度 ≥ 12、不在安全白名单fixture/test/test-key中)的值做包含比对。
一旦发现命中,写入会失败并抛出UnsafeCassetteError(message 列出每条命中的path (reason)),且不会替换已存在的录制文件——这保证了“旧的干净 cassette 不会被新的带密 cassette 覆盖”。
README 的提醒依然成立:脱敏是纵深防御,不是审查的替代品。提交 cassette 前应检查其 diff。
匹配(Matching)与顺序保证
Cassette 内含有序交互序列:第一个实时请求对第一个已录制请求,第二个对第二个,以此类推(matching.ts 的selectSequential按index顺序消费)。
这种严格顺序能正确建模“重复相同请求但响应不同”的场景,包括重试、轮询与缓存测试。匹配前,JSON 对象键会被规范化排序(canonicalizeJson),因此键序差异不会导致匹配失败。
并发请求以请求开始顺序录制,即使它们的响应乱序完成。
自定义匹配规则
当请求含故意易变的数据时,可提供自定义等价规则:
HttpRecorder.http("events/create", { match: (incoming, recorded) => incoming.method === recorded.method && new URL(incoming.url).pathname === new URL(recorded.url).pathname, })RequestMatcher的签名是(incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean。默认匹配器defaultMatcher对 method、url、规范化后的 headers、body(JSON 内容也做规范化)做全量字符串等价比较。
匹配失败时的报错信息由requestDiff生成:分别列出 method、url、headers(最多 8 条差异)与 body 的逐路径 diff(如$user.id expected ... received ...),并通过safeText对含敏感内容的片段以[REDACTED]输出、超 300 字符截断,避免把凭据打印进测试日志。
回放结束时会通过 finalizer 校验(recorder.ts 的makeReplayState):若已消费的交互数少于 cassette 总数,则Effect.die("Unused recorded interactions in ${name}: used X of Y"),防止“回放了一部分但测试却通过了”的假象。
配置选项(RecorderOptions)
interface RecorderOptions { readonly directory?: string readonly metadata?: Record<string, unknown> readonly redact?: RedactOptions readonly match?: RequestMatcher }directory:cassette 目录,默认<cwd>/test/fixtures/recordings(cassette.ts 中path.resolve(process.cwd(), "test", "fixtures", "recordings"))。metadata:附加 JSON 元数据,写入 cassette 时会与{ name, recordedAt }合并。redact:脱敏策略(见上文)。match:HTTP 请求等价规则(见上文)。
cassette 名称同时受路径安全检查约束(cassette.ts 的cassettePath):空名、绝对路径、含..的名称都会直接抛错,防止路径穿越。
Cassette 文件格式
Cassette 是可读的 JSON 文件,应随测试一并提交(对应formatCassette的 2 空格缩进 + 结尾换行)。其结构(cassette.ts 的buildCassette与 schema.ts 的CassetteSchema)为:
version:当前固定为1;metadata:{ name, recordedAt, ...用户自定义 metadata };interactions:有序交互数组。- HTTP 交互(types.ts 的
HttpInteraction):transport: "http"+request(method / url / headers / body)+response(status / headers / body,可带bodyEncoding: "text" | "base64")。 - WebSocket 交互(
WebSocketInteraction):transport: "websocket"+open(url / headers)+events({ direction: "client"|"server", kind: "text"|"binary", body, bodyEncoding? })。
- HTTP 交互(types.ts 的
文本内容保持可读,二进制 body 与帧以 base64 无损存储。读取时通过Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema))校验,因此格式不合法会在加载阶段直接暴露。
当前限制(Beta 阶段)
README 明确列出的限制应成为选型依据:
- 录制与回放期间响应会被完整缓冲,因此不适合断言流式时序、取消或背压(backpressure)的测试;
- WebSocket 回放只保留帧的时间顺序与内容,不还原真实网络时序或背压;
- WebSocket cassette 版本 1不重现终端关闭码、关闭原因或传输失败;失败与被中断的实时运行不会被录制;
- WebSocket 转录在连接结束前驻留内存,避免用于无界长会话;
- 当前要求上文列出的精确 Effect beta 版本;
- cassette 格式版本
1尚无迁移工具。
在仓库中继续探索
- 阅读包的完整规范:README.md
- 公开 API 与类型导出:index.ts、types.ts
- HTTP 录制/回放层组装:effect.ts、internal-effect.ts
- WebSocket 录制/回放实现:socket.ts
- 自动模式判定与回放状态机:recorder.ts
- 脱敏管线与凭据扫描:redactor.ts、redaction.ts
- Cassette 文件系统/内存存储与原子写入:cassette.ts
- 规范化匹配与 diff 报告:matching.ts
- 包声明与环境要求:package.json
- 端到端录制-回放测试:record-replay.test.ts
该包以 MIT 协议发布。在把线上流量固化成测试资产时,请始终把“先审查 cassette diff 再提交”当作与脱敏配置同等重要的一环。
【免费下载链接】kilocodeKilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/ki/kilocode
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考