@useairfoil/effect-vcr 0.4.0 → 0.4.1

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.
@@ -0,0 +1,2 @@
1
+ import { a as VcrMode, i as VcrEntry, n as CassetteFile, o as VcrRequest, r as VcrConfig, s as VcrResponse, t as Cassette } from "./types-DWwkMzxG.js";
2
+ export { Cassette, CassetteFile, VcrConfig, VcrEntry, VcrMode, VcrRequest, VcrResponse };
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ import "./types-BsuPrIpu.js";
2
+
3
+ export { };
@@ -0,0 +1,351 @@
1
+ import { t as __export } from "./chunk-Bp6m_JJh.js";
2
+ import { i as createEmptyCassette, t as CassetteStore } from "./cassette-store-CnXX5OLB.js";
3
+ import { t as getVitestState } from "./vitest-state-BOynp4Va.js";
4
+ import { Config, Data, Effect, Option, Path } from "effect";
5
+ import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
6
+ import stableStringify from "json-stable-stringify";
7
+
8
+ //#region src/sanitize.ts
9
+ /**
10
+ * Normalize header names to match HttpClient's lowercase behavior.
11
+ */
12
+ const normalizeHeaderKey = (key) => key.toLowerCase();
13
+ /**
14
+ * Canonicalize headers: lowercase keys and stable ordering.
15
+ */
16
+ const toHeaderRecord = (headers) => {
17
+ if (!headers) return void 0;
18
+ const entries = Object.entries(headers).map(([key, value]) => [normalizeHeaderKey(key), value]);
19
+ entries.sort(([a], [b]) => a.localeCompare(b));
20
+ const record$1 = {};
21
+ for (const [key, value] of entries) record$1[key] = value;
22
+ return record$1;
23
+ };
24
+ /**
25
+ * Remove specified header names (case-insensitive).
26
+ */
27
+ const omitHeaderKeys = (headers, ignore) => {
28
+ if (!headers) return Option.none();
29
+ if (!ignore || ignore.length === 0) return Option.some(headers);
30
+ const ignoreSet = new Set(ignore.map(normalizeHeaderKey));
31
+ const filtered = {};
32
+ for (const [key, value] of Object.entries(headers)) if (!ignoreSet.has(normalizeHeaderKey(key))) filtered[key] = value;
33
+ return Option.some(filtered);
34
+ };
35
+ /**
36
+ * Best-effort JSON parse; returns undefined for non-JSON bodies.
37
+ */
38
+ const tryParseJson = Option.liftThrowable((input) => JSON.parse(input));
39
+ /**
40
+ * Recursively drop keys from JSON objects to build stable request keys.
41
+ */
42
+ const stripKeys = (value, ignore) => {
43
+ if (Array.isArray(value)) return value.map((item) => stripKeys(item, ignore));
44
+ if (value && typeof value === "object") {
45
+ const record$1 = value;
46
+ const next = {};
47
+ for (const [key, entry] of Object.entries(record$1)) if (!ignore.has(key)) next[key] = stripKeys(entry, ignore);
48
+ return next;
49
+ }
50
+ return value;
51
+ };
52
+ /**
53
+ * Remove JSON keys from a string body when possible.
54
+ */
55
+ const omitBodyKeys = (body, ignore) => {
56
+ if (!body) return Option.none();
57
+ return Option.some(tryParseJson(body).pipe(Option.match({
58
+ onNone: () => body,
59
+ onSome: (parsed) => {
60
+ if (!ignore || ignore.length === 0) return stableStringify(parsed) ?? body;
61
+ return stableStringify(stripKeys(parsed, new Set(ignore))) ?? body;
62
+ }
63
+ })));
64
+ };
65
+ /**
66
+ * Normalize request for matching: header canonicalization + JSON body filtering.
67
+ */
68
+ const sanitizeRequest = (request, options) => {
69
+ const filteredHeaders = omitHeaderKeys(request.headers, options.ignoreHeaders);
70
+ return {
71
+ ...request,
72
+ headers: toHeaderRecord(Option.getOrUndefined(filteredHeaders)),
73
+ body: Option.getOrUndefined(omitBodyKeys(request.body, options.ignoreBodyKeys))
74
+ };
75
+ };
76
+ /**
77
+ * Compute the stable request key used for cassette lookups.
78
+ */
79
+ const buildRequestKey = (request, options) => Effect.sync(() => {
80
+ const sanitized = sanitizeRequest(request, {
81
+ ignoreHeaders: options.ignoreHeaders,
82
+ ignoreBodyKeys: options.ignoreBodyKeys
83
+ });
84
+ return stableStringify({
85
+ method: sanitized.method.toUpperCase(),
86
+ url: sanitized.url,
87
+ headers: sanitized.headers ?? {},
88
+ body: sanitized.body ?? ""
89
+ }) ?? JSON.stringify({
90
+ method: sanitized.method.toUpperCase(),
91
+ url: sanitized.url,
92
+ headers: sanitized.headers ?? {},
93
+ body: sanitized.body ?? ""
94
+ });
95
+ });
96
+ /**
97
+ * Remove sensitive request data before persisting to a cassette.
98
+ */
99
+ const redactRequest = (request, options) => ({
100
+ ...request,
101
+ headers: Option.getOrUndefined(omitHeaderKeys(request.headers, options.redactHeaders)),
102
+ body: Option.getOrUndefined(omitBodyKeys(request.body, options.redactBodyKeys))
103
+ });
104
+ /**
105
+ * Remove sensitive response data before persisting to a cassette.
106
+ */
107
+ const redactResponse = (response, options) => ({
108
+ ...response,
109
+ headers: Option.getOrUndefined(omitHeaderKeys(response.headers, options.redactHeaders)),
110
+ body: Option.getOrElse(omitBodyKeys(response.body, options.redactBodyKeys), () => response.body)
111
+ });
112
+
113
+ //#endregion
114
+ //#region src/vcr-http-client.ts
115
+ var vcr_http_client_exports = /* @__PURE__ */ __export({
116
+ VcrHttpClientError: () => VcrHttpClientError,
117
+ layer: () => layer
118
+ });
119
+ /**
120
+ * Decoder for Uint8Array request bodies when building cassette keys.
121
+ */
122
+ const decoder = new TextDecoder();
123
+ var VcrHttpClientError = class extends Data.TaggedError("VcrHttpClientError") {};
124
+ const toCassetteFileName = (name) => name.endsWith(".cassette") ? name : `${name}.cassette`;
125
+ /**
126
+ * Resolve the cassette file name and export key.
127
+ *
128
+ * If explicit cassetteName are provided, use them and default to
129
+ * the "default" export. Otherwise infer from Vitest state:
130
+ * - <file-name>.cassette file name
131
+ * - export key = current test name (describe > test)
132
+ */
133
+ const resolveCassetteLocation = Effect.fnUntraced(function* (config) {
134
+ const path = yield* Path.Path;
135
+ if (config.cassetteName) return {
136
+ name: toCassetteFileName(config.cassetteName),
137
+ exportKey: "default"
138
+ };
139
+ const { testPath, currentTestName } = getVitestState();
140
+ if (!testPath || !currentTestName) return yield* Effect.fail(new VcrHttpClientError({ message: "VCR cassette path could not be inferred. Provide cassetteName when not running in Vitest." }));
141
+ return {
142
+ name: `${path.basename(testPath, ".ts")}.cassette`,
143
+ exportKey: currentTestName
144
+ };
145
+ });
146
+ /**
147
+ * Apply defaults for common VCR behavior while preserving explicit overrides.
148
+ */
149
+ const normalizeConfig = (config) => ({
150
+ vcrName: config.vcrName,
151
+ cassetteName: config.cassetteName,
152
+ mode: config.mode ?? "auto",
153
+ matchIgnore: {
154
+ requestHeaders: ["authorization"],
155
+ ...config.matchIgnore
156
+ },
157
+ redact: {
158
+ requestHeaders: ["authorization"],
159
+ ...config.redact
160
+ },
161
+ match: config.match
162
+ });
163
+ const AckDisableVcrConfig = Config.option(Config.string("ACK_DISABLE_VCR")).pipe(Config.map((value) => Option.match(value, {
164
+ onNone: () => /* @__PURE__ */ new Set(),
165
+ onSome: (raw) => raw === "*" ? "*" : new Set(raw.split(",").map((segment) => segment.trim().toLowerCase()).filter((segment) => segment.length > 0))
166
+ })));
167
+ const shouldDisableVcr = (name, disabled) => {
168
+ if (disabled === "*") return true;
169
+ if (!name) return false;
170
+ const normalized = name.trim().toLowerCase();
171
+ return disabled.has(normalized);
172
+ };
173
+ /**
174
+ * Convert store or replay failures into HttpClient transport errors.
175
+ */
176
+ const toRequestError = (request, cause, description) => new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({
177
+ request,
178
+ cause,
179
+ description
180
+ }) });
181
+ /**
182
+ * Convert header map to a plain record for JSON persistence.
183
+ */
184
+ const recordFromHeaders = (headers) => {
185
+ const record$1 = {};
186
+ for (const [key, value] of Object.entries(headers)) record$1[key] = value;
187
+ return record$1;
188
+ };
189
+ /**
190
+ * Serialize an Effect HttpClientRequest into a cassette request shape.
191
+ *
192
+ * Note: some body variants (Stream, FormData) are not safely serializable
193
+ * without consuming the body, so we store placeholders to avoid breaking
194
+ * live requests.
195
+ */
196
+ const toVcrRequest = (request) => {
197
+ const headers = recordFromHeaders(request.headers);
198
+ const body = (() => {
199
+ switch (request.body._tag) {
200
+ case "Empty": return "";
201
+ case "Uint8Array": return decoder.decode(request.body.body);
202
+ case "Raw": {
203
+ const raw = request.body.body;
204
+ if (typeof raw === "string") return raw;
205
+ if (raw instanceof Uint8Array) return decoder.decode(raw);
206
+ try {
207
+ return JSON.stringify(raw);
208
+ } catch {
209
+ return "[raw]";
210
+ }
211
+ }
212
+ case "FormData": return "[form-data]";
213
+ case "Stream": return "[stream]";
214
+ }
215
+ })();
216
+ return {
217
+ method: request.method,
218
+ url: request.url,
219
+ headers,
220
+ body
221
+ };
222
+ };
223
+ /**
224
+ * Serialize an Effect HttpClientResponse into a cassette response shape.
225
+ */
226
+ const toVcrResponse = (response, body) => ({
227
+ status: response.status,
228
+ body,
229
+ headers: recordFromHeaders(response.headers)
230
+ });
231
+ /**
232
+ * Load a cassette and map store errors into HttpClient errors.
233
+ */
234
+ const readCassetteFile = (store, name, request) => store.load(name).pipe(Effect.mapError((error) => toRequestError(request, error)));
235
+ /**
236
+ * Load a cassette or initialize a new one if missing.
237
+ */
238
+ const loadOrInitCassetteFile = (store, name, request) => store.loadOrInit(name).pipe(Effect.mapError((error) => toRequestError(request, error)));
239
+ /**
240
+ * Persist the updated cassette.
241
+ */
242
+ const saveCassetteFile = (store, name, cassette, request) => store.save(name, cassette).pipe(Effect.mapError((error) => toRequestError(request, error)));
243
+ /**
244
+ * Load a named cassette export from the file, or return a fresh empty cassette.
245
+ */
246
+ const readCassetteExport = (store, name, exportKey, request) => readCassetteFile(store, name, request).pipe(Effect.flatMap((file) => {
247
+ const found = file.exports[exportKey];
248
+ if (found) return Effect.succeed(found);
249
+ return createEmptyCassette();
250
+ }));
251
+ /**
252
+ * Locate a cassette entry by custom matcher or stable request key.
253
+ */
254
+ const findEntry = (request, cassette, config) => {
255
+ if (config.match) return Effect.succeed(Object.values(cassette.entries).find((entry) => config.match?.(request, entry)));
256
+ return buildRequestKey(request, {
257
+ ignoreHeaders: config.matchIgnore?.requestHeaders,
258
+ ignoreBodyKeys: config.matchIgnore?.requestBodyKeys
259
+ }).pipe(Effect.map((key) => cassette.entries[key]));
260
+ };
261
+ const replayResponse = (request, entry) => {
262
+ const web = new Response(entry.response.body, {
263
+ status: entry.response.status,
264
+ headers: entry.response.headers
265
+ });
266
+ return HttpClientResponse.fromWeb(request, web);
267
+ };
268
+ /**
269
+ * Replay a response from cassette data using HttpClientResponse.fromWeb.
270
+ */
271
+ 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) => {
272
+ if (!entry) return Effect.fail(new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({
273
+ request,
274
+ description: `VCR replay missing entry for ${request.method} ${request.url}`
275
+ }) }));
276
+ return Effect.succeed(replayResponse(request, entry));
277
+ }))));
278
+ /**
279
+ * Record a live response into the cassette and return the original response.
280
+ */
281
+ const record = Effect.fnUntraced(function* (store, request, vcrRequest, effect, config, name, exportKey) {
282
+ const response = yield* effect;
283
+ const vcrResponse = toVcrResponse(response, yield* response.text);
284
+ const sanitizedRequest = config.redact ? redactRequest(vcrRequest, {
285
+ redactHeaders: config.redact.requestHeaders,
286
+ redactBodyKeys: config.redact.requestBodyKeys
287
+ }) : vcrRequest;
288
+ const sanitizedResponse = config.redact ? redactResponse(vcrResponse, {
289
+ redactHeaders: config.redact.responseHeaders,
290
+ redactBodyKeys: config.redact.responseBodyKeys
291
+ }) : vcrResponse;
292
+ const file = yield* loadOrInitCassetteFile(store, name, request);
293
+ const cassette = file.exports[exportKey] ?? (yield* createEmptyCassette());
294
+ const key = yield* buildRequestKey(vcrRequest, {
295
+ ignoreHeaders: config.matchIgnore?.requestHeaders,
296
+ ignoreBodyKeys: config.matchIgnore?.requestBodyKeys
297
+ });
298
+ const next = {
299
+ ...cassette,
300
+ entries: {
301
+ ...cassette.entries,
302
+ [key]: {
303
+ request: sanitizedRequest,
304
+ response: sanitizedResponse
305
+ }
306
+ }
307
+ };
308
+ yield* saveCassetteFile(store, name, {
309
+ ...file,
310
+ exports: {
311
+ ...file.exports,
312
+ [exportKey]: next
313
+ }
314
+ }, request);
315
+ return response;
316
+ });
317
+ /**
318
+ * Build a VCR-aware HttpClient that replays or records per config.
319
+ */
320
+ const makeVcrHttpClient = Effect.fnUntraced(function* (config = {}) {
321
+ const live = yield* HttpClient.HttpClient;
322
+ const normalized = normalizeConfig(config);
323
+ const isCi = yield* Config.boolean("CI").pipe(Config.withDefault(false));
324
+ const disabledVcrs = yield* AckDisableVcrConfig;
325
+ if (shouldDisableVcr(normalized.vcrName, disabledVcrs)) return live;
326
+ const store = yield* CassetteStore;
327
+ const { name, exportKey } = yield* resolveCassetteLocation(normalized);
328
+ return live.pipe(HttpClient.transform(Effect.fnUntraced(function* (effect, request) {
329
+ const vcrRequest = toVcrRequest(request);
330
+ if (normalized.mode === "replay") return yield* replay(store, request, vcrRequest, normalized, name, exportKey);
331
+ if (normalized.mode === "record") return yield* record(store, request, vcrRequest, effect, normalized, name, exportKey);
332
+ if (!(yield* store.exists(name).pipe(Effect.mapError((error) => toRequestError(request, error))))) {
333
+ if (isCi) return yield* Effect.fail(new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({
334
+ request,
335
+ description: "VCR cassette missing in CI for auto mode"
336
+ }) }));
337
+ return yield* record(store, request, vcrRequest, effect, normalized, name, exportKey);
338
+ }
339
+ const entry = yield* findEntry(vcrRequest, yield* readCassetteExport(store, name, exportKey, request), normalized);
340
+ if (entry) return replayResponse(request, entry);
341
+ return yield* record(store, request, vcrRequest, effect, normalized, name, exportKey);
342
+ })));
343
+ });
344
+ /**
345
+ * Layer that provides a VCR-wrapped HttpClient.
346
+ */
347
+ const layer = (config = {}) => HttpClient.layerMergedContext(makeVcrHttpClient(config));
348
+
349
+ //#endregion
350
+ export { layer as n, vcr_http_client_exports as r, VcrHttpClientError as t };
351
+ //# sourceMappingURL=vcr-http-client-BaMxQun5.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vcr-http-client-BaMxQun5.js","names":["record: Record<string, string>","record","filtered: Record<string, string>","next: Record<string, unknown>","record: Record<string, string>","record","next: Cassette"],"sources":["../src/sanitize.ts","../src/vcr-http-client.ts"],"sourcesContent":["import { Effect, Option } from \"effect\";\nimport stableStringify from \"json-stable-stringify\";\n\nimport type { VcrRequest, VcrResponse } from \"./types\";\n\n/**\n * Normalize header names to match HttpClient's lowercase behavior.\n */\nconst normalizeHeaderKey = (key: string) => key.toLowerCase();\n\n/**\n * Canonicalize headers: lowercase keys and stable ordering.\n */\nconst toHeaderRecord = (headers?: Record<string, string>) => {\n if (!headers) return undefined;\n const entries = Object.entries(headers).map(\n ([key, value]) => [normalizeHeaderKey(key), value] as const,\n );\n entries.sort(([a], [b]) => a.localeCompare(b));\n const record: Record<string, string> = {};\n for (const [key, value] of entries) {\n record[key] = value;\n }\n return record;\n};\n\n/**\n * Remove specified header names (case-insensitive).\n */\nconst omitHeaderKeys = (\n headers: Record<string, string> | undefined,\n ignore: ReadonlyArray<string> | undefined,\n): Option.Option<Record<string, string>> => {\n if (!headers) return Option.none();\n if (!ignore || ignore.length === 0) return Option.some(headers);\n const ignoreSet = new Set(ignore.map(normalizeHeaderKey));\n const filtered: Record<string, string> = {};\n for (const [key, value] of Object.entries(headers)) {\n if (!ignoreSet.has(normalizeHeaderKey(key))) {\n filtered[key] = value;\n }\n }\n return Option.some(filtered);\n};\n\n/**\n * Best-effort JSON parse; returns undefined for non-JSON bodies.\n */\nconst tryParseJson = Option.liftThrowable((input: string) => JSON.parse(input));\n\n/**\n * Recursively drop keys from JSON objects to build stable request keys.\n */\nconst stripKeys = (value: unknown, ignore: Set<string>): unknown => {\n if (Array.isArray(value)) {\n return value.map((item) => stripKeys(item, ignore));\n }\n if (value && typeof value === \"object\") {\n const record = value as Record<string, unknown>;\n const next: Record<string, unknown> = {};\n for (const [key, entry] of Object.entries(record)) {\n if (!ignore.has(key)) {\n next[key] = stripKeys(entry, ignore);\n }\n }\n return next;\n }\n return value;\n};\n\n/**\n * Remove JSON keys from a string body when possible.\n */\nconst omitBodyKeys = (\n body: string | undefined,\n ignore: ReadonlyArray<string> | undefined,\n): Option.Option<string> => {\n if (!body) return Option.none();\n return Option.some(\n tryParseJson(body).pipe(\n Option.match({\n onNone: () => body,\n onSome: (parsed) => {\n if (!ignore || ignore.length === 0) {\n return stableStringify(parsed) ?? body;\n }\n const ignoreSet = new Set(ignore);\n return stableStringify(stripKeys(parsed, ignoreSet)) ?? body;\n },\n }),\n ),\n );\n};\n\n/**\n * Normalize request for matching: header canonicalization + JSON body filtering.\n */\nexport const sanitizeRequest = (\n request: VcrRequest,\n options: {\n readonly ignoreHeaders?: ReadonlyArray<string>;\n readonly ignoreBodyKeys?: ReadonlyArray<string>;\n },\n): VcrRequest => {\n const filteredHeaders = omitHeaderKeys(request.headers, options.ignoreHeaders);\n return {\n ...request,\n headers: toHeaderRecord(Option.getOrUndefined(filteredHeaders)),\n body: Option.getOrUndefined(omitBodyKeys(request.body, options.ignoreBodyKeys)),\n };\n};\n\n/**\n * Compute the stable request key used for cassette lookups.\n */\nexport const buildRequestKey = (\n request: VcrRequest,\n options: {\n readonly ignoreHeaders?: ReadonlyArray<string>;\n readonly ignoreBodyKeys?: ReadonlyArray<string>;\n },\n): Effect.Effect<string> =>\n Effect.sync(() => {\n const sanitized = sanitizeRequest(request, {\n ignoreHeaders: options.ignoreHeaders,\n ignoreBodyKeys: options.ignoreBodyKeys,\n });\n return (\n stableStringify({\n method: sanitized.method.toUpperCase(),\n url: sanitized.url,\n headers: sanitized.headers ?? {},\n body: sanitized.body ?? \"\",\n }) ??\n JSON.stringify({\n method: sanitized.method.toUpperCase(),\n url: sanitized.url,\n headers: sanitized.headers ?? {},\n body: sanitized.body ?? \"\",\n })\n );\n });\n\n/**\n * Remove sensitive request data before persisting to a cassette.\n */\nexport const redactRequest = (\n request: VcrRequest,\n options: {\n readonly redactHeaders?: ReadonlyArray<string>;\n readonly redactBodyKeys?: ReadonlyArray<string>;\n },\n): VcrRequest => ({\n ...request,\n headers: Option.getOrUndefined(omitHeaderKeys(request.headers, options.redactHeaders)),\n body: Option.getOrUndefined(omitBodyKeys(request.body, options.redactBodyKeys)),\n});\n\n/**\n * Remove sensitive response data before persisting to a cassette.\n */\nexport const redactResponse = (\n response: VcrResponse,\n options: {\n readonly redactHeaders?: ReadonlyArray<string>;\n readonly redactBodyKeys?: ReadonlyArray<string>;\n },\n): VcrResponse => ({\n ...response,\n headers: Option.getOrUndefined(omitHeaderKeys(response.headers, options.redactHeaders)),\n body: Option.getOrElse(omitBodyKeys(response.body, options.redactBodyKeys), () => response.body),\n});\n","import { Config, Data, Effect, Option, Path } from \"effect\";\nimport {\n HttpClient,\n HttpClientError,\n type HttpClientRequest,\n HttpClientResponse,\n} from \"effect/unstable/http\";\n\nimport type { Cassette, CassetteFile, VcrConfig, VcrEntry, VcrRequest, VcrResponse } from \"./types\";\n\nimport { CassetteStore, type CassetteStoreService, createEmptyCassette } from \"./cassette-store\";\nimport { buildRequestKey, redactRequest, redactResponse } from \"./sanitize\";\nimport { getVitestState } from \"./vitest-state\";\n\n/**\n * Decoder for Uint8Array request bodies when building cassette keys.\n */\nconst decoder = new TextDecoder();\n\nexport class VcrHttpClientError extends Data.TaggedError(\"VcrHttpClientError\")<{\n readonly message: string;\n readonly cause?: unknown;\n}> {}\n\nconst toCassetteFileName = (name: string): string =>\n name.endsWith(\".cassette\") ? name : `${name}.cassette`;\n\n/**\n * Resolve the cassette file name and export key.\n *\n * If explicit cassetteName are provided, use them and default to\n * the \"default\" export. Otherwise infer from Vitest state:\n * - <file-name>.cassette file name\n * - export key = current test name (describe > test)\n */\nconst resolveCassetteLocation = Effect.fnUntraced(function* (config: VcrConfig) {\n const path = yield* Path.Path;\n if (config.cassetteName) {\n return {\n name: toCassetteFileName(config.cassetteName),\n exportKey: \"default\",\n };\n }\n\n const { testPath, currentTestName } = getVitestState();\n if (!testPath || !currentTestName) {\n return yield* Effect.fail(\n new VcrHttpClientError({\n message:\n \"VCR cassette path could not be inferred. Provide cassetteName when not running in Vitest.\",\n }),\n );\n }\n\n const fileName = path.basename(testPath, \".ts\");\n const cassetteName = `${fileName}.cassette`;\n\n return {\n name: cassetteName,\n exportKey: currentTestName,\n };\n});\n\n/**\n * Apply defaults for common VCR behavior while preserving explicit overrides.\n */\nconst normalizeConfig = (config: VcrConfig): VcrConfig => ({\n vcrName: config.vcrName,\n cassetteName: config.cassetteName,\n mode: config.mode ?? \"auto\",\n matchIgnore: {\n requestHeaders: [\"authorization\"],\n ...config.matchIgnore,\n },\n redact: {\n requestHeaders: [\"authorization\"],\n ...config.redact,\n },\n match: config.match,\n});\n\nconst AckDisableVcrConfig = Config.option(Config.string(\"ACK_DISABLE_VCR\")).pipe(\n Config.map((value) =>\n Option.match(value, {\n onNone: () => new Set<string>(),\n onSome: (raw) =>\n raw === \"*\"\n ? \"*\"\n : new Set(\n raw\n .split(\",\")\n .map((segment) => segment.trim().toLowerCase())\n .filter((segment) => segment.length > 0),\n ),\n }),\n ),\n);\n\nconst shouldDisableVcr = (name: string | undefined, disabled: \"*\" | Set<string>): boolean => {\n if (disabled === \"*\") return true;\n if (!name) return false;\n const normalized = name.trim().toLowerCase();\n return disabled.has(normalized);\n};\n\n/**\n * Convert store or replay failures into HttpClient transport errors.\n */\nconst toRequestError = (\n request: HttpClientRequest.HttpClientRequest,\n cause: unknown,\n description?: string,\n) =>\n new HttpClientError.HttpClientError({\n reason: new HttpClientError.TransportError({\n request,\n cause,\n description,\n }),\n });\n\n/**\n * Convert header map to a plain record for JSON persistence.\n */\nconst recordFromHeaders = (headers: Record<string, string>) => {\n const record: Record<string, string> = {};\n for (const [key, value] of Object.entries(headers)) {\n record[key] = value;\n }\n return record;\n};\n\n/**\n * Serialize an Effect HttpClientRequest into a cassette request shape.\n *\n * Note: some body variants (Stream, FormData) are not safely serializable\n * without consuming the body, so we store placeholders to avoid breaking\n * live requests.\n */\nconst toVcrRequest = (request: HttpClientRequest.HttpClientRequest): VcrRequest => {\n const headers = recordFromHeaders(request.headers as Record<string, string>);\n const body = (() => {\n switch (request.body._tag) {\n case \"Empty\":\n return \"\";\n case \"Uint8Array\":\n return decoder.decode(request.body.body);\n case \"Raw\": {\n const raw = request.body.body as unknown;\n if (typeof raw === \"string\") return raw;\n if (raw instanceof Uint8Array) return decoder.decode(raw);\n try {\n return JSON.stringify(raw);\n } catch {\n return \"[raw]\";\n }\n }\n case \"FormData\":\n return \"[form-data]\";\n case \"Stream\":\n return \"[stream]\";\n }\n })();\n\n return {\n method: request.method,\n url: request.url,\n headers,\n body,\n };\n};\n\n/**\n * Serialize an Effect HttpClientResponse into a cassette response shape.\n */\nconst toVcrResponse = (\n response: HttpClientResponse.HttpClientResponse,\n body: string,\n): VcrResponse => ({\n status: response.status,\n body,\n headers: recordFromHeaders(response.headers as Record<string, string>),\n});\n\n/**\n * Load a cassette and map store errors into HttpClient errors.\n */\nconst readCassetteFile = (\n store: CassetteStoreService,\n name: string,\n request: HttpClientRequest.HttpClientRequest,\n) => store.load(name).pipe(Effect.mapError((error) => toRequestError(request, error)));\n\n/**\n * Load a cassette or initialize a new one if missing.\n */\nconst loadOrInitCassetteFile = (\n store: CassetteStoreService,\n name: string,\n request: HttpClientRequest.HttpClientRequest,\n) => store.loadOrInit(name).pipe(Effect.mapError((error) => toRequestError(request, error)));\n\n/**\n * Persist the updated cassette.\n */\nconst saveCassetteFile = (\n store: CassetteStoreService,\n name: string,\n cassette: CassetteFile,\n request: HttpClientRequest.HttpClientRequest,\n) => store.save(name, cassette).pipe(Effect.mapError((error) => toRequestError(request, error)));\n\n/**\n * Load a named cassette export from the file, or return a fresh empty cassette.\n */\nconst readCassetteExport = (\n store: CassetteStoreService,\n name: string,\n exportKey: string,\n request: HttpClientRequest.HttpClientRequest,\n) =>\n readCassetteFile(store, name, request).pipe(\n Effect.flatMap((file) => {\n const found = file.exports[exportKey];\n if (found) return Effect.succeed(found);\n return createEmptyCassette();\n }),\n );\n\n/**\n * Locate a cassette entry by custom matcher or stable request key.\n */\nconst findEntry = (\n request: VcrRequest,\n cassette: Cassette,\n config: VcrConfig,\n): Effect.Effect<VcrEntry | undefined> => {\n if (config.match) {\n return Effect.succeed(\n Object.values(cassette.entries).find((entry) => config.match?.(request, entry)),\n );\n }\n return buildRequestKey(request, {\n ignoreHeaders: config.matchIgnore?.requestHeaders,\n ignoreBodyKeys: config.matchIgnore?.requestBodyKeys,\n }).pipe(Effect.map((key) => cassette.entries[key]));\n};\n\nconst replayResponse = (\n request: HttpClientRequest.HttpClientRequest,\n entry: VcrEntry,\n): HttpClientResponse.HttpClientResponse => {\n const web = new Response(entry.response.body, {\n status: entry.response.status,\n headers: entry.response.headers,\n });\n return HttpClientResponse.fromWeb(request, web);\n};\n\n/**\n * Replay a response from cassette data using HttpClientResponse.fromWeb.\n */\nconst replay = (\n store: CassetteStoreService,\n request: HttpClientRequest.HttpClientRequest,\n vcrRequest: VcrRequest,\n config: VcrConfig,\n name: string,\n exportKey: string,\n) =>\n readCassetteExport(store, name, exportKey, request).pipe(\n Effect.flatMap((cassette) =>\n findEntry(vcrRequest, cassette, config).pipe(\n Effect.flatMap((entry) => {\n if (!entry) {\n return Effect.fail(\n new HttpClientError.HttpClientError({\n reason: new HttpClientError.TransportError({\n request,\n description: `VCR replay missing entry for ${request.method} ${request.url}`,\n }),\n }),\n );\n }\n\n return Effect.succeed(replayResponse(request, entry));\n }),\n ),\n ),\n );\n\n/**\n * Record a live response into the cassette and return the original response.\n */\nconst record = Effect.fnUntraced(function* (\n store: CassetteStoreService,\n request: HttpClientRequest.HttpClientRequest,\n vcrRequest: VcrRequest,\n effect: Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>,\n config: VcrConfig,\n name: string,\n exportKey: string,\n): Effect.fn.Return<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError> {\n const response = yield* effect;\n const body = yield* response.text;\n const vcrResponse = toVcrResponse(response, body);\n\n const sanitizedRequest = config.redact\n ? redactRequest(vcrRequest, {\n redactHeaders: config.redact.requestHeaders,\n redactBodyKeys: config.redact.requestBodyKeys,\n })\n : vcrRequest;\n const sanitizedResponse = config.redact\n ? redactResponse(vcrResponse, {\n redactHeaders: config.redact.responseHeaders,\n redactBodyKeys: config.redact.responseBodyKeys,\n })\n : vcrResponse;\n\n const file = yield* loadOrInitCassetteFile(store, name, request);\n const cassette = file.exports[exportKey] ?? (yield* createEmptyCassette());\n const key = yield* buildRequestKey(vcrRequest, {\n ignoreHeaders: config.matchIgnore?.requestHeaders,\n ignoreBodyKeys: config.matchIgnore?.requestBodyKeys,\n });\n const next: Cassette = {\n ...cassette,\n entries: {\n ...cassette.entries,\n [key]: {\n request: sanitizedRequest,\n response: sanitizedResponse,\n },\n },\n };\n const nextFile: CassetteFile = {\n ...file,\n exports: {\n ...file.exports,\n [exportKey]: next,\n },\n };\n yield* saveCassetteFile(store, name, nextFile, request);\n return response;\n});\n\n/**\n * Build a VCR-aware HttpClient that replays or records per config.\n */\nconst makeVcrHttpClient = Effect.fnUntraced(function* (config: VcrConfig = {}) {\n const live = yield* HttpClient.HttpClient;\n const normalized = normalizeConfig(config);\n\n const isCi = yield* Config.boolean(\"CI\").pipe(Config.withDefault(false));\n\n const disabledVcrs = yield* AckDisableVcrConfig;\n if (shouldDisableVcr(normalized.vcrName, disabledVcrs)) {\n return live;\n }\n\n const store = yield* CassetteStore;\n\n const { name, exportKey } = yield* resolveCassetteLocation(normalized);\n\n return live.pipe(\n HttpClient.transform(\n Effect.fnUntraced(function* (effect, request) {\n const vcrRequest = toVcrRequest(request);\n if (normalized.mode === \"replay\") {\n return yield* replay(store, request, vcrRequest, normalized, name, exportKey);\n }\n\n if (normalized.mode === \"record\") {\n return yield* record(store, request, vcrRequest, effect, normalized, name, exportKey);\n }\n\n const available = yield* store\n .exists(name)\n .pipe(Effect.mapError((error) => toRequestError(request, error)));\n\n if (!available) {\n if (isCi) {\n return yield* Effect.fail(\n new HttpClientError.HttpClientError({\n reason: new HttpClientError.TransportError({\n request,\n description: \"VCR cassette missing in CI for auto mode\",\n }),\n }),\n );\n }\n\n return yield* record(store, request, vcrRequest, effect, normalized, name, exportKey);\n }\n\n const cassette = yield* readCassetteExport(store, name, exportKey, request);\n const entry = yield* findEntry(vcrRequest, cassette, normalized);\n\n if (entry) {\n return replayResponse(request, entry);\n }\n\n return yield* record(store, request, vcrRequest, effect, normalized, name, exportKey);\n }),\n ),\n );\n});\n\n/**\n * Layer that provides a VCR-wrapped HttpClient.\n */\nexport const layer = (config: VcrConfig = {}) =>\n HttpClient.layerMergedContext(makeVcrHttpClient(config));\n"],"mappings":"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"}
@@ -0,0 +1,26 @@
1
+ import { r as VcrConfig } from "./types-DWwkMzxG.js";
2
+ import { t as CassetteStore } from "./cassette-store-BunkvLoV.js";
3
+ import { Config, Path } from "effect";
4
+ import { HttpClient } from "effect/unstable/http";
5
+ import * as effect_Types0 from "effect/Types";
6
+ import * as effect_Cause0 from "effect/Cause";
7
+ import * as effect_Layer0 from "effect/Layer";
8
+
9
+ //#region src/vcr-http-client.d.ts
10
+ declare namespace vcr_http_client_d_exports {
11
+ export { VcrHttpClientError, layer };
12
+ }
13
+ declare const VcrHttpClientError_base: new <A extends Record<string, any> = {}>(args: effect_Types0.VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => effect_Cause0.YieldableError & {
14
+ readonly _tag: "VcrHttpClientError";
15
+ } & Readonly<A>;
16
+ declare class VcrHttpClientError extends VcrHttpClientError_base<{
17
+ readonly message: string;
18
+ readonly cause?: unknown;
19
+ }> {}
20
+ /**
21
+ * Layer that provides a VCR-wrapped HttpClient.
22
+ */
23
+ declare const layer: (config?: VcrConfig) => effect_Layer0.Layer<HttpClient.HttpClient, VcrHttpClientError | Config.ConfigError, CassetteStore | Path.Path | HttpClient.HttpClient>;
24
+ //#endregion
25
+ export { layer as n, vcr_http_client_d_exports as r, VcrHttpClientError as t };
26
+ //# sourceMappingURL=vcr-http-client-Mwb8ZMsc.d.ts.map
@@ -0,0 +1,4 @@
1
+ import "./types-DWwkMzxG.js";
2
+ import "./cassette-store-BunkvLoV.js";
3
+ import { n as layer, t as VcrHttpClientError } from "./vcr-http-client-Mwb8ZMsc.js";
4
+ export { VcrHttpClientError, layer };
@@ -0,0 +1,5 @@
1
+ import "./cassette-store-CnXX5OLB.js";
2
+ import "./vitest-state-BOynp4Va.js";
3
+ import { n as layer, t as VcrHttpClientError } from "./vcr-http-client-BaMxQun5.js";
4
+
5
+ export { VcrHttpClientError, layer };
@@ -0,0 +1,14 @@
1
+ //#region src/vitest-state.ts
2
+ const getVitestState = () => {
3
+ const expectGlobal = globalThis.expect;
4
+ const symbolExpect = globalThis[Symbol.for("expect-global")];
5
+ const state = expectGlobal?.getState?.() ?? symbolExpect?.getState?.();
6
+ return {
7
+ testPath: state?.testPath,
8
+ currentTestName: state?.currentTestName
9
+ };
10
+ };
11
+
12
+ //#endregion
13
+ export { getVitestState as t };
14
+ //# sourceMappingURL=vitest-state-BOynp4Va.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vitest-state-BOynp4Va.js","names":[],"sources":["../src/vitest-state.ts"],"sourcesContent":["export type VitestState = {\n readonly testPath?: string;\n readonly currentTestName?: string;\n};\n\nexport const getVitestState = (): VitestState => {\n const expectGlobal =\n // biome-ignore lint/suspicious/noExplicitAny: --\n (globalThis as { expect?: { getState?: () => any } }).expect;\n const symbolExpect =\n // biome-ignore lint/suspicious/noExplicitAny: --\n (globalThis as { [key: symbol]: { getState?: () => any } })[Symbol.for(\"expect-global\")];\n const state = expectGlobal?.getState?.() ?? symbolExpect?.getState?.();\n return {\n testPath: state?.testPath as string | undefined,\n currentTestName: state?.currentTestName as string | undefined,\n };\n};\n"],"mappings":";AAKA,MAAa,uBAAoC;CAC/C,MAAM,eAEH,WAAqD;CACxD,MAAM,eAEH,WAA2D,OAAO,IAAI,gBAAgB;CACzF,MAAM,QAAQ,cAAc,YAAY,IAAI,cAAc,YAAY;AACtE,QAAO;EACL,UAAU,OAAO;EACjB,iBAAiB,OAAO;EACzB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@useairfoil/effect-vcr",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "keywords": [
5
5
  "effect"
6
6
  ],
@@ -18,6 +18,26 @@
18
18
  "types": "./dist/index.d.ts",
19
19
  "import": "./dist/index.js",
20
20
  "default": "./dist/index.js"
21
+ },
22
+ "./cassette-store": {
23
+ "types": "./dist/cassette-store.d.ts",
24
+ "import": "./dist/cassette-store.js",
25
+ "default": "./dist/cassette-store.js"
26
+ },
27
+ "./file-system-cassette-store": {
28
+ "types": "./dist/file-system-cassette-store.d.ts",
29
+ "import": "./dist/file-system-cassette-store.js",
30
+ "default": "./dist/file-system-cassette-store.js"
31
+ },
32
+ "./types": {
33
+ "types": "./dist/types.d.ts",
34
+ "import": "./dist/types.js",
35
+ "default": "./dist/types.js"
36
+ },
37
+ "./vcr-http-client": {
38
+ "types": "./dist/vcr-http-client.d.ts",
39
+ "import": "./dist/vcr-http-client.js",
40
+ "default": "./dist/vcr-http-client.js"
21
41
  }
22
42
  },
23
43
  "scripts": {
@@ -30,14 +50,14 @@
30
50
  "json-stable-stringify": "^1.3.0"
31
51
  },
32
52
  "devDependencies": {
33
- "@effect/platform-node": "4.0.0-beta.54",
34
- "@effect/vitest": "4.0.0-beta.54",
53
+ "@effect/platform-node": "4.0.0-beta.83",
54
+ "@effect/vitest": "4.0.0-beta.83",
35
55
  "@types/node": "^24.2.0",
36
56
  "tsdown": "^0.15.6",
37
57
  "vitest": "^3.2.4"
38
58
  },
39
59
  "peerDependencies": {
40
- "effect": "4.0.0-beta.54"
60
+ "effect": "4.0.0-beta.83"
41
61
  },
42
62
  "peerDependenciesMeta": {
43
63
  "effect": {
@@ -1,12 +1,14 @@
1
- import { Context, DateTime, Effect, Schema } from "effect";
1
+ import { Context, Data, DateTime, Effect } from "effect";
2
2
 
3
3
  import type { Cassette, CassetteFile } from "./types";
4
4
 
5
+ export type CassetteStoreOperation = "exists" | "load" | "save" | "loadOrInit" | "resolveRoot";
6
+
5
7
  /**
6
8
  * Storage contract for VCR cassettes.
7
9
  *
8
10
  */
9
- export interface CassetteStore {
11
+ export interface CassetteStoreService {
10
12
  /** Returns true if a cassette with the given name exists. */
11
13
  readonly exists: (name: string) => Effect.Effect<boolean, CassetteStoreError>;
12
14
 
@@ -23,24 +25,23 @@ export interface CassetteStore {
23
25
  /**
24
26
  * Effect service tag for the cassette store.
25
27
  */
26
- export const CassetteStore: Context.Service<CassetteStore, CassetteStore> = Context.Service(
28
+ export class CassetteStore extends Context.Service<CassetteStore, CassetteStoreService>()(
27
29
  "@useairfoil/effect-vcr/CassetteStore",
28
- );
30
+ ) {}
29
31
 
30
32
  /**
31
33
  * Creates a new empty cassette with a timestamp and format version.
32
34
  */
33
- export const createEmptyCassette = (): Effect.Effect<Cassette> =>
34
- Effect.gen(function* () {
35
- const now = yield* DateTime.now;
36
- return {
37
- meta: {
38
- createdAt: DateTime.formatIso(now),
39
- version: "1",
40
- },
41
- entries: {},
42
- };
43
- });
35
+ export const createEmptyCassette = Effect.fnUntraced(function* (): Effect.fn.Return<Cassette> {
36
+ const now = yield* DateTime.now;
37
+ return {
38
+ meta: {
39
+ createdAt: DateTime.formatIso(now),
40
+ version: "1",
41
+ },
42
+ entries: {},
43
+ };
44
+ });
44
45
 
45
46
  export const createEmptyCassetteFile = (): Effect.Effect<CassetteFile> =>
46
47
  Effect.map(createEmptyCassette(), (cassette) => ({
@@ -53,18 +54,15 @@ export const createEmptyCassetteFile = (): Effect.Effect<CassetteFile> =>
53
54
  * `operation` indicates which store function failed.
54
55
  * `path` is the file or directory path that was targeted.
55
56
  */
56
- export class CassetteStoreError extends Schema.TaggedErrorClass<CassetteStoreError>()(
57
- "CassetteStoreError",
58
- {
59
- operation: Schema.String,
60
- path: Schema.String,
61
- message: Schema.optional(Schema.String),
62
- cause: Schema.optional(Schema.Unknown),
63
- },
64
- ) {}
57
+ export class CassetteStoreError extends Data.TaggedError("CassetteStoreError")<{
58
+ readonly operation: CassetteStoreOperation;
59
+ readonly path: string;
60
+ readonly message?: string;
61
+ readonly cause?: unknown;
62
+ }> {}
65
63
 
66
64
  export const toStoreError = (
67
- operation: "exists" | "load" | "save" | "loadOrInit",
65
+ operation: CassetteStoreOperation,
68
66
  path: string,
69
67
  cause?: unknown,
70
68
  message?: string,
@@ -93,7 +91,5 @@ export const parseCassette = (content: string, path: string) =>
93
91
  );
94
92
 
95
93
  /** Creates a `CassetteStore` implementation that uses the given `impl` object. */
96
- export const make = (impl: CassetteStore): CassetteStore =>
97
- CassetteStore.of({
98
- ...impl,
99
- });
94
+ export const make = (impl: CassetteStoreService): CassetteStoreService =>
95
+ CassetteStore.of({ ...impl });
@@ -5,24 +5,35 @@ import type { CassetteFile } from "./types";
5
5
  import * as CassetteStore from "./cassette-store";
6
6
  import { getVitestState } from "./vitest-state";
7
7
 
8
+ export type FileSystemCassetteStoreConfig = {
9
+ readonly cassetteDir?: string;
10
+ };
11
+
8
12
  /**
9
13
  * FileSystem-backed cassette store.
10
14
  *
11
15
  * Requires a platform FileSystem layer (Node/Bun) to be provided by the user.
12
16
  */
13
- export const layer = ({ cassetteDir }: { cassetteDir?: string } = {}) =>
17
+ export const layer = (config: FileSystemCassetteStoreConfig = {}) =>
14
18
  Layer.effect(CassetteStore.CassetteStore)(
15
19
  Effect.gen(function* () {
16
20
  const fs = yield* FileSystem.FileSystem;
17
21
  const path = yield* Path.Path;
18
22
 
19
23
  let rootDir: string;
20
- if (cassetteDir) {
21
- rootDir = cassetteDir;
24
+ if (config.cassetteDir) {
25
+ rootDir = config.cassetteDir;
22
26
  } else {
23
27
  const { testPath } = getVitestState();
24
28
  if (!testPath) {
25
- return yield* Effect.fail(new Error("vcr must be used within a vitest test"));
29
+ return yield* Effect.fail(
30
+ CassetteStore.toStoreError(
31
+ "resolveRoot",
32
+ "__cassettes__",
33
+ undefined,
34
+ "VCR cassette directory could not be inferred. Provide cassetteDir when not running in Vitest.",
35
+ ),
36
+ );
26
37
  }
27
38
  const testFolder = path.dirname(testPath);
28
39
  rootDir = path.join(testFolder, "__cassettes__");
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export * as CassetteStore from "./cassette-store";
2
2
  export * as FileSystemCassetteStore from "./file-system-cassette-store";
3
-
4
3
  export * as VcrHttpClient from "./vcr-http-client";
4
+ export * from "./types";