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