test-proxy-recorder 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -7,8 +7,6 @@ import crypto from 'crypto';
7
7
  import path from 'path';
8
8
  import filenamify2 from 'filenamify';
9
9
 
10
- // src/ProxyServer.ts
11
-
12
10
  // src/constants.ts
13
11
  var DEFAULT_TIMEOUT_MS = 120 * 1e3;
14
12
  var HTTP_STATUS_BAD_GATEWAY = 502;
@@ -16,6 +14,7 @@ var HTTP_STATUS_OK = 200;
16
14
  var HTTP_STATUS_BAD_REQUEST = 400;
17
15
  var HTTP_STATUS_NOT_FOUND = 404;
18
16
  var CONTROL_ENDPOINT = "/__control";
17
+ var RECORDING_ID_HEADER = "x-test-rcrd-id";
19
18
 
20
19
  // src/types.ts
21
20
  var Modes = {
@@ -49,13 +48,23 @@ async function loadRecordingSession(filePath) {
49
48
  return JSON.parse(fileContent);
50
49
  }
51
50
  function processRecordings(recordings) {
52
- const keySequenceMap = /* @__PURE__ */ new Map();
53
- return recordings.map((recording) => {
51
+ const recordingsByKey = /* @__PURE__ */ new Map();
52
+ for (const recording of recordings) {
54
53
  const key = recording.key;
55
- const currentSeq = keySequenceMap.get(key) || 0;
56
- keySequenceMap.set(key, currentSeq + 1);
57
- return { ...recording, sequence: currentSeq };
58
- });
54
+ if (!recordingsByKey.has(key)) {
55
+ recordingsByKey.set(key, []);
56
+ }
57
+ recordingsByKey.get(key).push(recording);
58
+ }
59
+ const processedRecordings = [];
60
+ for (const [_key, keyRecordings] of recordingsByKey) {
61
+ keyRecordings.sort((a, b) => a.recordingId - b.recordingId);
62
+ keyRecordings.forEach((recording, index) => {
63
+ processedRecordings.push({ ...recording, sequence: index });
64
+ });
65
+ }
66
+ processedRecordings.sort((a, b) => a.recordingId - b.recordingId);
67
+ return processedRecordings;
59
68
  }
60
69
  async function saveRecordingSession(recordingsDir, session) {
61
70
  const filePath = getRecordingPath(recordingsDir, session.id);
@@ -118,19 +127,25 @@ var ProxyServer = class {
118
127
  recordingsDir;
119
128
  recordingIdCounter;
120
129
  // Unique ID for each recording entry
130
+ sequenceCounterByKey;
131
+ // Sequence counter per key (endpoint)
121
132
  replaySessions;
122
133
  // Track multiple concurrent replay sessions by recording ID
134
+ recordingPromises;
135
+ // Stack of promises that resolve to completed recordings
123
136
  constructor(targets, recordingsDir) {
124
137
  this.targets = targets;
125
138
  this.currentTargetIndex = 0;
126
139
  this.mode = Modes.transparent;
127
140
  this.recordingId = null;
128
141
  this.recordingIdCounter = 0;
142
+ this.sequenceCounterByKey = /* @__PURE__ */ new Map();
129
143
  this.replayId = null;
130
144
  this.modeTimeout = null;
131
145
  this.currentSession = null;
132
146
  this.recordingsDir = recordingsDir;
133
147
  this.replaySessions = /* @__PURE__ */ new Map();
148
+ this.recordingPromises = [];
134
149
  this.proxy = httpProxy.createProxyServer({
135
150
  secure: false,
136
151
  changeOrigin: true,
@@ -156,7 +171,7 @@ var ProxyServer = class {
156
171
  }
157
172
  setupProxyEventHandlers() {
158
173
  this.proxy.on("error", this.handleProxyError.bind(this));
159
- this.proxy.on("proxyRes", this.handleProxyResponse.bind(this));
174
+ this.proxy.on("proxyRes", this.addCorsHeaders.bind(this));
160
175
  }
161
176
  handleProxyError(err, req, res) {
162
177
  console.error("Proxy error:", err);
@@ -172,12 +187,6 @@ var ProxyServer = class {
172
187
  }
173
188
  res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
174
189
  }
175
- handleProxyResponse(proxyRes, req) {
176
- this.addCorsHeaders(proxyRes, req);
177
- if (this.mode === Modes.record && this.recordingId) {
178
- this.recordResponse(req, proxyRes);
179
- }
180
- }
181
190
  /**
182
191
  * Get CORS headers for a given request
183
192
  * @param req The incoming HTTP request
@@ -188,7 +197,7 @@ var ProxyServer = class {
188
197
  return {
189
198
  "access-control-allow-origin": origin || "*",
190
199
  "access-control-allow-credentials": "true",
191
- "access-control-allow-headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
200
+ "access-control-allow-headers": req.headers["access-control-request-headers"] || `Origin, X-Requested-With, Content-Type, Accept, Authorization, ${RECORDING_ID_HEADER}`,
192
201
  "access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
193
202
  "access-control-expose-headers": "*"
194
203
  };
@@ -202,9 +211,22 @@ var ProxyServer = class {
202
211
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
203
212
  return target;
204
213
  }
214
+ /**
215
+ * Extract recording ID from custom HTTP header
216
+ * Used for concurrent replay session routing, especially with Next.js
217
+ * @param req The incoming HTTP request
218
+ * @returns The recording ID from header, or null if not found
219
+ */
220
+ getRecordingIdFromHeader(req) {
221
+ const headerValue = req.headers[RECORDING_ID_HEADER];
222
+ if (!headerValue) {
223
+ return null;
224
+ }
225
+ return Array.isArray(headerValue) ? headerValue[0] : headerValue;
226
+ }
205
227
  /**
206
228
  * Extract recording ID from request cookie
207
- * Used for concurrent replay session routing
229
+ * Used for concurrent replay session routing (fallback method)
208
230
  * @param req The incoming HTTP request
209
231
  * @returns The recording ID from cookie, or null if not found
210
232
  */
@@ -216,6 +238,14 @@ var ProxyServer = class {
216
238
  const match = cookies.match(/proxy-recording-id=([^;]+)/);
217
239
  return match ? decodeURIComponent(match[1]) : null;
218
240
  }
241
+ /**
242
+ * Extract recording ID from request using custom header (preferred) or cookie (fallback)
243
+ * @param req The incoming HTTP request
244
+ * @returns The recording ID, or null if not found
245
+ */
246
+ getRecordingIdFromRequest(req) {
247
+ return this.getRecordingIdFromHeader(req) || this.getRecordingIdFromCookie(req);
248
+ }
219
249
  /**
220
250
  * Get or create a replay session state for a given recording ID
221
251
  * @param recordingId The recording ID to get/create session for
@@ -250,18 +280,20 @@ var ProxyServer = class {
250
280
  }
251
281
  return { mode, id, timeout };
252
282
  }
283
+ async parseControlRequest(req) {
284
+ if (req.method === "GET") {
285
+ return this.parseGetParams(req);
286
+ }
287
+ if (req.method === "POST") {
288
+ const body = await readRequestBody(req);
289
+ console.log(`MODE CHANGE (${req.method})`, body);
290
+ return JSON.parse(body);
291
+ }
292
+ throw new Error("Unsupported control method");
293
+ }
253
294
  async handleControlRequest(req, res) {
254
295
  try {
255
- let data;
256
- if (req.method === "GET") {
257
- data = this.parseGetParams(req);
258
- } else if (req.method === "POST") {
259
- const body = await readRequestBody(req);
260
- console.log(`MODE CHANGE (${req.method})`, body);
261
- data = JSON.parse(body);
262
- } else {
263
- return;
264
- }
296
+ const data = await this.parseControlRequest(req);
265
297
  const { mode, id, timeout: requestTimeout } = data;
266
298
  const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
267
299
  this.clearModeTimeout();
@@ -294,7 +326,7 @@ var ProxyServer = class {
294
326
  async switchMode(mode, id) {
295
327
  console.log(`Switching to ${mode.toUpperCase()} mode`);
296
328
  if (this.currentSession && this.mode === Modes.record) {
297
- await this.saveCurrentSession(true);
329
+ await this.saveCurrentSession();
298
330
  console.log("Session saved, continuing with mode switch");
299
331
  }
300
332
  switch (mode) {
@@ -334,6 +366,8 @@ var ProxyServer = class {
334
366
  this.recordingId = id;
335
367
  this.replayId = null;
336
368
  this.currentSession = { id, recordings: [], websocketRecordings: [] };
369
+ this.recordingIdCounter = 0;
370
+ this.sequenceCounterByKey.clear();
337
371
  console.log(`Switched to record mode with ID: ${id}`);
338
372
  }
339
373
  async switchToReplayMode(id) {
@@ -353,169 +387,91 @@ var ProxyServer = class {
353
387
  setupModeTimeout(timeout) {
354
388
  this.modeTimeout = setTimeout(async () => {
355
389
  console.log("Timeout reached, switching back to transparent mode");
356
- await this.saveCurrentSession(true);
390
+ await this.saveCurrentSession();
357
391
  this.switchToTransparentMode();
358
392
  this.modeTimeout = null;
359
393
  }, timeout);
360
394
  }
361
- async saveCurrentSession(filterIncomplete = false) {
362
- if (!this.currentSession) {
395
+ async flushPendingRecordings() {
396
+ if (this.recordingPromises.length === 0) {
363
397
  return;
364
398
  }
365
- if (filterIncomplete) {
366
- const incompleteCount = this.currentSession.recordings.filter(
367
- (r) => !r.response
368
- ).length;
369
- if (incompleteCount > 0) {
370
- this.currentSession.recordings = this.currentSession.recordings.filter(
371
- (r) => r.response
372
- );
399
+ const results = await Promise.allSettled(this.recordingPromises);
400
+ if (this.currentSession) {
401
+ for (const result of results) {
402
+ if (result.status === "fulfilled" && result.value) {
403
+ this.currentSession.recordings.push(result.value);
404
+ }
373
405
  }
406
+ console.log(
407
+ `Flushed ${results.length} recordings to session (total: ${this.currentSession.recordings.length})`
408
+ );
374
409
  }
375
- console.log(
376
- `Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
377
- );
378
- await saveRecordingSession(this.recordingsDir, this.currentSession);
410
+ this.recordingPromises = [];
379
411
  }
380
- saveRequestRecordSync(req, body) {
412
+ async saveCurrentSession() {
381
413
  if (!this.currentSession) {
382
414
  return;
383
415
  }
384
- const key = getReqID(req);
385
- const recordingId = this.recordingIdCounter++;
386
- req.__recordingId = recordingId;
387
- const record = {
388
- request: {
389
- method: req.method,
390
- url: req.url,
391
- headers: req.headers,
392
- body: body || null
393
- },
394
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
395
- key,
396
- recordingId
397
- };
398
- this.currentSession.recordings.push(record);
416
+ await this.flushPendingRecordings();
399
417
  console.log(
400
- // eslint-disable-next-line sonarjs/no-nested-template-literals
401
- `saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key}, recordingId: ${recordingId}, body: ${body ? `${body.length} chars` : "null"}, total: ${this.currentSession.recordings.length}, sessionId: ${this.currentSession.id})`
418
+ `Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
402
419
  );
420
+ await saveRecordingSession(this.recordingsDir, this.currentSession);
403
421
  }
404
- updateRequestBodySync(req, body) {
405
- if (!this.currentSession) {
406
- return;
407
- }
408
- const recordingId = req.__recordingId;
409
- if (recordingId === void 0) {
410
- console.error(
411
- `updateRequestBodySync: No recording ID found on request ${req.method} ${req.url}`
412
- );
413
- return;
414
- }
415
- const record = this.currentSession.recordings.find(
416
- (r) => r.recordingId === recordingId
417
- );
418
- if (!record) {
419
- console.error(
420
- `updateRequestBodySync: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
421
- );
422
- return;
422
+ getRecordingIdOrError(req, res) {
423
+ const recordingId = this.getRecordingIdFromRequest(req) || this.replayId;
424
+ if (!recordingId) {
425
+ const corsHeaders = this.getCorsHeaders(req);
426
+ res.writeHead(HTTP_STATUS_BAD_REQUEST, {
427
+ "Content-Type": "application/json",
428
+ ...corsHeaders
429
+ });
430
+ res.end(JSON.stringify({ error: "No replay session active" }));
431
+ return null;
423
432
  }
424
- record.request.body = body || null;
425
- console.log(
426
- `updateRequestBodySync: Updated body for ${req.method} ${req.url} (${body.length} chars, recordingId: ${recordingId})`
427
- );
433
+ return recordingId;
428
434
  }
429
- async recordResponse(req, proxyRes) {
430
- if (!this.currentSession) {
431
- return;
432
- }
433
- const recordingId = req.__recordingId;
434
- if (recordingId === void 0) {
435
- console.error(
436
- `recordResponse: No recording ID found on request ${req.method} ${req.url}`
437
- );
438
- return;
435
+ async ensureSessionLoaded(recordingId, filePath) {
436
+ const sessionState = this.getOrCreateReplaySession(recordingId);
437
+ if (!sessionState.loadedSession) {
438
+ sessionState.loadedSession = await loadRecordingSession(filePath);
439
+ console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
439
440
  }
440
- const record = this.currentSession.recordings.find(
441
- (r) => r.recordingId === recordingId
442
- );
443
- if (!record) {
444
- console.error(
445
- `recordResponse: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
446
- );
447
- return;
448
- }
449
- const chunks = [];
450
- proxyRes.on("data", (chunk) => {
451
- chunks.push(chunk);
452
- });
453
- proxyRes.on("end", async () => {
454
- const body = Buffer.concat(chunks).toString("utf8");
455
- record.response = {
456
- statusCode: proxyRes.statusCode,
457
- headers: proxyRes.headers,
458
- body: body || null
459
- };
460
- console.log(
461
- `Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
462
- );
463
- });
441
+ return sessionState;
464
442
  }
465
- async recordResponseData(req, proxyRes, body) {
466
- if (!this.currentSession) {
467
- return false;
443
+ getServedTracker(sessionState, key) {
444
+ if (!sessionState.servedRecordingIdsByKey.has(key)) {
445
+ sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
468
446
  }
469
- const recordingId = req.__recordingId;
470
- if (recordingId === void 0) {
471
- console.error(
472
- `recordResponseData: No recording ID found on request ${req.method} ${req.url}`
473
- );
474
- return false;
447
+ return sessionState.servedRecordingIdsByKey.get(key);
448
+ }
449
+ selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
450
+ for (const rec of recordsWithKey) {
451
+ if (!servedForThisKey.has(rec.recordingId)) {
452
+ return rec;
453
+ }
475
454
  }
476
- const record = this.currentSession.recordings.find(
477
- (r) => r.recordingId === recordingId
478
- );
479
- if (!record) {
480
- console.error(
481
- `recordResponseData: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
455
+ if (recordsWithKey.length > 0) {
456
+ console.log(
457
+ `[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
482
458
  );
483
- return false;
459
+ return recordsWithKey[recordsWithKey.length - 1];
484
460
  }
485
- record.response = {
486
- statusCode: proxyRes.statusCode,
487
- headers: proxyRes.headers,
488
- body: body || null
489
- };
490
- console.log(
491
- `recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
492
- );
493
- return true;
461
+ return null;
494
462
  }
495
463
  async handleReplayRequest(req, res) {
496
- const recordingId = this.getRecordingIdFromCookie(req) || this.replayId;
497
- if (!recordingId) {
498
- const corsHeaders = this.getCorsHeaders(req);
499
- res.writeHead(HTTP_STATUS_BAD_REQUEST, {
500
- "Content-Type": "application/json",
501
- ...corsHeaders
502
- });
503
- res.end(JSON.stringify({ error: "No replay session active" }));
504
- return;
505
- }
464
+ const recordingId = this.getRecordingIdOrError(req, res);
465
+ if (!recordingId) return;
506
466
  const key = getReqID(req);
507
467
  const filePath = getRecordingPath(this.recordingsDir, recordingId);
508
468
  try {
509
- const sessionState = this.getOrCreateReplaySession(recordingId);
510
- if (!sessionState.loadedSession) {
511
- sessionState.loadedSession = await loadRecordingSession(filePath);
512
- console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
513
- }
469
+ const sessionState = await this.ensureSessionLoaded(
470
+ recordingId,
471
+ filePath
472
+ );
514
473
  const session = sessionState.loadedSession;
515
- if (!sessionState.servedRecordingIdsByKey.has(key)) {
516
- sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
517
- }
518
- const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
474
+ const servedForThisKey = this.getServedTracker(sessionState, key);
519
475
  const host = req.headers.host || "unknown";
520
476
  const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
521
477
  const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
@@ -544,30 +500,23 @@ var ProxyServer = class {
544
500
  }
545
501
  const requestCount = servedForThisKey.size + 1;
546
502
  console.log(
547
- `[replay request #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
503
+ `[replay request #${requestCount}] ${req.method} ${req.url} (key: ${key}, session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
548
504
  );
549
- let record;
550
- for (const rec of recordsWithKey) {
551
- if (!servedForThisKey.has(rec.recordingId)) {
552
- record = rec;
553
- break;
554
- }
555
- }
556
- if (!record) {
557
- console.log(
558
- `[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
505
+ const record = this.selectReplayRecord(
506
+ recordsWithKey,
507
+ servedForThisKey,
508
+ key,
509
+ recordingId
510
+ );
511
+ if (!record || !record.response) {
512
+ throw new Error(
513
+ `No response recorded for this request: ${req.method} ${host}${req.url}`
559
514
  );
560
- record = recordsWithKey[recordsWithKey.length - 1];
561
515
  }
562
516
  servedForThisKey.add(record.recordingId);
563
517
  console.log(
564
518
  `[replay serving] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
565
519
  );
566
- if (!record.response) {
567
- throw new Error(
568
- `No response recorded for this request: ${req.method} ${host}${req.url}`
569
- );
570
- }
571
520
  const { statusCode, headers, body } = record.response;
572
521
  const responseHeaders = {
573
522
  ...headers,
@@ -622,82 +571,124 @@ var ProxyServer = class {
622
571
  const target = this.getTarget();
623
572
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
624
573
  if (this.mode === Modes.record) {
625
- this.saveRequestRecordSync(req, null);
626
- await this.bufferAndProxyRequest(req, res, target);
574
+ await this.recordAndProxyRequest(req, res, target);
627
575
  } else {
628
576
  this.proxy.web(req, res, { target });
629
577
  }
630
578
  }
631
- // TODO: check if can handle streaming requests
632
- async bufferAndProxyRequest(req, res, target) {
633
- const chunks = [];
634
- req.on("data", (chunk) => {
635
- chunks.push(chunk);
636
- });
637
- try {
638
- await new Promise((resolve, reject) => {
639
- req.on("end", () => resolve());
640
- req.on("error", (err) => reject(err));
641
- setTimeout(
642
- () => reject(new Error("Request buffering timeout")),
643
- 3e4
644
- );
645
- });
646
- } catch (error) {
647
- console.error("Error buffering request:", error);
648
- }
649
- const body = Buffer.concat(chunks).toString("utf8");
650
- this.updateRequestBodySync(req, body);
651
- const targetUrl = new URL(target);
652
- const isHttps = targetUrl.protocol === "https:";
653
- const requestModule = isHttps ? https : http;
654
- const defaultPort = isHttps ? 443 : 80;
655
- const proxyReq = requestModule.request(
656
- {
657
- hostname: targetUrl.hostname,
658
- port: targetUrl.port || defaultPort,
659
- path: req.url,
660
- method: req.method,
661
- headers: req.headers
662
- },
663
- (proxyRes) => {
664
- this.addCorsHeaders(proxyRes, req);
665
- const responseChunks = [];
666
- proxyRes.on("data", (chunk) => {
667
- responseChunks.push(chunk);
668
- });
669
- proxyRes.on("end", async () => {
670
- const responseBody = Buffer.concat(responseChunks);
671
- const recorded = await this.recordResponseData(
672
- req,
673
- proxyRes,
674
- responseBody.toString("utf8")
675
- );
676
- const responseHeaders = {
677
- ...proxyRes.headers,
678
- ...this.getCorsHeaders(req)
679
- };
680
- res.writeHead(proxyRes.statusCode || 200, responseHeaders);
681
- res.end(responseBody);
682
- if (recorded) {
683
- console.log(`Recorded: ${req.method} ${req.url}`);
579
+ // Note: streaming requests are buffered before proxying; streaming passthrough is not yet implemented
580
+ async recordAndProxyRequest(req, res, target) {
581
+ if (!this.currentSession) {
582
+ return;
583
+ }
584
+ const key = getReqID(req);
585
+ const recordingId = this.recordingIdCounter++;
586
+ const sequence = this.sequenceCounterByKey.get(key) || 0;
587
+ this.sequenceCounterByKey.set(key, sequence + 1);
588
+ const recordingPromise = new Promise((resolve) => {
589
+ (async () => {
590
+ try {
591
+ const chunks = [];
592
+ req.on("data", (chunk) => {
593
+ chunks.push(chunk);
594
+ });
595
+ try {
596
+ await new Promise((resolveBuffer, rejectBuffer) => {
597
+ req.on("end", () => resolveBuffer());
598
+ req.on("error", (err) => rejectBuffer(err));
599
+ setTimeout(
600
+ () => rejectBuffer(new Error("Request buffering timeout")),
601
+ 3e4
602
+ );
603
+ });
604
+ } catch (error) {
605
+ console.error("Error buffering request:", error);
684
606
  }
685
- });
686
- proxyRes.on("error", (err) => {
687
- console.error("Proxy response error:", err);
688
- if (!res.headersSent) {
607
+ const requestBody = Buffer.concat(chunks).toString("utf8");
608
+ const targetUrl = new URL(target);
609
+ const isHttps = targetUrl.protocol === "https:";
610
+ const requestModule = isHttps ? https : http;
611
+ const defaultPort = isHttps ? 443 : 80;
612
+ const proxyReq = requestModule.request(
613
+ {
614
+ hostname: targetUrl.hostname,
615
+ port: targetUrl.port || defaultPort,
616
+ path: req.url,
617
+ method: req.method,
618
+ headers: req.headers
619
+ },
620
+ (proxyRes) => {
621
+ this.addCorsHeaders(proxyRes, req);
622
+ const responseChunks = [];
623
+ proxyRes.on("data", (chunk) => {
624
+ responseChunks.push(chunk);
625
+ });
626
+ proxyRes.on("end", async () => {
627
+ try {
628
+ const responseBody = Buffer.concat(responseChunks);
629
+ const responseBodyStr = responseBody.toString("utf8");
630
+ const recording = {
631
+ request: {
632
+ method: req.method,
633
+ url: req.url,
634
+ headers: req.headers,
635
+ body: requestBody || null
636
+ },
637
+ response: {
638
+ statusCode: proxyRes.statusCode,
639
+ headers: proxyRes.headers,
640
+ body: responseBodyStr || null
641
+ },
642
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
643
+ key,
644
+ recordingId,
645
+ sequence
646
+ };
647
+ const responseHeaders = {
648
+ ...proxyRes.headers,
649
+ ...this.getCorsHeaders(req)
650
+ };
651
+ res.writeHead(proxyRes.statusCode || 200, responseHeaders);
652
+ res.end(responseBody);
653
+ console.log(
654
+ `Recorded: ${req.method} ${req.url} (recordingId: ${recordingId}, sequence: ${sequence})`
655
+ );
656
+ resolve(recording);
657
+ } catch (error) {
658
+ console.error("Error completing recording:", error);
659
+ resolve(null);
660
+ }
661
+ });
662
+ proxyRes.on("error", (err) => {
663
+ console.error("Proxy response error:", err);
664
+ if (!res.headersSent) {
665
+ this.handleProxyError(err, req, res);
666
+ }
667
+ resolve(null);
668
+ });
669
+ }
670
+ );
671
+ proxyReq.on("error", (err) => {
689
672
  this.handleProxyError(err, req, res);
673
+ resolve(null);
674
+ });
675
+ if (chunks.length > 0) {
676
+ proxyReq.write(Buffer.concat(chunks));
690
677
  }
691
- });
692
- }
693
- );
694
- proxyReq.on("error", (err) => {
695
- this.handleProxyError(err, req, res);
678
+ proxyReq.end();
679
+ } catch (error) {
680
+ console.error("Error in recordAndProxyRequest:", error);
681
+ try {
682
+ this.handleProxyError(error, req, res);
683
+ } catch (error_) {
684
+ console.error("Failed to handle proxy error:", error_);
685
+ }
686
+ resolve(null);
687
+ }
688
+ })();
696
689
  });
697
- if (chunks.length > 0) {
698
- proxyReq.write(Buffer.concat(chunks));
699
- }
700
- proxyReq.end();
690
+ this.recordingPromises.push(recordingPromise);
691
+ await recordingPromise;
701
692
  }
702
693
  handleUpgrade(req, socket, head) {
703
694
  if (this.mode === Modes.replay) {
@@ -942,23 +933,29 @@ async function stopProxy(testInfo) {
942
933
  }
943
934
  var playwrightProxy = {
944
935
  /**
945
- * Setup before test - sets the proxy mode
936
+ * Setup before test - sets the proxy mode and configures page with custom header
937
+ * Automatically sets up page.on('close') handler for cleanup
938
+ * @param page - Playwright page object
946
939
  * @param testInfo - Playwright test info object
947
940
  * @param mode - The proxy mode to use for this test
948
941
  * @param timeout - Optional timeout in milliseconds
949
942
  */
950
- async before(testInfo, mode, timeout) {
943
+ async before(page, testInfo, mode, timeout) {
951
944
  const sessionId = generateSessionId(testInfo);
945
+ await page.setExtraHTTPHeaders({
946
+ [RECORDING_ID_HEADER]: sessionId
947
+ });
952
948
  await setProxyMode(mode, sessionId, timeout);
953
- },
954
- /**
955
- * Cleanup after test - resets replay session by re-entering replay mode
956
- * switchToReplayMode automatically clears sequence counters
957
- * @param testInfo - Playwright test info object
958
- */
959
- async after(testInfo) {
960
- const sessionId = generateSessionId(testInfo);
961
- await setProxyMode(Modes.replay, sessionId);
949
+ page.on("close", async () => {
950
+ try {
951
+ await setProxyMode(Modes.replay, sessionId);
952
+ console.log(
953
+ `[Cleanup] Switched to replay mode for session: ${sessionId}`
954
+ );
955
+ } catch (error) {
956
+ console.error("[Cleanup] Error during page close cleanup:", error);
957
+ }
958
+ });
962
959
  },
963
960
  /**
964
961
  * Global teardown - switches proxy to transparent mode
@@ -969,6 +966,38 @@ var playwrightProxy = {
969
966
  }
970
967
  };
971
968
 
972
- export { ProxyServer, generateSessionId, playwrightProxy, setProxyMode, startRecording, startReplay, stopProxy };
969
+ // src/nextjs/middleware.ts
970
+ function isRecorderEnabled() {
971
+ const isProduction = process.env.NODE_ENV === "production";
972
+ const isExplicitlyEnabled = process.env.TEST_PROXY_RECORDER_ENABLED === "true" || Number.parseInt(process.env.TEST_PROXY_RECORDER_ENABLED || "") === 1;
973
+ return !isProduction || isExplicitlyEnabled;
974
+ }
975
+ function setNextProxyHeaders(request, response) {
976
+ if (!isRecorderEnabled()) {
977
+ return;
978
+ }
979
+ const recordingId = request.headers.get(RECORDING_ID_HEADER);
980
+ if (recordingId) {
981
+ response.headers.set(RECORDING_ID_HEADER, recordingId);
982
+ }
983
+ }
984
+ function getRecordingId(requestHeaders) {
985
+ if (requestHeaders instanceof Headers) {
986
+ return requestHeaders.get(RECORDING_ID_HEADER);
987
+ }
988
+ return requestHeaders.headers.get(RECORDING_ID_HEADER);
989
+ }
990
+ function createHeadersWithRecordingId(requestHeaders, additionalHeaders = {}) {
991
+ if (!isRecorderEnabled()) {
992
+ return additionalHeaders;
993
+ }
994
+ const recordingId = getRecordingId(requestHeaders);
995
+ return {
996
+ ...additionalHeaders,
997
+ ...recordingId && { [RECORDING_ID_HEADER]: recordingId }
998
+ };
999
+ }
1000
+
1001
+ export { ProxyServer, RECORDING_ID_HEADER, createHeadersWithRecordingId, generateSessionId, getRecordingId, playwrightProxy, setNextProxyHeaders, setProxyMode, startRecording, startReplay, stopProxy };
973
1002
  //# sourceMappingURL=index.mjs.map
974
1003
  //# sourceMappingURL=index.mjs.map