test-proxy-recorder 0.3.5 → 0.3.8

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