Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions packages/core/test/session-runner-recorded.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { Database } from "@opencode-ai/core/database/database"
Expand Down Expand Up @@ -43,15 +42,13 @@ import { Effect, Layer } from "effect"
import path from "node:path"
import { testEffect } from "./lib/effect"

const cassette =
process.env.RECORD === "true"
? HttpRecorderInternal.cassetteLayer("session-runner/openai-chat-streams-text", {
directory: path.resolve(import.meta.dir, "fixtures/recordings"),
mode: "record",
})
: HttpRecorder.http("session-runner/openai-chat-streams-text", {
directory: path.resolve(import.meta.dir, "fixtures/recordings"),
})
const cassetteName = "session-runner/openai-chat-streams-text"
const cassetteDirectory = path.resolve(import.meta.dir, "fixtures/recordings")
if (process.env.RECORD === "true") {
if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes")
HttpRecorder.removeCassetteSync(cassetteName, { directory: cassetteDirectory })
}
const cassette = HttpRecorder.layerFetch(cassetteName, { directory: cassetteDirectory })
const executor = RequestExecutor.layer.pipe(Layer.provide(cassette))
const client = LLMClient.layer.pipe(Layer.provide(executor))
const permission = Layer.succeed(
Expand Down
105 changes: 72 additions & 33 deletions packages/http-recorder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ Use it for provider integrations, retries, polling, multi-step flows, and any te
## Install

```sh
bun add effect@4.0.0-beta.74
bun add -d @opencode-ai/http-recorder@beta @effect/vitest vitest
bun add effect@4.0.0-beta.83
bun add -d @opencode-ai/http-recorder @effect/vitest@4.0.0-beta.83 vitest@^4
```

The package supports Node.js 22+ and Bun. It is not intended for browsers, workers, or Deno.

Effect `4.0.0-beta.74` has a known declaration error (`SchemaErrorTypeId` is missing). Until that upstream declaration is fixed, TypeScript consumers need:
Effect `4.0.0-beta.83` currently contains unresolved symbols in its published declarations. Until those upstream declarations are fixed, TypeScript consumers need:

```json
{
Expand Down Expand Up @@ -51,7 +51,7 @@ describe("getUser", () => {

assert.strictEqual(user.id, 1)
assert.strictEqual(user.name, "Leanne Graham")
}).pipe(Effect.provide(HttpRecorder.http("users/get-one"))),
}).pipe(Effect.provide(HttpRecorder.layerFetch("users/get-one"))),
)
})
```
Expand Down Expand Up @@ -81,49 +81,83 @@ Application code does not need to know whether a response is live or replayed.
## API

```ts
HttpRecorder.http(name, options?)
HttpRecorder.socket(name, options?)
HttpRecorder.layer(name, options?)
HttpRecorder.layerFetch(name, options?)
HttpRecorder.layerSocket(name, options?)
HttpRecorder.layerWebSocketConstructor(name, options?)
HttpRecorder.hasCassetteSync(name, options?)
HttpRecorder.removeCassetteSync(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.
That is the complete runtime API. `layer` decorates an application-provided `HttpClient`; `layerFetch` is the convenience layer that supplies Effect's fetch client. `layerWebSocketConstructor` decorates Effect's `Socket.WebSocketConstructor`, recording every dynamically selected URL and protocol. `layerSocket` is the lower-level transport-neutral decorator for an application-provided `Socket.Socket`.

Use `hasCassetteSync` when registering fixture-gated tests. `removeCassetteSync` explicitly removes one cassette before a focused refresh; removing a missing cassette is a no-op. Both helpers use the same cassette-name validation and default directory as the recorder layers.

Use `layer` to record through another Effect HTTP transport:

```ts
import { NodeHttpClient } from "@effect/platform-node"
import { Layer } from "effect"

const recorder = HttpRecorder.layer("users/get-one").pipe(Layer.provide(NodeHttpClient.layerUndici))
```

The `HttpRecorder` namespace also exposes the configuration types `RecorderOptions`, `SocketRecorderOptions`, `RedactOptions`, `RequestMatcher`, `RequestSnapshot`, and `CassetteMetadata`.

## 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.
Real applications often select WebSocket URLs inside domain services. Effect represents that capability with `Socket.WebSocketConstructor`; production supplies the platform implementation, while tests can decorate it without changing application code.

```ts
import { assert, it } from "@effect/vitest"
import { NodeSocket } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { it } from "@effect/vitest"
import { Deferred, 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 roundTrip = Effect.fn("Echo.roundTrip")(function* (url: string, message: string) {
const socket = yield* Socket.makeWebSocket(url, { closeCodeIsError: () => false })
const write = yield* socket.writer
const echoed = yield* Deferred.make<string>()

yield* socket.runString(
(message) =>
Effect.gen(function* () {
assert.strictEqual(message, "hello")
yield* write(new Socket.CloseEvent(1000))
}),
{ onOpen: write("hello") },
(response) => {
return Deferred.succeed(echoed, response).pipe(
Effect.andThen(write(new Socket.CloseEvent(1000, "done"))),
Effect.orDie,
)
},
{ onOpen: write(message).pipe(Effect.orDie) },
)

return yield* Deferred.await(echoed)
})

const recordedSocket = HttpRecorder.socket("echo/hello").pipe(
Layer.provide(
NodeSocket.layerWebSocket("wss://ws.postman-echo.com/raw", {
closeCodeIsError: (code) => code !== 1000,
}),
it.effect("round trips a message", () =>
roundTrip("wss://ws.postman-echo.com/raw", "hello").pipe(
Effect.scoped,
Effect.provide(
HttpRecorder.layerWebSocketConstructor("echo/round-trip").pipe(
Layer.provide(NodeSocket.layerWebSocketConstructor),
),
),
),
)

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.
The production application supplies only `NodeSocket.layerWebSocketConstructor`. The recorder appears in test wiring and observes each call to `Socket.makeWebSocket`, including URLs selected at runtime.

`socket.runString` owns the receive loop and finishes when the connection closes or fails. Its optional `onOpen` effect is the safe place to send protocols whose client speaks first. The writer is scoped because sending is valid only while a connection run is active.

WebSocket cassettes preserve one ordered transcript of client and server text or binary frames. Replay releases recorded server frames until it reaches a client frame, waits for the application to write the matching frame, then continues. This preserves causal ordering without reproducing network timing.

Client text frames containing JSON compare canonically, so object-key order does not matter. Changed fields, extra fields, non-JSON text, and binary frames must match exactly after redaction. There is intentionally no custom WebSocket matcher in this beta.

Incoming frame handlers start in recorded order and may run concurrently, matching Effect's socket abstraction. Replay waits for all handlers before the socket run completes, but handler completion order is not guaranteed. Use Effect synchronization such as `Queue`, `Ref`, or `Deferred` instead of unsynchronized mutable state.

A constructor cassette records the URL, requested protocols, frames, and terminal close for each connection. Replay validates the URL and protocols before opening the simulated socket. Closing before every recorded frame is consumed fails the test.

Use `layerSocket` when a protocol layer already consumes one application-provided `Socket.Socket`, including non-WebSocket transports. Because that lower-level abstraction has no URL or protocols, its cassettes use the cassette name and connection order as identity.

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.

Expand All @@ -143,7 +177,7 @@ There is intentionally no public overwrite mode. Deletion makes the set of recor
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", {
HttpRecorder.layerFetch("anthropic/messages", {
redact: {
headers: ["x-project-token"],
allowRequestHeaders: ["anthropic-version"],
Expand Down Expand Up @@ -171,16 +205,16 @@ Redaction is defense in depth, not a substitute for review. Inspect cassette dif

## 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.
A runtime request atomically claims the first unused recorded interaction that matches it. Distinct requests may replay in any order or concurrently.

This strict ordering correctly models repeated identical requests whose responses change, including retries, polling, and cache tests. JSON object keys are canonicalized before matching.
Repeated identical requests consume their matching responses in cassette order, which models retries, polling, and cache tests deterministically. A mismatch consumes nothing, and JSON object keys are canonicalized before matching.

Concurrent requests are recorded in request-start order even when their responses complete out of order.
Concurrent requests are recorded in request-start order even when their responses complete out of order. Each recorded interaction can be claimed only once, and leaving interactions unused fails when the recorder layer closes.

Supply a custom equivalence rule when a request contains intentionally volatile data:

```ts
HttpRecorder.http("events/create", {
HttpRecorder.layerFetch("events/create", {
match: (incoming, recorded) =>
incoming.method === recorded.method && new URL(incoming.url).pathname === new URL(recorded.url).pathname,
})
Expand All @@ -191,14 +225,18 @@ HttpRecorder.http("events/create", {
```ts
interface RecorderOptions {
readonly directory?: string
readonly metadata?: Record<string, unknown>
readonly metadata?: Readonly<Record<string, JsonValue>>
readonly redact?: RedactOptions
readonly match?: RequestMatcher
}

type SocketRecorderOptions = Omit<RecorderOptions, "match">
```

`directory` defaults to `<cwd>/test/fixtures/recordings`.

See [`examples/`](./examples) for complete HTTP and WebSocket examples.

## 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.
Expand All @@ -207,7 +245,8 @@ Cassettes are readable JSON files intended to be committed with your tests. HTTP

- 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.
- Constructor-level WebSocket cassettes reproduce terminal close codes and reasons, but not selected subprotocols, handshake headers, transport timing, or transport failures. Lower-level `layerSocket` cassettes contain frames only.
- Failed and interrupted live WebSocket connections 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.
Expand Down
12 changes: 6 additions & 6 deletions packages/http-recorder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.14",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
"license": "MIT",
"repository": {
Expand All @@ -28,14 +28,14 @@
},
"scripts": {
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit",
"typecheck": "tsgo --noEmit && tsgo -p test/tsconfig.json --noEmit",
"build": "bun ./script/build.ts",
"verify:package": "bun ./script/verify-package.ts"
},
"exports": {
".": "./src/index.ts",
"./internal": "./src/internal.ts"
".": "./src/index.ts"
},
"sideEffects": false,
"files": [
"dist",
"README.md",
Expand All @@ -47,14 +47,14 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"@effect/platform-node": "catalog:",
"effect": "catalog:",
"typescript": "catalog:"
},
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83"
},
"peerDependencies": {
"effect": "4.0.0-beta.83"
"effect": "catalog:"
}
}
10 changes: 8 additions & 2 deletions packages/http-recorder/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ const build = await Bun.build({
})
if (!build.success) throw new AggregateError(build.logs, "Failed to build @opencode-ai/http-recorder")

const publicFiles = new Set(["index.js", "index.d.ts", "effect.d.ts", "socket.d.ts", "types.d.ts"])
await Promise.all(
(await readdir("dist")).filter((file) => !publicFiles.has(file)).map((file) => rm(`dist/${file}`, { force: true })),
(await readdir("dist", { recursive: true }))
.filter((file) => file.endsWith(".d.ts") && file !== "index.d.ts" && file !== "api.d.ts")
.map((file) => rm(`dist/${file}`)),
)

for (const file of ["dist/index.d.ts", "dist/api.d.ts"]) {
if ((await Bun.file(file).text()).includes(["import", "("].join("")))
throw new Error(`${file} contains dynamic import syntax`)
}
14 changes: 10 additions & 4 deletions packages/http-recorder/script/pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,6 @@ export const pack = async () => {
}

for (const [key, value] of Object.entries(pkg.exports)) {
if (key === "./internal") {
delete pkg.exports[key]
continue
}
if (typeof value !== "string") continue
const file = value.replace("./src/", "./dist/").replace(/\.ts$/, "")
pkg.exports[key] = { import: `${file}.js`, types: `${file}.d.ts` }
Expand All @@ -32,4 +28,14 @@ export const pack = async () => {
}
}

export const withPackedArchive = async <A>(use: (archive: string) => Promise<A>) => {
const archive = await pack()
try {
return await use(archive)
} finally {
const file = Bun.file(archive)
if (await file.exists()) await file.delete()
}
}

if (import.meta.main) await pack()
Loading
Loading