|
| 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. | |
0 commit comments