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