mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-17 10:02:51 +00:00
141 lines
5.7 KiB
TypeScript
141 lines
5.7 KiB
TypeScript
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 { appendOrFail, 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<string> = ["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<RecordReplayOptions, "directory"> = {},
|
|
): Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient | CassetteService.Service> =>
|
|
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<string, string>,
|
|
...captured,
|
|
}),
|
|
}
|
|
yield* appendOrFail(cassetteService, 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<HttpClient.HttpClient> =>
|
|
recordingLayer(name, options).pipe(
|
|
Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
|
|
Layer.provide(FetchHttpClient.layer),
|
|
Layer.provide(NodeFileSystem.layer),
|
|
)
|