import { NodeFileSystem } from "@effect/platform-node" import { Effect, Layer, Option } from "effect" import { FetchHttpClient, Headers, HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse, UrlParams, } from "effect/unstable/http" import * as CassetteService from "./cassette" import { defaultMatcher, selectSequential, type RequestMatcher } from "./matching" import { makeReplayState, resolveAutoMode } from "./recorder" import { defaults, type Redactor } from "./redactor" import { redactUrl } from "./redaction" import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema" export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough" export interface RecordReplayOptions { readonly mode?: RecordReplayMode readonly directory?: string readonly metadata?: CassetteMetadata readonly redactor?: Redactor readonly match?: RequestMatcher } const BINARY_CONTENT_TYPES: ReadonlyArray = ["vnd.amazon.eventstream", "octet-stream"] const isBinaryContentType = (contentType: string | undefined) => contentType !== undefined && BINARY_CONTENT_TYPES.some((token) => contentType.toLowerCase().includes(token)) const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) => isBinaryContentType(contentType) ? response.arrayBuffer.pipe( Effect.map((bytes) => ({ body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const })), ) : response.text.pipe(Effect.map((body) => ({ body }))) const decodeResponseBody = (snapshot: ResponseSnapshot) => snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) => HttpClientRequest.makeWith( request.method, redactUrl(request.url), UrlParams.empty, Option.none(), Headers.empty, HttpBody.empty, ) const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) => new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }), }) export const recordingLayer = ( name: string, options: Omit = {}, ): Layer.Layer => Layer.effect( HttpClient.HttpClient, Effect.gen(function* () { const upstream = yield* HttpClient.HttpClient const cassetteService = yield* CassetteService.Service const redactor = options.redactor ?? defaults() const match = options.match ?? defaultMatcher const requested = options.mode ?? "auto" const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested const replay = yield* makeReplayState(cassetteService, name, httpInteractions) const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) return redactor.request({ method: web.method, url: web.url, headers: Object.fromEntries(web.headers.entries()), body: yield* Effect.promise(() => web.text()), }) }) return HttpClient.make((request) => { if (mode === "passthrough") return upstream.execute(request) if (mode === "record") { return Effect.gen(function* () { const incoming = yield* snapshotRequest(request) const response = yield* upstream.execute(request) const captured = yield* captureResponseBody(response, response.headers["content-type"]) const interaction: HttpInteraction = { transport: "http", request: incoming, response: redactor.response({ status: response.status, headers: response.headers as Record, ...captured, }), } yield* cassetteService .append(name, interaction, options.metadata) .pipe( Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))), ) return HttpClientResponse.fromWeb( request, new Response(decodeResponseBody(interaction.response), interaction.response), ) }) } return Effect.gen(function* () { const incoming = yield* snapshotRequest(request) const interactions = yield* replay.load.pipe( Effect.mapError(() => transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`), ), ) const result = selectSequential(interactions, incoming, match, yield* replay.cursor) if (!result.interaction) return yield* Effect.fail( transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`), ) yield* replay.advance return HttpClientResponse.fromWeb( request, new Response(decodeResponseBody(result.interaction.response), result.interaction.response), ) }) }) }), ) export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer => recordingLayer(name, options).pipe( Layer.provide(CassetteService.fileSystem({ directory: options.directory })), Layer.provide(FetchHttpClient.layer), Layer.provide(NodeFileSystem.layer), )