Turning request-capture into a VCR-style record/replay tool

The one thing that has to change

request-capture today is inbound middleware. withRequestLogging(handler) sits at the server edge and snapshots requests coming into the val. There is no response in the blob because, at capture time, the handler hasn't run yet — so there is nothing to "replay" in the VCR sense.

VCR is outbound. A cassette records the requests your code sends to an upstream service paired with the responses that service sends back, then short-circuits future outbound calls to return the stored response without touching the network.

So this is not a feature to bolt on — it's a mirror-image tool. Different capture site (outbound fetch, not the inbound handler), different payload (request + response, not request only), different injection point (the fetch your val calls out with, not the handler it exports).

Recommendation: build it as a separate module (vcr.ts) alongside the existing inbound capture. Keep request-capture as-is — it's still the right tool for reproducing production traffic against a dev deploy. Don't conflate the two.

0. How a cassette is used in a test

Cassettes are a test-only tool. Production code calls the real fetch; tests inject the cassette fetch. The code under test is byte-identical in both worlds — that's the point, and it's what makes the test trustworthy.

The seam. Production code takes a fetch, defaulting to the real one. That default parameter is the only concession production makes:

// user-service.ts — production code, knows nothing about cassettes export function makeGetUser(fetchImpl = fetch) { return async (id: number) => { const res = await fetchImpl(`https://api.example.com/users/${id}`); if (!res.ok) throw new Error(`upstream ${res.status}`); return res.json() as Promise<{ id: number; name: string }>; }; }

In production nobody passes anything, so it's the real fetch. The test injects the cassette version instead:

// user-service.test.ts import { makeGetUser } from "./user-service.ts"; import { makeCassetteFetch } from "./vcr.ts"; Deno.test("getUser loads a user", async () => { const fetch = makeCassetteFetch("users/get-one"); // ← the only VCR-specific line const getUser = makeGetUser(fetch); const user = await getUser(1); assertEquals(user.id, 1); assertEquals(user.name, "Leanne Graham"); });

The two-phase lifecycle. The same test, unchanged, runs two ways depending on whether the cassette exists:

  • First run (record). No cassettes:users/get-one blob → the wrapped fetch calls the real API, records the request/response pair, and the assertions run against the live response. You review the cassette diff (nothing sensitive leaked) and commit it next to the test.
  • Every run after (replay). The blob exists → the wrapped fetch matches the outgoing request to a recorded interaction and returns the stored response with no network call. Fast, offline, deterministic, no rate limits, no flake. The assertions and the code under test never changed — the test can't tell.

Two things this depends on, both detailed later: CI must fail on a missing cassette rather than silently record (§3c), and refreshing means delete the cassette and re-record so the change shows up as a reviewable diff (§6).

1. Move the capture point: wrap fetch, not the handler

Instead of wrapping the exported handler, produce a fetch-compatible function that the val injects wherever it calls out:

const fetch = makeCassetteFetch("anthropic/messages"); // pass `fetch` into your SDK/client, or use it directly const res = await fetch("https://api.anthropic.com/v1/messages", { ... });

The whole record/replay decision lives inside that wrapped fetch.

2. Storage model: a cassette is a named collection of interactions

Today: one blob per request, key captures:<uuid>, shape { id, method, url, timestamp, headers, body? }.

VCR: one blob per cassette, key cassettes:<name>, holding an ordered array of interactions, where an interaction is a matched request/response pair:

type Interaction = { request: { method: string; url: string; headers: Record<string,string>; body?: string }; response: { status: number; headers: Record<string,string>; body?: string }; }; type Cassette = { name: string; version: 1; interactions: Interaction[] };

That "response paired to request" is the piece the current capture is missing and the reason replay isn't possible yet.

3. The three pieces to build

a. Recording fetch (cassette absent). Do the real fetch, read the response via res.clone() so the caller still gets a live body, append the interaction to the cassette, write the blob, return the real response.

b. Matcher (cassette present). Given an outgoing request, find the first unclaimed interaction whose recorded request matches it. Define request identity explicitly — start with method + url; add a canonicalized (key-sorted) JSON body compare when the body is JSON. Mark each interaction claimed once consumed, so repeated identical requests replay their responses in recorded order — that's what makes retries and polling deterministic. Make the matcher pluggable (match?: (incoming, recorded) => boolean) for volatile fields like timestamps or idempotency keys.

c. Replay / reconstruct (the actual "replay" you're missing). On a match, build a real Response from the stored interaction and return it without hitting the network:

return new Response(interaction.response.body, { status: interaction.response.status, headers: interaction.response.headers, });

The calling code can't tell it wasn't live. That's the whole trick.

Mode is implied by cassette presence: no cassette → record; cassette → replay. Add a CI guard: when Deno.env.get("CI"), a missing cassette should throw rather than silently record (prevents CI making live calls).

4. Redaction has to move to both sides — and not break matching

Current redaction covers request headers only. Now:

  • Redact response bodies/headers too — upstream responses routinely contain tokens, signed URLs, PII.
  • Redaction runs on record. If you redact a field that's also used for matching, the matcher has to compare the redacted form on both sides, or exclude that field from identity. Easiest rule: never match on a redacted field.
  • Keep the existing "scan before write, fail on unsafe cassette" instinct — a committed cassette with a live key is worse than a broken test.

5. What to keep unchanged

  • Blob storage as the backing store — fine. Cassettes are just bigger, fewer blobs.
  • req.clone() / res.clone() body discipline — same principle, now on both ends.
  • The redaction option surface — extend it, don't replace it.
  • listCaptures / pruneCaptures style helpers — mirror as listCassettes / removeCassette.

6. Decisions you need to make

  • Cassette granularity. One cassette per test (VCR default, most reviewable diffs) vs one per upstream. Per-test is the better default.

  • Refresh semantics. effect-http-recorder deliberately has no overwrite mode — you delete the cassette and re-record, so the refresh is visible in the diff. Worth copying; it's a good constraint.

  • Where fetch gets injected. If your outbound calls go through an SDK that doesn't accept a custom fetch, you may need to monkeypatch globalThis.fetch for the duration of the test scope instead. Uglier, but sometimes the only hook.

  • Where cassettes live (open question). Three options, and this is a correctness/artifact-model choice, not a performance one — at cassette scale (a few interactions, a few KB) replay is "one round trip, then match in memory" for all of them, and matching can't be pushed into a query anyway.

    • Blob storage (current backing store). Simplest record path (blob.set). But recording is read-modify-write of the whole cassette per interaction, so concurrent requests into one cassette are a lost-update race — a real problem given this plan allows concurrent recording. Mitigate by serializing writes, or keep one-blob-per-interaction and assemble the cassette at teardown.
    • SQLite (one row per interaction). Kills the concurrency race — a single INSERT in a transaction has no read-modify-write. Cheaper appends too. But the query engine goes unused (matching stays in memory), and you lose the diffable artifact: a val-scoped DB isn't reviewable in a PR.
    • Files in the val (most VCR-faithful). Preserves the property that makes cassettes worth it — a committed, diffable artifact, "delete to refresh, diff shows what changed." The catch on Val Town: runtime-writing a val file goes through the API, not the blob SDK, so the record path is clunkier.

    Rough guidance: decide by whether the diffable artifact matters (it's most of why cassettes beat hand-written mocks — lean files) or whether concurrent recording is real for your tests (lean SQLite, or serialize blob writes). Don't switch off blob for performance alone; you won't observe it.

7. Limitations to accept up front

  • Bodies are buffered on record and replay, so this won't test streaming timing, cancellation, or backpressure. (Same limitation effect-http-recorder documents. Fine for provider integration tests.)
  • Binary response bodies need base64 encoding in the cassette; text stays readable.
  • This is a test-time tool. Nothing here belongs in a production request path.

Reference

Prior art with the same model, worth reading before building: Ruby vcr, JS nock / polly-js / msw, and anomalyco/effect-http-recorder (Effect 4, good cassette-format and matcher design — note it explicitly does not run on Workers/Deno, so it's reference only, not a dependency).