@clinebot/shared 0.0.20 → 0.0.21
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/dist/index.browser.d.ts +4 -1
- package/dist/index.browser.js +21 -21
- package/dist/index.d.ts +4 -1
- package/dist/index.js +27 -27
- package/dist/parse/shell.d.ts +2 -0
- package/dist/parse/string.d.ts +1 -0
- package/dist/session/index.d.ts +1 -0
- package/dist/storage/index.js +1 -1
- package/package.json +2 -3
- package/src/auth/constants.ts +0 -41
- package/src/connectors/adapters.ts +0 -152
- package/src/connectors/events.ts +0 -73
- package/src/db/index.ts +0 -14
- package/src/db/sqlite-db.ts +0 -329
- package/src/index.browser.ts +0 -187
- package/src/index.ts +0 -187
- package/src/llms/model-id.ts +0 -154
- package/src/llms/tools.ts +0 -137
- package/src/logging/logger.ts +0 -9
- package/src/parse/json.ts +0 -43
- package/src/parse/time.ts +0 -21
- package/src/parse/zod.ts +0 -23
- package/src/prompt/format.ts +0 -22
- package/src/remote-config/constants.ts +0 -5
- package/src/remote-config/schema.test.ts +0 -1004
- package/src/remote-config/schema.ts +0 -263
- package/src/rpc/index.ts +0 -4
- package/src/rpc/runtime.ts +0 -317
- package/src/rpc/team-progress.ts +0 -71
- package/src/services/telemetry-config.ts +0 -55
- package/src/services/telemetry.ts +0 -141
- package/src/session/hook-context.ts +0 -54
- package/src/session/records.ts +0 -40
- package/src/session/runtime-config.ts +0 -24
- package/src/session/runtime-env.ts +0 -8
- package/src/storage/index.ts +0 -37
- package/src/storage/paths.test.ts +0 -99
- package/src/storage/paths.ts +0 -400
- package/src/vcr.ts +0 -717
package/src/vcr.ts
DELETED
|
@@ -1,717 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* VCR (Video Cassette Recorder) for HTTP requests.
|
|
3
|
-
*
|
|
4
|
-
* Patches `globalThis.fetch` to record and replay HTTP interactions,
|
|
5
|
-
* enabling deterministic testing without making real API calls.
|
|
6
|
-
*
|
|
7
|
-
* Unlike nock (which patches Node's `http` module), this works by wrapping
|
|
8
|
-
* `globalThis.fetch` directly — catching all HTTP traffic in this codebase
|
|
9
|
-
* including calls made through the OpenAI, Anthropic, Gemini, and Vercel AI
|
|
10
|
-
* SDKs (all of which delegate to the global fetch).
|
|
11
|
-
*
|
|
12
|
-
* Environment variables:
|
|
13
|
-
* CLINE_VCR - "record" to record HTTP requests, "playback" to replay them
|
|
14
|
-
* CLINE_VCR_CASSETTE - Path to the cassette file (default: ./vcr-cassette.json)
|
|
15
|
-
* CLINE_VCR_FILTER - Substring to filter recorded/replayed request paths.
|
|
16
|
-
* When set to a non-empty string, only requests whose path
|
|
17
|
-
* contains this substring are recorded/replayed; all other
|
|
18
|
-
* requests pass through to the real network.
|
|
19
|
-
* When empty or unset, ALL requests are intercepted (no filter).
|
|
20
|
-
* CLINE_VCR_SSE_DELAY - Milliseconds between SSE chunks during playback (default: 100).
|
|
21
|
-
* Set to 0 for instant delivery.
|
|
22
|
-
*
|
|
23
|
-
* Usage:
|
|
24
|
-
* # Record only inference requests
|
|
25
|
-
* CLINE_VCR=record CLINE_VCR_CASSETTE=./fixtures/my-test.json clite task "hello"
|
|
26
|
-
*
|
|
27
|
-
* # Replay — auth/S3/etc. requests go through normally, only inference is mocked
|
|
28
|
-
* CLINE_VCR=playback CLINE_VCR_CASSETTE=./fixtures/my-test.json clite task "hello"
|
|
29
|
-
*
|
|
30
|
-
* # Record everything (no filter)
|
|
31
|
-
* CLINE_VCR=record CLINE_VCR_FILTER="" CLINE_VCR_CASSETTE=./fixtures/all.json clite task "hello"
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
35
|
-
import { dirname, resolve } from "node:path";
|
|
36
|
-
|
|
37
|
-
// ── Types ───────────────────────────────────────────────────────────────
|
|
38
|
-
|
|
39
|
-
type VcrMode = "record" | "playback";
|
|
40
|
-
|
|
41
|
-
/** A single recorded HTTP interaction (nock-compatible shape). */
|
|
42
|
-
export interface VcrRecording {
|
|
43
|
-
scope: string;
|
|
44
|
-
method: string;
|
|
45
|
-
path: string;
|
|
46
|
-
body?: string;
|
|
47
|
-
status: number;
|
|
48
|
-
response: unknown;
|
|
49
|
-
responseIsBinary: boolean;
|
|
50
|
-
/** Content-Type header from the original response (captured at record time). */
|
|
51
|
-
contentType?: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
interface VcrConfig {
|
|
55
|
-
mode: VcrMode;
|
|
56
|
-
cassettePath: string;
|
|
57
|
-
/**
|
|
58
|
-
* Only record/replay requests whose path includes this substring.
|
|
59
|
-
* Empty string ("") means no filtering — ALL requests are intercepted.
|
|
60
|
-
* A non-empty string enables selective mode where only matching requests
|
|
61
|
-
* are intercepted and non-matching requests pass through to the real network.
|
|
62
|
-
*/
|
|
63
|
-
filter: string;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ── Sensitive data sanitization ─────────────────────────────────────────
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Sanitization is key-based: any JSON key whose name matches a rule gets
|
|
70
|
-
* its value redacted. This is more robust than regex-matching values,
|
|
71
|
-
* because it works regardless of the value format.
|
|
72
|
-
*
|
|
73
|
-
* Three categories of keys are redacted:
|
|
74
|
-
*
|
|
75
|
-
* 1. **Exact key names** (case-insensitive) — secrets, tokens, credentials.
|
|
76
|
-
* 2. **Key name patterns** (substring/suffix) — catches ID fields, PII, etc.
|
|
77
|
-
* 3. **Value-level regex patterns** — for values embedded in plain strings
|
|
78
|
-
* (e.g. filesystem paths, AWS key IDs in URLs).
|
|
79
|
-
*
|
|
80
|
-
* To add new sanitization rules, just add entries to the sets/arrays below.
|
|
81
|
-
*/
|
|
82
|
-
|
|
83
|
-
/** Keys whose values are always fully redacted (case-insensitive exact match). */
|
|
84
|
-
const REDACT_KEYS_EXACT = new Set([
|
|
85
|
-
// Secrets & tokens
|
|
86
|
-
"accesskeyid",
|
|
87
|
-
"secretaccesskey",
|
|
88
|
-
"idtoken",
|
|
89
|
-
"refreshtoken",
|
|
90
|
-
"accessToken",
|
|
91
|
-
"access_token",
|
|
92
|
-
"refresh_token",
|
|
93
|
-
"apikey",
|
|
94
|
-
"api_key",
|
|
95
|
-
"authorization",
|
|
96
|
-
"password",
|
|
97
|
-
"secret",
|
|
98
|
-
"token",
|
|
99
|
-
// PII
|
|
100
|
-
"email",
|
|
101
|
-
"displayname",
|
|
102
|
-
"display_name",
|
|
103
|
-
"userInfo",
|
|
104
|
-
]);
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Keys whose values are redacted if the key name ends with or contains
|
|
108
|
-
* one of these substrings (case-insensitive). Catches fields like
|
|
109
|
-
* "userId", "organizationId", "memberId", "sessionId", etc.
|
|
110
|
-
*/
|
|
111
|
-
const REDACT_KEY_SUFFIXES = [
|
|
112
|
-
"id", // matches *Id, *_id — covers most entity identifiers
|
|
113
|
-
"balance",
|
|
114
|
-
"cost",
|
|
115
|
-
"secret",
|
|
116
|
-
];
|
|
117
|
-
|
|
118
|
-
/** Check whether a key name should have its value redacted. */
|
|
119
|
-
function shouldRedactKey(key: string): boolean {
|
|
120
|
-
const lower = key.toLowerCase();
|
|
121
|
-
if (REDACT_KEYS_EXACT.has(lower)) {
|
|
122
|
-
return true;
|
|
123
|
-
}
|
|
124
|
-
for (const suffix of REDACT_KEY_SUFFIXES) {
|
|
125
|
-
// Match "userId", "user_id", "id" but not "video" or "valid"
|
|
126
|
-
if (lower === suffix) {
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
|
-
// camelCase: ends with "Id", "Balance", etc.
|
|
130
|
-
if (lower.endsWith(suffix) && lower.length > suffix.length) {
|
|
131
|
-
const charBefore = lower[lower.length - suffix.length - 1];
|
|
132
|
-
// Must be preceded by a word boundary character (_, -, or uppercase transition)
|
|
133
|
-
if (charBefore === "_" || charBefore === "-") {
|
|
134
|
-
return true;
|
|
135
|
-
}
|
|
136
|
-
// camelCase: the suffix starts with lowercase but original key has uppercase
|
|
137
|
-
const originalChar = key[key.length - suffix.length];
|
|
138
|
-
if (
|
|
139
|
-
originalChar &&
|
|
140
|
-
originalChar === originalChar.toUpperCase() &&
|
|
141
|
-
originalChar !== originalChar.toLowerCase()
|
|
142
|
-
) {
|
|
143
|
-
return true;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
// snake_case: ends with "_id", "_balance", etc.
|
|
147
|
-
if (lower.endsWith(`_${suffix}`)) {
|
|
148
|
-
return true;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
return false;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Regex patterns applied to plain string values (not key-based). */
|
|
155
|
-
const SENSITIVE_VALUE_PATTERNS: { pattern: RegExp; replacement: string }[] = [
|
|
156
|
-
// AWS access key IDs
|
|
157
|
-
{ pattern: /AKIA[A-Z0-9]{16}/g, replacement: "AKIA_REDACTED" },
|
|
158
|
-
// Filesystem paths with usernames
|
|
159
|
-
{ pattern: /\/Users\/[A-Za-z0-9._-]+/g, replacement: "/Users/REDACTED_USER" },
|
|
160
|
-
{ pattern: /\/home\/[A-Za-z0-9._-]+/g, replacement: "/home/REDACTED_USER" },
|
|
161
|
-
];
|
|
162
|
-
|
|
163
|
-
/** Apply value-level regex sanitization to a plain string. */
|
|
164
|
-
function sanitizeStringValue(input: string): string {
|
|
165
|
-
let result = input;
|
|
166
|
-
for (const { pattern, replacement } of SENSITIVE_VALUE_PATTERNS) {
|
|
167
|
-
result = result.replace(pattern, replacement);
|
|
168
|
-
}
|
|
169
|
-
return result;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Path-level patterns for normalizing request paths in recordings.
|
|
174
|
-
* These replace dynamic path segments with stable test values so that
|
|
175
|
-
* playback matching works across different environments/users.
|
|
176
|
-
*
|
|
177
|
-
* Patterns are applied in order — more specific patterns should come first.
|
|
178
|
-
*/
|
|
179
|
-
const PATH_NORMALIZATION_PATTERNS: { pattern: RegExp; replacement: string }[] =
|
|
180
|
-
[
|
|
181
|
-
// S3-style task artifact paths: /tasks/<userId>/<taskId>/api_conversation_history.json
|
|
182
|
-
{
|
|
183
|
-
pattern:
|
|
184
|
-
/tasks\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/api_conversation_history/g,
|
|
185
|
-
replacement: "tasks/usr-test/taskid/api_conversation_history",
|
|
186
|
-
},
|
|
187
|
-
// Prefixed entity IDs in path segments (org-XXX, usr-XXX, mbr-XXX, ses-XXX, etc.)
|
|
188
|
-
// Matches common Cline ID formats: prefix + ULID/UUID-like suffix
|
|
189
|
-
{
|
|
190
|
-
pattern:
|
|
191
|
-
/\/(org|usr|mbr|ses|gen|req|msg|tsk|sch|exe|srv|cli|wkr|evt|sub|tkn)-[A-Za-z0-9]{10,}(?=[/?#]|$)/g,
|
|
192
|
-
replacement: "/$1-REDACTED",
|
|
193
|
-
},
|
|
194
|
-
];
|
|
195
|
-
|
|
196
|
-
/** Normalize a request path for stable matching. */
|
|
197
|
-
function normalizePath(input: string): string {
|
|
198
|
-
let result = input;
|
|
199
|
-
for (const { pattern, replacement } of PATH_NORMALIZATION_PATTERNS) {
|
|
200
|
-
result = result.replace(pattern, replacement);
|
|
201
|
-
}
|
|
202
|
-
return result;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Deep-sanitize a value, redacting sensitive keys and patterns.
|
|
207
|
-
* Handles objects, arrays, plain strings, and JSON-encoded strings.
|
|
208
|
-
*/
|
|
209
|
-
function sanitizeValue(obj: unknown): unknown {
|
|
210
|
-
if (obj === null || obj === undefined) {
|
|
211
|
-
return obj;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (typeof obj === "string") {
|
|
215
|
-
// Try to parse as JSON and sanitize recursively
|
|
216
|
-
try {
|
|
217
|
-
const parsed = JSON.parse(obj);
|
|
218
|
-
if (typeof parsed === "object" && parsed !== null) {
|
|
219
|
-
return JSON.stringify(sanitizeValue(parsed));
|
|
220
|
-
}
|
|
221
|
-
} catch {
|
|
222
|
-
// Not JSON — apply string-level patterns
|
|
223
|
-
}
|
|
224
|
-
return sanitizeStringValue(obj);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (Array.isArray(obj)) {
|
|
228
|
-
return obj.map(sanitizeValue);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
if (typeof obj === "object") {
|
|
232
|
-
const result: Record<string, unknown> = {};
|
|
233
|
-
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
234
|
-
if (
|
|
235
|
-
shouldRedactKey(key) &&
|
|
236
|
-
(typeof value === "string" || typeof value === "number")
|
|
237
|
-
) {
|
|
238
|
-
result[key] = "REDACTED";
|
|
239
|
-
} else {
|
|
240
|
-
result[key] = sanitizeValue(value);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
return result;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return obj;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Sanitize a single recorded interaction, stripping sensitive data. */
|
|
250
|
-
function sanitizeRecording(rec: VcrRecording): VcrRecording {
|
|
251
|
-
const cleaned = { ...rec };
|
|
252
|
-
|
|
253
|
-
// Remove request body (may contain prompts, API keys, etc.)
|
|
254
|
-
if (cleaned.body) {
|
|
255
|
-
delete cleaned.body;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Normalize the request path for stable matching
|
|
259
|
-
if (typeof cleaned.path === "string") {
|
|
260
|
-
cleaned.path = normalizePath(cleaned.path);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Deep-sanitize response body
|
|
264
|
-
if (cleaned.response !== undefined) {
|
|
265
|
-
cleaned.response = sanitizeValue(cleaned.response);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
return cleaned;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// ── URL helpers ─────────────────────────────────────────────────────────
|
|
272
|
-
|
|
273
|
-
function parseScope(url: string): { scope: string; path: string } {
|
|
274
|
-
try {
|
|
275
|
-
const parsed = new URL(url);
|
|
276
|
-
const scope = `${parsed.protocol}//${parsed.host}`;
|
|
277
|
-
const path = parsed.pathname + parsed.search;
|
|
278
|
-
return { scope, path };
|
|
279
|
-
} catch {
|
|
280
|
-
return { scope: "", path: url };
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function resolveRequestUrl(input: string | URL | Request): string {
|
|
285
|
-
if (typeof input === "string") {
|
|
286
|
-
return input;
|
|
287
|
-
}
|
|
288
|
-
if (input instanceof URL) {
|
|
289
|
-
return input.toString();
|
|
290
|
-
}
|
|
291
|
-
if (input && typeof (input as Request).url === "string") {
|
|
292
|
-
return (input as Request).url;
|
|
293
|
-
}
|
|
294
|
-
return String(input);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function resolveRequestMethod(
|
|
298
|
-
input: string | URL | Request,
|
|
299
|
-
init?: RequestInit,
|
|
300
|
-
): string {
|
|
301
|
-
if (init?.method) {
|
|
302
|
-
return init.method.toUpperCase();
|
|
303
|
-
}
|
|
304
|
-
if (input && typeof (input as Request).method === "string") {
|
|
305
|
-
return (input as Request).method.toUpperCase();
|
|
306
|
-
}
|
|
307
|
-
return "GET";
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// ── Config resolution ───────────────────────────────────────────────────
|
|
311
|
-
|
|
312
|
-
function getVcrConfig(vcrMode: string | undefined): VcrConfig | null {
|
|
313
|
-
if (!vcrMode) {
|
|
314
|
-
return null;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
if (!process.env.CLINE_VCR_CASSETTE) {
|
|
318
|
-
process.stderr.write(
|
|
319
|
-
"[VCR] No CLINE_VCR_CASSETTE: requests will not be recorded or played back.\n",
|
|
320
|
-
);
|
|
321
|
-
return null;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if (vcrMode !== "record" && vcrMode !== "playback") {
|
|
325
|
-
process.stderr.write(
|
|
326
|
-
`[VCR] Invalid CLINE_VCR value: "${vcrMode}". Expected "record" or "playback".\n`,
|
|
327
|
-
);
|
|
328
|
-
process.exit(1);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const cassettePath = resolve(process.env.CLINE_VCR_CASSETTE);
|
|
332
|
-
const filter = process.env.CLINE_VCR_FILTER ?? "";
|
|
333
|
-
|
|
334
|
-
return { mode: vcrMode, cassettePath, filter };
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// ── Record mode ─────────────────────────────────────────────────────────
|
|
338
|
-
|
|
339
|
-
/** An in-progress stream capture that can be finalized synchronously. */
|
|
340
|
-
interface InFlightCapture {
|
|
341
|
-
scope: string;
|
|
342
|
-
method: string;
|
|
343
|
-
path: string;
|
|
344
|
-
body: string;
|
|
345
|
-
status: number;
|
|
346
|
-
contentType: string | undefined;
|
|
347
|
-
chunks: Uint8Array[];
|
|
348
|
-
finalized: boolean;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function startRecordingRequests(cassettePath: string, filter: string): void {
|
|
352
|
-
const recordings: VcrRecording[] = [];
|
|
353
|
-
/** Streams still being consumed — finalized on flush or on process exit. */
|
|
354
|
-
const inFlight: InFlightCapture[] = [];
|
|
355
|
-
const originalFetch = globalThis.fetch;
|
|
356
|
-
|
|
357
|
-
/** Convert accumulated chunks into a recording entry. */
|
|
358
|
-
function finalizeCapture(capture: InFlightCapture): void {
|
|
359
|
-
if (capture.finalized) {
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
capture.finalized = true;
|
|
363
|
-
|
|
364
|
-
const decoder = new TextDecoder();
|
|
365
|
-
const bodyText =
|
|
366
|
-
capture.chunks.map((c) => decoder.decode(c, { stream: true })).join("") +
|
|
367
|
-
decoder.decode();
|
|
368
|
-
|
|
369
|
-
let responseBody: unknown;
|
|
370
|
-
try {
|
|
371
|
-
responseBody = JSON.parse(bodyText);
|
|
372
|
-
} catch {
|
|
373
|
-
responseBody = bodyText;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
recordings.push({
|
|
377
|
-
scope: capture.scope,
|
|
378
|
-
method: capture.method,
|
|
379
|
-
path: capture.path,
|
|
380
|
-
body: capture.body,
|
|
381
|
-
status: capture.status,
|
|
382
|
-
response: responseBody,
|
|
383
|
-
responseIsBinary: false,
|
|
384
|
-
contentType: capture.contentType,
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
globalThis.fetch = Object.assign(
|
|
389
|
-
async (
|
|
390
|
-
input: string | URL | Request,
|
|
391
|
-
init?: RequestInit,
|
|
392
|
-
): Promise<Response> => {
|
|
393
|
-
const url = resolveRequestUrl(input);
|
|
394
|
-
const method = resolveRequestMethod(input, init);
|
|
395
|
-
const { scope, path } = parseScope(url);
|
|
396
|
-
|
|
397
|
-
// Capture request body
|
|
398
|
-
let requestBody: string | undefined;
|
|
399
|
-
if (init?.body) {
|
|
400
|
-
requestBody =
|
|
401
|
-
typeof init.body === "string"
|
|
402
|
-
? init.body
|
|
403
|
-
: init.body instanceof ArrayBuffer
|
|
404
|
-
? new TextDecoder().decode(init.body)
|
|
405
|
-
: undefined;
|
|
406
|
-
} else if (input instanceof Request) {
|
|
407
|
-
try {
|
|
408
|
-
requestBody = await input.clone().text();
|
|
409
|
-
} catch {
|
|
410
|
-
// Ignore if body can't be read
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// Call real fetch
|
|
415
|
-
const response = await originalFetch(input, init);
|
|
416
|
-
|
|
417
|
-
// Check filter
|
|
418
|
-
if (filter && !path.includes(filter)) {
|
|
419
|
-
return response;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// Capture content-type from the real response
|
|
423
|
-
const contentType = response.headers.get("content-type") ?? undefined;
|
|
424
|
-
|
|
425
|
-
// No body — record immediately
|
|
426
|
-
if (!response.body) {
|
|
427
|
-
recordings.push({
|
|
428
|
-
scope,
|
|
429
|
-
method,
|
|
430
|
-
path,
|
|
431
|
-
body: requestBody ?? "",
|
|
432
|
-
status: response.status,
|
|
433
|
-
response: "",
|
|
434
|
-
responseIsBinary: false,
|
|
435
|
-
contentType,
|
|
436
|
-
});
|
|
437
|
-
return response;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Wrap the response body with a TransformStream that captures
|
|
441
|
-
// chunks as the caller consumes them. The capture is tracked in
|
|
442
|
-
// `inFlight` so the exit handler can finalize it even if the
|
|
443
|
-
// stream hasn't completed (e.g. process.exit() during SSE).
|
|
444
|
-
const capture: InFlightCapture = {
|
|
445
|
-
scope,
|
|
446
|
-
method,
|
|
447
|
-
path,
|
|
448
|
-
body: requestBody ?? "",
|
|
449
|
-
status: response.status,
|
|
450
|
-
contentType,
|
|
451
|
-
chunks: [],
|
|
452
|
-
finalized: false,
|
|
453
|
-
};
|
|
454
|
-
inFlight.push(capture);
|
|
455
|
-
|
|
456
|
-
const originalBody = response.body;
|
|
457
|
-
const transform = new TransformStream<Uint8Array, Uint8Array>({
|
|
458
|
-
transform(chunk, controller) {
|
|
459
|
-
capture.chunks.push(chunk);
|
|
460
|
-
controller.enqueue(chunk);
|
|
461
|
-
},
|
|
462
|
-
flush() {
|
|
463
|
-
finalizeCapture(capture);
|
|
464
|
-
},
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
const wrappedBody = originalBody.pipeThrough(transform);
|
|
468
|
-
|
|
469
|
-
return new Response(wrappedBody, {
|
|
470
|
-
status: response.status,
|
|
471
|
-
statusText: response.statusText,
|
|
472
|
-
headers: response.headers,
|
|
473
|
-
});
|
|
474
|
-
},
|
|
475
|
-
{ preconnect: (_url: string | URL) => {} },
|
|
476
|
-
);
|
|
477
|
-
|
|
478
|
-
const filterDesc = filter ? `matching path "*${filter}*"` : "all paths";
|
|
479
|
-
process.stderr.write(
|
|
480
|
-
`[VCR] Recording HTTP requests (${filterDesc}). Cassette will be saved to: ${cassettePath}\n`,
|
|
481
|
-
);
|
|
482
|
-
|
|
483
|
-
// Save recordings — finalizes any in-flight stream captures first
|
|
484
|
-
let saved = false;
|
|
485
|
-
const saveRecordings = () => {
|
|
486
|
-
if (saved) {
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
saved = true;
|
|
490
|
-
|
|
491
|
-
// Restore original fetch
|
|
492
|
-
globalThis.fetch = originalFetch;
|
|
493
|
-
|
|
494
|
-
// Finalize any in-flight stream captures with whatever data
|
|
495
|
-
// has been received so far (critical for SSE streams that may
|
|
496
|
-
// still be open when process.exit() is called).
|
|
497
|
-
for (const capture of inFlight) {
|
|
498
|
-
finalizeCapture(capture);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
if (recordings.length === 0) {
|
|
502
|
-
process.stderr.write(
|
|
503
|
-
`[VCR] No HTTP requests${filter ? ` matching "${filter}"` : ""} were recorded.\n`,
|
|
504
|
-
);
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
const dir = dirname(cassettePath);
|
|
509
|
-
mkdirSync(dir, { recursive: true });
|
|
510
|
-
|
|
511
|
-
const sanitized = recordings.map(sanitizeRecording);
|
|
512
|
-
writeFileSync(cassettePath, JSON.stringify(sanitized, null, 2));
|
|
513
|
-
process.stderr.write(
|
|
514
|
-
`[VCR] Saved ${sanitized.length} recorded HTTP interaction(s) to ${cassettePath}\n`,
|
|
515
|
-
);
|
|
516
|
-
};
|
|
517
|
-
|
|
518
|
-
process.on("exit", saveRecordings);
|
|
519
|
-
process.on("SIGTERM", () => {
|
|
520
|
-
saveRecordings();
|
|
521
|
-
process.exit(0);
|
|
522
|
-
});
|
|
523
|
-
process.on("SIGINT", () => {
|
|
524
|
-
saveRecordings();
|
|
525
|
-
process.exit(0);
|
|
526
|
-
});
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// ── Playback mode ───────────────────────────────────────────────────────
|
|
530
|
-
|
|
531
|
-
/**
|
|
532
|
-
* Split an SSE response body into individual event chunks.
|
|
533
|
-
* Each chunk is a complete "data: ...\n\n" segment.
|
|
534
|
-
*/
|
|
535
|
-
function splitSseChunks(body: string): string[] {
|
|
536
|
-
// Split on double-newline boundaries that separate SSE events
|
|
537
|
-
const chunks: string[] = [];
|
|
538
|
-
const parts = body.split(/\n\n/);
|
|
539
|
-
for (const part of parts) {
|
|
540
|
-
const trimmed = part.trim();
|
|
541
|
-
if (trimmed) {
|
|
542
|
-
chunks.push(`${trimmed}\n\n`);
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
return chunks;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/**
|
|
549
|
-
* Create a ReadableStream that delivers SSE chunks with a delay between each.
|
|
550
|
-
*/
|
|
551
|
-
function createDelayedSseStream(
|
|
552
|
-
chunks: string[],
|
|
553
|
-
delayMs: number,
|
|
554
|
-
): ReadableStream<Uint8Array> {
|
|
555
|
-
const encoder = new TextEncoder();
|
|
556
|
-
let index = 0;
|
|
557
|
-
|
|
558
|
-
return new ReadableStream<Uint8Array>({
|
|
559
|
-
async pull(controller) {
|
|
560
|
-
if (index >= chunks.length) {
|
|
561
|
-
controller.close();
|
|
562
|
-
return;
|
|
563
|
-
}
|
|
564
|
-
if (index > 0 && delayMs > 0) {
|
|
565
|
-
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
566
|
-
}
|
|
567
|
-
controller.enqueue(encoder.encode(chunks[index]!));
|
|
568
|
-
index += 1;
|
|
569
|
-
},
|
|
570
|
-
});
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
function startPlayingBackRequests(cassettePath: string, filter: string): void {
|
|
574
|
-
if (!existsSync(cassettePath)) {
|
|
575
|
-
process.stderr.write(`[VCR] Cassette file not found: ${cassettePath}\n`);
|
|
576
|
-
process.exit(1);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
const recordings: VcrRecording[] = JSON.parse(
|
|
580
|
-
readFileSync(cassettePath, "utf-8"),
|
|
581
|
-
);
|
|
582
|
-
|
|
583
|
-
const sseDelayMs = Number.parseInt(
|
|
584
|
-
process.env.CLINE_VCR_SSE_DELAY ?? "100",
|
|
585
|
-
10,
|
|
586
|
-
);
|
|
587
|
-
|
|
588
|
-
// Track which recordings have been consumed (each can be used once)
|
|
589
|
-
const consumed = new Array<boolean>(recordings.length).fill(false);
|
|
590
|
-
const originalFetch = globalThis.fetch;
|
|
591
|
-
|
|
592
|
-
globalThis.fetch = Object.assign(
|
|
593
|
-
async (
|
|
594
|
-
input: string | URL | Request,
|
|
595
|
-
init?: RequestInit,
|
|
596
|
-
): Promise<Response> => {
|
|
597
|
-
const url = resolveRequestUrl(input);
|
|
598
|
-
const method = resolveRequestMethod(input, init);
|
|
599
|
-
const { path } = parseScope(url);
|
|
600
|
-
const normalizedPath = normalizePath(path);
|
|
601
|
-
|
|
602
|
-
// Check filter: if filter is set and path doesn't match, passthrough
|
|
603
|
-
if (filter && !path.includes(filter)) {
|
|
604
|
-
return originalFetch(input, init);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// Find a matching unconsumed recording
|
|
608
|
-
const matchIndex = recordings.findIndex((rec, index) => {
|
|
609
|
-
if (consumed[index]) {
|
|
610
|
-
return false;
|
|
611
|
-
}
|
|
612
|
-
// Match on method + normalized path. Scope is checked loosely
|
|
613
|
-
// (hostname may differ between record and playback environments).
|
|
614
|
-
const recNormalizedPath = normalizePath(rec.path);
|
|
615
|
-
return (
|
|
616
|
-
rec.method.toUpperCase() === method &&
|
|
617
|
-
recNormalizedPath === normalizedPath
|
|
618
|
-
);
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
if (matchIndex >= 0) {
|
|
622
|
-
consumed[matchIndex] = true;
|
|
623
|
-
const rec = recordings[matchIndex]!;
|
|
624
|
-
|
|
625
|
-
// Build response body
|
|
626
|
-
const body =
|
|
627
|
-
typeof rec.response === "string"
|
|
628
|
-
? rec.response
|
|
629
|
-
: JSON.stringify(rec.response);
|
|
630
|
-
|
|
631
|
-
// Use recorded content-type if available, otherwise infer from response shape
|
|
632
|
-
const headers = new Headers();
|
|
633
|
-
if (rec.contentType) {
|
|
634
|
-
headers.set("content-type", rec.contentType);
|
|
635
|
-
} else {
|
|
636
|
-
// Fallback heuristic for cassettes recorded before contentType was captured
|
|
637
|
-
const isSSE =
|
|
638
|
-
typeof rec.response === "string" &&
|
|
639
|
-
rec.response.trimStart().startsWith("data:");
|
|
640
|
-
if (isSSE) {
|
|
641
|
-
headers.set("content-type", "text/event-stream");
|
|
642
|
-
} else if (typeof rec.response === "object") {
|
|
643
|
-
headers.set("content-type", "application/json");
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
const isSSEResponse =
|
|
648
|
-
headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
649
|
-
|
|
650
|
-
// SSE responses need streaming-friendly headers
|
|
651
|
-
if (isSSEResponse) {
|
|
652
|
-
headers.set("cache-control", "no-cache");
|
|
653
|
-
headers.set("connection", "keep-alive");
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// For SSE responses, stream chunks with a delay to simulate
|
|
657
|
-
// real-time delivery (controlled by CLINE_VCR_SSE_DELAY).
|
|
658
|
-
if (isSSEResponse && typeof rec.response === "string") {
|
|
659
|
-
const chunks = splitSseChunks(rec.response);
|
|
660
|
-
if (chunks.length > 1) {
|
|
661
|
-
const stream = createDelayedSseStream(chunks, sseDelayMs);
|
|
662
|
-
return new Response(stream, {
|
|
663
|
-
status: rec.status,
|
|
664
|
-
headers,
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
return new Response(body, {
|
|
670
|
-
status: rec.status,
|
|
671
|
-
headers,
|
|
672
|
-
});
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// No match found
|
|
676
|
-
if (!filter) {
|
|
677
|
-
// Full isolation mode — no filter means nothing should leak
|
|
678
|
-
throw new Error(
|
|
679
|
-
`[VCR] No matching recording for ${method} ${url} (path: ${normalizedPath}). ` +
|
|
680
|
-
`${recordings.length} recording(s) loaded from ${cassettePath}.`,
|
|
681
|
-
);
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
// Filtered mode — passthrough non-matching requests
|
|
685
|
-
return originalFetch(input, init);
|
|
686
|
-
},
|
|
687
|
-
{ preconnect: (_url: string | URL) => {} },
|
|
688
|
-
);
|
|
689
|
-
|
|
690
|
-
const filterDesc = filter
|
|
691
|
-
? `(only paths matching "*${filter}*", all other requests go through normally)`
|
|
692
|
-
: "(all requests intercepted)";
|
|
693
|
-
process.stderr.write(
|
|
694
|
-
`[VCR] Playing back ${recordings.length} recorded HTTP interaction(s) from ${cassettePath} ${filterDesc}\n`,
|
|
695
|
-
);
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// ── Public API ──────────────────────────────────────────────────────────
|
|
699
|
-
|
|
700
|
-
/**
|
|
701
|
-
* Initialize VCR mode based on environment variables.
|
|
702
|
-
* Must be called early in startup, before HTTP requests are made.
|
|
703
|
-
*
|
|
704
|
-
* Does nothing if `CLINE_VCR` is not set.
|
|
705
|
-
*/
|
|
706
|
-
export function initVcr(vcrMode: string | undefined): void {
|
|
707
|
-
const config = getVcrConfig(vcrMode);
|
|
708
|
-
if (!config) {
|
|
709
|
-
return;
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
if (config.mode === "record") {
|
|
713
|
-
startRecordingRequests(config.cassettePath, config.filter);
|
|
714
|
-
} else {
|
|
715
|
-
startPlayingBackRequests(config.cassettePath, config.filter);
|
|
716
|
-
}
|
|
717
|
-
}
|