@useairfoil/effect-vcr 0.0.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # @useairfoil/effect-vcr
2
+
3
+ An HttpClient for Effect that records all HTTP interactions and stores them on tape, ready to be replayed for future test runs.
4
+
5
+ The first time the following test is run, it sends the HTTP request to httpbin.org and stores the request/response pair to a cassette. All subsequent tests will replay the response from the cassette, making no HTTP requests.
6
+
7
+ ```ts
8
+ // my-program.ts
9
+ export const program = Effect.gen(function* () {
10
+ const client = yield* HttpClient.HttpClient;
11
+ const response = yield* client.get("https://httpbin.org");
12
+ return yield* response.text;
13
+ });
14
+
15
+ // my-program.test.ts
16
+ import { VcrHttpClient, FileSystemCassetteStore } from "@useairfoil/effect-vcr";
17
+
18
+ describe("my awesome program", () => {
19
+ it.effect("works", () =>
20
+ Effect.gen(function* () {
21
+ yield* program;
22
+ }),
23
+ ).pipe(
24
+ // Wrap the HttpClient with the VCR
25
+ Effect.provide(VcrHttpClient.layer()),
26
+ // Store cassettes on the file system
27
+ Effect.provide(FileSystemCassetteStore.layer()),
28
+ // HttpClient based on fetch
29
+ Effect.provide(FetchHttpClient.layer),
30
+ // Node is used to access the file system
31
+ Effect.provide(NodeServices.layer),
32
+ );
33
+ });
34
+ ```
35
+
36
+ The main use case for the VCR is to allow open source projects to run tests against a real API endpoint without exposing secrets to forks.
37
+
38
+ - Project owners generate cassettes locally by interacting with the upstream API
39
+ - External contributors run their tests using the cassettes
40
+ - CI runs using cassettes
41
+ - Optionally, CI runs against the upstream API periodically, to catch any schema drift
42
+
43
+ ## Usage
44
+
45
+ Use the VCR by providing the VcrHttpClient and FileSystemCassetteStore layers to the Effect being tested.
46
+
47
+ - VcrHttpClient: this HttpClient wraps another HttpClient, adding support for recording and replaying HTTP interactions.
48
+ - FileSystemCassetteStore: stores cassettes on the file system.
49
+
50
+ ## Configuration and defaults
51
+
52
+ ### VcrHttpClient
53
+
54
+ - `vcrName?: string`: the VCR name. Used to selectively disable VCRs during test runs (more on this in the runtime configuration section).
55
+ - `cassetteName?: string`: the cassette name. If not set, defaults to the current vitest file name.
56
+ - `mode: "record" | "replay" | "auto"`: controls the VCR behaviour. Defaults to `auto`.
57
+ - `record`: always call the live client and write a cassette.
58
+ - `replay`: only serve from cassette; missing entries fail.
59
+ - `auto`: replay if cassette exists, otherwise record. If `CI=true`, missing cassette fails.
60
+ - `redact`: control which sensitive headers and JSON body keys to remove from requests and responses.
61
+ - `.requestHeaders?: string[]`: redact the specified request headers.
62
+ - `.responseHeaders?: string[]`: redact the specified response headers.
63
+ - `.requestBodyKeys?: string[]`: redact the specified request JSON keys.
64
+ - `.responseBodyKeys?: string[]`: redact the specified response JSON keys.
65
+ - `matchIgnore`: control which fields are ignored when matching requests.
66
+ - `.requestHeaders?: string[]`: ignore the specified request headers.
67
+ - `.requestBodyKeys?: string[]`: ignore the specified request JSON keys.
68
+ - `match: (request: VcrRequest, entry: VcrEntry) => boolean`: custom request to entry matcher.
69
+
70
+ ### FileSystemCassetteStore
71
+
72
+ - `cassetteDir?: string`: change the location of the cassette. By default, the VCR stores cassettes in the `__cassettes__` folder next to the test.
73
+
74
+ ## Runtime configuration
75
+
76
+ The VCR is controlled by the following environment variables:
77
+
78
+ - `CI=true`: if set and the VCR mode is `auto`, tests with a missing cassette fail.
79
+ - `ACK_DISABLE_VCR=<...>`: disable the VCR. If `'*'`, then all VCRs are disabled. To selectively disable VCRs, pass a comma separated list of VCRs to disable (based on their `vcrName` config). Use this to test against the upstream API. In this case, the cassettes are not updated.
@@ -0,0 +1,13 @@
1
+ //#region rolldown:runtime
2
+ var __defProp = Object.defineProperty;
3
+ var __export = (all) => {
4
+ let target = {};
5
+ for (var name in all) __defProp(target, name, {
6
+ get: all[name],
7
+ enumerable: true
8
+ });
9
+ return target;
10
+ };
11
+
12
+ //#endregion
13
+ export { __export as t };
@@ -0,0 +1,150 @@
1
+ import { Config, Context, Effect, FileSystem, Layer, Path, Schema } from "effect";
2
+ import { HttpClient } from "effect/unstable/http";
3
+ import * as effect_Cause0 from "effect/Cause";
4
+ import * as effect_Layer0 from "effect/Layer";
5
+
6
+ //#region src/types.d.ts
7
+ /**
8
+ * VCR operating mode.
9
+ * - record: always call the live client and persist.
10
+ * - replay: serve only from cassette, fail if missing.
11
+ * - auto: replay if cassette exists, otherwise record (fail in CI).
12
+ */
13
+ type VcrMode = "record" | "replay" | "auto";
14
+ /**
15
+ * Serialized request shape stored in a cassette.
16
+ */
17
+ type VcrRequest = {
18
+ readonly method: string;
19
+ readonly url: string;
20
+ readonly headers?: Record<string, string>;
21
+ readonly body?: string;
22
+ };
23
+ /**
24
+ * Serialized response shape stored in a cassette.
25
+ */
26
+ type VcrResponse = {
27
+ readonly status: number;
28
+ readonly body: string;
29
+ readonly headers?: Record<string, string>;
30
+ };
31
+ /**
32
+ * A single request/response pair keyed in the cassette.
33
+ */
34
+ type VcrEntry = {
35
+ readonly request: VcrRequest;
36
+ readonly response: VcrResponse;
37
+ };
38
+ /**
39
+ * Cassette file format.
40
+ */
41
+ type Cassette = {
42
+ readonly meta: {
43
+ readonly createdAt: string;
44
+ readonly version: string;
45
+ };
46
+ readonly entries: Record<string, VcrEntry>;
47
+ };
48
+ /**
49
+ * Cassette file format with multiple exports.
50
+ */
51
+ type CassetteFile = {
52
+ readonly exports: Record<string, Cassette>;
53
+ };
54
+ /**
55
+ * VCR configuration.
56
+ */
57
+ type Configuration = {
58
+ readonly vcrName?: string;
59
+ readonly cassetteName?: string;
60
+ readonly mode?: VcrMode;
61
+ /**
62
+ * Remove sensitive data before writing to disk.
63
+ */
64
+ readonly redact?: {
65
+ readonly requestHeaders?: ReadonlyArray<string>;
66
+ readonly responseHeaders?: ReadonlyArray<string>;
67
+ readonly requestBodyKeys?: ReadonlyArray<string>;
68
+ readonly responseBodyKeys?: ReadonlyArray<string>;
69
+ };
70
+ /**
71
+ * Ignore fields when computing the request key for replay matching.
72
+ */
73
+ readonly matchIgnore?: {
74
+ readonly requestHeaders?: ReadonlyArray<string>;
75
+ readonly requestBodyKeys?: ReadonlyArray<string>;
76
+ };
77
+ /**
78
+ * Custom matcher to override key-based lookup.
79
+ */
80
+ readonly match?: (request: VcrRequest, entry: VcrEntry) => boolean;
81
+ };
82
+ declare namespace cassette_store_d_exports {
83
+ export { CassetteStore, CassetteStoreError, createEmptyCassette, createEmptyCassetteFile, make, parseCassette, toStoreError };
84
+ }
85
+ /**
86
+ * Storage contract for VCR cassettes.
87
+ *
88
+ */
89
+ interface CassetteStore {
90
+ /** Returns true if a cassette with the given name exists. */
91
+ readonly exists: (name: string) => Effect.Effect<boolean, CassetteStoreError>;
92
+ /** Loads a cassette by name, returning a `CassetteFile` if found. */
93
+ readonly load: (name: string) => Effect.Effect<CassetteFile, CassetteStoreError>;
94
+ /** Saves a cassette by name, overwriting any existing cassette with the same name. */
95
+ readonly save: (name: string, content: CassetteFile) => Effect.Effect<void, CassetteStoreError>;
96
+ /** Loads a cassette by name, initializing a new empty cassette if not found. */
97
+ readonly loadOrInit: (name: string) => Effect.Effect<CassetteFile, CassetteStoreError>;
98
+ }
99
+ /**
100
+ * Effect service tag for the cassette store.
101
+ */
102
+ declare const CassetteStore: Context.Service<CassetteStore, CassetteStore>;
103
+ /**
104
+ * Creates a new empty cassette with a timestamp and format version.
105
+ */
106
+ declare const createEmptyCassette: () => Effect.Effect<Cassette>;
107
+ declare const createEmptyCassetteFile: () => Effect.Effect<CassetteFile>;
108
+ declare const CassetteStoreError_base: Schema.Class<CassetteStoreError, Schema.TaggedStruct<"CassetteStoreError", {
109
+ readonly operation: Schema.String;
110
+ readonly path: Schema.String;
111
+ readonly message: Schema.optional<Schema.String>;
112
+ readonly cause: Schema.optional<Schema.Unknown>;
113
+ }>, effect_Cause0.YieldableError>;
114
+ /**
115
+ * Structured error for cassette persistence operations.
116
+ *
117
+ * `operation` indicates which store function failed.
118
+ * `path` is the file or directory path that was targeted.
119
+ */
120
+ declare class CassetteStoreError extends CassetteStoreError_base {}
121
+ declare const toStoreError: (operation: "exists" | "load" | "save" | "loadOrInit", path: string, cause?: unknown, message?: string) => CassetteStoreError;
122
+ /**
123
+ * Parses cassette JSON and maps invalid JSON to a store error.
124
+ */
125
+ declare const parseCassette: (content: string, path: string) => Effect.Effect<CassetteFile, CassetteStoreError, never>;
126
+ /** Creates a `CassetteStore` implementation that uses the given `impl` object. */
127
+ declare const make: (impl: CassetteStore) => CassetteStore;
128
+ declare namespace file_system_cassette_store_d_exports {
129
+ export { layer$1 as layer };
130
+ }
131
+ /**
132
+ * FileSystem-backed cassette store.
133
+ *
134
+ * Requires a platform FileSystem layer (Node/Bun) to be provided by the user.
135
+ */
136
+ declare const layer$1: ({
137
+ cassetteDir
138
+ }?: {
139
+ cassetteDir?: string;
140
+ }) => Layer.Layer<CassetteStore, Error, Path.Path | FileSystem.FileSystem>;
141
+ declare namespace vcr_http_client_d_exports {
142
+ export { layer };
143
+ }
144
+ /**
145
+ * Layer that provides a VCR-wrapped HttpClient.
146
+ */
147
+ declare const layer: (config?: Configuration) => effect_Layer0.Layer<HttpClient.HttpClient, Config.ConfigError, HttpClient.HttpClient | CassetteStore | Path.Path>;
148
+ //#endregion
149
+ export { cassette_store_d_exports as CassetteStore, file_system_cassette_store_d_exports as FileSystemCassetteStore, vcr_http_client_d_exports as VcrHttpClient };
150
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,451 @@
1
+ import { t as __export } from "./chunk-Bp6m_JJh.js";
2
+ import { Config, Context, DateTime, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
3
+ import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
4
+ import stableStringify from "json-stable-stringify";
5
+
6
+ //#region src/cassette-store.ts
7
+ var cassette_store_exports = /* @__PURE__ */ __export({
8
+ CassetteStore: () => CassetteStore,
9
+ CassetteStoreError: () => CassetteStoreError,
10
+ createEmptyCassette: () => createEmptyCassette,
11
+ createEmptyCassetteFile: () => createEmptyCassetteFile,
12
+ make: () => make,
13
+ parseCassette: () => parseCassette,
14
+ toStoreError: () => toStoreError
15
+ });
16
+ /**
17
+ * Effect service tag for the cassette store.
18
+ */
19
+ const CassetteStore = Context.Service("@useairfoil/effect-vcr/CassetteStore");
20
+ /**
21
+ * Creates a new empty cassette with a timestamp and format version.
22
+ */
23
+ const createEmptyCassette = () => Effect.gen(function* () {
24
+ const now = yield* DateTime.now;
25
+ return {
26
+ meta: {
27
+ createdAt: DateTime.formatIso(now),
28
+ version: "1"
29
+ },
30
+ entries: {}
31
+ };
32
+ });
33
+ const createEmptyCassetteFile = () => Effect.map(createEmptyCassette(), (cassette) => ({ exports: { default: cassette } }));
34
+ /**
35
+ * Structured error for cassette persistence operations.
36
+ *
37
+ * `operation` indicates which store function failed.
38
+ * `path` is the file or directory path that was targeted.
39
+ */
40
+ var CassetteStoreError = class extends Schema.TaggedErrorClass()("CassetteStoreError", {
41
+ operation: Schema.String,
42
+ path: Schema.String,
43
+ message: Schema.optional(Schema.String),
44
+ cause: Schema.optional(Schema.Unknown)
45
+ }) {};
46
+ const toStoreError = (operation, path, cause, message) => new CassetteStoreError({
47
+ operation,
48
+ path,
49
+ cause,
50
+ message
51
+ });
52
+ /**
53
+ * Parses cassette JSON and maps invalid JSON to a store error.
54
+ */
55
+ const parseCassette = (content, path) => Effect.try({
56
+ try: () => JSON.parse(content),
57
+ catch: (error) => toStoreError("load", path, error, "Invalid JSON")
58
+ }).pipe(Effect.flatMap((parsed) => {
59
+ if (!parsed || typeof parsed !== "object" || !("exports" in parsed)) return Effect.fail(toStoreError("load", path, void 0, "Invalid cassette file format"));
60
+ return Effect.succeed(parsed);
61
+ }));
62
+ /** Creates a `CassetteStore` implementation that uses the given `impl` object. */
63
+ const make = (impl) => CassetteStore.of({ ...impl });
64
+
65
+ //#endregion
66
+ //#region src/vitest-state.ts
67
+ const getVitestState = () => {
68
+ const expectGlobal = globalThis.expect;
69
+ const symbolExpect = globalThis[Symbol.for("expect-global")];
70
+ const state = expectGlobal?.getState?.() ?? symbolExpect?.getState?.();
71
+ return {
72
+ testPath: state?.testPath,
73
+ currentTestName: state?.currentTestName
74
+ };
75
+ };
76
+
77
+ //#endregion
78
+ //#region src/file-system-cassette-store.ts
79
+ var file_system_cassette_store_exports = /* @__PURE__ */ __export({ layer: () => layer$1 });
80
+ /**
81
+ * FileSystem-backed cassette store.
82
+ *
83
+ * Requires a platform FileSystem layer (Node/Bun) to be provided by the user.
84
+ */
85
+ const layer$1 = ({ cassetteDir } = {}) => Layer.effect(CassetteStore)(Effect.gen(function* () {
86
+ const fs = yield* FileSystem.FileSystem;
87
+ const path = yield* Path.Path;
88
+ let rootDir;
89
+ if (cassetteDir) rootDir = cassetteDir;
90
+ else {
91
+ const { testPath } = getVitestState();
92
+ if (!testPath) return yield* Effect.fail(/* @__PURE__ */ new Error("vcr must be used within a vitest test"));
93
+ const testFolder = path.dirname(testPath);
94
+ rootDir = path.join(testFolder, "__cassettes__");
95
+ }
96
+ const fullPath = (name) => path.join(rootDir, name);
97
+ const load = (name) => fs.readFileString(fullPath(name)).pipe(Effect.mapError((error) => toStoreError("load", name, error)), Effect.flatMap((content) => parseCassette(content, name)));
98
+ const save = (name, cassette) => {
99
+ const filePath = fullPath(name);
100
+ const dir = path.dirname(filePath);
101
+ return (dir === "" ? Effect.void : fs.makeDirectory(dir, { recursive: true }).pipe(Effect.mapError((error) => toStoreError("save", dir, error)))).pipe(Effect.andThen(fs.writeFileString(filePath, JSON.stringify(cassette, null, 2)).pipe(Effect.mapError((error) => toStoreError("save", name, error)))));
102
+ };
103
+ const loadOrInit = (name) => fs.exists(fullPath(name)).pipe(Effect.mapError((error) => toStoreError("loadOrInit", name, error)), Effect.flatMap((exists) => exists ? load(name) : createEmptyCassetteFile().pipe(Effect.tap((cassette) => save(name, cassette)))));
104
+ return make({
105
+ exists: (name) => fs.exists(fullPath(name)).pipe(Effect.mapError((error) => toStoreError("exists", name, error))),
106
+ load,
107
+ save,
108
+ loadOrInit
109
+ });
110
+ }));
111
+
112
+ //#endregion
113
+ //#region src/sanitize.ts
114
+ /**
115
+ * Normalize header names to match HttpClient's lowercase behavior.
116
+ */
117
+ const normalizeHeaderKey = (key) => key.toLowerCase();
118
+ /**
119
+ * Canonicalize headers: lowercase keys and stable ordering.
120
+ */
121
+ const toHeaderRecord = (headers) => {
122
+ if (!headers) return void 0;
123
+ const entries = Object.entries(headers).map(([key, value]) => [normalizeHeaderKey(key), value]);
124
+ entries.sort(([a], [b]) => a.localeCompare(b));
125
+ const record$1 = {};
126
+ for (const [key, value] of entries) record$1[key] = value;
127
+ return record$1;
128
+ };
129
+ /**
130
+ * Remove specified header names (case-insensitive).
131
+ */
132
+ const omitHeaderKeys = (headers, ignore) => {
133
+ if (!headers) return Option.none();
134
+ if (!ignore || ignore.length === 0) return Option.some(headers);
135
+ const ignoreSet = new Set(ignore.map(normalizeHeaderKey));
136
+ const filtered = {};
137
+ for (const [key, value] of Object.entries(headers)) if (!ignoreSet.has(normalizeHeaderKey(key))) filtered[key] = value;
138
+ return Option.some(filtered);
139
+ };
140
+ /**
141
+ * Best-effort JSON parse; returns undefined for non-JSON bodies.
142
+ */
143
+ const tryParseJson = Option.liftThrowable((input) => JSON.parse(input));
144
+ /**
145
+ * Recursively drop keys from JSON objects to build stable request keys.
146
+ */
147
+ const stripKeys = (value, ignore) => {
148
+ if (Array.isArray(value)) return value.map((item) => stripKeys(item, ignore));
149
+ if (value && typeof value === "object") {
150
+ const record$1 = value;
151
+ const next = {};
152
+ for (const [key, entry] of Object.entries(record$1)) if (!ignore.has(key)) next[key] = stripKeys(entry, ignore);
153
+ return next;
154
+ }
155
+ return value;
156
+ };
157
+ /**
158
+ * Remove JSON keys from a string body when possible.
159
+ */
160
+ const omitBodyKeys = (body, ignore) => {
161
+ if (!body) return Option.none();
162
+ return Option.some(tryParseJson(body).pipe(Option.match({
163
+ onNone: () => body,
164
+ onSome: (parsed) => {
165
+ if (!ignore || ignore.length === 0) return stableStringify(parsed) ?? body;
166
+ return stableStringify(stripKeys(parsed, new Set(ignore))) ?? body;
167
+ }
168
+ })));
169
+ };
170
+ /**
171
+ * Normalize request for matching: header canonicalization + JSON body filtering.
172
+ */
173
+ const sanitizeRequest = (request, options) => {
174
+ const filteredHeaders = omitHeaderKeys(request.headers, options.ignoreHeaders);
175
+ return {
176
+ ...request,
177
+ headers: toHeaderRecord(Option.getOrUndefined(filteredHeaders)),
178
+ body: Option.getOrUndefined(omitBodyKeys(request.body, options.ignoreBodyKeys))
179
+ };
180
+ };
181
+ /**
182
+ * Compute the stable request key used for cassette lookups.
183
+ */
184
+ const buildRequestKey = (request, options) => Effect.sync(() => {
185
+ const sanitized = sanitizeRequest(request, {
186
+ ignoreHeaders: options.ignoreHeaders,
187
+ ignoreBodyKeys: options.ignoreBodyKeys
188
+ });
189
+ const key = stableStringify({
190
+ method: sanitized.method.toUpperCase(),
191
+ url: sanitized.url,
192
+ headers: sanitized.headers ?? {},
193
+ body: sanitized.body ?? ""
194
+ });
195
+ if (!key) throw new Error("Failed to build VCR request key");
196
+ return key;
197
+ });
198
+ /**
199
+ * Remove sensitive request data before persisting to a cassette.
200
+ */
201
+ const redactRequest = (request, options) => ({
202
+ ...request,
203
+ headers: Option.getOrUndefined(omitHeaderKeys(request.headers, options.redactHeaders)),
204
+ body: Option.getOrUndefined(omitBodyKeys(request.body, options.redactBodyKeys))
205
+ });
206
+ /**
207
+ * Remove sensitive response data before persisting to a cassette.
208
+ */
209
+ const redactResponse = (response, options) => ({
210
+ ...response,
211
+ headers: Option.getOrUndefined(omitHeaderKeys(response.headers, options.redactHeaders)),
212
+ body: Option.getOrElse(omitBodyKeys(response.body, options.redactBodyKeys), () => response.body)
213
+ });
214
+
215
+ //#endregion
216
+ //#region src/vcr-http-client.ts
217
+ var vcr_http_client_exports = /* @__PURE__ */ __export({ layer: () => layer });
218
+ /**
219
+ * Decoder for Uint8Array request bodies when building cassette keys.
220
+ */
221
+ const decoder = new TextDecoder();
222
+ /**
223
+ * Resolve the cassette file name and export key.
224
+ *
225
+ * If explicit cassetteName are provided, use them and default to
226
+ * the "default" export. Otherwise infer from Vitest state:
227
+ * - <file-name>.cassette file name
228
+ * - export key = current test name (describe > test)
229
+ */
230
+ const resolveCassetteLocation = (config) => Effect.gen(function* () {
231
+ const path = yield* Path.Path;
232
+ if (config.cassetteName) return {
233
+ name: `${config.cassetteName}.cassette`,
234
+ exportKey: "default"
235
+ };
236
+ const { testPath, currentTestName } = getVitestState();
237
+ if (!testPath || !currentTestName) throw new Error("VCR cassette path could not be inferred. Provide cassetteName.");
238
+ return {
239
+ name: `${path.basename(testPath, ".ts")}.cassette`,
240
+ exportKey: currentTestName
241
+ };
242
+ });
243
+ /**
244
+ * Apply defaults for common VCR behavior while preserving explicit overrides.
245
+ */
246
+ const normalizeConfig = (config) => ({
247
+ vcrName: config.vcrName,
248
+ cassetteName: config.cassetteName,
249
+ mode: config.mode ?? "auto",
250
+ matchIgnore: {
251
+ requestHeaders: ["authorization"],
252
+ ...config.matchIgnore
253
+ },
254
+ redact: {
255
+ requestHeaders: ["authorization"],
256
+ ...config.redact
257
+ },
258
+ match: config.match
259
+ });
260
+ const AckDisableVcrConfig = Config.option(Config.string("ACK_DISABLE_VCR")).pipe(Config.map((value) => Option.match(value, {
261
+ onNone: () => /* @__PURE__ */ new Set(),
262
+ onSome: (raw) => raw === "*" ? "*" : new Set(raw.split(",").map((segment) => segment.trim().toLowerCase()).filter((segment) => segment.length > 0))
263
+ })));
264
+ const shouldDisableVcr = (name, disabled) => {
265
+ if (disabled === "*") return true;
266
+ if (!name) return false;
267
+ const normalized = name.trim().toLowerCase();
268
+ return disabled.has(normalized);
269
+ };
270
+ /**
271
+ * Convert store or replay failures into HttpClient transport errors.
272
+ */
273
+ const toRequestError = (request, cause, description) => new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({
274
+ request,
275
+ cause,
276
+ description
277
+ }) });
278
+ /**
279
+ * Convert header map to a plain record for JSON persistence.
280
+ */
281
+ const recordFromHeaders = (headers) => {
282
+ const record$1 = {};
283
+ for (const [key, value] of Object.entries(headers)) record$1[key] = value;
284
+ return record$1;
285
+ };
286
+ /**
287
+ * Serialize an Effect HttpClientRequest into a cassette request shape.
288
+ *
289
+ * Note: some body variants (Stream, FormData) are not safely serializable
290
+ * without consuming the body, so we store placeholders to avoid breaking
291
+ * live requests.
292
+ */
293
+ const toVcrRequest = (request) => {
294
+ const headers = recordFromHeaders(request.headers);
295
+ const body = (() => {
296
+ switch (request.body._tag) {
297
+ case "Empty": return "";
298
+ case "Uint8Array": return decoder.decode(request.body.body);
299
+ case "Raw": {
300
+ const raw = request.body.body;
301
+ if (typeof raw === "string") return raw;
302
+ if (raw instanceof Uint8Array) return decoder.decode(raw);
303
+ try {
304
+ return JSON.stringify(raw);
305
+ } catch {
306
+ return "[raw]";
307
+ }
308
+ }
309
+ case "FormData": return "[form-data]";
310
+ case "Stream": return "[stream]";
311
+ }
312
+ })();
313
+ return {
314
+ method: request.method,
315
+ url: request.url,
316
+ headers,
317
+ body
318
+ };
319
+ };
320
+ /**
321
+ * Serialize an Effect HttpClientResponse into a cassette response shape.
322
+ */
323
+ const toVcrResponse = (response, body) => ({
324
+ status: response.status,
325
+ body,
326
+ headers: recordFromHeaders(response.headers)
327
+ });
328
+ /**
329
+ * Load a cassette and map store errors into HttpClient errors.
330
+ */
331
+ const readCassetteFile = (store, name, request) => store.load(name).pipe(Effect.mapError((error) => toRequestError(request, error)));
332
+ /**
333
+ * Load a cassette or initialize a new one if missing.
334
+ */
335
+ const loadOrInitCassetteFile = (store, name, request) => store.loadOrInit(name).pipe(Effect.mapError((error) => toRequestError(request, error)));
336
+ /**
337
+ * Persist the updated cassette.
338
+ */
339
+ const saveCassetteFile = (store, name, cassette, request) => store.save(name, cassette).pipe(Effect.mapError((error) => toRequestError(request, error)));
340
+ /**
341
+ * Load a named cassette export from the file, or return a fresh empty cassette.
342
+ */
343
+ const readCassetteExport = (store, name, exportKey, request) => readCassetteFile(store, name, request).pipe(Effect.flatMap((file) => {
344
+ const found = file.exports[exportKey];
345
+ if (found) return Effect.succeed(found);
346
+ return createEmptyCassette();
347
+ }));
348
+ /**
349
+ * Locate a cassette entry by custom matcher or stable request key.
350
+ */
351
+ const findEntry = (request, cassette, config) => {
352
+ if (config.match) return Effect.succeed(Object.values(cassette.entries).find((entry) => config.match?.(request, entry)));
353
+ return buildRequestKey(request, {
354
+ ignoreHeaders: config.matchIgnore?.requestHeaders,
355
+ ignoreBodyKeys: config.matchIgnore?.requestBodyKeys
356
+ }).pipe(Effect.map((key) => cassette.entries[key]));
357
+ };
358
+ /**
359
+ * Replay a response from cassette data using HttpClientResponse.fromWeb.
360
+ */
361
+ const replay = (store, request, vcrRequest, config, name, exportKey) => readCassetteExport(store, name, exportKey, request).pipe(Effect.flatMap((cassette) => findEntry(vcrRequest, cassette, config).pipe(Effect.flatMap((entry) => {
362
+ if (!entry) return Effect.fail(new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({
363
+ request,
364
+ description: `VCR replay missing entry for ${request.method} ${request.url}`
365
+ }) }));
366
+ const web = new Response(entry.response.body, {
367
+ status: entry.response.status,
368
+ headers: entry.response.headers
369
+ });
370
+ return Effect.succeed(HttpClientResponse.fromWeb(request, web));
371
+ }))));
372
+ /**
373
+ * Record a live response into the cassette and return the original response.
374
+ */
375
+ const record = (store, request, vcrRequest, effect, config, name, exportKey) => Effect.gen(function* () {
376
+ const response = yield* effect;
377
+ const vcrResponse = toVcrResponse(response, yield* response.text);
378
+ const sanitizedRequest = config.redact ? redactRequest(vcrRequest, {
379
+ redactHeaders: config.redact.requestHeaders,
380
+ redactBodyKeys: config.redact.requestBodyKeys
381
+ }) : vcrRequest;
382
+ const sanitizedResponse = config.redact ? redactResponse(vcrResponse, {
383
+ redactHeaders: config.redact.responseHeaders,
384
+ redactBodyKeys: config.redact.responseBodyKeys
385
+ }) : vcrResponse;
386
+ const file = yield* loadOrInitCassetteFile(store, name, request);
387
+ const cassette = file.exports[exportKey] ?? (yield* createEmptyCassette());
388
+ const key = yield* buildRequestKey(vcrRequest, {
389
+ ignoreHeaders: config.matchIgnore?.requestHeaders,
390
+ ignoreBodyKeys: config.matchIgnore?.requestBodyKeys
391
+ });
392
+ const next = {
393
+ ...cassette,
394
+ entries: {
395
+ ...cassette.entries,
396
+ [key]: {
397
+ request: sanitizedRequest,
398
+ response: sanitizedResponse
399
+ }
400
+ }
401
+ };
402
+ yield* saveCassetteFile(store, name, {
403
+ ...file,
404
+ exports: {
405
+ ...file.exports,
406
+ [exportKey]: next
407
+ }
408
+ }, request);
409
+ return response;
410
+ });
411
+ /**
412
+ * Build a VCR-aware HttpClient that replays or records per config.
413
+ */
414
+ const makeVcrHttpClient = (config) => Effect.gen(function* () {
415
+ const live = yield* HttpClient.HttpClient;
416
+ const normalized = normalizeConfig(config);
417
+ const isCi = yield* Config.boolean("CI").pipe(Config.withDefault(false));
418
+ const disabledVcrs = yield* AckDisableVcrConfig;
419
+ if (shouldDisableVcr(normalized.vcrName, disabledVcrs)) return live;
420
+ const store = yield* CassetteStore;
421
+ const { name, exportKey } = yield* resolveCassetteLocation(normalized);
422
+ return live.pipe(HttpClient.transform((effect, request) => Effect.gen(function* () {
423
+ const vcrRequest = toVcrRequest(request);
424
+ if (normalized.mode === "replay") return yield* replay(store, request, vcrRequest, normalized, name, exportKey);
425
+ if (normalized.mode === "record") return yield* record(store, request, vcrRequest, effect, normalized, name, exportKey);
426
+ if (!(yield* store.exists(name).pipe(Effect.mapError((error) => toRequestError(request, error))))) {
427
+ if (isCi) return yield* Effect.fail(new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({
428
+ request,
429
+ description: "VCR cassette missing in CI for auto mode"
430
+ }) }));
431
+ return yield* record(store, request, vcrRequest, effect, normalized, name, exportKey);
432
+ }
433
+ const entry = yield* findEntry(vcrRequest, yield* readCassetteExport(store, name, exportKey, request), normalized);
434
+ if (entry) {
435
+ const web = new Response(entry.response.body, {
436
+ status: entry.response.status,
437
+ headers: entry.response.headers
438
+ });
439
+ return HttpClientResponse.fromWeb(request, web);
440
+ }
441
+ return yield* record(store, request, vcrRequest, effect, normalized, name, exportKey);
442
+ })));
443
+ });
444
+ /**
445
+ * Layer that provides a VCR-wrapped HttpClient.
446
+ */
447
+ const layer = (config = {}) => HttpClient.layerMergedContext(makeVcrHttpClient(config));
448
+
449
+ //#endregion
450
+ export { cassette_store_exports as CassetteStore, file_system_cassette_store_exports as FileSystemCassetteStore, vcr_http_client_exports as VcrHttpClient };
451
+ //# sourceMappingURL=index.js.map