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