@useairfoil/effect-vcr 0.4.3 → 0.4.5

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.
Files changed (34) hide show
  1. package/README.md +15 -5
  2. package/dist/{cassette-store-CnXX5OLB.js → cassette-store-BF9e0rz3.js} +5 -7
  3. package/dist/cassette-store-BF9e0rz3.js.map +1 -0
  4. package/dist/{cassette-store-BunkvLoV.d.ts → cassette-store-BMtcjh5h.d.ts} +29 -5
  5. package/dist/cassette-store.d.ts +2 -2
  6. package/dist/cassette-store.js +2 -1
  7. package/dist/{file-system-cassette-store-DUEtpVCu.d.ts → file-system-cassette-store-CYmQT7qj.d.ts} +3 -3
  8. package/dist/{file-system-cassette-store-zWkrhhlO.js → file-system-cassette-store-CyOsu3z5.js} +3 -3
  9. package/dist/{file-system-cassette-store-zWkrhhlO.js.map → file-system-cassette-store-CyOsu3z5.js.map} +1 -1
  10. package/dist/file-system-cassette-store.d.ts +3 -3
  11. package/dist/file-system-cassette-store.js +4 -3
  12. package/dist/index.d.ts +5 -5
  13. package/dist/index.js +6 -6
  14. package/dist/types-DMuz_JGd.js +45 -0
  15. package/dist/types-DMuz_JGd.js.map +1 -0
  16. package/dist/types-XZehMu_v.d.ts +138 -0
  17. package/dist/types.d.ts +2 -2
  18. package/dist/types.js +2 -2
  19. package/dist/{vcr-http-client-BaMxQun5.js → vcr-http-client-Ba0WYPV9.js} +143 -66
  20. package/dist/vcr-http-client-Ba0WYPV9.js.map +1 -0
  21. package/dist/{vcr-http-client-Mwb8ZMsc.d.ts → vcr-http-client-zQZTK-ru.d.ts} +4 -4
  22. package/dist/vcr-http-client.d.ts +3 -3
  23. package/dist/vcr-http-client.js +4 -3
  24. package/dist/{vitest-state-BOynp4Va.js → vitest-state-CiW5dzXZ.js} +1 -1
  25. package/dist/{vitest-state-BOynp4Va.js.map → vitest-state-CiW5dzXZ.js.map} +1 -1
  26. package/package.json +6 -5
  27. package/src/cassette-store.ts +15 -11
  28. package/src/sanitize.ts +166 -48
  29. package/src/types.ts +40 -26
  30. package/src/vcr-http-client.ts +33 -30
  31. package/dist/cassette-store-CnXX5OLB.js.map +0 -1
  32. package/dist/types-BsuPrIpu.js +0 -1
  33. package/dist/types-DWwkMzxG.d.ts +0 -85
  34. package/dist/vcr-http-client-BaMxQun5.js.map +0 -1
package/src/sanitize.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { Effect, Option } from "effect";
1
+ import { Effect, Option, Record } from "effect";
2
+ import { UrlParams } from "effect/unstable/http";
2
3
  import stableStringify from "json-stable-stringify";
3
4
 
4
- import type { VcrRequest, VcrResponse } from "./types";
5
+ import type { VcrRedactedValue, VcrRequest, VcrResponse } from "./types";
5
6
 
6
7
  /**
7
8
  * Normalize header names to match HttpClient's lowercase behavior.
@@ -13,15 +14,8 @@ const normalizeHeaderKey = (key: string) => key.toLowerCase();
13
14
  */
14
15
  const toHeaderRecord = (headers?: Record<string, string>) => {
15
16
  if (!headers) return undefined;
16
- const entries = Object.entries(headers).map(
17
- ([key, value]) => [normalizeHeaderKey(key), value] as const,
18
- );
19
- entries.sort(([a], [b]) => a.localeCompare(b));
20
- const record: Record<string, string> = {};
21
- for (const [key, value] of entries) {
22
- record[key] = value;
23
- }
24
- return record;
17
+ const lowered = Record.mapKeys(headers, normalizeHeaderKey);
18
+ return Record.fromEntries(Record.toEntries(lowered).sort(([a], [b]) => a.localeCompare(b)));
25
19
  };
26
20
 
27
21
  /**
@@ -34,13 +28,7 @@ const omitHeaderKeys = (
34
28
  if (!headers) return Option.none();
35
29
  if (!ignore || ignore.length === 0) return Option.some(headers);
36
30
  const ignoreSet = new Set(ignore.map(normalizeHeaderKey));
37
- const filtered: Record<string, string> = {};
38
- for (const [key, value] of Object.entries(headers)) {
39
- if (!ignoreSet.has(normalizeHeaderKey(key))) {
40
- filtered[key] = value;
41
- }
42
- }
43
- return Option.some(filtered);
31
+ return Option.some(Record.filter(headers, (_, key) => !ignoreSet.has(normalizeHeaderKey(key))));
44
32
  };
45
33
 
46
34
  /**
@@ -48,44 +36,126 @@ const omitHeaderKeys = (
48
36
  */
49
37
  const tryParseJson = Option.liftThrowable((input: string) => JSON.parse(input));
50
38
 
39
+ type BodyOptions = {
40
+ readonly omit?: ReadonlyArray<string>;
41
+ readonly replacements?: Readonly<Record<string, VcrRedactedValue>>;
42
+ };
43
+
44
+ type BodyTransformation<A> = {
45
+ readonly value: A;
46
+ readonly redacted: boolean;
47
+ };
48
+
51
49
  /**
52
- * Recursively drop keys from JSON objects to build stable request keys.
50
+ * Recursively remove or replace keys in JSON values.
53
51
  */
54
- const stripKeys = (value: unknown, ignore: Set<string>): unknown => {
52
+ const transformJsonKeys = (
53
+ value: unknown,
54
+ omit: ReadonlySet<string>,
55
+ replacements: Readonly<Record<string, VcrRedactedValue>>,
56
+ ): BodyTransformation<unknown> => {
55
57
  if (Array.isArray(value)) {
56
- return value.map((item) => stripKeys(item, ignore));
58
+ let redacted = false;
59
+ const values = value.map((item) => {
60
+ const transformed = transformJsonKeys(item, omit, replacements);
61
+ redacted ||= transformed.redacted;
62
+ return transformed.value;
63
+ });
64
+ return { value: values, redacted };
57
65
  }
58
66
  if (value && typeof value === "object") {
59
67
  const record = value as Record<string, unknown>;
60
68
  const next: Record<string, unknown> = {};
69
+ let redacted = false;
61
70
  for (const [key, entry] of Object.entries(record)) {
62
- if (!ignore.has(key)) {
63
- next[key] = stripKeys(entry, ignore);
71
+ if (omit.has(key)) {
72
+ redacted = true;
73
+ continue;
74
+ }
75
+ if (Record.has(replacements, key)) {
76
+ next[key] = replacements[key];
77
+ redacted = true;
78
+ continue;
64
79
  }
80
+ const transformed = transformJsonKeys(entry, omit, replacements);
81
+ next[key] = transformed.value;
82
+ redacted ||= transformed.redacted;
65
83
  }
66
- return next;
84
+ return { value: next, redacted };
67
85
  }
68
- return value;
86
+ return { value, redacted: false };
87
+ };
88
+
89
+ const headerValue = (
90
+ headers: Readonly<Record<string, string>> | undefined,
91
+ name: string,
92
+ ): string | undefined => {
93
+ if (!headers) return undefined;
94
+ const normalizedName = normalizeHeaderKey(name);
95
+ return Object.entries(headers).find(([key]) => normalizeHeaderKey(key) === normalizedName)?.[1];
96
+ };
97
+
98
+ const isUrlEncoded = (headers: Readonly<Record<string, string>> | undefined): boolean =>
99
+ headerValue(headers, "content-type")?.split(";", 1)[0]?.trim().toLowerCase() ===
100
+ "application/x-www-form-urlencoded";
101
+
102
+ const transformUrlParams = (
103
+ body: string,
104
+ omit: ReadonlySet<string>,
105
+ replacements: Readonly<Record<string, VcrRedactedValue>>,
106
+ ): BodyTransformation<string> => {
107
+ let redacted = false;
108
+ const value = UrlParams.fromInput(new URLSearchParams(body)).pipe(
109
+ UrlParams.transform((params) =>
110
+ params.flatMap(([key, value]) => {
111
+ if (omit.has(key)) {
112
+ redacted = true;
113
+ return [];
114
+ }
115
+ if (Record.has(replacements, key)) {
116
+ redacted = true;
117
+ return [[key, String(replacements[key])] as const];
118
+ }
119
+ return [[key, value] as const];
120
+ }),
121
+ ),
122
+ UrlParams.toString,
123
+ );
124
+ return { value, redacted };
69
125
  };
70
126
 
71
127
  /**
72
- * Remove JSON keys from a string body when possible.
128
+ * Remove or replace JSON and URL-encoded body fields when possible.
73
129
  */
74
- const omitBodyKeys = (
130
+ const transformBody = (
75
131
  body: string | undefined,
76
- ignore: ReadonlyArray<string> | undefined,
77
- ): Option.Option<string> => {
132
+ headers: Readonly<Record<string, string>> | undefined,
133
+ options: BodyOptions,
134
+ ): Option.Option<BodyTransformation<string>> => {
78
135
  if (!body) return Option.none();
136
+ const omit = new Set(options.omit);
137
+ const replacements = options.replacements ?? {};
138
+ if (isUrlEncoded(headers)) {
139
+ const hasMatch = Array.from(new URLSearchParams(body).keys()).some(
140
+ (key) => omit.has(key) || Record.has(replacements, key),
141
+ );
142
+ if (!hasMatch) {
143
+ return Option.some({ value: body, redacted: false });
144
+ }
145
+ return Option.some(transformUrlParams(body, omit, replacements));
146
+ }
79
147
  return Option.some(
80
148
  tryParseJson(body).pipe(
81
149
  Option.match({
82
- onNone: () => body,
150
+ onNone: () => ({ value: body, redacted: false }),
83
151
  onSome: (parsed) => {
84
- if (!ignore || ignore.length === 0) {
85
- return stableStringify(parsed) ?? body;
86
- }
87
- const ignoreSet = new Set(ignore);
88
- return stableStringify(stripKeys(parsed, ignoreSet)) ?? body;
152
+ const transformed = transformJsonKeys(parsed, omit, replacements);
153
+ return {
154
+ // stableStringify's return type is `string | undefined` for cyclic input;
155
+ // parsed JSON is never cyclic, but the fallback keeps the types honest.
156
+ value: stableStringify(transformed.value) ?? body,
157
+ redacted: transformed.redacted,
158
+ };
89
159
  },
90
160
  }),
91
161
  ),
@@ -93,7 +163,7 @@ const omitBodyKeys = (
93
163
  };
94
164
 
95
165
  /**
96
- * Normalize request for matching: header canonicalization + JSON body filtering.
166
+ * Normalize request for matching: header canonicalization and structured body filtering.
97
167
  */
98
168
  export const sanitizeRequest = (
99
169
  request: VcrRequest,
@@ -102,11 +172,22 @@ export const sanitizeRequest = (
102
172
  readonly ignoreBodyKeys?: ReadonlyArray<string>;
103
173
  },
104
174
  ): VcrRequest => {
105
- const filteredHeaders = omitHeaderKeys(request.headers, options.ignoreHeaders);
175
+ const transformedBody = transformBody(request.body, request.headers, {
176
+ omit: options.ignoreBodyKeys,
177
+ });
178
+ const body = Option.getOrUndefined(
179
+ transformedBody.pipe(Option.map((transformed) => transformed.value)),
180
+ );
181
+ const filteredHeaders = omitHeaderKeys(
182
+ request.headers,
183
+ transformedBody.pipe(Option.exists((transformed) => transformed.redacted))
184
+ ? [...(options.ignoreHeaders ?? []), "content-length"]
185
+ : options.ignoreHeaders,
186
+ );
106
187
  return {
107
188
  ...request,
108
189
  headers: toHeaderRecord(Option.getOrUndefined(filteredHeaders)),
109
- body: Option.getOrUndefined(omitBodyKeys(request.body, options.ignoreBodyKeys)),
190
+ body,
110
191
  };
111
192
  };
112
193
 
@@ -125,6 +206,8 @@ export const buildRequestKey = (
125
206
  ignoreHeaders: options.ignoreHeaders,
126
207
  ignoreBodyKeys: options.ignoreBodyKeys,
127
208
  });
209
+ // stableStringify only returns undefined for cyclic input; this literal never is,
210
+ // but its declared type is `string | undefined`, so keep the fallback for TS.
128
211
  return (
129
212
  stableStringify({
130
213
  method: sanitized.method.toUpperCase(),
@@ -149,12 +232,29 @@ export const redactRequest = (
149
232
  options: {
150
233
  readonly redactHeaders?: ReadonlyArray<string>;
151
234
  readonly redactBodyKeys?: ReadonlyArray<string>;
235
+ readonly bodyReplacements?: Readonly<Record<string, VcrRedactedValue>>;
152
236
  },
153
- ): VcrRequest => ({
154
- ...request,
155
- headers: Option.getOrUndefined(omitHeaderKeys(request.headers, options.redactHeaders)),
156
- body: Option.getOrUndefined(omitBodyKeys(request.body, options.redactBodyKeys)),
157
- });
237
+ ): VcrRequest => {
238
+ const transformedBody = transformBody(request.body, request.headers, {
239
+ omit: options.redactBodyKeys,
240
+ replacements: options.bodyReplacements,
241
+ });
242
+ const body = Option.getOrUndefined(
243
+ transformedBody.pipe(Option.map((transformed) => transformed.value)),
244
+ );
245
+ return {
246
+ ...request,
247
+ headers: Option.getOrUndefined(
248
+ omitHeaderKeys(
249
+ request.headers,
250
+ transformedBody.pipe(Option.exists((transformed) => transformed.redacted))
251
+ ? [...(options.redactHeaders ?? []), "content-length"]
252
+ : options.redactHeaders,
253
+ ),
254
+ ),
255
+ body,
256
+ };
257
+ };
158
258
 
159
259
  /**
160
260
  * Remove sensitive response data before persisting to a cassette.
@@ -164,9 +264,27 @@ export const redactResponse = (
164
264
  options: {
165
265
  readonly redactHeaders?: ReadonlyArray<string>;
166
266
  readonly redactBodyKeys?: ReadonlyArray<string>;
267
+ readonly bodyReplacements?: Readonly<Record<string, VcrRedactedValue>>;
167
268
  },
168
- ): VcrResponse => ({
169
- ...response,
170
- headers: Option.getOrUndefined(omitHeaderKeys(response.headers, options.redactHeaders)),
171
- body: Option.getOrElse(omitBodyKeys(response.body, options.redactBodyKeys), () => response.body),
172
- });
269
+ ): VcrResponse => {
270
+ const transformedBody = transformBody(response.body, response.headers, {
271
+ omit: options.redactBodyKeys,
272
+ replacements: options.bodyReplacements,
273
+ });
274
+ const body = Option.getOrElse(
275
+ transformedBody.pipe(Option.map((transformed) => transformed.value)),
276
+ () => response.body,
277
+ );
278
+ return {
279
+ ...response,
280
+ headers: Option.getOrUndefined(
281
+ omitHeaderKeys(
282
+ response.headers,
283
+ transformedBody.pipe(Option.exists((transformed) => transformed.redacted))
284
+ ? [...(options.redactHeaders ?? []), "content-length"]
285
+ : options.redactHeaders,
286
+ ),
287
+ ),
288
+ body,
289
+ };
290
+ };
package/src/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { Schema } from "effect";
2
+
1
3
  /**
2
4
  * VCR operating mode.
3
5
  * - record: always call the live client and persist.
@@ -6,50 +8,60 @@
6
8
  */
7
9
  export type VcrMode = "record" | "replay" | "auto";
8
10
 
11
+ /**
12
+ * Safe scalar value written in place of a sensitive body field.
13
+ */
14
+ export type VcrRedactedValue = string | number | boolean | null;
15
+
9
16
  /**
10
17
  * Serialized request shape stored in a cassette.
11
18
  */
12
- export type VcrRequest = {
13
- readonly method: string;
14
- readonly url: string;
15
- readonly headers?: Record<string, string>;
16
- readonly body?: string;
17
- };
19
+ export const VcrRequestSchema = Schema.Struct({
20
+ method: Schema.String,
21
+ url: Schema.String,
22
+ headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
23
+ body: Schema.optional(Schema.String),
24
+ });
25
+ export type VcrRequest = Schema.Schema.Type<typeof VcrRequestSchema>;
18
26
 
19
27
  /**
20
28
  * Serialized response shape stored in a cassette.
21
29
  */
22
- export type VcrResponse = {
23
- readonly status: number;
24
- readonly body: string;
25
- readonly headers?: Record<string, string>;
26
- };
30
+ export const VcrResponseSchema = Schema.Struct({
31
+ status: Schema.Number,
32
+ body: Schema.String,
33
+ headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
34
+ });
35
+ export type VcrResponse = Schema.Schema.Type<typeof VcrResponseSchema>;
27
36
 
28
37
  /**
29
38
  * A single request/response pair keyed in the cassette.
30
39
  */
31
- export type VcrEntry = {
32
- readonly request: VcrRequest;
33
- readonly response: VcrResponse;
34
- };
40
+ export const VcrEntrySchema = Schema.Struct({
41
+ request: VcrRequestSchema,
42
+ response: VcrResponseSchema,
43
+ });
44
+ export type VcrEntry = Schema.Schema.Type<typeof VcrEntrySchema>;
35
45
 
36
46
  /**
37
47
  * Cassette file format.
38
48
  */
39
- export type Cassette = {
40
- readonly meta: {
41
- readonly createdAt: string;
42
- readonly version: string;
43
- };
44
- readonly entries: Record<string, VcrEntry>;
45
- };
49
+ export const CassetteSchema = Schema.Struct({
50
+ meta: Schema.Struct({
51
+ createdAt: Schema.String,
52
+ version: Schema.String,
53
+ }),
54
+ entries: Schema.Record(Schema.String, VcrEntrySchema),
55
+ });
56
+ export type Cassette = Schema.Schema.Type<typeof CassetteSchema>;
46
57
 
47
58
  /**
48
59
  * Cassette file format with multiple exports.
49
60
  */
50
- export type CassetteFile = {
51
- readonly exports: Record<string, Cassette>;
52
- };
61
+ export const CassetteFileSchema = Schema.Struct({
62
+ exports: Schema.Record(Schema.String, CassetteSchema),
63
+ });
64
+ export type CassetteFile = Schema.Schema.Type<typeof CassetteFileSchema>;
53
65
 
54
66
  /**
55
67
  * VCR configuration.
@@ -65,13 +77,15 @@ export type VcrConfig = {
65
77
  readonly cassetteName?: string;
66
78
  readonly mode?: VcrMode;
67
79
  /**
68
- * Remove sensitive data before writing to disk.
80
+ * Remove or replace sensitive data before writing to disk.
69
81
  */
70
82
  readonly redact?: {
71
83
  readonly requestHeaders?: ReadonlyArray<string>;
72
84
  readonly responseHeaders?: ReadonlyArray<string>;
73
85
  readonly requestBodyKeys?: ReadonlyArray<string>;
74
86
  readonly responseBodyKeys?: ReadonlyArray<string>;
87
+ readonly requestBodyReplacements?: Readonly<Record<string, VcrRedactedValue>>;
88
+ readonly responseBodyReplacements?: Readonly<Record<string, VcrRedactedValue>>;
75
89
  };
76
90
  /**
77
91
  * Ignore fields when computing the request key for replay matching.
@@ -1,4 +1,4 @@
1
- import { Config, Data, Effect, Option, Path } from "effect";
1
+ import { Array as Arr, Config, Data, Effect, Option, Path } from "effect";
2
2
  import {
3
3
  HttpClient,
4
4
  HttpClientError,
@@ -64,20 +64,31 @@ const resolveCassetteLocation = Effect.fnUntraced(function* (config: VcrConfig)
64
64
  /**
65
65
  * Apply defaults for common VCR behavior while preserving explicit overrides.
66
66
  */
67
- const normalizeConfig = (config: VcrConfig): VcrConfig => ({
68
- vcrName: config.vcrName,
69
- cassetteName: config.cassetteName,
70
- mode: config.mode ?? "auto",
71
- matchIgnore: {
72
- requestHeaders: ["authorization"],
73
- ...config.matchIgnore,
74
- },
75
- redact: {
76
- requestHeaders: ["authorization"],
77
- ...config.redact,
78
- },
79
- match: config.match,
80
- });
67
+ const normalizeConfig = (config: VcrConfig): VcrConfig => {
68
+ const redactedRequestBodyKeys = Arr.union(
69
+ config.redact?.requestBodyKeys ?? [],
70
+ Object.keys(config.redact?.requestBodyReplacements ?? {}),
71
+ );
72
+ const redactedRequestHeaders = Arr.union(["authorization"], config.redact?.requestHeaders ?? []);
73
+
74
+ return {
75
+ vcrName: config.vcrName,
76
+ cassetteName: config.cassetteName,
77
+ mode: config.mode ?? "auto",
78
+ matchIgnore: {
79
+ requestHeaders: Arr.union(redactedRequestHeaders, config.matchIgnore?.requestHeaders ?? []),
80
+ requestBodyKeys: Arr.union(
81
+ redactedRequestBodyKeys,
82
+ config.matchIgnore?.requestBodyKeys ?? [],
83
+ ),
84
+ },
85
+ redact: {
86
+ ...config.redact,
87
+ requestHeaders: redactedRequestHeaders,
88
+ },
89
+ match: config.match,
90
+ };
91
+ };
81
92
 
82
93
  const AckDisableVcrConfig = Config.option(Config.string("ACK_DISABLE_VCR")).pipe(
83
94
  Config.map((value) =>
@@ -120,15 +131,9 @@ const toRequestError = (
120
131
  });
121
132
 
122
133
  /**
123
- * Convert header map to a plain record for JSON persistence.
134
+ * Best-effort JSON serialization for opaque raw bodies.
124
135
  */
125
- const recordFromHeaders = (headers: Record<string, string>) => {
126
- const record: Record<string, string> = {};
127
- for (const [key, value] of Object.entries(headers)) {
128
- record[key] = value;
129
- }
130
- return record;
131
- };
136
+ const tryStringify = Option.liftThrowable((value: unknown) => JSON.stringify(value));
132
137
 
133
138
  /**
134
139
  * Serialize an Effect HttpClientRequest into a cassette request shape.
@@ -138,7 +143,7 @@ const recordFromHeaders = (headers: Record<string, string>) => {
138
143
  * live requests.
139
144
  */
140
145
  const toVcrRequest = (request: HttpClientRequest.HttpClientRequest): VcrRequest => {
141
- const headers = recordFromHeaders(request.headers as Record<string, string>);
146
+ const headers: Record<string, string> = { ...request.headers };
142
147
  const body = (() => {
143
148
  switch (request.body._tag) {
144
149
  case "Empty":
@@ -149,11 +154,7 @@ const toVcrRequest = (request: HttpClientRequest.HttpClientRequest): VcrRequest
149
154
  const raw = request.body.body as unknown;
150
155
  if (typeof raw === "string") return raw;
151
156
  if (raw instanceof Uint8Array) return decoder.decode(raw);
152
- try {
153
- return JSON.stringify(raw);
154
- } catch {
155
- return "[raw]";
156
- }
157
+ return tryStringify(raw).pipe(Option.getOrElse(() => "[raw]"));
157
158
  }
158
159
  case "FormData":
159
160
  return "[form-data]";
@@ -179,7 +180,7 @@ const toVcrResponse = (
179
180
  ): VcrResponse => ({
180
181
  status: response.status,
181
182
  body,
182
- headers: recordFromHeaders(response.headers as Record<string, string>),
183
+ headers: { ...response.headers },
183
184
  });
184
185
 
185
186
  /**
@@ -309,12 +310,14 @@ const record = Effect.fnUntraced(function* (
309
310
  ? redactRequest(vcrRequest, {
310
311
  redactHeaders: config.redact.requestHeaders,
311
312
  redactBodyKeys: config.redact.requestBodyKeys,
313
+ bodyReplacements: config.redact.requestBodyReplacements,
312
314
  })
313
315
  : vcrRequest;
314
316
  const sanitizedResponse = config.redact
315
317
  ? redactResponse(vcrResponse, {
316
318
  redactHeaders: config.redact.responseHeaders,
317
319
  redactBodyKeys: config.redact.responseBodyKeys,
320
+ bodyReplacements: config.redact.responseBodyReplacements,
318
321
  })
319
322
  : vcrResponse;
320
323
 
@@ -1 +0,0 @@
1
- {"version":3,"file":"cassette-store-CnXX5OLB.js","names":[],"sources":["../src/cassette-store.ts"],"sourcesContent":["import { Context, Data, DateTime, Effect } from \"effect\";\n\nimport type { Cassette, CassetteFile } from \"./types\";\n\nexport type CassetteStoreOperation = \"exists\" | \"load\" | \"save\" | \"loadOrInit\" | \"resolveRoot\";\n\n/**\n * Storage contract for VCR cassettes.\n *\n */\nexport interface CassetteStoreService {\n /** Returns true if a cassette with the given name exists. */\n readonly exists: (name: string) => Effect.Effect<boolean, CassetteStoreError>;\n\n /** Loads a cassette by name, returning a `CassetteFile` if found. */\n readonly load: (name: string) => Effect.Effect<CassetteFile, CassetteStoreError>;\n\n /** Saves a cassette by name, overwriting any existing cassette with the same name. */\n readonly save: (name: string, content: CassetteFile) => Effect.Effect<void, CassetteStoreError>;\n\n /** Loads a cassette by name, initializing a new empty cassette if not found. */\n readonly loadOrInit: (name: string) => Effect.Effect<CassetteFile, CassetteStoreError>;\n}\n\n/**\n * Effect service tag for the cassette store.\n */\nexport class CassetteStore extends Context.Service<CassetteStore, CassetteStoreService>()(\n \"@useairfoil/effect-vcr/CassetteStore\",\n) {}\n\n/**\n * Creates a new empty cassette with a timestamp and format version.\n */\nexport const createEmptyCassette = Effect.fnUntraced(function* (): Effect.fn.Return<Cassette> {\n const now = yield* DateTime.now;\n return {\n meta: {\n createdAt: DateTime.formatIso(now),\n version: \"1\",\n },\n entries: {},\n };\n});\n\nexport const createEmptyCassetteFile = (): Effect.Effect<CassetteFile> =>\n Effect.map(createEmptyCassette(), (cassette) => ({\n exports: { default: cassette },\n }));\n\n/**\n * Structured error for cassette persistence operations.\n *\n * `operation` indicates which store function failed.\n * `path` is the file or directory path that was targeted.\n */\nexport class CassetteStoreError extends Data.TaggedError(\"CassetteStoreError\")<{\n readonly operation: CassetteStoreOperation;\n readonly path: string;\n readonly message?: string;\n readonly cause?: unknown;\n}> {}\n\nexport const toStoreError = (\n operation: CassetteStoreOperation,\n path: string,\n cause?: unknown,\n message?: string,\n) =>\n new CassetteStoreError({\n operation,\n path,\n cause,\n message,\n });\n\n/**\n * Parses cassette JSON and maps invalid JSON to a store error.\n */\nexport const parseCassette = (content: string, path: string) =>\n Effect.try({\n try: () => JSON.parse(content) as CassetteFile,\n catch: (error) => toStoreError(\"load\", path, error, \"Invalid JSON\"),\n }).pipe(\n Effect.flatMap((parsed) => {\n if (!parsed || typeof parsed !== \"object\" || !(\"exports\" in parsed)) {\n return Effect.fail(toStoreError(\"load\", path, undefined, \"Invalid cassette file format\"));\n }\n return Effect.succeed(parsed);\n }),\n );\n\n/** Creates a `CassetteStore` implementation that uses the given `impl` object. */\nexport const make = (impl: CassetteStoreService): CassetteStoreService =>\n CassetteStore.of({ ...impl });\n"],"mappings":";;;;;;;;;;;;;;;;AA2BA,IAAa,gBAAb,cAAmC,QAAQ,SAA8C,CACvF,uCACD,CAAC;;;;AAKF,MAAa,sBAAsB,OAAO,WAAW,aAAyC;CAC5F,MAAM,MAAM,OAAO,SAAS;AAC5B,QAAO;EACL,MAAM;GACJ,WAAW,SAAS,UAAU,IAAI;GAClC,SAAS;GACV;EACD,SAAS,EAAE;EACZ;EACD;AAEF,MAAa,gCACX,OAAO,IAAI,qBAAqB,GAAG,cAAc,EAC/C,SAAS,EAAE,SAAS,UAAU,EAC/B,EAAE;;;;;;;AAQL,IAAa,qBAAb,cAAwC,KAAK,YAAY,qBAAqB,CAK3E;AAEH,MAAa,gBACX,WACA,MACA,OACA,YAEA,IAAI,mBAAmB;CACrB;CACA;CACA;CACA;CACD,CAAC;;;;AAKJ,MAAa,iBAAiB,SAAiB,SAC7C,OAAO,IAAI;CACT,WAAW,KAAK,MAAM,QAAQ;CAC9B,QAAQ,UAAU,aAAa,QAAQ,MAAM,OAAO,eAAe;CACpE,CAAC,CAAC,KACD,OAAO,SAAS,WAAW;AACzB,KAAI,CAAC,UAAU,OAAO,WAAW,YAAY,EAAE,aAAa,QAC1D,QAAO,OAAO,KAAK,aAAa,QAAQ,MAAM,QAAW,+BAA+B,CAAC;AAE3F,QAAO,OAAO,QAAQ,OAAO;EAC7B,CACH;;AAGH,MAAa,QAAQ,SACnB,cAAc,GAAG,EAAE,GAAG,MAAM,CAAC"}
@@ -1 +0,0 @@
1
- export { };
@@ -1,85 +0,0 @@
1
- //#region src/types.d.ts
2
- /**
3
- * VCR operating mode.
4
- * - record: always call the live client and persist.
5
- * - replay: serve only from cassette, fail if missing.
6
- * - auto: replay if cassette exists, otherwise record (fail in CI).
7
- */
8
- type VcrMode = "record" | "replay" | "auto";
9
- /**
10
- * Serialized request shape stored in a cassette.
11
- */
12
- type VcrRequest = {
13
- readonly method: string;
14
- readonly url: string;
15
- readonly headers?: Record<string, string>;
16
- readonly body?: string;
17
- };
18
- /**
19
- * Serialized response shape stored in a cassette.
20
- */
21
- type VcrResponse = {
22
- readonly status: number;
23
- readonly body: string;
24
- readonly headers?: Record<string, string>;
25
- };
26
- /**
27
- * A single request/response pair keyed in the cassette.
28
- */
29
- type VcrEntry = {
30
- readonly request: VcrRequest;
31
- readonly response: VcrResponse;
32
- };
33
- /**
34
- * Cassette file format.
35
- */
36
- type Cassette = {
37
- readonly meta: {
38
- readonly createdAt: string;
39
- readonly version: string;
40
- };
41
- readonly entries: Record<string, VcrEntry>;
42
- };
43
- /**
44
- * Cassette file format with multiple exports.
45
- */
46
- type CassetteFile = {
47
- readonly exports: Record<string, Cassette>;
48
- };
49
- /**
50
- * VCR configuration.
51
- */
52
- type VcrConfig = {
53
- readonly vcrName?: string;
54
- /**
55
- * Cassette file name or basename.
56
- *
57
- * `users` resolves to `users.cassette`.
58
- * `users.cassette` is preserved as-is.
59
- */
60
- readonly cassetteName?: string;
61
- readonly mode?: VcrMode;
62
- /**
63
- * Remove sensitive data before writing to disk.
64
- */
65
- readonly redact?: {
66
- readonly requestHeaders?: ReadonlyArray<string>;
67
- readonly responseHeaders?: ReadonlyArray<string>;
68
- readonly requestBodyKeys?: ReadonlyArray<string>;
69
- readonly responseBodyKeys?: ReadonlyArray<string>;
70
- };
71
- /**
72
- * Ignore fields when computing the request key for replay matching.
73
- */
74
- readonly matchIgnore?: {
75
- readonly requestHeaders?: ReadonlyArray<string>;
76
- readonly requestBodyKeys?: ReadonlyArray<string>;
77
- };
78
- /**
79
- * Custom matcher to override key-based lookup.
80
- */
81
- readonly match?: (request: VcrRequest, entry: VcrEntry) => boolean;
82
- };
83
- //#endregion
84
- export { VcrMode as a, VcrEntry as i, CassetteFile as n, VcrRequest as o, VcrConfig as r, VcrResponse as s, Cassette as t };
85
- //# sourceMappingURL=types-DWwkMzxG.d.ts.map