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.cjs CHANGED
@@ -19,8 +19,6 @@ var crypto__default = /*#__PURE__*/_interopDefault(crypto);
19
19
  var path__default = /*#__PURE__*/_interopDefault(path);
20
20
  var filenamify2__default = /*#__PURE__*/_interopDefault(filenamify2);
21
21
 
22
- // src/ProxyServer.ts
23
-
24
22
  // src/constants.ts
25
23
  var DEFAULT_TIMEOUT_MS = 120 * 1e3;
26
24
  var HTTP_STATUS_BAD_GATEWAY = 502;
@@ -28,6 +26,7 @@ var HTTP_STATUS_OK = 200;
28
26
  var HTTP_STATUS_BAD_REQUEST = 400;
29
27
  var HTTP_STATUS_NOT_FOUND = 404;
30
28
  var CONTROL_ENDPOINT = "/__control";
29
+ var RECORDING_ID_HEADER = "x-test-rcrd-id";
31
30
 
32
31
  // src/types.ts
33
32
  var Modes = {
@@ -61,13 +60,23 @@ async function loadRecordingSession(filePath) {
61
60
  return JSON.parse(fileContent);
62
61
  }
63
62
  function processRecordings(recordings) {
64
- const keySequenceMap = /* @__PURE__ */ new Map();
65
- return recordings.map((recording) => {
63
+ const recordingsByKey = /* @__PURE__ */ new Map();
64
+ for (const recording of recordings) {
66
65
  const key = recording.key;
67
- const currentSeq = keySequenceMap.get(key) || 0;
68
- keySequenceMap.set(key, currentSeq + 1);
69
- return { ...recording, sequence: currentSeq };
70
- });
66
+ if (!recordingsByKey.has(key)) {
67
+ recordingsByKey.set(key, []);
68
+ }
69
+ recordingsByKey.get(key).push(recording);
70
+ }
71
+ const processedRecordings = [];
72
+ for (const [_key, keyRecordings] of recordingsByKey) {
73
+ keyRecordings.sort((a, b) => a.recordingId - b.recordingId);
74
+ keyRecordings.forEach((recording, index) => {
75
+ processedRecordings.push({ ...recording, sequence: index });
76
+ });
77
+ }
78
+ processedRecordings.sort((a, b) => a.recordingId - b.recordingId);
79
+ return processedRecordings;
71
80
  }
72
81
  async function saveRecordingSession(recordingsDir, session) {
73
82
  const filePath = getRecordingPath(recordingsDir, session.id);
@@ -130,19 +139,25 @@ var ProxyServer = class {
130
139
  recordingsDir;
131
140
  recordingIdCounter;
132
141
  // Unique ID for each recording entry
142
+ sequenceCounterByKey;
143
+ // Sequence counter per key (endpoint)
133
144
  replaySessions;
134
145
  // Track multiple concurrent replay sessions by recording ID
146
+ recordingPromises;
147
+ // Stack of promises that resolve to completed recordings
135
148
  constructor(targets, recordingsDir) {
136
149
  this.targets = targets;
137
150
  this.currentTargetIndex = 0;
138
151
  this.mode = Modes.transparent;
139
152
  this.recordingId = null;
140
153
  this.recordingIdCounter = 0;
154
+ this.sequenceCounterByKey = /* @__PURE__ */ new Map();
141
155
  this.replayId = null;
142
156
  this.modeTimeout = null;
143
157
  this.currentSession = null;
144
158
  this.recordingsDir = recordingsDir;
145
159
  this.replaySessions = /* @__PURE__ */ new Map();
160
+ this.recordingPromises = [];
146
161
  this.proxy = httpProxy__default.default.createProxyServer({
147
162
  secure: false,
148
163
  changeOrigin: true,
@@ -168,7 +183,7 @@ var ProxyServer = class {
168
183
  }
169
184
  setupProxyEventHandlers() {
170
185
  this.proxy.on("error", this.handleProxyError.bind(this));
171
- this.proxy.on("proxyRes", this.handleProxyResponse.bind(this));
186
+ this.proxy.on("proxyRes", this.addCorsHeaders.bind(this));
172
187
  }
173
188
  handleProxyError(err, req, res) {
174
189
  console.error("Proxy error:", err);
@@ -184,12 +199,6 @@ var ProxyServer = class {
184
199
  }
185
200
  res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
186
201
  }
187
- handleProxyResponse(proxyRes, req) {
188
- this.addCorsHeaders(proxyRes, req);
189
- if (this.mode === Modes.record && this.recordingId) {
190
- this.recordResponse(req, proxyRes);
191
- }
192
- }
193
202
  /**
194
203
  * Get CORS headers for a given request
195
204
  * @param req The incoming HTTP request
@@ -200,7 +209,7 @@ var ProxyServer = class {
200
209
  return {
201
210
  "access-control-allow-origin": origin || "*",
202
211
  "access-control-allow-credentials": "true",
203
- "access-control-allow-headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
212
+ "access-control-allow-headers": req.headers["access-control-request-headers"] || `Origin, X-Requested-With, Content-Type, Accept, Authorization, ${RECORDING_ID_HEADER}`,
204
213
  "access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
205
214
  "access-control-expose-headers": "*"
206
215
  };
@@ -214,9 +223,22 @@ var ProxyServer = class {
214
223
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
215
224
  return target;
216
225
  }
226
+ /**
227
+ * Extract recording ID from custom HTTP header
228
+ * Used for concurrent replay session routing, especially with Next.js
229
+ * @param req The incoming HTTP request
230
+ * @returns The recording ID from header, or null if not found
231
+ */
232
+ getRecordingIdFromHeader(req) {
233
+ const headerValue = req.headers[RECORDING_ID_HEADER];
234
+ if (!headerValue) {
235
+ return null;
236
+ }
237
+ return Array.isArray(headerValue) ? headerValue[0] : headerValue;
238
+ }
217
239
  /**
218
240
  * Extract recording ID from request cookie
219
- * Used for concurrent replay session routing
241
+ * Used for concurrent replay session routing (fallback method)
220
242
  * @param req The incoming HTTP request
221
243
  * @returns The recording ID from cookie, or null if not found
222
244
  */
@@ -228,6 +250,14 @@ var ProxyServer = class {
228
250
  const match = cookies.match(/proxy-recording-id=([^;]+)/);
229
251
  return match ? decodeURIComponent(match[1]) : null;
230
252
  }
253
+ /**
254
+ * Extract recording ID from request using custom header (preferred) or cookie (fallback)
255
+ * @param req The incoming HTTP request
256
+ * @returns The recording ID, or null if not found
257
+ */
258
+ getRecordingIdFromRequest(req) {
259
+ return this.getRecordingIdFromHeader(req) || this.getRecordingIdFromCookie(req);
260
+ }
231
261
  /**
232
262
  * Get or create a replay session state for a given recording ID
233
263
  * @param recordingId The recording ID to get/create session for
@@ -262,18 +292,20 @@ var ProxyServer = class {
262
292
  }
263
293
  return { mode, id, timeout };
264
294
  }
295
+ async parseControlRequest(req) {
296
+ if (req.method === "GET") {
297
+ return this.parseGetParams(req);
298
+ }
299
+ if (req.method === "POST") {
300
+ const body = await readRequestBody(req);
301
+ console.log(`MODE CHANGE (${req.method})`, body);
302
+ return JSON.parse(body);
303
+ }
304
+ throw new Error("Unsupported control method");
305
+ }
265
306
  async handleControlRequest(req, res) {
266
307
  try {
267
- let data;
268
- if (req.method === "GET") {
269
- data = this.parseGetParams(req);
270
- } else if (req.method === "POST") {
271
- const body = await readRequestBody(req);
272
- console.log(`MODE CHANGE (${req.method})`, body);
273
- data = JSON.parse(body);
274
- } else {
275
- return;
276
- }
308
+ const data = await this.parseControlRequest(req);
277
309
  const { mode, id, timeout: requestTimeout } = data;
278
310
  const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
279
311
  this.clearModeTimeout();
@@ -306,7 +338,7 @@ var ProxyServer = class {
306
338
  async switchMode(mode, id) {
307
339
  console.log(`Switching to ${mode.toUpperCase()} mode`);
308
340
  if (this.currentSession && this.mode === Modes.record) {
309
- await this.saveCurrentSession(true);
341
+ await this.saveCurrentSession();
310
342
  console.log("Session saved, continuing with mode switch");
311
343
  }
312
344
  switch (mode) {
@@ -346,6 +378,8 @@ var ProxyServer = class {
346
378
  this.recordingId = id;
347
379
  this.replayId = null;
348
380
  this.currentSession = { id, recordings: [], websocketRecordings: [] };
381
+ this.recordingIdCounter = 0;
382
+ this.sequenceCounterByKey.clear();
349
383
  console.log(`Switched to record mode with ID: ${id}`);
350
384
  }
351
385
  async switchToReplayMode(id) {
@@ -365,169 +399,91 @@ var ProxyServer = class {
365
399
  setupModeTimeout(timeout) {
366
400
  this.modeTimeout = setTimeout(async () => {
367
401
  console.log("Timeout reached, switching back to transparent mode");
368
- await this.saveCurrentSession(true);
402
+ await this.saveCurrentSession();
369
403
  this.switchToTransparentMode();
370
404
  this.modeTimeout = null;
371
405
  }, timeout);
372
406
  }
373
- async saveCurrentSession(filterIncomplete = false) {
374
- if (!this.currentSession) {
407
+ async flushPendingRecordings() {
408
+ if (this.recordingPromises.length === 0) {
375
409
  return;
376
410
  }
377
- if (filterIncomplete) {
378
- const incompleteCount = this.currentSession.recordings.filter(
379
- (r) => !r.response
380
- ).length;
381
- if (incompleteCount > 0) {
382
- this.currentSession.recordings = this.currentSession.recordings.filter(
383
- (r) => r.response
384
- );
411
+ const results = await Promise.allSettled(this.recordingPromises);
412
+ if (this.currentSession) {
413
+ for (const result of results) {
414
+ if (result.status === "fulfilled" && result.value) {
415
+ this.currentSession.recordings.push(result.value);
416
+ }
385
417
  }
418
+ console.log(
419
+ `Flushed ${results.length} recordings to session (total: ${this.currentSession.recordings.length})`
420
+ );
386
421
  }
387
- console.log(
388
- `Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
389
- );
390
- await saveRecordingSession(this.recordingsDir, this.currentSession);
422
+ this.recordingPromises = [];
391
423
  }
392
- saveRequestRecordSync(req, body) {
424
+ async saveCurrentSession() {
393
425
  if (!this.currentSession) {
394
426
  return;
395
427
  }
396
- const key = getReqID(req);
397
- const recordingId = this.recordingIdCounter++;
398
- req.__recordingId = recordingId;
399
- const record = {
400
- request: {
401
- method: req.method,
402
- url: req.url,
403
- headers: req.headers,
404
- body: body || null
405
- },
406
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
407
- key,
408
- recordingId
409
- };
410
- this.currentSession.recordings.push(record);
428
+ await this.flushPendingRecordings();
411
429
  console.log(
412
- // eslint-disable-next-line sonarjs/no-nested-template-literals
413
- `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})`
430
+ `Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
414
431
  );
432
+ await saveRecordingSession(this.recordingsDir, this.currentSession);
415
433
  }
416
- updateRequestBodySync(req, body) {
417
- if (!this.currentSession) {
418
- return;
419
- }
420
- const recordingId = req.__recordingId;
421
- if (recordingId === void 0) {
422
- console.error(
423
- `updateRequestBodySync: No recording ID found on request ${req.method} ${req.url}`
424
- );
425
- return;
426
- }
427
- const record = this.currentSession.recordings.find(
428
- (r) => r.recordingId === recordingId
429
- );
430
- if (!record) {
431
- console.error(
432
- `updateRequestBodySync: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
433
- );
434
- return;
434
+ getRecordingIdOrError(req, res) {
435
+ const recordingId = this.getRecordingIdFromRequest(req) || this.replayId;
436
+ if (!recordingId) {
437
+ const corsHeaders = this.getCorsHeaders(req);
438
+ res.writeHead(HTTP_STATUS_BAD_REQUEST, {
439
+ "Content-Type": "application/json",
440
+ ...corsHeaders
441
+ });
442
+ res.end(JSON.stringify({ error: "No replay session active" }));
443
+ return null;
435
444
  }
436
- record.request.body = body || null;
437
- console.log(
438
- `updateRequestBodySync: Updated body for ${req.method} ${req.url} (${body.length} chars, recordingId: ${recordingId})`
439
- );
445
+ return recordingId;
440
446
  }
441
- async recordResponse(req, proxyRes) {
442
- if (!this.currentSession) {
443
- return;
444
- }
445
- const recordingId = req.__recordingId;
446
- if (recordingId === void 0) {
447
- console.error(
448
- `recordResponse: No recording ID found on request ${req.method} ${req.url}`
449
- );
450
- return;
447
+ async ensureSessionLoaded(recordingId, filePath) {
448
+ const sessionState = this.getOrCreateReplaySession(recordingId);
449
+ if (!sessionState.loadedSession) {
450
+ sessionState.loadedSession = await loadRecordingSession(filePath);
451
+ console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
451
452
  }
452
- const record = this.currentSession.recordings.find(
453
- (r) => r.recordingId === recordingId
454
- );
455
- if (!record) {
456
- console.error(
457
- `recordResponse: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
458
- );
459
- return;
460
- }
461
- const chunks = [];
462
- proxyRes.on("data", (chunk) => {
463
- chunks.push(chunk);
464
- });
465
- proxyRes.on("end", async () => {
466
- const body = Buffer.concat(chunks).toString("utf8");
467
- record.response = {
468
- statusCode: proxyRes.statusCode,
469
- headers: proxyRes.headers,
470
- body: body || null
471
- };
472
- console.log(
473
- `Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
474
- );
475
- });
453
+ return sessionState;
476
454
  }
477
- async recordResponseData(req, proxyRes, body) {
478
- if (!this.currentSession) {
479
- return false;
455
+ getServedTracker(sessionState, key) {
456
+ if (!sessionState.servedRecordingIdsByKey.has(key)) {
457
+ sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
480
458
  }
481
- const recordingId = req.__recordingId;
482
- if (recordingId === void 0) {
483
- console.error(
484
- `recordResponseData: No recording ID found on request ${req.method} ${req.url}`
485
- );
486
- return false;
459
+ return sessionState.servedRecordingIdsByKey.get(key);
460
+ }
461
+ selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
462
+ for (const rec of recordsWithKey) {
463
+ if (!servedForThisKey.has(rec.recordingId)) {
464
+ return rec;
465
+ }
487
466
  }
488
- const record = this.currentSession.recordings.find(
489
- (r) => r.recordingId === recordingId
490
- );
491
- if (!record) {
492
- console.error(
493
- `recordResponseData: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
467
+ if (recordsWithKey.length > 0) {
468
+ console.log(
469
+ `[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
494
470
  );
495
- return false;
471
+ return recordsWithKey[recordsWithKey.length - 1];
496
472
  }
497
- record.response = {
498
- statusCode: proxyRes.statusCode,
499
- headers: proxyRes.headers,
500
- body: body || null
501
- };
502
- console.log(
503
- `recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
504
- );
505
- return true;
473
+ return null;
506
474
  }
507
475
  async handleReplayRequest(req, res) {
508
- const recordingId = this.getRecordingIdFromCookie(req) || this.replayId;
509
- if (!recordingId) {
510
- const corsHeaders = this.getCorsHeaders(req);
511
- res.writeHead(HTTP_STATUS_BAD_REQUEST, {
512
- "Content-Type": "application/json",
513
- ...corsHeaders
514
- });
515
- res.end(JSON.stringify({ error: "No replay session active" }));
516
- return;
517
- }
476
+ const recordingId = this.getRecordingIdOrError(req, res);
477
+ if (!recordingId) return;
518
478
  const key = getReqID(req);
519
479
  const filePath = getRecordingPath(this.recordingsDir, recordingId);
520
480
  try {
521
- const sessionState = this.getOrCreateReplaySession(recordingId);
522
- if (!sessionState.loadedSession) {
523
- sessionState.loadedSession = await loadRecordingSession(filePath);
524
- console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
525
- }
481
+ const sessionState = await this.ensureSessionLoaded(
482
+ recordingId,
483
+ filePath
484
+ );
526
485
  const session = sessionState.loadedSession;
527
- if (!sessionState.servedRecordingIdsByKey.has(key)) {
528
- sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
529
- }
530
- const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
486
+ const servedForThisKey = this.getServedTracker(sessionState, key);
531
487
  const host = req.headers.host || "unknown";
532
488
  const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
533
489
  const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
@@ -556,30 +512,23 @@ var ProxyServer = class {
556
512
  }
557
513
  const requestCount = servedForThisKey.size + 1;
558
514
  console.log(
559
- `[replay request #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
515
+ `[replay request #${requestCount}] ${req.method} ${req.url} (key: ${key}, session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
560
516
  );
561
- let record;
562
- for (const rec of recordsWithKey) {
563
- if (!servedForThisKey.has(rec.recordingId)) {
564
- record = rec;
565
- break;
566
- }
567
- }
568
- if (!record) {
569
- console.log(
570
- `[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
517
+ const record = this.selectReplayRecord(
518
+ recordsWithKey,
519
+ servedForThisKey,
520
+ key,
521
+ recordingId
522
+ );
523
+ if (!record || !record.response) {
524
+ throw new Error(
525
+ `No response recorded for this request: ${req.method} ${host}${req.url}`
571
526
  );
572
- record = recordsWithKey[recordsWithKey.length - 1];
573
527
  }
574
528
  servedForThisKey.add(record.recordingId);
575
529
  console.log(
576
530
  `[replay serving] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
577
531
  );
578
- if (!record.response) {
579
- throw new Error(
580
- `No response recorded for this request: ${req.method} ${host}${req.url}`
581
- );
582
- }
583
532
  const { statusCode, headers, body } = record.response;
584
533
  const responseHeaders = {
585
534
  ...headers,
@@ -634,82 +583,124 @@ var ProxyServer = class {
634
583
  const target = this.getTarget();
635
584
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
636
585
  if (this.mode === Modes.record) {
637
- this.saveRequestRecordSync(req, null);
638
- await this.bufferAndProxyRequest(req, res, target);
586
+ await this.recordAndProxyRequest(req, res, target);
639
587
  } else {
640
588
  this.proxy.web(req, res, { target });
641
589
  }
642
590
  }
643
- // TODO: check if can handle streaming requests
644
- async bufferAndProxyRequest(req, res, target) {
645
- const chunks = [];
646
- req.on("data", (chunk) => {
647
- chunks.push(chunk);
648
- });
649
- try {
650
- await new Promise((resolve, reject) => {
651
- req.on("end", () => resolve());
652
- req.on("error", (err) => reject(err));
653
- setTimeout(
654
- () => reject(new Error("Request buffering timeout")),
655
- 3e4
656
- );
657
- });
658
- } catch (error) {
659
- console.error("Error buffering request:", error);
660
- }
661
- const body = Buffer.concat(chunks).toString("utf8");
662
- this.updateRequestBodySync(req, body);
663
- const targetUrl = new URL(target);
664
- const isHttps = targetUrl.protocol === "https:";
665
- const requestModule = isHttps ? https__default.default : http__default.default;
666
- const defaultPort = isHttps ? 443 : 80;
667
- const proxyReq = requestModule.request(
668
- {
669
- hostname: targetUrl.hostname,
670
- port: targetUrl.port || defaultPort,
671
- path: req.url,
672
- method: req.method,
673
- headers: req.headers
674
- },
675
- (proxyRes) => {
676
- this.addCorsHeaders(proxyRes, req);
677
- const responseChunks = [];
678
- proxyRes.on("data", (chunk) => {
679
- responseChunks.push(chunk);
680
- });
681
- proxyRes.on("end", async () => {
682
- const responseBody = Buffer.concat(responseChunks);
683
- const recorded = await this.recordResponseData(
684
- req,
685
- proxyRes,
686
- responseBody.toString("utf8")
687
- );
688
- const responseHeaders = {
689
- ...proxyRes.headers,
690
- ...this.getCorsHeaders(req)
691
- };
692
- res.writeHead(proxyRes.statusCode || 200, responseHeaders);
693
- res.end(responseBody);
694
- if (recorded) {
695
- console.log(`Recorded: ${req.method} ${req.url}`);
591
+ // Note: streaming requests are buffered before proxying; streaming passthrough is not yet implemented
592
+ async recordAndProxyRequest(req, res, target) {
593
+ if (!this.currentSession) {
594
+ return;
595
+ }
596
+ const key = getReqID(req);
597
+ const recordingId = this.recordingIdCounter++;
598
+ const sequence = this.sequenceCounterByKey.get(key) || 0;
599
+ this.sequenceCounterByKey.set(key, sequence + 1);
600
+ const recordingPromise = new Promise((resolve) => {
601
+ (async () => {
602
+ try {
603
+ const chunks = [];
604
+ req.on("data", (chunk) => {
605
+ chunks.push(chunk);
606
+ });
607
+ try {
608
+ await new Promise((resolveBuffer, rejectBuffer) => {
609
+ req.on("end", () => resolveBuffer());
610
+ req.on("error", (err) => rejectBuffer(err));
611
+ setTimeout(
612
+ () => rejectBuffer(new Error("Request buffering timeout")),
613
+ 3e4
614
+ );
615
+ });
616
+ } catch (error) {
617
+ console.error("Error buffering request:", error);
696
618
  }
697
- });
698
- proxyRes.on("error", (err) => {
699
- console.error("Proxy response error:", err);
700
- if (!res.headersSent) {
619
+ const requestBody = Buffer.concat(chunks).toString("utf8");
620
+ const targetUrl = new URL(target);
621
+ const isHttps = targetUrl.protocol === "https:";
622
+ const requestModule = isHttps ? https__default.default : http__default.default;
623
+ const defaultPort = isHttps ? 443 : 80;
624
+ const proxyReq = requestModule.request(
625
+ {
626
+ hostname: targetUrl.hostname,
627
+ port: targetUrl.port || defaultPort,
628
+ path: req.url,
629
+ method: req.method,
630
+ headers: req.headers
631
+ },
632
+ (proxyRes) => {
633
+ this.addCorsHeaders(proxyRes, req);
634
+ const responseChunks = [];
635
+ proxyRes.on("data", (chunk) => {
636
+ responseChunks.push(chunk);
637
+ });
638
+ proxyRes.on("end", async () => {
639
+ try {
640
+ const responseBody = Buffer.concat(responseChunks);
641
+ const responseBodyStr = responseBody.toString("utf8");
642
+ const recording = {
643
+ request: {
644
+ method: req.method,
645
+ url: req.url,
646
+ headers: req.headers,
647
+ body: requestBody || null
648
+ },
649
+ response: {
650
+ statusCode: proxyRes.statusCode,
651
+ headers: proxyRes.headers,
652
+ body: responseBodyStr || null
653
+ },
654
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
655
+ key,
656
+ recordingId,
657
+ sequence
658
+ };
659
+ const responseHeaders = {
660
+ ...proxyRes.headers,
661
+ ...this.getCorsHeaders(req)
662
+ };
663
+ res.writeHead(proxyRes.statusCode || 200, responseHeaders);
664
+ res.end(responseBody);
665
+ console.log(
666
+ `Recorded: ${req.method} ${req.url} (recordingId: ${recordingId}, sequence: ${sequence})`
667
+ );
668
+ resolve(recording);
669
+ } catch (error) {
670
+ console.error("Error completing recording:", error);
671
+ resolve(null);
672
+ }
673
+ });
674
+ proxyRes.on("error", (err) => {
675
+ console.error("Proxy response error:", err);
676
+ if (!res.headersSent) {
677
+ this.handleProxyError(err, req, res);
678
+ }
679
+ resolve(null);
680
+ });
681
+ }
682
+ );
683
+ proxyReq.on("error", (err) => {
701
684
  this.handleProxyError(err, req, res);
685
+ resolve(null);
686
+ });
687
+ if (chunks.length > 0) {
688
+ proxyReq.write(Buffer.concat(chunks));
702
689
  }
703
- });
704
- }
705
- );
706
- proxyReq.on("error", (err) => {
707
- this.handleProxyError(err, req, res);
690
+ proxyReq.end();
691
+ } catch (error) {
692
+ console.error("Error in recordAndProxyRequest:", error);
693
+ try {
694
+ this.handleProxyError(error, req, res);
695
+ } catch (error_) {
696
+ console.error("Failed to handle proxy error:", error_);
697
+ }
698
+ resolve(null);
699
+ }
700
+ })();
708
701
  });
709
- if (chunks.length > 0) {
710
- proxyReq.write(Buffer.concat(chunks));
711
- }
712
- proxyReq.end();
702
+ this.recordingPromises.push(recordingPromise);
703
+ await recordingPromise;
713
704
  }
714
705
  handleUpgrade(req, socket, head) {
715
706
  if (this.mode === Modes.replay) {
@@ -954,23 +945,29 @@ async function stopProxy(testInfo) {
954
945
  }
955
946
  var playwrightProxy = {
956
947
  /**
957
- * Setup before test - sets the proxy mode
948
+ * Setup before test - sets the proxy mode and configures page with custom header
949
+ * Automatically sets up page.on('close') handler for cleanup
950
+ * @param page - Playwright page object
958
951
  * @param testInfo - Playwright test info object
959
952
  * @param mode - The proxy mode to use for this test
960
953
  * @param timeout - Optional timeout in milliseconds
961
954
  */
962
- async before(testInfo, mode, timeout) {
955
+ async before(page, testInfo, mode, timeout) {
963
956
  const sessionId = generateSessionId(testInfo);
957
+ await page.setExtraHTTPHeaders({
958
+ [RECORDING_ID_HEADER]: sessionId
959
+ });
964
960
  await setProxyMode(mode, sessionId, timeout);
965
- },
966
- /**
967
- * Cleanup after test - resets replay session by re-entering replay mode
968
- * switchToReplayMode automatically clears sequence counters
969
- * @param testInfo - Playwright test info object
970
- */
971
- async after(testInfo) {
972
- const sessionId = generateSessionId(testInfo);
973
- await setProxyMode(Modes.replay, sessionId);
961
+ page.on("close", async () => {
962
+ try {
963
+ await setProxyMode(Modes.replay, sessionId);
964
+ console.log(
965
+ `[Cleanup] Switched to replay mode for session: ${sessionId}`
966
+ );
967
+ } catch (error) {
968
+ console.error("[Cleanup] Error during page close cleanup:", error);
969
+ }
970
+ });
974
971
  },
975
972
  /**
976
973
  * Global teardown - switches proxy to transparent mode
@@ -981,9 +978,45 @@ var playwrightProxy = {
981
978
  }
982
979
  };
983
980
 
981
+ // src/nextjs/middleware.ts
982
+ function isRecorderEnabled() {
983
+ const isProduction = process.env.NODE_ENV === "production";
984
+ const isExplicitlyEnabled = process.env.TEST_PROXY_RECORDER_ENABLED === "true" || Number.parseInt(process.env.TEST_PROXY_RECORDER_ENABLED || "") === 1;
985
+ return !isProduction || isExplicitlyEnabled;
986
+ }
987
+ function setNextProxyHeaders(request, response) {
988
+ if (!isRecorderEnabled()) {
989
+ return;
990
+ }
991
+ const recordingId = request.headers.get(RECORDING_ID_HEADER);
992
+ if (recordingId) {
993
+ response.headers.set(RECORDING_ID_HEADER, recordingId);
994
+ }
995
+ }
996
+ function getRecordingId(requestHeaders) {
997
+ if (requestHeaders instanceof Headers) {
998
+ return requestHeaders.get(RECORDING_ID_HEADER);
999
+ }
1000
+ return requestHeaders.headers.get(RECORDING_ID_HEADER);
1001
+ }
1002
+ function createHeadersWithRecordingId(requestHeaders, additionalHeaders = {}) {
1003
+ if (!isRecorderEnabled()) {
1004
+ return additionalHeaders;
1005
+ }
1006
+ const recordingId = getRecordingId(requestHeaders);
1007
+ return {
1008
+ ...additionalHeaders,
1009
+ ...recordingId && { [RECORDING_ID_HEADER]: recordingId }
1010
+ };
1011
+ }
1012
+
984
1013
  exports.ProxyServer = ProxyServer;
1014
+ exports.RECORDING_ID_HEADER = RECORDING_ID_HEADER;
1015
+ exports.createHeadersWithRecordingId = createHeadersWithRecordingId;
985
1016
  exports.generateSessionId = generateSessionId;
1017
+ exports.getRecordingId = getRecordingId;
986
1018
  exports.playwrightProxy = playwrightProxy;
1019
+ exports.setNextProxyHeaders = setNextProxyHeaders;
987
1020
  exports.setProxyMode = setProxyMode;
988
1021
  exports.startRecording = startRecording;
989
1022
  exports.startReplay = startReplay;