@useairfoil/effect-vcr 0.4.0 → 0.4.2
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 +202 -44
- package/dist/cassette-store-BunkvLoV.d.ts +59 -0
- package/dist/cassette-store-CnXX5OLB.js +60 -0
- package/dist/cassette-store-CnXX5OLB.js.map +1 -0
- package/dist/cassette-store.d.ts +3 -0
- package/dist/cassette-store.js +3 -0
- package/dist/file-system-cassette-store-LXpwwrw3.d.ts +19 -0
- package/dist/file-system-cassette-store-zWkrhhlO.js +42 -0
- package/dist/file-system-cassette-store-zWkrhhlO.js.map +1 -0
- package/dist/file-system-cassette-store.d.ts +4 -0
- package/dist/file-system-cassette-store.js +5 -0
- package/dist/index.d.ts +5 -150
- package/dist/index.js +6 -450
- package/dist/types-BsuPrIpu.js +1 -0
- package/dist/types-DWwkMzxG.d.ts +85 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +3 -0
- package/dist/vcr-http-client-BaMxQun5.js +351 -0
- package/dist/vcr-http-client-BaMxQun5.js.map +1 -0
- package/dist/vcr-http-client-aedmnq0C.d.ts +26 -0
- package/dist/vcr-http-client.d.ts +4 -0
- package/dist/vcr-http-client.js +5 -0
- package/dist/vitest-state-BOynp4Va.js +14 -0
- package/dist/vitest-state-BOynp4Va.js.map +1 -0
- package/package.json +24 -4
- package/src/cassette-store.ts +25 -29
- package/src/file-system-cassette-store.ts +15 -4
- package/src/index.ts +1 -1
- package/src/sanitize.ts +14 -10
- package/src/types.ts +7 -1
- package/src/vcr-http-client.ts +148 -147
- package/dist/index.js.map +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,150 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import
|
|
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
|
|
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
|
+
import { a as cassette_store_d_exports } from "./cassette-store-BunkvLoV.js";
|
|
3
|
+
import { n as file_system_cassette_store_d_exports } from "./file-system-cassette-store-LXpwwrw3.js";
|
|
4
|
+
import { r as vcr_http_client_d_exports } from "./vcr-http-client-aedmnq0C.js";
|
|
5
|
+
export { Cassette, CassetteFile, cassette_store_d_exports as CassetteStore, file_system_cassette_store_d_exports as FileSystemCassetteStore, VcrConfig, VcrEntry, vcr_http_client_d_exports as VcrHttpClient, VcrMode, VcrRequest, VcrResponse };
|
package/dist/index.js
CHANGED
|
@@ -1,451 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
1
|
+
import { r as cassette_store_exports } from "./cassette-store-CnXX5OLB.js";
|
|
2
|
+
import "./vitest-state-BOynp4Va.js";
|
|
3
|
+
import { t as file_system_cassette_store_exports } from "./file-system-cassette-store-zWkrhhlO.js";
|
|
4
|
+
import { r as vcr_http_client_exports } from "./vcr-http-client-BaMxQun5.js";
|
|
5
|
+
import "./types-BsuPrIpu.js";
|
|
5
6
|
|
|
6
|
-
|
|
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
|
|
7
|
+
export { cassette_store_exports as CassetteStore, file_system_cassette_store_exports as FileSystemCassetteStore, vcr_http_client_exports as VcrHttpClient };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|