feat(http-recorder): prepare public beta release (#31018)
This commit is contained in:
+184
-175
@@ -1,208 +1,217 @@
|
||||
# @opencode-ai/http-recorder
|
||||
|
||||
Record and replay HTTP and WebSocket traffic for Effect's `HttpClient`. Tests
|
||||
exercise real request shapes against deterministic, version-controlled
|
||||
cassettes — no manual mocks, no flakes from upstream drift.
|
||||
Record real Effect HTTP and WebSocket traffic once, then replay it from deterministic JSON cassettes.
|
||||
|
||||
Use it for provider integrations, retries, polling, multi-step flows, and any test where hand-written HTTP mocks hide too much of the real request shape.
|
||||
|
||||
> Public beta. The API depends on Effect 4 beta and may change with Effect's unstable transport modules.
|
||||
|
||||
## Install
|
||||
|
||||
Internal package; depended on as `@opencode-ai/http-recorder` from another
|
||||
workspace package.
|
||||
|
||||
```ts
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
```sh
|
||||
bun add effect@4.0.0-beta.74
|
||||
bun add -d @opencode-ai/http-recorder@beta @effect/vitest vitest
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
The package supports Node.js 22+ and Bun. It is not intended for browsers, workers, or Deno.
|
||||
|
||||
Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`.
|
||||
By default the layer records on first run and replays on subsequent runs —
|
||||
no env-var ternary at the call site, and `CI=true` forces strict replay so
|
||||
missing cassettes fail loudly in CI rather than silently re-recording.
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const response = yield* http.execute(HttpClientRequest.get("https://api.example.com/users/1"))
|
||||
return yield* response.json
|
||||
})
|
||||
|
||||
// Records if the cassette is missing, replays if it exists.
|
||||
// In CI (CI=true) always replays — fails loudly on missing fixtures.
|
||||
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one"))))
|
||||
|
||||
// Force a refresh — always hits upstream and overwrites.
|
||||
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" }))))
|
||||
```
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
| ------------- | ----------------------------------------------------------------------------------- |
|
||||
| `auto` | Default. Replay if the cassette exists; record if missing. `CI=true` forces replay. |
|
||||
| `replay` | Strict — match the request to a recorded interaction; error if none. |
|
||||
| `record` | Execute upstream, append the interaction, write the cassette. |
|
||||
| `passthrough` | Bypass the recorder entirely — just call upstream. |
|
||||
|
||||
## Cassette format
|
||||
|
||||
A cassette is JSON at `test/fixtures/recordings/<name>.json`:
|
||||
Effect `4.0.0-beta.74` has a known declaration error (`SchemaErrorTypeId` is missing). Until that upstream declaration is fixed, TypeScript consumers need:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": { "name": "users/get-one", "recordedAt": "2026-05-09T..." },
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": { "method": "GET", "url": "...", "headers": {...}, "body": "" },
|
||||
"response": { "status": 200, "headers": {...}, "body": "..." }
|
||||
}
|
||||
]
|
||||
"compilerOptions": {
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Cassettes are normal source files — review them, diff them, commit them.
|
||||
|
||||
## Request matching
|
||||
|
||||
Replay walks the cassette in record order via an internal cursor: the Nth
|
||||
request executed at runtime is served by the Nth recorded interaction, and
|
||||
each one is validated as the cursor advances. Request equality is computed
|
||||
on canonicalized method, URL, headers, and JSON body (object keys sorted).
|
||||
|
||||
This is deliberately strict — content-based dispatch was removed because
|
||||
it silently returns the first recorded response for repeated identical
|
||||
requests, masking state changes that retry/polling/cache-hit tests need to
|
||||
observe. If you reorder requests in a test, re-record the cassette.
|
||||
|
||||
Supply your own matcher via `match: (incoming, recorded) => boolean` for
|
||||
custom equivalence (e.g. ignoring a timestamp field in the body).
|
||||
|
||||
## Redaction & secret safety
|
||||
|
||||
Cassettes get checked in, so the recorder is aggressive about not letting
|
||||
secrets escape. Redaction is configured by composing a `Redactor`:
|
||||
|
||||
```ts
|
||||
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
|
||||
|
||||
HttpRecorder.cassetteLayer("anthropic/messages", {
|
||||
redactor: Redactor.defaults({
|
||||
requestHeaders: { allow: ["content-type", "anthropic-version"] },
|
||||
url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") },
|
||||
body: (parsed) => ({ ...(parsed as object), user_id: "{user}" }),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
`Redactor.defaults({ … })` composes the four built-in redactors with your
|
||||
overrides. For full control, build the stack yourself:
|
||||
|
||||
```ts
|
||||
const redactor = Redactor.compose(
|
||||
Redactor.requestHeaders({ allow: ["content-type", "x-custom"] }),
|
||||
Redactor.responseHeaders(),
|
||||
Redactor.url({ query: ["session-id"] }),
|
||||
Redactor.body((parsed) => /* … */),
|
||||
)
|
||||
```
|
||||
|
||||
What each layer does:
|
||||
|
||||
- **`requestHeaders` / `responseHeaders`** — strip headers to a small
|
||||
allow-list (request default: `content-type`, `accept`, `openai-beta`;
|
||||
response default: `content-type`). Sensitive headers within the
|
||||
allow-list (`authorization`, `cookie`, API-key headers, AWS/GCP tokens,
|
||||
…) are replaced with `[REDACTED]`.
|
||||
- **`url`** — query parameters matching common secret names (`api_key`,
|
||||
`token`, `signature`, AWS signing params, …) are replaced with
|
||||
`[REDACTED]`. URL user/password are replaced. `transform` runs after
|
||||
built-in redaction for path-level scrubbing.
|
||||
- **`body`** — receives the parsed JSON request body and returns a redacted
|
||||
version. No-op for non-JSON bodies.
|
||||
|
||||
After assembling the cassette, the recorder scans every string for known
|
||||
secret patterns (Bearer tokens, `sk-…`, `sk-ant-…`, Google `AIza…` keys,
|
||||
AWS access keys, GitHub tokens, PEM blocks) and for values matching any
|
||||
environment variable named like a credential. If anything is found, the
|
||||
cassette is **not written** and the request fails with `UnsafeCassetteError`
|
||||
listing what was detected.
|
||||
|
||||
## WebSocket recording
|
||||
|
||||
WebSocket support records the open frame plus client/server message
|
||||
streams. It uses the shared `Cassette.Service`, so HTTP and WS interactions
|
||||
can live in the same cassette.
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
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"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
||||
name: "ws/subscribe",
|
||||
cassette,
|
||||
live: liveExecutor,
|
||||
})
|
||||
// use executor.open(...)
|
||||
const User = Schema.Struct({
|
||||
id: Schema.Number,
|
||||
name: Schema.String,
|
||||
})
|
||||
```
|
||||
|
||||
## Inspecting cassettes programmatically
|
||||
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)
|
||||
})
|
||||
|
||||
`Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read`
|
||||
returns the recorded interactions for a name; the file format is hidden
|
||||
behind the seam. Useful for CI checks:
|
||||
describe("getUser", () => {
|
||||
it.effect("loads a user", () =>
|
||||
Effect.gen(function* () {
|
||||
const user = yield* getUser
|
||||
|
||||
```ts
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const audit = Effect.gen(function* () {
|
||||
const cassettes = yield* HttpRecorder.Cassette.Service
|
||||
const names = yield* cassettes.list()
|
||||
const issues = yield* Effect.forEach(names, (name) =>
|
||||
cassettes
|
||||
.read(name)
|
||||
.pipe(Effect.map((interactions) => ({ name, findings: HttpRecorder.secretFindings(interactions) }))),
|
||||
assert.strictEqual(user.id, 1)
|
||||
assert.strictEqual(user.name, "Leanne Graham")
|
||||
}).pipe(Effect.provide(HttpRecorder.http("users/get-one"))),
|
||||
)
|
||||
return issues.filter((i) => i.findings.length > 0)
|
||||
})
|
||||
```
|
||||
|
||||
`cassetteLayer` is the batteries-included entry point — it provides
|
||||
`Cassette.fileSystem({ directory })` automatically. If you want to provide
|
||||
your own `Cassette.Service` (e.g. an in-memory adapter for the recorder's
|
||||
own unit tests), use `recordingLayer` and supply `Cassette.fileSystem` /
|
||||
`Cassette.memory` yourself.
|
||||
Run the test with Vitest. The first local run calls the real API and records:
|
||||
|
||||
## Options reference
|
||||
```sh
|
||||
bunx vitest run users.test.ts
|
||||
```
|
||||
|
||||
```text
|
||||
test/fixtures/recordings/users/get-one.json
|
||||
```
|
||||
|
||||
Later runs replay that cassette without contacting the upstream server. When `CI=true`, missing cassettes fail instead of recording.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Run[Run test] --> Recorded{Cassette recorded?}
|
||||
Recorded -->|Yes| Replay[Replay cassette]
|
||||
Recorded -->|No, local| Record[Call service and record cassette]
|
||||
Recorded -->|No, CI| Fail[Fail: cassette missing]
|
||||
```
|
||||
|
||||
Application code does not need to know whether a response is live or replayed.
|
||||
|
||||
## API
|
||||
|
||||
```ts
|
||||
type RecordReplayOptions = {
|
||||
mode?: "auto" | "replay" | "record" | "passthrough" // default: "auto" (CI=true forces "replay")
|
||||
directory?: string // default: <cwd>/test/fixtures/recordings
|
||||
metadata?: Record<string, unknown> // merged into cassette.metadata
|
||||
redactor?: Redactor // default: Redactor.defaults()
|
||||
match?: (incoming, recorded) => boolean // custom matcher
|
||||
HttpRecorder.http(name, options?)
|
||||
HttpRecorder.socket(name, options?)
|
||||
```
|
||||
|
||||
That is the complete public API. `http` provides a fetch-backed recorded `HttpClient`. `socket` decorates a standard Effect `Socket.Socket` supplied beneath it.
|
||||
|
||||
## WebSockets
|
||||
|
||||
WebSocket cassettes preserve one ordered transcript of client and server text or binary frames. Replay follows that chronology: server frames are released until the next recorded client frame, then replay waits for the application to send the matching frame before continuing.
|
||||
|
||||
```ts
|
||||
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)))
|
||||
```
|
||||
|
||||
The application owns the WebSocket URL and protocols through normal Effect layer wiring. The recorder wraps that socket without duplicating its URL in recorder configuration. Provide separate socket layers for separate endpoints or concurrent connections.
|
||||
|
||||
Text frames use the same JSON-field and body redaction as HTTP bodies. Binary frames are stored losslessly as base64. Client and server frame kinds must match during replay.
|
||||
|
||||
## Refresh A Cassette
|
||||
|
||||
Delete exactly the recordings you want to replace, then rerun their tests:
|
||||
|
||||
```sh
|
||||
rm test/fixtures/recordings/users/get-one.json
|
||||
bun run test users.test.ts
|
||||
```
|
||||
|
||||
There is intentionally no public overwrite mode. Deletion makes the set of recordings being refreshed visible and reviewable.
|
||||
|
||||
## Redaction
|
||||
|
||||
Secure defaults remove most headers and redact common credentials in headers, URLs, and JSON bodies. Extend those defaults at layer construction:
|
||||
|
||||
```ts
|
||||
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` | Add sensitive header names. They are retained as `[REDACTED]`. |
|
||||
| `allowRequestHeaders` | Preserve additional non-sensitive request headers for matching. |
|
||||
| `allowResponseHeaders` | Preserve additional non-sensitive response headers for replay. |
|
||||
| `queryParameters` | Add sensitive URL query parameter names. |
|
||||
| `jsonFields` | Recursively redact matching JSON keys in requests and responses. |
|
||||
| `url` | Stabilize a URL after built-in redaction. |
|
||||
| `body` | Stabilize request and response bodies after built-in JSON redaction. |
|
||||
|
||||
Before writing, the recorder scans the complete cassette for common credential formats and values from credential-like environment variables. Unsafe cassettes fail without replacing an existing recording.
|
||||
|
||||
Redaction is defense in depth, not a substitute for review. Inspect cassette diffs before committing them.
|
||||
|
||||
## Matching And Ordering
|
||||
|
||||
A cassette contains an ordered sequence of interactions. The first runtime request is checked against the first recorded request, the second against the second, and so on.
|
||||
|
||||
This strict ordering correctly models repeated identical requests whose responses change, including retries, polling, and cache tests. JSON object keys are canonicalized before matching.
|
||||
|
||||
Concurrent requests are recorded in request-start order even when their responses complete out of order.
|
||||
|
||||
Supply a custom equivalence rule when a request contains intentionally volatile data:
|
||||
|
||||
```ts
|
||||
HttpRecorder.http("events/create", {
|
||||
match: (incoming, recorded) =>
|
||||
incoming.method === recorded.method && new URL(incoming.url).pathname === new URL(recorded.url).pathname,
|
||||
})
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```ts
|
||||
interface RecorderOptions {
|
||||
readonly directory?: string
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly redact?: RedactOptions
|
||||
readonly match?: RequestMatcher
|
||||
}
|
||||
```
|
||||
|
||||
## Layout
|
||||
`directory` defaults to `<cwd>/test/fixtures/recordings`.
|
||||
|
||||
| File | Purpose |
|
||||
| -------------- | --------------------------------------------------------------------------- |
|
||||
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
|
||||
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
|
||||
| `cassette.ts` | `Cassette.Service` — `fileSystem` / `memory` adapters, error types. |
|
||||
| `recorder.ts` | Shared transport plumbing: `resolveAutoMode`, `ReplayState`. |
|
||||
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
|
||||
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
|
||||
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
|
||||
| `matching.ts` | Request matcher, canonicalization, sequential cursor, mismatch diagnostics. |
|
||||
## Cassettes
|
||||
|
||||
Cassettes are readable JSON files intended to be committed with your tests. HTTP interactions are stored in request order. WebSocket cassettes preserve the observed order of client and server frames. Text stays readable; binary bodies and frames are stored losslessly as base64.
|
||||
|
||||
## Current Limits
|
||||
|
||||
- Responses are buffered while recording and replaying, so this beta is not suitable for tests that assert streaming timing, cancellation, or backpressure.
|
||||
- WebSocket replay preserves frame chronology and content, not real network timing or backpressure.
|
||||
- WebSocket V1 cassettes do not reproduce terminal close codes, close reasons, or transport failures. Failed and interrupted live runs are not recorded.
|
||||
- WebSocket transcripts are retained in memory until the connection finishes; avoid using this beta for unbounded sessions.
|
||||
- The package currently requires the exact Effect beta listed above.
|
||||
- Cassette format version `1` has no migration tooling yet.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
Reference in New Issue
Block a user