test-proxy-recorder 0.3.4 → 0.3.6

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
@@ -1,20 +1,20 @@
1
1
  'use strict';
2
2
 
3
3
  var fs = require('fs/promises');
4
- var http = require('http');
5
- var https = require('https');
4
+ var http2 = require('http');
6
5
  var httpProxy = require('http-proxy');
7
- var ws = require('ws');
6
+ var https = require('https');
8
7
  var crypto = require('crypto');
9
8
  var path2 = require('path');
10
9
  var filenamify2 = require('filenamify');
10
+ var ws = require('ws');
11
11
 
12
12
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
13
13
 
14
14
  var fs__default = /*#__PURE__*/_interopDefault(fs);
15
- var http__default = /*#__PURE__*/_interopDefault(http);
16
- var https__default = /*#__PURE__*/_interopDefault(https);
15
+ var http2__default = /*#__PURE__*/_interopDefault(http2);
17
16
  var httpProxy__default = /*#__PURE__*/_interopDefault(httpProxy);
17
+ var https__default = /*#__PURE__*/_interopDefault(https);
18
18
  var crypto__default = /*#__PURE__*/_interopDefault(crypto);
19
19
  var path2__default = /*#__PURE__*/_interopDefault(path2);
20
20
  var filenamify2__default = /*#__PURE__*/_interopDefault(filenamify2);
@@ -28,6 +28,244 @@ var HTTP_STATUS_NOT_FOUND = 404;
28
28
  var CONTROL_ENDPOINT = "/__control";
29
29
  var RECORDING_ID_HEADER = "x-test-rcrd-id";
30
30
 
31
+ // src/utils/cors.ts
32
+ function getCorsHeaders(req) {
33
+ const origin = req.headers.origin;
34
+ return {
35
+ "access-control-allow-origin": origin || "*",
36
+ "access-control-allow-credentials": "true",
37
+ "access-control-allow-headers": req.headers["access-control-request-headers"] || `Origin, X-Requested-With, Content-Type, Accept, Authorization, ${RECORDING_ID_HEADER}`,
38
+ "access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
39
+ "access-control-expose-headers": "*"
40
+ };
41
+ }
42
+ function addCorsHeaders(proxyRes, req) {
43
+ Object.assign(proxyRes.headers, getCorsHeaders(req));
44
+ }
45
+
46
+ // src/httpRecorder.ts
47
+ async function bufferRequestBody(req) {
48
+ const chunks = [];
49
+ req.on("data", (chunk) => {
50
+ chunks.push(chunk);
51
+ });
52
+ try {
53
+ await new Promise((resolveBuffer, rejectBuffer) => {
54
+ req.on("end", () => resolveBuffer());
55
+ req.on("error", (err) => rejectBuffer(err));
56
+ setTimeout(
57
+ () => rejectBuffer(new Error("Request buffering timeout")),
58
+ 3e4
59
+ );
60
+ });
61
+ } catch (error) {
62
+ console.error("Error buffering request:", error);
63
+ }
64
+ return chunks;
65
+ }
66
+ function handleProxyResponse(proxyRes, context) {
67
+ const { options, requestBody, resolve } = context;
68
+ const { req, res, key, recordingId, sequence, onProxyError } = options;
69
+ addCorsHeaders(proxyRes, req);
70
+ const responseChunks = [];
71
+ proxyRes.on("data", (chunk) => {
72
+ responseChunks.push(chunk);
73
+ });
74
+ proxyRes.on("end", () => {
75
+ try {
76
+ const responseBody = Buffer.concat(responseChunks);
77
+ const responseBodyStr = responseBody.toString("utf8");
78
+ const recording = {
79
+ request: {
80
+ method: req.method,
81
+ url: req.url,
82
+ headers: req.headers,
83
+ body: requestBody || null
84
+ },
85
+ response: {
86
+ statusCode: proxyRes.statusCode,
87
+ headers: proxyRes.headers,
88
+ body: responseBodyStr || null
89
+ },
90
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
91
+ key,
92
+ recordingId,
93
+ sequence
94
+ };
95
+ const responseHeaders = {
96
+ ...proxyRes.headers,
97
+ ...getCorsHeaders(req)
98
+ };
99
+ res.writeHead(proxyRes.statusCode || 200, responseHeaders);
100
+ res.end(responseBody);
101
+ console.log(
102
+ `Recorded: ${req.method} ${req.url} (recordingId: ${recordingId}, sequence: ${sequence})`
103
+ );
104
+ resolve(recording);
105
+ } catch (error) {
106
+ console.error("Error completing recording:", error);
107
+ resolve(null);
108
+ }
109
+ });
110
+ proxyRes.on("error", (err) => {
111
+ console.error("Proxy response error:", err);
112
+ if (!res.headersSent) {
113
+ onProxyError(err, req, res);
114
+ }
115
+ resolve(null);
116
+ });
117
+ }
118
+ function proxyWithBufferedBody(options, chunks) {
119
+ const { req, res, target, onProxyError } = options;
120
+ const requestBody = Buffer.concat(chunks).toString("utf8");
121
+ const targetUrl = new URL(target);
122
+ const isHttps = targetUrl.protocol === "https:";
123
+ const requestModule = isHttps ? https__default.default : http2__default.default;
124
+ const defaultPort = isHttps ? 443 : 80;
125
+ return new Promise((resolve) => {
126
+ const proxyReq = requestModule.request(
127
+ {
128
+ hostname: targetUrl.hostname,
129
+ port: targetUrl.port || defaultPort,
130
+ path: req.url,
131
+ method: req.method,
132
+ headers: req.headers
133
+ },
134
+ (proxyRes) => {
135
+ handleProxyResponse(proxyRes, { options, requestBody, resolve });
136
+ }
137
+ );
138
+ proxyReq.on("error", (err) => {
139
+ onProxyError(err, req, res);
140
+ resolve(null);
141
+ });
142
+ if (chunks.length > 0) {
143
+ proxyReq.write(Buffer.concat(chunks));
144
+ }
145
+ proxyReq.end();
146
+ });
147
+ }
148
+ async function recordAndProxyRequest(options) {
149
+ const { req, res, onProxyError } = options;
150
+ try {
151
+ const chunks = await bufferRequestBody(req);
152
+ return await proxyWithBufferedBody(options, chunks);
153
+ } catch (error) {
154
+ console.error("Error in recordAndProxyRequest:", error);
155
+ try {
156
+ onProxyError(error, req, res);
157
+ } catch (error_) {
158
+ console.error("Failed to handle proxy error:", error_);
159
+ }
160
+ return null;
161
+ }
162
+ }
163
+
164
+ // src/replaySessions.ts
165
+ var ReplaySessionManager = class {
166
+ sessions = /* @__PURE__ */ new Map();
167
+ evictionTimer = null;
168
+ timeoutMs;
169
+ constructor(timeoutMs) {
170
+ this.timeoutMs = timeoutMs;
171
+ }
172
+ get size() {
173
+ return this.sessions.size;
174
+ }
175
+ keys() {
176
+ return this.sessions.keys();
177
+ }
178
+ /**
179
+ * Get or create a replay session state for a given recording ID
180
+ */
181
+ getOrCreate(recordingId) {
182
+ let session = this.sessions.get(recordingId);
183
+ if (session) {
184
+ session.lastAccessTime = Date.now();
185
+ } else {
186
+ session = {
187
+ recordingId,
188
+ servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
189
+ loadedSession: null,
190
+ lastAccessTime: Date.now(),
191
+ sortedRecordingsByKey: /* @__PURE__ */ new Map()
192
+ };
193
+ this.sessions.set(recordingId, session);
194
+ this.startEvictionTimer();
195
+ console.log(
196
+ `[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
197
+ );
198
+ }
199
+ return session;
200
+ }
201
+ delete(sessionId) {
202
+ this.sessions.delete(sessionId);
203
+ if (this.sessions.size === 0) {
204
+ this.stopEvictionTimer();
205
+ }
206
+ }
207
+ startEvictionTimer() {
208
+ if (this.evictionTimer) {
209
+ return;
210
+ }
211
+ const CHECK_INTERVAL_MS = 3e4;
212
+ this.evictionTimer = setInterval(() => {
213
+ const now = Date.now();
214
+ for (const [id, session] of this.sessions) {
215
+ if (now - session.lastAccessTime >= this.timeoutMs) {
216
+ console.log(
217
+ `[EVICTION] Evicting idle replay session: ${id} (idle for ${Math.round((now - session.lastAccessTime) / 1e3)}s)`
218
+ );
219
+ this.sessions.delete(id);
220
+ }
221
+ }
222
+ if (this.sessions.size === 0) {
223
+ this.stopEvictionTimer();
224
+ }
225
+ }, CHECK_INTERVAL_MS);
226
+ this.evictionTimer.unref();
227
+ }
228
+ stopEvictionTimer() {
229
+ if (this.evictionTimer) {
230
+ clearInterval(this.evictionTimer);
231
+ this.evictionTimer = null;
232
+ }
233
+ }
234
+ };
235
+ function getServedTracker(sessionState, key) {
236
+ if (!sessionState.servedRecordingIdsByKey.has(key)) {
237
+ sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
238
+ }
239
+ return sessionState.servedRecordingIdsByKey.get(key);
240
+ }
241
+ function getSortedRecordings(sessionState, key) {
242
+ if (sessionState.sortedRecordingsByKey.has(key)) {
243
+ return sessionState.sortedRecordingsByKey.get(key);
244
+ }
245
+ const session = sessionState.loadedSession;
246
+ const sortedRecords = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
247
+ const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
248
+ const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
249
+ return aSeq - bSeq;
250
+ });
251
+ sessionState.sortedRecordingsByKey.set(key, sortedRecords);
252
+ return sortedRecords;
253
+ }
254
+ function selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
255
+ for (const rec of recordsWithKey) {
256
+ if (!servedForThisKey.has(rec.recordingId)) {
257
+ return rec;
258
+ }
259
+ }
260
+ if (recordsWithKey.length > 0) {
261
+ console.log(
262
+ `[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
263
+ );
264
+ return recordsWithKey[recordsWithKey.length - 1];
265
+ }
266
+ return null;
267
+ }
268
+
31
269
  // src/types.ts
32
270
  var Modes = {
33
271
  transparent: "transparent",
@@ -71,9 +309,9 @@ function processRecordings(recordings) {
71
309
  const processedRecordings = [];
72
310
  for (const [_key, keyRecordings] of recordingsByKey) {
73
311
  keyRecordings.sort((a, b) => a.recordingId - b.recordingId);
74
- keyRecordings.forEach((recording, index) => {
312
+ for (const [index, recording] of keyRecordings.entries()) {
75
313
  processedRecordings.push({ ...recording, sequence: index });
76
- });
314
+ }
77
315
  }
78
316
  processedRecordings.sort((a, b) => a.recordingId - b.recordingId);
79
317
  return processedRecordings;
@@ -129,6 +367,180 @@ function sendJsonResponse(res, statusCode, data) {
129
367
  res.end(JSON.stringify(data));
130
368
  }
131
369
 
370
+ // src/utils/recordingId.ts
371
+ function getRecordingIdFromHeader(req) {
372
+ const headerValue = req.headers[RECORDING_ID_HEADER];
373
+ if (!headerValue) {
374
+ return null;
375
+ }
376
+ return Array.isArray(headerValue) ? headerValue[0] : headerValue;
377
+ }
378
+ function getRecordingIdFromCookie(req) {
379
+ const cookies = req.headers.cookie;
380
+ if (!cookies) {
381
+ return null;
382
+ }
383
+ const match = cookies.match(/proxy-recording-id=([^;]+)/);
384
+ return match ? decodeURIComponent(match[1]) : null;
385
+ }
386
+ function getRecordingIdFromRequest(req) {
387
+ const fromHeader = getRecordingIdFromHeader(req);
388
+ const fromCookie = getRecordingIdFromCookie(req);
389
+ return fromHeader ?? fromCookie ?? null;
390
+ }
391
+ function getWsRecordingKey(url) {
392
+ return `WS_${url.replaceAll("/", "_")}`;
393
+ }
394
+ var WS_INTERNAL_HEADERS = /* @__PURE__ */ new Set([
395
+ "host",
396
+ "connection",
397
+ "upgrade",
398
+ "sec-websocket-key",
399
+ "sec-websocket-version",
400
+ "sec-websocket-extensions"
401
+ ]);
402
+ function getRecordableWsHeaders(req) {
403
+ const headers = {};
404
+ for (const [name, value] of Object.entries(req.headers)) {
405
+ if (!WS_INTERNAL_HEADERS.has(name) && value !== void 0) {
406
+ headers[name] = value;
407
+ }
408
+ }
409
+ return headers;
410
+ }
411
+ function getForwardableWsHeaders(req) {
412
+ const headers = {};
413
+ for (const [name, value] of Object.entries(getRecordableWsHeaders(req))) {
414
+ if (name !== "sec-websocket-protocol" && value !== void 0) {
415
+ headers[name] = Array.isArray(value) ? value.join(", ") : value;
416
+ }
417
+ }
418
+ return headers;
419
+ }
420
+ function getClientSubprotocols(req) {
421
+ const header = req.headers["sec-websocket-protocol"];
422
+ if (!header) {
423
+ return [];
424
+ }
425
+ const raw = Array.isArray(header) ? header.join(",") : header;
426
+ return raw.split(",").map((p) => p.trim()).filter(Boolean);
427
+ }
428
+ function recordWebSocket(req, clientSocket, head, target, session) {
429
+ const url = req.url || "/";
430
+ const wsRecording = {
431
+ url,
432
+ messages: [],
433
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
434
+ key: getWsRecordingKey(url),
435
+ headers: getRecordableWsHeaders(req)
436
+ };
437
+ if (session) {
438
+ session.websocketRecordings.push(wsRecording);
439
+ }
440
+ const backendWsUrl = `${target.replace("http", "ws")}${url}`;
441
+ const backendWs = new ws.WebSocket(backendWsUrl, getClientSubprotocols(req), {
442
+ headers: getForwardableWsHeaders(req)
443
+ });
444
+ const wss = new ws.WebSocketServer({
445
+ noServer: true,
446
+ handleProtocols: (protocols) => backendWs.protocol && protocols.has(backendWs.protocol) ? backendWs.protocol : protocols.values().next().value ?? false
447
+ });
448
+ backendWs.on("open", () => {
449
+ console.log(`WebSocket recording: connected to backend ${backendWsUrl}`);
450
+ if (backendWs.protocol) {
451
+ wsRecording.protocol = backendWs.protocol;
452
+ }
453
+ wss.handleUpgrade(req, clientSocket, head, (clientWs) => {
454
+ clientWs.on("message", (data) => {
455
+ const message = data.toString();
456
+ wsRecording.messages.push({
457
+ direction: "client-to-server",
458
+ data: message,
459
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
460
+ });
461
+ if (backendWs.readyState === ws.WebSocket.OPEN) {
462
+ backendWs.send(message);
463
+ }
464
+ });
465
+ backendWs.on("message", (data) => {
466
+ const message = data.toString();
467
+ wsRecording.messages.push({
468
+ direction: "server-to-client",
469
+ data: message,
470
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
471
+ });
472
+ if (clientWs.readyState === ws.WebSocket.OPEN) {
473
+ clientWs.send(message);
474
+ }
475
+ });
476
+ clientWs.on("error", (err) => {
477
+ console.error("Client WebSocket error:", err);
478
+ });
479
+ backendWs.on("error", (err) => {
480
+ console.error("Backend WebSocket error:", err);
481
+ });
482
+ clientWs.on("close", () => {
483
+ backendWs.close();
484
+ console.log("Client WebSocket closed");
485
+ });
486
+ backendWs.on("close", () => {
487
+ clientWs.close();
488
+ console.log("Backend WebSocket closed");
489
+ });
490
+ });
491
+ });
492
+ backendWs.on("error", (err) => {
493
+ console.error("Backend WebSocket connection error:", err);
494
+ clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
495
+ clientSocket.destroy();
496
+ });
497
+ wss.on("error", (err) => {
498
+ console.error("WebSocket server error:", err);
499
+ });
500
+ }
501
+ function replayWebSocket(req, socket, wsRecording, recordingId) {
502
+ const url = req.url || "/";
503
+ const wss = new ws.WebSocketServer({
504
+ noServer: true,
505
+ handleProtocols: (protocols) => wsRecording.protocol && protocols.has(wsRecording.protocol) ? wsRecording.protocol : protocols.values().next().value ?? false
506
+ });
507
+ const fakeReq = Object.assign(req, {
508
+ headers: {
509
+ ...req.headers,
510
+ "sec-websocket-key": req.headers["sec-websocket-key"] || "replay-key",
511
+ "sec-websocket-version": "13"
512
+ }
513
+ });
514
+ wss.handleUpgrade(fakeReq, socket, Buffer.alloc(0), (ws$1) => {
515
+ console.log(`Replaying WebSocket: ${url} (session: ${recordingId})`);
516
+ const messages = wsRecording.messages;
517
+ let cursor = 0;
518
+ const flushServerMessages = () => {
519
+ while (cursor < messages.length && messages[cursor].direction === "server-to-client") {
520
+ const msg = messages[cursor];
521
+ cursor++;
522
+ if (ws$1.readyState === ws.WebSocket.OPEN) {
523
+ ws$1.send(msg.data);
524
+ }
525
+ }
526
+ };
527
+ ws$1.on("message", (data) => {
528
+ console.log(`Replay: Client sent: ${data.toString()}`);
529
+ if (cursor < messages.length && messages[cursor].direction === "client-to-server") {
530
+ cursor++;
531
+ }
532
+ flushServerMessages();
533
+ });
534
+ ws$1.on("error", (err) => {
535
+ console.error("Replay WebSocket error:", err);
536
+ });
537
+ ws$1.on("close", () => {
538
+ console.log("Replay WebSocket closed");
539
+ });
540
+ flushServerMessages();
541
+ });
542
+ }
543
+
132
544
  // src/ProxyServer.ts
133
545
  var ProxyServer = class {
134
546
  target;
@@ -139,6 +551,7 @@ var ProxyServer = class {
139
551
  proxy;
140
552
  currentSession;
141
553
  recordingsDir;
554
+ timeoutMs;
142
555
  recordingIdCounter;
143
556
  // Unique ID for each recording entry
144
557
  sequenceCounterByKey;
@@ -149,8 +562,9 @@ var ProxyServer = class {
149
562
  // Stack of promises that resolve to completed recordings
150
563
  flushPromise;
151
564
  // Promise for in-progress flush operation
152
- constructor(target, recordingsDir) {
565
+ constructor(target, recordingsDir, timeoutMs) {
153
566
  this.target = target;
567
+ this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
154
568
  this.mode = Modes.transparent;
155
569
  this.recordingId = null;
156
570
  this.recordingIdCounter = 0;
@@ -159,7 +573,7 @@ var ProxyServer = class {
159
573
  this.modeTimeout = null;
160
574
  this.currentSession = null;
161
575
  this.recordingsDir = recordingsDir;
162
- this.replaySessions = /* @__PURE__ */ new Map();
576
+ this.replaySessions = new ReplaySessionManager(this.timeoutMs);
163
577
  this.recordingPromises = [];
164
578
  this.flushPromise = null;
165
579
  this.proxy = httpProxy__default.default.createProxyServer({
@@ -173,7 +587,7 @@ var ProxyServer = class {
173
587
  await fs__default.default.mkdir(this.recordingsDir, { recursive: true });
174
588
  }
175
589
  listen(port) {
176
- const server = http__default.default.createServer((req, res) => {
590
+ const server = http2__default.default.createServer((req, res) => {
177
591
  this.handleRequest(req, res);
178
592
  });
179
593
  server.on("upgrade", (req, socket, head) => {
@@ -187,15 +601,15 @@ var ProxyServer = class {
187
601
  }
188
602
  setupProxyEventHandlers() {
189
603
  this.proxy.on("error", this.handleProxyError.bind(this));
190
- this.proxy.on("proxyRes", this.addCorsHeaders.bind(this));
604
+ this.proxy.on("proxyRes", addCorsHeaders);
191
605
  }
192
606
  handleProxyError(err, req, res) {
193
607
  console.error("Proxy error:", err);
194
- if (!(res instanceof http__default.default.ServerResponse)) {
608
+ if (!(res instanceof http2__default.default.ServerResponse)) {
195
609
  return;
196
610
  }
197
611
  if (!res.headersSent) {
198
- const corsHeaders = this.getCorsHeaders(req);
612
+ const corsHeaders = getCorsHeaders(req);
199
613
  res.writeHead(HTTP_STATUS_BAD_GATEWAY, {
200
614
  "Content-Type": "application/json",
201
615
  ...corsHeaders
@@ -203,103 +617,12 @@ var ProxyServer = class {
203
617
  }
204
618
  res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
205
619
  }
206
- /**
207
- * Get CORS headers for a given request
208
- * @param req The incoming HTTP request
209
- * @returns An object containing CORS headers
210
- */
211
- getCorsHeaders(req) {
212
- const origin = req.headers.origin;
213
- return {
214
- "access-control-allow-origin": origin || "*",
215
- "access-control-allow-credentials": "true",
216
- "access-control-allow-headers": req.headers["access-control-request-headers"] || `Origin, X-Requested-With, Content-Type, Accept, Authorization, ${RECORDING_ID_HEADER}`,
217
- "access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
218
- "access-control-expose-headers": "*"
219
- };
220
- }
221
- addCorsHeaders(proxyRes, req) {
222
- const corsHeaders = this.getCorsHeaders(req);
223
- Object.assign(proxyRes.headers, corsHeaders);
224
- }
225
- getTarget() {
226
- return this.target;
227
- }
228
- /**
229
- * Extract recording ID from custom HTTP header
230
- * Used for concurrent replay session routing, especially with Next.js
231
- * @param req The incoming HTTP request
232
- * @returns The recording ID from header, or null if not found
233
- */
234
- getRecordingIdFromHeader(req) {
235
- const headerValue = req.headers[RECORDING_ID_HEADER];
236
- if (!headerValue) {
237
- return null;
238
- }
239
- return Array.isArray(headerValue) ? headerValue[0] : headerValue;
240
- }
241
- /**
242
- * Extract recording ID from request cookie
243
- * Used for concurrent replay session routing (fallback method)
244
- * @param req The incoming HTTP request
245
- * @returns The recording ID from cookie, or null if not found
246
- */
247
- getRecordingIdFromCookie(req) {
248
- const cookies = req.headers.cookie;
249
- if (!cookies) {
250
- return null;
251
- }
252
- const match = cookies.match(/proxy-recording-id=([^;]+)/);
253
- return match ? decodeURIComponent(match[1]) : null;
254
- }
255
- /**
256
- * Extract recording ID from request using custom header (preferred) or cookie (fallback)
257
- * @param req The incoming HTTP request
258
- * @returns The recording ID, or null if not found
259
- */
260
- getRecordingIdFromRequest(req) {
261
- const fromHeader = this.getRecordingIdFromHeader(req);
262
- const fromCookie = this.getRecordingIdFromCookie(req);
263
- if (fromHeader) {
264
- return fromHeader;
265
- }
266
- if (fromCookie) {
267
- return fromCookie;
268
- }
269
- return null;
270
- }
271
- /**
272
- * Get or create a replay session state for a given recording ID
273
- * @param recordingId The recording ID to get/create session for
274
- * @returns The replay session state
275
- */
276
- getOrCreateReplaySession(recordingId) {
277
- let session = this.replaySessions.get(recordingId);
278
- if (session) {
279
- session.lastAccessTime = Date.now();
280
- } else {
281
- session = {
282
- recordingId,
283
- servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
284
- loadedSession: null,
285
- lastAccessTime: Date.now(),
286
- sortedRecordingsByKey: /* @__PURE__ */ new Map()
287
- };
288
- this.replaySessions.set(recordingId, session);
289
- console.log(
290
- `[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
291
- );
292
- }
293
- return session;
294
- }
295
620
  /**
296
621
  * Clean up a session - removes it from memory and resets counters
297
622
  * @param sessionId The session ID to clean up
298
623
  */
299
624
  async cleanupSession(sessionId) {
300
- if (this.replaySessions.has(sessionId)) {
301
- this.replaySessions.delete(sessionId);
302
- }
625
+ this.replaySessions.delete(sessionId);
303
626
  if (this.recordingId === sessionId) {
304
627
  await this.saveCurrentSession();
305
628
  this.currentSession = null;
@@ -310,29 +633,17 @@ var ProxyServer = class {
310
633
  }
311
634
  console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
312
635
  }
313
- parseGetParams(req) {
314
- const url = new URL(req.url || "", `http://${req.headers.host}`);
315
- const mode = url.searchParams.get("mode");
316
- const id = url.searchParams.get("id") || void 0;
317
- const timeoutParam = url.searchParams.get("timeout");
318
- const timeout = timeoutParam ? Number.parseInt(timeoutParam, 10) : void 0;
319
- if (!mode) {
320
- throw new Error("Mode parameter is required");
321
- }
322
- return { mode, id, timeout };
323
- }
324
- async parseControlRequest(req) {
325
- if (req.method === "GET") {
326
- return this.parseGetParams(req);
327
- }
328
- if (req.method === "POST") {
329
- const body = await readRequestBody(req);
330
- console.log(`MODE CHANGE (${req.method})`, body);
331
- return JSON.parse(body);
332
- }
333
- throw new Error("Unsupported control method");
636
+ async parseControlBody(req) {
637
+ const body = await readRequestBody(req);
638
+ console.log(`MODE CHANGE (${req.method})`, body);
639
+ return JSON.parse(body);
334
640
  }
335
641
  async handleControlRequest(req, res) {
642
+ if (req.method === "HEAD") {
643
+ res.writeHead(HTTP_STATUS_OK);
644
+ res.end();
645
+ return;
646
+ }
336
647
  if (req.method === "GET") {
337
648
  sendJsonResponse(res, HTTP_STATUS_OK, {
338
649
  recordingsDir: this.recordingsDir,
@@ -341,8 +652,11 @@ var ProxyServer = class {
341
652
  });
342
653
  return;
343
654
  }
655
+ await this.handleControlPost(req, res);
656
+ }
657
+ async handleControlPost(req, res) {
344
658
  try {
345
- const data = await this.parseControlRequest(req);
659
+ const data = await this.parseControlBody(req);
346
660
  const { mode, id, timeout: requestTimeout, cleanup } = data;
347
661
  if (cleanup && id) {
348
662
  await this.cleanupSession(id);
@@ -353,29 +667,7 @@ var ProxyServer = class {
353
667
  });
354
668
  return;
355
669
  }
356
- if (!mode) {
357
- throw new Error(
358
- "Mode parameter is required when cleanup is not specified"
359
- );
360
- }
361
- const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
362
- this.clearModeTimeout();
363
- await this.switchMode(mode, id);
364
- this.setupModeTimeout(timeout);
365
- if (mode === Modes.replay && id) {
366
- res.setHeader(
367
- "Set-Cookie",
368
- `proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
369
- );
370
- console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
371
- }
372
- sendJsonResponse(res, HTTP_STATUS_OK, {
373
- success: true,
374
- mode: this.mode,
375
- id: this.recordingId || this.replayId,
376
- timeout,
377
- recordingsDir: this.recordingsDir
378
- });
670
+ await this.applyModeChange(res, mode, id, requestTimeout);
379
671
  } catch (error) {
380
672
  console.error("Control request error:", error);
381
673
  sendJsonResponse(res, HTTP_STATUS_BAD_REQUEST, {
@@ -383,6 +675,31 @@ var ProxyServer = class {
383
675
  });
384
676
  }
385
677
  }
678
+ async applyModeChange(res, mode, id, requestTimeout) {
679
+ if (!mode) {
680
+ throw new Error(
681
+ "Mode parameter is required when cleanup is not specified"
682
+ );
683
+ }
684
+ const timeout = requestTimeout ?? this.timeoutMs;
685
+ this.clearModeTimeout();
686
+ await this.switchMode(mode, id);
687
+ this.setupModeTimeout(timeout);
688
+ if (mode === Modes.replay && id) {
689
+ res.setHeader(
690
+ "Set-Cookie",
691
+ `proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
692
+ );
693
+ console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
694
+ }
695
+ sendJsonResponse(res, HTTP_STATUS_OK, {
696
+ success: true,
697
+ mode: this.mode,
698
+ id: this.recordingId || this.replayId,
699
+ timeout,
700
+ recordingsDir: this.recordingsDir
701
+ });
702
+ }
386
703
  clearModeTimeout() {
387
704
  clearTimeout(this.modeTimeout || 0);
388
705
  this.modeTimeout = null;
@@ -422,7 +739,7 @@ var ProxyServer = class {
422
739
  this.recordingId = null;
423
740
  this.replayId = null;
424
741
  this.currentSession = null;
425
- clearTimeout(this.modeTimeout || 0);
742
+ this.clearModeTimeout();
426
743
  console.log("Switched to transparent mode");
427
744
  }
428
745
  switchToRecordMode(id) {
@@ -439,7 +756,7 @@ var ProxyServer = class {
439
756
  this.replayId = id;
440
757
  this.recordingId = null;
441
758
  this.currentSession = null;
442
- const sessionState = this.getOrCreateReplaySession(id);
759
+ const sessionState = this.replaySessions.getOrCreate(id);
443
760
  sessionState.servedRecordingIdsByKey.clear();
444
761
  sessionState.sortedRecordingsByKey.clear();
445
762
  const filePath = getRecordingPath(this.recordingsDir, id);
@@ -453,7 +770,7 @@ var ProxyServer = class {
453
770
  console.log(`Switched to replay mode with ID: ${id}`);
454
771
  }
455
772
  setupModeTimeout(timeout) {
456
- clearTimeout(this.modeTimeout || 0);
773
+ this.clearModeTimeout();
457
774
  this.modeTimeout = setTimeout(async () => {
458
775
  console.log("Timeout reached, switching back to transparent mode");
459
776
  await this.saveCurrentSession();
@@ -500,7 +817,7 @@ var ProxyServer = class {
500
817
  await saveRecordingSession(this.recordingsDir, this.currentSession);
501
818
  }
502
819
  getRecordingIdOrError(req, res) {
503
- const recordingIdFromRequest = this.getRecordingIdFromRequest(req);
820
+ const recordingIdFromRequest = getRecordingIdFromRequest(req);
504
821
  if (recordingIdFromRequest) {
505
822
  return recordingIdFromRequest;
506
823
  }
@@ -508,7 +825,7 @@ var ProxyServer = class {
508
825
  console.warn(
509
826
  `[CONCURRENT REPLAY WARNING] Request to ${req.method} ${req.url} is missing ${RECORDING_ID_HEADER} header/cookie. Active sessions: ${[...this.replaySessions.keys()].join(", ")}. this.replayId fallback would be: ${this.replayId} (NOT USING - could be wrong session)`
510
827
  );
511
- const corsHeaders = this.getCorsHeaders(req);
828
+ const corsHeaders = getCorsHeaders(req);
512
829
  res.writeHead(HTTP_STATUS_BAD_REQUEST, {
513
830
  "Content-Type": "application/json",
514
831
  ...corsHeaders
@@ -524,7 +841,7 @@ var ProxyServer = class {
524
841
  }
525
842
  const recordingId = this.replayId;
526
843
  if (!recordingId) {
527
- const corsHeaders = this.getCorsHeaders(req);
844
+ const corsHeaders = getCorsHeaders(req);
528
845
  res.writeHead(HTTP_STATUS_BAD_REQUEST, {
529
846
  "Content-Type": "application/json",
530
847
  ...corsHeaders
@@ -537,56 +854,22 @@ var ProxyServer = class {
537
854
  );
538
855
  return recordingId;
539
856
  }
540
- getServedTracker(sessionState, key) {
541
- if (!sessionState.servedRecordingIdsByKey.has(key)) {
542
- sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
543
- }
544
- return sessionState.servedRecordingIdsByKey.get(key);
545
- }
546
- getSortedRecordings(sessionState, key) {
547
- if (sessionState.sortedRecordingsByKey.has(key)) {
548
- return sessionState.sortedRecordingsByKey.get(key);
549
- }
550
- const session = sessionState.loadedSession;
551
- const sortedRecords = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
552
- const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
553
- const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
554
- return aSeq - bSeq;
555
- });
556
- sessionState.sortedRecordingsByKey.set(key, sortedRecords);
557
- return sortedRecords;
558
- }
559
- selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
560
- for (const rec of recordsWithKey) {
561
- if (!servedForThisKey.has(rec.recordingId)) {
562
- return rec;
563
- }
564
- }
565
- if (recordsWithKey.length > 0) {
566
- console.log(
567
- `[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
568
- );
569
- return recordsWithKey[recordsWithKey.length - 1];
570
- }
571
- return null;
572
- }
573
857
  async handleReplayRequest(req, res) {
574
858
  const recordingId = this.getRecordingIdOrError(req, res);
575
859
  if (!recordingId) return;
576
860
  const key = getReqID(req);
577
861
  const filePath = getRecordingPath(this.recordingsDir, recordingId);
578
862
  try {
579
- const sessionState = this.getOrCreateReplaySession(recordingId);
863
+ const sessionState = this.replaySessions.getOrCreate(recordingId);
580
864
  if (!sessionState.loadedSession) {
581
- const error = new Error(
582
- `Recording session file not found: ${filePath}`
865
+ throw Object.assign(
866
+ new Error(`Recording session file not found: ${filePath}`),
867
+ { code: "ENOENT" }
583
868
  );
584
- error.code = "ENOENT";
585
- throw error;
586
869
  }
587
- const servedForThisKey = this.getServedTracker(sessionState, key);
870
+ const servedForThisKey = getServedTracker(sessionState, key);
588
871
  const host = req.headers.host || "unknown";
589
- const recordsWithKey = this.getSortedRecordings(sessionState, key);
872
+ const recordsWithKey = getSortedRecordings(sessionState, key);
590
873
  if (recordsWithKey.length === 0) {
591
874
  const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
592
875
  console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
@@ -599,7 +882,7 @@ var ProxyServer = class {
599
882
  key,
600
883
  sessionId: recordingId
601
884
  };
602
- const corsHeaders = this.getCorsHeaders(req);
885
+ const corsHeaders = getCorsHeaders(req);
603
886
  res.writeHead(HTTP_STATUS_NOT_FOUND, {
604
887
  "Content-Type": "application/json",
605
888
  ...corsHeaders
@@ -611,7 +894,7 @@ var ProxyServer = class {
611
894
  console.log(
612
895
  `[replay request #${requestCount}] ${req.method} ${req.url} (key: ${key}, session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
613
896
  );
614
- const record = this.selectReplayRecord(
897
+ const record = selectReplayRecord(
615
898
  recordsWithKey,
616
899
  servedForThisKey,
617
900
  key,
@@ -629,7 +912,7 @@ var ProxyServer = class {
629
912
  const { statusCode, headers, body } = record.response;
630
913
  const responseHeaders = {
631
914
  ...headers,
632
- ...this.getCorsHeaders(req)
915
+ ...getCorsHeaders(req)
633
916
  };
634
917
  res.writeHead(statusCode, responseHeaders);
635
918
  res.end(body);
@@ -640,7 +923,7 @@ var ProxyServer = class {
640
923
  handleReplayError(req, res, err, key, filePath) {
641
924
  const isFileNotFound = err instanceof Error && "code" in err && err.code === "ENOENT";
642
925
  console.error("Replay error:", err);
643
- const corsHeaders = this.getCorsHeaders(req);
926
+ const corsHeaders = getCorsHeaders(req);
644
927
  res.writeHead(HTTP_STATUS_NOT_FOUND, {
645
928
  "Content-Type": "application/json",
646
929
  ...corsHeaders
@@ -668,7 +951,7 @@ var ProxyServer = class {
668
951
  await this.handleProxyRequest(req, res);
669
952
  }
670
953
  handleCorsPreflightRequest(req, res) {
671
- const corsHeaders = this.getCorsHeaders(req);
954
+ const corsHeaders = getCorsHeaders(req);
672
955
  res.writeHead(HTTP_STATUS_OK, {
673
956
  ...corsHeaders,
674
957
  "Access-Control-Max-Age": "86400"
@@ -677,16 +960,15 @@ var ProxyServer = class {
677
960
  res.end();
678
961
  }
679
962
  async handleProxyRequest(req, res) {
680
- const target = this.getTarget();
963
+ const target = this.target;
681
964
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
682
965
  if (this.mode === Modes.record) {
683
- await this.recordAndProxyRequest(req, res, target);
966
+ this.recordAndProxy(req, res, target);
684
967
  } else {
685
968
  this.proxy.web(req, res, { target });
686
969
  }
687
970
  }
688
- // Note: streaming requests are buffered before proxying; streaming passthrough is not yet implemented
689
- async recordAndProxyRequest(req, res, target) {
971
+ recordAndProxy(req, res, target) {
690
972
  if (!this.currentSession) {
691
973
  return;
692
974
  }
@@ -694,194 +976,66 @@ var ProxyServer = class {
694
976
  const recordingId = this.recordingIdCounter++;
695
977
  const sequence = this.sequenceCounterByKey.get(key) || 0;
696
978
  this.sequenceCounterByKey.set(key, sequence + 1);
697
- const recordingPromise = new Promise((resolve) => {
698
- (async () => {
699
- try {
700
- const chunks = [];
701
- req.on("data", (chunk) => {
702
- chunks.push(chunk);
703
- });
704
- try {
705
- await new Promise((resolveBuffer, rejectBuffer) => {
706
- req.on("end", () => resolveBuffer());
707
- req.on("error", (err) => rejectBuffer(err));
708
- setTimeout(
709
- () => rejectBuffer(new Error("Request buffering timeout")),
710
- 3e4
711
- );
712
- });
713
- } catch (error) {
714
- console.error("Error buffering request:", error);
715
- }
716
- const requestBody = Buffer.concat(chunks).toString("utf8");
717
- const targetUrl = new URL(target);
718
- const isHttps = targetUrl.protocol === "https:";
719
- const requestModule = isHttps ? https__default.default : http__default.default;
720
- const defaultPort = isHttps ? 443 : 80;
721
- const proxyReq = requestModule.request(
722
- {
723
- hostname: targetUrl.hostname,
724
- port: targetUrl.port || defaultPort,
725
- path: req.url,
726
- method: req.method,
727
- headers: req.headers
728
- },
729
- (proxyRes) => {
730
- this.addCorsHeaders(proxyRes, req);
731
- const responseChunks = [];
732
- proxyRes.on("data", (chunk) => {
733
- responseChunks.push(chunk);
734
- });
735
- proxyRes.on("end", async () => {
736
- try {
737
- const responseBody = Buffer.concat(responseChunks);
738
- const responseBodyStr = responseBody.toString("utf8");
739
- const recording = {
740
- request: {
741
- method: req.method,
742
- url: req.url,
743
- headers: req.headers,
744
- body: requestBody || null
745
- },
746
- response: {
747
- statusCode: proxyRes.statusCode,
748
- headers: proxyRes.headers,
749
- body: responseBodyStr || null
750
- },
751
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
752
- key,
753
- recordingId,
754
- sequence
755
- };
756
- const responseHeaders = {
757
- ...proxyRes.headers,
758
- ...this.getCorsHeaders(req)
759
- };
760
- res.writeHead(proxyRes.statusCode || 200, responseHeaders);
761
- res.end(responseBody);
762
- console.log(
763
- `Recorded: ${req.method} ${req.url} (recordingId: ${recordingId}, sequence: ${sequence})`
764
- );
765
- resolve(recording);
766
- } catch (error) {
767
- console.error("Error completing recording:", error);
768
- resolve(null);
769
- }
770
- });
771
- proxyRes.on("error", (err) => {
772
- console.error("Proxy response error:", err);
773
- if (!res.headersSent) {
774
- this.handleProxyError(err, req, res);
775
- }
776
- resolve(null);
777
- });
778
- }
779
- );
780
- proxyReq.on("error", (err) => {
781
- this.handleProxyError(err, req, res);
782
- resolve(null);
783
- });
784
- if (chunks.length > 0) {
785
- proxyReq.write(Buffer.concat(chunks));
786
- }
787
- proxyReq.end();
788
- } catch (error) {
789
- console.error("Error in recordAndProxyRequest:", error);
790
- try {
791
- this.handleProxyError(error, req, res);
792
- } catch (error_) {
793
- console.error("Failed to handle proxy error:", error_);
794
- }
795
- resolve(null);
796
- }
797
- })();
798
- });
799
- this.recordingPromises.push(recordingPromise);
979
+ this.recordingPromises.push(
980
+ recordAndProxyRequest({
981
+ req,
982
+ res,
983
+ target,
984
+ key,
985
+ recordingId,
986
+ sequence,
987
+ onProxyError: this.handleProxyError.bind(this)
988
+ })
989
+ );
800
990
  }
801
991
  handleUpgrade(req, socket, head) {
802
992
  if (this.mode === Modes.replay) {
803
993
  this.handleReplayWebSocket(req, socket);
804
994
  return;
805
995
  }
806
- const target = this.getTarget();
996
+ const target = this.target;
807
997
  console.log(`[${this.mode}] WebSocket upgrade ${req.url} -> ${target}`);
808
998
  if (this.mode === Modes.record) {
809
- this.handleRecordWebSocket(req, socket, head, target);
999
+ recordWebSocket(req, socket, head, target, this.currentSession);
810
1000
  } else {
811
1001
  this.proxy.ws(req, socket, head, { target });
812
1002
  }
813
1003
  }
814
- handleRecordWebSocket(req, clientSocket, head, target) {
815
- const url = req.url || "/";
816
- const key = `WS_${url.replaceAll("/", "_")}`;
817
- const wsRecording = {
818
- url,
819
- messages: [],
820
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
821
- key
822
- };
823
- if (this.currentSession) {
824
- this.currentSession.websocketRecordings.push(wsRecording);
1004
+ /**
1005
+ * Resolve the recording ID for a WebSocket upgrade request.
1006
+ * Mirrors getRecordingIdOrError(): prefer the header/cookie from the request,
1007
+ * fall back to this.replayId only when there is at most one active session.
1008
+ * Browsers cannot set custom headers on WebSocket handshakes from JS, but
1009
+ * Playwright's setExtraHTTPHeaders / cookies still reach the upgrade request.
1010
+ */
1011
+ getWsRecordingId(req) {
1012
+ const fromRequest = getRecordingIdFromRequest(req);
1013
+ if (fromRequest) {
1014
+ return fromRequest;
825
1015
  }
826
- const backendWsUrl = `${target.replace("http", "ws")}${url}`;
827
- const backendWs = new ws.WebSocket(backendWsUrl);
828
- const wss = new ws.WebSocketServer({ noServer: true });
829
- backendWs.on("open", () => {
830
- console.log(`WebSocket recording: connected to backend ${backendWsUrl}`);
831
- wss.handleUpgrade(req, clientSocket, head, (clientWs) => {
832
- clientWs.on("message", (data) => {
833
- const message = data.toString();
834
- wsRecording.messages.push({
835
- direction: "client-to-server",
836
- data: message,
837
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
838
- });
839
- if (backendWs.readyState === ws.WebSocket.OPEN) {
840
- backendWs.send(message);
841
- }
842
- });
843
- backendWs.on("message", (data) => {
844
- const message = data.toString();
845
- wsRecording.messages.push({
846
- direction: "server-to-client",
847
- data: message,
848
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
849
- });
850
- if (clientWs.readyState === ws.WebSocket.OPEN) {
851
- clientWs.send(message);
852
- }
853
- });
854
- clientWs.on("error", (err) => {
855
- console.error("Client WebSocket error:", err);
856
- });
857
- backendWs.on("error", (err) => {
858
- console.error("Backend WebSocket error:", err);
859
- });
860
- clientWs.on("close", () => {
861
- backendWs.close();
862
- console.log("Client WebSocket closed");
863
- });
864
- backendWs.on("close", () => {
865
- clientWs.close();
866
- console.log("Backend WebSocket closed");
867
- });
868
- });
869
- });
870
- backendWs.on("error", (err) => {
871
- console.error("Backend WebSocket connection error:", err);
872
- clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
873
- clientSocket.destroy();
874
- });
875
- wss.on("error", (err) => {
876
- console.error("WebSocket server error:", err);
877
- });
1016
+ if (this.replaySessions.size > 1) {
1017
+ console.warn(
1018
+ `[CONCURRENT REPLAY WARNING] WebSocket upgrade ${req.url} is missing ${RECORDING_ID_HEADER} header/cookie. Active sessions: ${[...this.replaySessions.keys()].join(", ")}. this.replayId fallback would be: ${this.replayId} (NOT USING - could be wrong session)`
1019
+ );
1020
+ return null;
1021
+ }
1022
+ return this.replayId;
878
1023
  }
879
- handleReplayWebSocket(req, socket) {
880
- const url = req.url || "/";
881
- const key = `WS_${url.replaceAll("/", "_")}`;
882
- const filePath = getRecordingPath(this.recordingsDir, this.replayId);
883
- loadRecordingSession(filePath).then((session) => {
884
- const wsRecording = session.websocketRecordings.find(
1024
+ async handleReplayWebSocket(req, socket) {
1025
+ const key = getWsRecordingKey(req.url || "/");
1026
+ const recordingId = this.getWsRecordingId(req);
1027
+ if (!recordingId) {
1028
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
1029
+ socket.destroy();
1030
+ return;
1031
+ }
1032
+ try {
1033
+ const sessionState = this.replaySessions.getOrCreate(recordingId);
1034
+ if (!sessionState.loadedSession) {
1035
+ const filePath = getRecordingPath(this.recordingsDir, recordingId);
1036
+ sessionState.loadedSession = await loadRecordingSession(filePath);
1037
+ }
1038
+ const wsRecording = sessionState.loadedSession.websocketRecordings.find(
885
1039
  (r) => r.key === key
886
1040
  );
887
1041
  if (!wsRecording) {
@@ -890,67 +1044,12 @@ var ProxyServer = class {
890
1044
  console.log(`No WebSocket recording found for ${key}`);
891
1045
  return;
892
1046
  }
893
- const wss = new ws.WebSocketServer({ noServer: true });
894
- const fakeReq = Object.assign(req, {
895
- headers: {
896
- ...req.headers,
897
- "sec-websocket-key": req.headers["sec-websocket-key"] || "replay-key",
898
- "sec-websocket-version": "13"
899
- }
900
- });
901
- wss.handleUpgrade(fakeReq, socket, Buffer.alloc(0), (ws$1) => {
902
- console.log(`Replaying WebSocket: ${url}`);
903
- const serverMessages = wsRecording.messages.filter(
904
- (m) => m.direction === "server-to-client"
905
- );
906
- let messageIndex = 0;
907
- ws$1.on("message", (data) => {
908
- const clientMessage = data.toString();
909
- console.log(`Replay: Client sent: ${clientMessage}`);
910
- if (messageIndex < serverMessages.length) {
911
- setTimeout(() => {
912
- if (ws$1.readyState === ws.WebSocket.OPEN) {
913
- ws$1.send(serverMessages[messageIndex].data);
914
- console.log(`Replay: Sent server message ${messageIndex}`);
915
- messageIndex++;
916
- }
917
- }, 10);
918
- }
919
- });
920
- let initialMessagesSent = 0;
921
- for (let i = 0; i < wsRecording.messages.length; i++) {
922
- const msg = wsRecording.messages[i];
923
- if (msg.direction === "client-to-server") {
924
- break;
925
- }
926
- if (msg.direction === "server-to-client") {
927
- setTimeout(
928
- () => {
929
- if (ws$1.readyState === ws.WebSocket.OPEN) {
930
- ws$1.send(msg.data);
931
- console.log(
932
- `Replay: Sent initial server message: ${msg.data}`
933
- );
934
- messageIndex++;
935
- initialMessagesSent++;
936
- }
937
- },
938
- 10 * (initialMessagesSent + 1)
939
- );
940
- }
941
- }
942
- ws$1.on("error", (err) => {
943
- console.error("Replay WebSocket error:", err);
944
- });
945
- ws$1.on("close", () => {
946
- console.log("Replay WebSocket closed");
947
- });
948
- });
949
- }).catch((error) => {
1047
+ replayWebSocket(req, socket, wsRecording, recordingId);
1048
+ } catch (error) {
950
1049
  console.error("Replay error:", error);
951
1050
  socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
952
1051
  socket.destroy();
953
- });
1052
+ }
954
1053
  }
955
1054
  logServerStartup(port) {
956
1055
  console.log(`Proxy server running on http://localhost:${port}`);
@@ -961,6 +1060,7 @@ var ProxyServer = class {
961
1060
  );
962
1061
  }
963
1062
  };
1063
+ var registeredContexts = /* @__PURE__ */ new WeakSet();
964
1064
  function getProxyPort() {
965
1065
  const envPort = process.env.TEST_PROXY_RECORDER_PORT;
966
1066
  if (envPort) {
@@ -979,7 +1079,7 @@ async function setProxyMode(mode, sessionId, timeout) {
979
1079
  id: sessionId,
980
1080
  ...timeout && { timeout }
981
1081
  };
982
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
1082
+ const response = await fetch(`http://localhost:${proxyPort}/__control`, {
983
1083
  method: "POST",
984
1084
  headers: { "Content-Type": "application/json" },
985
1085
  body: JSON.stringify(body)
@@ -1002,7 +1102,7 @@ async function cleanupSession(sessionId) {
1002
1102
  cleanup: true,
1003
1103
  id: sessionId
1004
1104
  };
1005
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
1105
+ const response = await fetch(`http://localhost:${proxyPort}/__control`, {
1006
1106
  method: "POST",
1007
1107
  headers: { "Content-Type": "application/json" },
1008
1108
  body: JSON.stringify(body)
@@ -1066,7 +1166,7 @@ async function getRecordingsDir() {
1066
1166
  }
1067
1167
  const proxyPort = getProxyPort();
1068
1168
  try {
1069
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`);
1169
+ const response = await fetch(`http://localhost:${proxyPort}/__control`);
1070
1170
  if (response.ok) {
1071
1171
  const data = await response.json();
1072
1172
  if (data.recordingsDir) {
@@ -1119,6 +1219,14 @@ var playwrightProxy = {
1119
1219
  await page.setExtraHTTPHeaders({
1120
1220
  [RECORDING_ID_HEADER]: sessionId
1121
1221
  });
1222
+ const fallbackProxyPort = getProxyPort();
1223
+ await page.context().addCookies([
1224
+ {
1225
+ name: "proxy-recording-id",
1226
+ value: encodeURIComponent(sessionId),
1227
+ url: `http://localhost:${fallbackProxyPort}`
1228
+ }
1229
+ ]);
1122
1230
  await setProxyMode(mode, sessionId, timeout);
1123
1231
  if (clientSideOptions?.url) {
1124
1232
  await setupClientSideRecording(
@@ -1153,10 +1261,8 @@ var playwrightProxy = {
1153
1261
  // Ensure the handler applies to all matching requests
1154
1262
  );
1155
1263
  const context = page.context();
1156
- const contextId = context._guid || "default";
1157
- const handlerKey = `cleanup_${contextId}`;
1158
- if (!globalThis[handlerKey]) {
1159
- globalThis[handlerKey] = true;
1264
+ if (!registeredContexts.has(context)) {
1265
+ registeredContexts.add(context);
1160
1266
  context.on("close", async () => {
1161
1267
  try {
1162
1268
  await cleanupSession(sessionId);
@@ -1166,7 +1272,7 @@ var playwrightProxy = {
1166
1272
  error
1167
1273
  );
1168
1274
  } finally {
1169
- delete globalThis[handlerKey];
1275
+ registeredContexts.delete(context);
1170
1276
  }
1171
1277
  });
1172
1278
  }