test-proxy-recorder 0.3.5 → 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,12 @@
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
10
 
11
11
  // src/cli.ts
12
12
 
@@ -63,6 +63,244 @@ function parseCliArgs() {
63
63
  return { target: target2, port: port2, recordingsDir: recordingsDir2, timeout: timeout2 };
64
64
  }
65
65
 
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
+ }
303
+
66
304
  // src/types.ts
67
305
  var Modes = {
68
306
  transparent: "transparent",
@@ -164,6 +402,180 @@ function sendJsonResponse(res, statusCode, data) {
164
402
  res.end(JSON.stringify(data));
165
403
  }
166
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
+
167
579
  // src/ProxyServer.ts
168
580
  var ProxyServer = class {
169
581
  target;
@@ -181,8 +593,6 @@ var ProxyServer = class {
181
593
  // Sequence counter per key (endpoint)
182
594
  replaySessions;
183
595
  // Track multiple concurrent replay sessions by recording ID
184
- sessionEvictionTimer;
185
- // Periodic timer to evict idle replay sessions
186
596
  recordingPromises;
187
597
  // Stack of promises that resolve to completed recordings
188
598
  flushPromise;
@@ -198,8 +608,7 @@ var ProxyServer = class {
198
608
  this.modeTimeout = null;
199
609
  this.currentSession = null;
200
610
  this.recordingsDir = recordingsDir2;
201
- this.replaySessions = /* @__PURE__ */ new Map();
202
- this.sessionEvictionTimer = null;
611
+ this.replaySessions = new ReplaySessionManager(this.timeoutMs);
203
612
  this.recordingPromises = [];
204
613
  this.flushPromise = null;
205
614
  this.proxy = httpProxy.createProxyServer({
@@ -213,7 +622,7 @@ var ProxyServer = class {
213
622
  await fs.mkdir(this.recordingsDir, { recursive: true });
214
623
  }
215
624
  listen(port2) {
216
- const server = http.createServer((req, res) => {
625
+ const server = http2.createServer((req, res) => {
217
626
  this.handleRequest(req, res);
218
627
  });
219
628
  server.on("upgrade", (req, socket, head) => {
@@ -227,15 +636,15 @@ var ProxyServer = class {
227
636
  }
228
637
  setupProxyEventHandlers() {
229
638
  this.proxy.on("error", this.handleProxyError.bind(this));
230
- this.proxy.on("proxyRes", this.addCorsHeaders.bind(this));
639
+ this.proxy.on("proxyRes", addCorsHeaders);
231
640
  }
232
641
  handleProxyError(err, req, res) {
233
642
  console.error("Proxy error:", err);
234
- if (!(res instanceof http.ServerResponse)) {
643
+ if (!(res instanceof http2.ServerResponse)) {
235
644
  return;
236
645
  }
237
646
  if (!res.headersSent) {
238
- const corsHeaders = this.getCorsHeaders(req);
647
+ const corsHeaders = getCorsHeaders(req);
239
648
  res.writeHead(HTTP_STATUS_BAD_GATEWAY, {
240
649
  "Content-Type": "application/json",
241
650
  ...corsHeaders
@@ -243,96 +652,12 @@ var ProxyServer = class {
243
652
  }
244
653
  res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
245
654
  }
246
- /**
247
- * Get CORS headers for a given request
248
- * @param req The incoming HTTP request
249
- * @returns An object containing CORS headers
250
- */
251
- getCorsHeaders(req) {
252
- const origin = req.headers.origin;
253
- return {
254
- "access-control-allow-origin": origin || "*",
255
- "access-control-allow-credentials": "true",
256
- "access-control-allow-headers": req.headers["access-control-request-headers"] || `Origin, X-Requested-With, Content-Type, Accept, Authorization, ${RECORDING_ID_HEADER}`,
257
- "access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
258
- "access-control-expose-headers": "*"
259
- };
260
- }
261
- addCorsHeaders(proxyRes, req) {
262
- const corsHeaders = this.getCorsHeaders(req);
263
- Object.assign(proxyRes.headers, corsHeaders);
264
- }
265
- /**
266
- * Extract recording ID from custom HTTP header
267
- * Used for concurrent replay session routing, especially with Next.js
268
- * @param req The incoming HTTP request
269
- * @returns The recording ID from header, or null if not found
270
- */
271
- getRecordingIdFromHeader(req) {
272
- const headerValue = req.headers[RECORDING_ID_HEADER];
273
- if (!headerValue) {
274
- return null;
275
- }
276
- return Array.isArray(headerValue) ? headerValue[0] : headerValue;
277
- }
278
- /**
279
- * Extract recording ID from request cookie
280
- * Used for concurrent replay session routing (fallback method)
281
- * @param req The incoming HTTP request
282
- * @returns The recording ID from cookie, or null if not found
283
- */
284
- getRecordingIdFromCookie(req) {
285
- const cookies = req.headers.cookie;
286
- if (!cookies) {
287
- return null;
288
- }
289
- const match = cookies.match(/proxy-recording-id=([^;]+)/);
290
- return match ? decodeURIComponent(match[1]) : null;
291
- }
292
- /**
293
- * Extract recording ID from request using custom header (preferred) or cookie (fallback)
294
- * @param req The incoming HTTP request
295
- * @returns The recording ID, or null if not found
296
- */
297
- getRecordingIdFromRequest(req) {
298
- const fromHeader = this.getRecordingIdFromHeader(req);
299
- const fromCookie = this.getRecordingIdFromCookie(req);
300
- return fromHeader ?? fromCookie ?? null;
301
- }
302
- /**
303
- * Get or create a replay session state for a given recording ID
304
- * @param recordingId The recording ID to get/create session for
305
- * @returns The replay session state
306
- */
307
- getOrCreateReplaySession(recordingId) {
308
- let session = this.replaySessions.get(recordingId);
309
- if (session) {
310
- session.lastAccessTime = Date.now();
311
- } else {
312
- session = {
313
- recordingId,
314
- servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
315
- loadedSession: null,
316
- lastAccessTime: Date.now(),
317
- sortedRecordingsByKey: /* @__PURE__ */ new Map()
318
- };
319
- this.replaySessions.set(recordingId, session);
320
- this.startSessionEvictionTimer();
321
- console.log(
322
- `[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
323
- );
324
- }
325
- return session;
326
- }
327
655
  /**
328
656
  * Clean up a session - removes it from memory and resets counters
329
657
  * @param sessionId The session ID to clean up
330
658
  */
331
659
  async cleanupSession(sessionId) {
332
660
  this.replaySessions.delete(sessionId);
333
- if (this.replaySessions.size === 0) {
334
- this.stopSessionEvictionTimer();
335
- }
336
661
  if (this.recordingId === sessionId) {
337
662
  await this.saveCurrentSession();
338
663
  this.currentSession = null;
@@ -343,33 +668,6 @@ var ProxyServer = class {
343
668
  }
344
669
  console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
345
670
  }
346
- startSessionEvictionTimer() {
347
- if (this.sessionEvictionTimer) {
348
- return;
349
- }
350
- const CHECK_INTERVAL_MS = 3e4;
351
- this.sessionEvictionTimer = setInterval(() => {
352
- const now = Date.now();
353
- for (const [id, session] of this.replaySessions) {
354
- if (now - session.lastAccessTime >= this.timeoutMs) {
355
- console.log(
356
- `[EVICTION] Evicting idle replay session: ${id} (idle for ${Math.round((now - session.lastAccessTime) / 1e3)}s)`
357
- );
358
- this.replaySessions.delete(id);
359
- }
360
- }
361
- if (this.replaySessions.size === 0) {
362
- this.stopSessionEvictionTimer();
363
- }
364
- }, CHECK_INTERVAL_MS);
365
- this.sessionEvictionTimer.unref();
366
- }
367
- stopSessionEvictionTimer() {
368
- if (this.sessionEvictionTimer) {
369
- clearInterval(this.sessionEvictionTimer);
370
- this.sessionEvictionTimer = null;
371
- }
372
- }
373
671
  async parseControlBody(req) {
374
672
  const body = await readRequestBody(req);
375
673
  console.log(`MODE CHANGE (${req.method})`, body);
@@ -493,7 +791,7 @@ var ProxyServer = class {
493
791
  this.replayId = id;
494
792
  this.recordingId = null;
495
793
  this.currentSession = null;
496
- const sessionState = this.getOrCreateReplaySession(id);
794
+ const sessionState = this.replaySessions.getOrCreate(id);
497
795
  sessionState.servedRecordingIdsByKey.clear();
498
796
  sessionState.sortedRecordingsByKey.clear();
499
797
  const filePath = getRecordingPath(this.recordingsDir, id);
@@ -554,7 +852,7 @@ var ProxyServer = class {
554
852
  await saveRecordingSession(this.recordingsDir, this.currentSession);
555
853
  }
556
854
  getRecordingIdOrError(req, res) {
557
- const recordingIdFromRequest = this.getRecordingIdFromRequest(req);
855
+ const recordingIdFromRequest = getRecordingIdFromRequest(req);
558
856
  if (recordingIdFromRequest) {
559
857
  return recordingIdFromRequest;
560
858
  }
@@ -562,7 +860,7 @@ var ProxyServer = class {
562
860
  console.warn(
563
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)`
564
862
  );
565
- const corsHeaders = this.getCorsHeaders(req);
863
+ const corsHeaders = getCorsHeaders(req);
566
864
  res.writeHead(HTTP_STATUS_BAD_REQUEST, {
567
865
  "Content-Type": "application/json",
568
866
  ...corsHeaders
@@ -578,7 +876,7 @@ var ProxyServer = class {
578
876
  }
579
877
  const recordingId = this.replayId;
580
878
  if (!recordingId) {
581
- const corsHeaders = this.getCorsHeaders(req);
879
+ const corsHeaders = getCorsHeaders(req);
582
880
  res.writeHead(HTTP_STATUS_BAD_REQUEST, {
583
881
  "Content-Type": "application/json",
584
882
  ...corsHeaders
@@ -591,55 +889,22 @@ var ProxyServer = class {
591
889
  );
592
890
  return recordingId;
593
891
  }
594
- getServedTracker(sessionState, key) {
595
- if (!sessionState.servedRecordingIdsByKey.has(key)) {
596
- sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
597
- }
598
- return sessionState.servedRecordingIdsByKey.get(key);
599
- }
600
- getSortedRecordings(sessionState, key) {
601
- if (sessionState.sortedRecordingsByKey.has(key)) {
602
- return sessionState.sortedRecordingsByKey.get(key);
603
- }
604
- const session = sessionState.loadedSession;
605
- const sortedRecords = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
606
- const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
607
- const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
608
- return aSeq - bSeq;
609
- });
610
- sessionState.sortedRecordingsByKey.set(key, sortedRecords);
611
- return sortedRecords;
612
- }
613
- selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
614
- for (const rec of recordsWithKey) {
615
- if (!servedForThisKey.has(rec.recordingId)) {
616
- return rec;
617
- }
618
- }
619
- if (recordsWithKey.length > 0) {
620
- console.log(
621
- `[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
622
- );
623
- return recordsWithKey[recordsWithKey.length - 1];
624
- }
625
- return null;
626
- }
627
892
  async handleReplayRequest(req, res) {
628
893
  const recordingId = this.getRecordingIdOrError(req, res);
629
894
  if (!recordingId) return;
630
895
  const key = getReqID(req);
631
896
  const filePath = getRecordingPath(this.recordingsDir, recordingId);
632
897
  try {
633
- const sessionState = this.getOrCreateReplaySession(recordingId);
898
+ const sessionState = this.replaySessions.getOrCreate(recordingId);
634
899
  if (!sessionState.loadedSession) {
635
900
  throw Object.assign(
636
901
  new Error(`Recording session file not found: ${filePath}`),
637
902
  { code: "ENOENT" }
638
903
  );
639
904
  }
640
- const servedForThisKey = this.getServedTracker(sessionState, key);
905
+ const servedForThisKey = getServedTracker(sessionState, key);
641
906
  const host = req.headers.host || "unknown";
642
- const recordsWithKey = this.getSortedRecordings(sessionState, key);
907
+ const recordsWithKey = getSortedRecordings(sessionState, key);
643
908
  if (recordsWithKey.length === 0) {
644
909
  const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
645
910
  console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
@@ -652,7 +917,7 @@ var ProxyServer = class {
652
917
  key,
653
918
  sessionId: recordingId
654
919
  };
655
- const corsHeaders = this.getCorsHeaders(req);
920
+ const corsHeaders = getCorsHeaders(req);
656
921
  res.writeHead(HTTP_STATUS_NOT_FOUND, {
657
922
  "Content-Type": "application/json",
658
923
  ...corsHeaders
@@ -664,7 +929,7 @@ var ProxyServer = class {
664
929
  console.log(
665
930
  `[replay request #${requestCount}] ${req.method} ${req.url} (key: ${key}, session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
666
931
  );
667
- const record = this.selectReplayRecord(
932
+ const record = selectReplayRecord(
668
933
  recordsWithKey,
669
934
  servedForThisKey,
670
935
  key,
@@ -682,7 +947,7 @@ var ProxyServer = class {
682
947
  const { statusCode, headers, body } = record.response;
683
948
  const responseHeaders = {
684
949
  ...headers,
685
- ...this.getCorsHeaders(req)
950
+ ...getCorsHeaders(req)
686
951
  };
687
952
  res.writeHead(statusCode, responseHeaders);
688
953
  res.end(body);
@@ -693,7 +958,7 @@ var ProxyServer = class {
693
958
  handleReplayError(req, res, err, key, filePath) {
694
959
  const isFileNotFound = err instanceof Error && "code" in err && err.code === "ENOENT";
695
960
  console.error("Replay error:", err);
696
- const corsHeaders = this.getCorsHeaders(req);
961
+ const corsHeaders = getCorsHeaders(req);
697
962
  res.writeHead(HTTP_STATUS_NOT_FOUND, {
698
963
  "Content-Type": "application/json",
699
964
  ...corsHeaders
@@ -721,7 +986,7 @@ var ProxyServer = class {
721
986
  await this.handleProxyRequest(req, res);
722
987
  }
723
988
  handleCorsPreflightRequest(req, res) {
724
- const corsHeaders = this.getCorsHeaders(req);
989
+ const corsHeaders = getCorsHeaders(req);
725
990
  res.writeHead(HTTP_STATUS_OK, {
726
991
  ...corsHeaders,
727
992
  "Access-Control-Max-Age": "86400"
@@ -733,13 +998,12 @@ var ProxyServer = class {
733
998
  const target2 = this.target;
734
999
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target2}`);
735
1000
  if (this.mode === Modes.record) {
736
- await this.recordAndProxyRequest(req, res, target2);
1001
+ this.recordAndProxy(req, res, target2);
737
1002
  } else {
738
1003
  this.proxy.web(req, res, { target: target2 });
739
1004
  }
740
1005
  }
741
- // Note: streaming requests are buffered before proxying; streaming passthrough is not yet implemented
742
- async recordAndProxyRequest(req, res, target2) {
1006
+ recordAndProxy(req, res, target2) {
743
1007
  if (!this.currentSession) {
744
1008
  return;
745
1009
  }
@@ -747,109 +1011,17 @@ var ProxyServer = class {
747
1011
  const recordingId = this.recordingIdCounter++;
748
1012
  const sequence = this.sequenceCounterByKey.get(key) || 0;
749
1013
  this.sequenceCounterByKey.set(key, sequence + 1);
750
- const recordingPromise = new Promise((resolve) => {
751
- (async () => {
752
- try {
753
- const chunks = [];
754
- req.on("data", (chunk) => {
755
- chunks.push(chunk);
756
- });
757
- try {
758
- await new Promise((resolveBuffer, rejectBuffer) => {
759
- req.on("end", () => resolveBuffer());
760
- req.on("error", (err) => rejectBuffer(err));
761
- setTimeout(
762
- () => rejectBuffer(new Error("Request buffering timeout")),
763
- 3e4
764
- );
765
- });
766
- } catch (error) {
767
- console.error("Error buffering request:", error);
768
- }
769
- const requestBody = Buffer.concat(chunks).toString("utf8");
770
- const targetUrl = new URL(target2);
771
- const isHttps = targetUrl.protocol === "https:";
772
- const requestModule = isHttps ? https : http;
773
- const defaultPort = isHttps ? 443 : 80;
774
- const proxyReq = requestModule.request(
775
- {
776
- hostname: targetUrl.hostname,
777
- port: targetUrl.port || defaultPort,
778
- path: req.url,
779
- method: req.method,
780
- headers: req.headers
781
- },
782
- (proxyRes) => {
783
- this.addCorsHeaders(proxyRes, req);
784
- const responseChunks = [];
785
- proxyRes.on("data", (chunk) => {
786
- responseChunks.push(chunk);
787
- });
788
- proxyRes.on("end", async () => {
789
- try {
790
- const responseBody = Buffer.concat(responseChunks);
791
- const responseBodyStr = responseBody.toString("utf8");
792
- const recording = {
793
- request: {
794
- method: req.method,
795
- url: req.url,
796
- headers: req.headers,
797
- body: requestBody || null
798
- },
799
- response: {
800
- statusCode: proxyRes.statusCode,
801
- headers: proxyRes.headers,
802
- body: responseBodyStr || null
803
- },
804
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
805
- key,
806
- recordingId,
807
- sequence
808
- };
809
- const responseHeaders = {
810
- ...proxyRes.headers,
811
- ...this.getCorsHeaders(req)
812
- };
813
- res.writeHead(proxyRes.statusCode || 200, responseHeaders);
814
- res.end(responseBody);
815
- console.log(
816
- `Recorded: ${req.method} ${req.url} (recordingId: ${recordingId}, sequence: ${sequence})`
817
- );
818
- resolve(recording);
819
- } catch (error) {
820
- console.error("Error completing recording:", error);
821
- resolve(null);
822
- }
823
- });
824
- proxyRes.on("error", (err) => {
825
- console.error("Proxy response error:", err);
826
- if (!res.headersSent) {
827
- this.handleProxyError(err, req, res);
828
- }
829
- resolve(null);
830
- });
831
- }
832
- );
833
- proxyReq.on("error", (err) => {
834
- this.handleProxyError(err, req, res);
835
- resolve(null);
836
- });
837
- if (chunks.length > 0) {
838
- proxyReq.write(Buffer.concat(chunks));
839
- }
840
- proxyReq.end();
841
- } catch (error) {
842
- console.error("Error in recordAndProxyRequest:", error);
843
- try {
844
- this.handleProxyError(error, req, res);
845
- } catch (error_) {
846
- console.error("Failed to handle proxy error:", error_);
847
- }
848
- resolve(null);
849
- }
850
- })();
851
- });
852
- 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
+ );
853
1025
  }
854
1026
  handleUpgrade(req, socket, head) {
855
1027
  if (this.mode === Modes.replay) {
@@ -859,83 +1031,46 @@ var ProxyServer = class {
859
1031
  const target2 = this.target;
860
1032
  console.log(`[${this.mode}] WebSocket upgrade ${req.url} -> ${target2}`);
861
1033
  if (this.mode === Modes.record) {
862
- this.handleRecordWebSocket(req, socket, head, target2);
1034
+ recordWebSocket(req, socket, head, target2, this.currentSession);
863
1035
  } else {
864
1036
  this.proxy.ws(req, socket, head, { target: target2 });
865
1037
  }
866
1038
  }
867
- handleRecordWebSocket(req, clientSocket, head, target2) {
868
- const url = req.url || "/";
869
- const key = `WS_${url.replaceAll("/", "_")}`;
870
- const wsRecording = {
871
- url,
872
- messages: [],
873
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
874
- key
875
- };
876
- if (this.currentSession) {
877
- this.currentSession.websocketRecordings.push(wsRecording);
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;
878
1050
  }
879
- const backendWsUrl = `${target2.replace("http", "ws")}${url}`;
880
- const backendWs = new WebSocket(backendWsUrl);
881
- const wss = new WebSocketServer({ noServer: true });
882
- backendWs.on("open", () => {
883
- console.log(`WebSocket recording: connected to backend ${backendWsUrl}`);
884
- wss.handleUpgrade(req, clientSocket, head, (clientWs) => {
885
- clientWs.on("message", (data) => {
886
- const message = data.toString();
887
- wsRecording.messages.push({
888
- direction: "client-to-server",
889
- data: message,
890
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
891
- });
892
- if (backendWs.readyState === WebSocket.OPEN) {
893
- backendWs.send(message);
894
- }
895
- });
896
- backendWs.on("message", (data) => {
897
- const message = data.toString();
898
- wsRecording.messages.push({
899
- direction: "server-to-client",
900
- data: message,
901
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
902
- });
903
- if (clientWs.readyState === WebSocket.OPEN) {
904
- clientWs.send(message);
905
- }
906
- });
907
- clientWs.on("error", (err) => {
908
- console.error("Client WebSocket error:", err);
909
- });
910
- backendWs.on("error", (err) => {
911
- console.error("Backend WebSocket error:", err);
912
- });
913
- clientWs.on("close", () => {
914
- backendWs.close();
915
- console.log("Client WebSocket closed");
916
- });
917
- backendWs.on("close", () => {
918
- clientWs.close();
919
- console.log("Backend WebSocket closed");
920
- });
921
- });
922
- });
923
- backendWs.on("error", (err) => {
924
- console.error("Backend WebSocket connection error:", err);
925
- clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
926
- clientSocket.destroy();
927
- });
928
- wss.on("error", (err) => {
929
- console.error("WebSocket server error:", err);
930
- });
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;
931
1058
  }
932
1059
  async handleReplayWebSocket(req, socket) {
933
- const url = req.url || "/";
934
- const key = `WS_${url.replaceAll("/", "_")}`;
935
- const filePath = getRecordingPath(this.recordingsDir, this.replayId);
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
+ }
936
1067
  try {
937
- const session = await loadRecordingSession(filePath);
938
- const wsRecording = session.websocketRecordings.find(
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(
939
1074
  (r) => r.key === key
940
1075
  );
941
1076
  if (!wsRecording) {
@@ -944,62 +1079,7 @@ var ProxyServer = class {
944
1079
  console.log(`No WebSocket recording found for ${key}`);
945
1080
  return;
946
1081
  }
947
- const wss = new WebSocketServer({ noServer: true });
948
- const fakeReq = Object.assign(req, {
949
- headers: {
950
- ...req.headers,
951
- "sec-websocket-key": req.headers["sec-websocket-key"] || "replay-key",
952
- "sec-websocket-version": "13"
953
- }
954
- });
955
- wss.handleUpgrade(fakeReq, socket, Buffer.alloc(0), (ws) => {
956
- console.log(`Replaying WebSocket: ${url}`);
957
- const serverMessages = wsRecording.messages.filter(
958
- (m) => m.direction === "server-to-client"
959
- );
960
- let messageIndex = 0;
961
- ws.on("message", (data) => {
962
- const clientMessage = data.toString();
963
- console.log(`Replay: Client sent: ${clientMessage}`);
964
- if (messageIndex < serverMessages.length) {
965
- setTimeout(() => {
966
- if (ws.readyState === WebSocket.OPEN) {
967
- ws.send(serverMessages[messageIndex].data);
968
- console.log(`Replay: Sent server message ${messageIndex}`);
969
- messageIndex++;
970
- }
971
- }, 10);
972
- }
973
- });
974
- let initialMessagesSent = 0;
975
- for (let i = 0; i < wsRecording.messages.length; i++) {
976
- const msg = wsRecording.messages[i];
977
- if (msg.direction === "client-to-server") {
978
- break;
979
- }
980
- if (msg.direction === "server-to-client") {
981
- setTimeout(
982
- () => {
983
- if (ws.readyState === WebSocket.OPEN) {
984
- ws.send(msg.data);
985
- console.log(
986
- `Replay: Sent initial server message: ${msg.data}`
987
- );
988
- messageIndex++;
989
- initialMessagesSent++;
990
- }
991
- },
992
- 10 * (initialMessagesSent + 1)
993
- );
994
- }
995
- }
996
- ws.on("error", (err) => {
997
- console.error("Replay WebSocket error:", err);
998
- });
999
- ws.on("close", () => {
1000
- console.log("Replay WebSocket closed");
1001
- });
1002
- });
1082
+ replayWebSocket(req, socket, wsRecording, recordingId);
1003
1083
  } catch (error) {
1004
1084
  console.error("Replay error:", error);
1005
1085
  socket.write("HTTP/1.1 404 Not Found\r\n\r\n");