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.
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:
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.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).
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.
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.
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).
Current redaction covers request headers only. Now:
req.clone() / res.clone() body discipline — same principle, now on both ends.listCaptures / pruneCaptures style helpers — mirror as
listCassettes / removeCassette.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.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.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.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.
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).