Skip to content

Commit d28b5ad

Browse files
authored
refactor(http-recorder): Redactor + Recorder seams, README (#26636)
1 parent 6589a66 commit d28b5ad

13 files changed

Lines changed: 577 additions & 348 deletions

File tree

packages/http-recorder/README.md

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# @opencode-ai/http-recorder
2+
3+
Record and replay HTTP and WebSocket traffic for Effect's `HttpClient`. Tests
4+
exercise real request shapes against deterministic, version-controlled
5+
cassettes — no manual mocks, no flakes from upstream drift.
6+
7+
## Install
8+
9+
Internal package; depended on as `@opencode-ai/http-recorder` from another
10+
workspace package.
11+
12+
```ts
13+
import { HttpRecorder } from "@opencode-ai/http-recorder"
14+
```
15+
16+
## Quickstart
17+
18+
Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`.
19+
The first run records to `test/fixtures/recordings/<name>.json`; subsequent
20+
runs replay from it.
21+
22+
```ts
23+
import { Effect } from "effect"
24+
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
25+
import { HttpRecorder } from "@opencode-ai/http-recorder"
26+
27+
const program = Effect.gen(function* () {
28+
const http = yield* HttpClient.HttpClient
29+
const response = yield* http.execute(HttpClientRequest.get("https://api.example.com/users/1"))
30+
return yield* response.json
31+
})
32+
33+
// Replay (default). Fails if the cassette is missing.
34+
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one"))))
35+
36+
// Record. Hits the upstream and writes the cassette.
37+
Effect.runPromise(
38+
program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" }))),
39+
)
40+
```
41+
42+
Set the mode from the environment in your test setup:
43+
44+
```ts
45+
HttpRecorder.cassetteLayer("users/get-one", {
46+
mode: process.env.RECORD === "true" ? "record" : "replay",
47+
})
48+
```
49+
50+
## Modes
51+
52+
| Mode | Behavior |
53+
| ------------- | -------------------------------------------------------------------- |
54+
| `replay` | Default. Match the request to a recorded interaction; error if none. |
55+
| `record` | Execute upstream, append the interaction, write the cassette. |
56+
| `passthrough` | Bypass the recorder entirely — just call upstream. |
57+
58+
## Cassette format
59+
60+
A cassette is JSON at `test/fixtures/recordings/<name>.json`:
61+
62+
```json
63+
{
64+
"version": 1,
65+
"metadata": { "name": "users/get-one", "recordedAt": "2026-05-09T..." },
66+
"interactions": [
67+
{
68+
"transport": "http",
69+
"request": { "method": "GET", "url": "...", "headers": {...}, "body": "" },
70+
"response": { "status": 200, "headers": {...}, "body": "..." }
71+
}
72+
]
73+
}
74+
```
75+
76+
Cassettes are normal source files — review them, diff them, commit them.
77+
78+
## Request matching
79+
80+
By default, requests match on canonicalized method, URL, headers, and JSON
81+
body (object keys sorted). Two dispatch strategies are available:
82+
83+
- **`match`** (default) — find the first recorded interaction whose request
84+
matches the incoming request. Same request twice returns the same response.
85+
- **`sequential`** — return interactions in the order they were recorded,
86+
validating each one matches as the cursor advances. Use for ordered flows
87+
where the same URL is hit multiple times with meaningful state changes
88+
(pagination, retries, polling).
89+
90+
```ts
91+
HttpRecorder.cassetteLayer("flow/poll-until-done", { dispatch: "sequential" })
92+
```
93+
94+
Supply your own matcher via `match: (incoming, recorded) => boolean` for
95+
custom equivalence (e.g. ignoring a timestamp field in the body).
96+
97+
## Redaction & secret safety
98+
99+
Cassettes get checked in, so the recorder is aggressive about not letting
100+
secrets escape. Redaction is configured by composing a `Redactor`:
101+
102+
```ts
103+
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
104+
105+
HttpRecorder.cassetteLayer("anthropic/messages", {
106+
mode: process.env.RECORD === "true" ? "record" : "replay",
107+
redactor: Redactor.defaults({
108+
requestHeaders: { allow: ["content-type", "anthropic-version"] },
109+
url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") },
110+
body: (parsed) => ({ ...(parsed as object), user_id: "{user}" }),
111+
}),
112+
})
113+
```
114+
115+
`Redactor.defaults({ … })` composes the four built-in redactors with your
116+
overrides. For full control, build the stack yourself:
117+
118+
```ts
119+
const redactor = Redactor.compose(
120+
Redactor.requestHeaders({ allow: ["content-type", "x-custom"] }),
121+
Redactor.responseHeaders(),
122+
Redactor.url({ query: ["session-id"] }),
123+
Redactor.body((parsed) => /**/),
124+
)
125+
```
126+
127+
What each layer does:
128+
129+
- **`requestHeaders` / `responseHeaders`** — strip headers to a small
130+
allow-list (request default: `content-type`, `accept`, `openai-beta`;
131+
response default: `content-type`). Sensitive headers within the
132+
allow-list (`authorization`, `cookie`, API-key headers, AWS/GCP tokens,
133+
…) are replaced with `[REDACTED]`.
134+
- **`url`** — query parameters matching common secret names (`api_key`,
135+
`token`, `signature`, AWS signing params, …) are replaced with
136+
`[REDACTED]`. URL user/password are replaced. `transform` runs after
137+
built-in redaction for path-level scrubbing.
138+
- **`body`** — receives the parsed JSON request body and returns a redacted
139+
version. No-op for non-JSON bodies.
140+
141+
After assembling the cassette, the recorder scans every string for known
142+
secret patterns (Bearer tokens, `sk-…`, `sk-ant-…`, Google `AIza…` keys,
143+
AWS access keys, GitHub tokens, PEM blocks) and for values matching any
144+
environment variable named like a credential. If anything is found, the
145+
cassette is **not written** and the request fails with `UnsafeCassetteError`
146+
listing what was detected.
147+
148+
## WebSocket recording
149+
150+
WebSocket support records the open frame plus client/server message
151+
streams. It uses the shared `Cassette.Service`, so HTTP and WS interactions
152+
can live in the same cassette.
153+
154+
```ts
155+
import { HttpRecorder } from "@opencode-ai/http-recorder"
156+
import { Effect } from "effect"
157+
158+
const program = Effect.gen(function* () {
159+
const cassette = yield* HttpRecorder.Cassette.Service
160+
const executor = yield* HttpRecorder.makeWebSocketExecutor({
161+
name: "ws/subscribe",
162+
mode: process.env.RECORD === "true" ? "record" : "replay",
163+
cassette,
164+
live: liveExecutor,
165+
})
166+
// use executor.open(...)
167+
})
168+
```
169+
170+
## Inspecting cassettes programmatically
171+
172+
`Cassette.Service` exposes `read`, `write`, `append`, `exists`, `list`, and
173+
`scan` (re-running the secret detector over an existing cassette). Useful
174+
for CI checks:
175+
176+
```ts
177+
import { HttpRecorder } from "@opencode-ai/http-recorder"
178+
import { Effect } from "effect"
179+
180+
const audit = Effect.gen(function* () {
181+
const cassettes = yield* HttpRecorder.Cassette.Service
182+
const findings = yield* Effect.forEach(yield* cassettes.list(), (entry) =>
183+
cassettes.read(entry.name).pipe(Effect.map((c) => ({ entry, findings: cassettes.scan(c) }))),
184+
)
185+
return findings.filter((r) => r.findings.length > 0)
186+
})
187+
```
188+
189+
## Options reference
190+
191+
```ts
192+
type RecordReplayOptions = {
193+
mode?: "record" | "replay" | "passthrough" // default: "replay"
194+
directory?: string // default: <cwd>/test/fixtures/recordings
195+
metadata?: Record<string, unknown> // merged into cassette.metadata
196+
redactor?: Redactor // default: Redactor.defaults()
197+
dispatch?: "match" | "sequential" // default: "match"
198+
match?: (incoming, recorded) => boolean // custom matcher
199+
}
200+
```
201+
202+
## Layout
203+
204+
| File | Purpose |
205+
| -------------- | -------------------------------------------------------------------------------- |
206+
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
207+
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
208+
| `cassette.ts` | `Cassette.Service` — reads/writes cassette files, accumulates state. |
209+
| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. |
210+
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
211+
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
212+
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
213+
| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. |
214+
| `matching.ts` | Request matcher, canonicalization, dispatch strategies, mismatch diagnostics. |

packages/http-recorder/src/cassette.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { Context, Effect, FileSystem, Layer, PlatformError, Ref } from "effect"
1+
import { Context, Effect, FileSystem, Layer, PlatformError } from "effect"
22
import * as path from "node:path"
3-
import { cassetteSecretFindings, type SecretFinding } from "./redaction"
3+
import { cassetteSecretFindings, secretFindings, type SecretFinding } from "./redaction"
44
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
55
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
66

@@ -37,10 +37,18 @@ export const layer = (options: { readonly directory?: string } = {}) =>
3737
Effect.gen(function* () {
3838
const fileSystem = yield* FileSystem.FileSystem
3939
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
40-
const recorded = yield* Ref.make(new Map<string, ReadonlyArray<Interaction>>())
40+
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
41+
const directoriesEnsured = new Set<string>()
4142

4243
const pathFor = (name: string) => cassettePath(name, directory)
4344

45+
const ensureDirectory = Effect.fn("Cassette.ensureDirectory")(function* (name: string) {
46+
const dir = path.dirname(pathFor(name))
47+
if (directoriesEnsured.has(dir)) return
48+
yield* fileSystem.makeDirectory(dir, { recursive: true })
49+
directoriesEnsured.add(dir)
50+
})
51+
4452
const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
4553
Effect.gen(function* () {
4654
const entries = yield* fileSystem
@@ -61,7 +69,7 @@ export const layer = (options: { readonly directory?: string } = {}) =>
6169
})
6270

6371
const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
64-
yield* fileSystem.makeDirectory(path.dirname(pathFor(name)), { recursive: true })
72+
yield* ensureDirectory(name)
6573
yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
6674
})
6775

@@ -70,11 +78,12 @@ export const layer = (options: { readonly directory?: string } = {}) =>
7078
interaction: Interaction,
7179
metadata: CassetteMetadata | undefined,
7280
) {
73-
const interactions = yield* Ref.updateAndGet(recorded, (previous) =>
74-
new Map(previous).set(name, [...(previous.get(name) ?? []), interaction]),
75-
)
76-
const cassette = cassetteFor(name, interactions.get(name) ?? [], metadata)
77-
const findings = cassetteSecretFindings(cassette)
81+
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
82+
entry.interactions.push(interaction)
83+
entry.findings.push(...secretFindings(interaction))
84+
recorded.set(name, entry)
85+
const cassette = cassetteFor(name, entry.interactions, metadata)
86+
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
7887
if (findings.length === 0) yield* write(name, cassette)
7988
return { cassette, findings }
8089
})
@@ -103,6 +112,3 @@ export const layer = (options: { readonly directory?: string } = {}) =>
103112
}),
104113
)
105114

106-
export const defaultLayer = layer()
107-
108-
export * as Cassette from "./cassette"

packages/http-recorder/src/diff.ts

Lines changed: 0 additions & 95 deletions
This file was deleted.

0 commit comments

Comments
 (0)