@clinebot/shared 0.0.8 → 0.0.10

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/src/vcr.ts ADDED
@@ -0,0 +1,717 @@
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
+ }