Kilo 仓库 @opencode-ai/http-recorder 实战指南:用 Effect 4 把真实 HTTP/WebSocket 流量录制成确定性 JSON Cassette
2026/9/11 14:28:22 网站建设 项目流程

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仅声明了distREADME.mdCHANGELOG.mdLICENSE,但实际源码以./src/index.ts直接导出)。
  • 运行环境支持Node.js 22+ 与 Bunengines.node >= 22,测试脚本使用bun test),支持浏览器、Worker 或 Deno。
  • API 依赖Effect 4 beta(peer 依赖为effect@4.0.0-beta.83,同时依赖@effect/platform-node@effect/platform-node-shared4.0.0-beta.83),其传输层模块(effect/unstable/httpeffect/unstable/socket)仍不稳定,API 可能随上游变化。
  • 由于 Effect4.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.layerNodeFileSystem.layer,因此应用拿到的仍然是一个标准的HttpClient,无需任何改动。

完整公开 API

整个包的公开 API 只有两个,定义在 index.ts:

HttpRecorder.http(name, options?) HttpRecorder.socket(name, options?)
  • http:提供基于fetch的、带录制/回放能力的HttpClientLayer.Layer<HttpClient.HttpClient>)。
  • socket:在下方提供的标准 EffectSocket.Socket之上做装饰,返回Layer.Layer<Socket.Socket, never, Socket.Socket>,即需要再提供一层真实的 URL 绑定 socket 层。

同时 index.ts 的HttpRecorder命名空间还导出五个类型别名,便于用户引用:CassetteMetadataRecorderOptionsRedactOptionsRequestMatcherRequestSnapshot

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"), }, })

配置项一览

OptionPurpose
headers添加敏感 header 名,保留为[REDACTED]
allowRequestHeaders额外保留非敏感请求 header(用于匹配)。
allowResponseHeaders额外保留非敏感响应 header(用于回放)。
queryParameters添加敏感 URL 查询参数名。
jsonFields在请求与响应中递归脱敏匹配的 JSON 键。
url在内置脱敏之后稳定 URL。
body在内置 JSON 脱敏之后稳定请求与响应 body。

内置默认规则(源码级)

redactor.ts 与 redaction.ts 中固化了几层默认值:

  • 允许保留的请求 headerDEFAULT_REQUEST_HEADERS):content-typeacceptopenai-beta
  • 允许保留的响应 headerDEFAULT_RESPONSE_HEADERS):content-type
  • 默认脱敏 headerDEFAULT_REDACT_HEADERS):authorizationcookieproxy-authorizationset-cookiex-api-keyx-amz-security-tokenx-goog-api-key
  • 默认脱敏查询参数DEFAULT_REDACT_QUERY):access_tokenapi-keyapi_keyapikeycodekeysignaturesigtokenx-amz-credentialx-amz-security-tokenx-amz-signature
  • 默认脱敏 JSON 字段DEFAULT_REDACT_JSON_FIELDS):access_tokenapi_keyapikeyclient_secretpasswordrefresh_tokensecrettoken。匹配时字段名会先做归一化(去除非字母数字并转小写),因此user_iduserIduser-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 的selectSequentialindex顺序消费)。

这种严格顺序能正确建模“重复相同请求但响应不同”的场景,包括重试、轮询与缓存测试。匹配前,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? })。

文本内容保持可读,二进制 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询