test-proxy-recorder 0.3.4 → 0.3.5

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
@@ -59,9 +59,9 @@ function processRecordings(recordings) {
59
59
  const processedRecordings = [];
60
60
  for (const [_key, keyRecordings] of recordingsByKey) {
61
61
  keyRecordings.sort((a, b) => a.recordingId - b.recordingId);
62
- keyRecordings.forEach((recording, index) => {
62
+ for (const [index, recording] of keyRecordings.entries()) {
63
63
  processedRecordings.push({ ...recording, sequence: index });
64
- });
64
+ }
65
65
  }
66
66
  processedRecordings.sort((a, b) => a.recordingId - b.recordingId);
67
67
  return processedRecordings;
@@ -127,18 +127,22 @@ var ProxyServer = class {
127
127
  proxy;
128
128
  currentSession;
129
129
  recordingsDir;
130
+ timeoutMs;
130
131
  recordingIdCounter;
131
132
  // Unique ID for each recording entry
132
133
  sequenceCounterByKey;
133
134
  // Sequence counter per key (endpoint)
134
135
  replaySessions;
135
136
  // Track multiple concurrent replay sessions by recording ID
137
+ sessionEvictionTimer;
138
+ // Periodic timer to evict idle replay sessions
136
139
  recordingPromises;
137
140
  // Stack of promises that resolve to completed recordings
138
141
  flushPromise;
139
142
  // Promise for in-progress flush operation
140
- constructor(target, recordingsDir) {
143
+ constructor(target, recordingsDir, timeoutMs) {
141
144
  this.target = target;
145
+ this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
142
146
  this.mode = Modes.transparent;
143
147
  this.recordingId = null;
144
148
  this.recordingIdCounter = 0;
@@ -148,6 +152,7 @@ var ProxyServer = class {
148
152
  this.currentSession = null;
149
153
  this.recordingsDir = recordingsDir;
150
154
  this.replaySessions = /* @__PURE__ */ new Map();
155
+ this.sessionEvictionTimer = null;
151
156
  this.recordingPromises = [];
152
157
  this.flushPromise = null;
153
158
  this.proxy = httpProxy.createProxyServer({
@@ -210,9 +215,6 @@ var ProxyServer = class {
210
215
  const corsHeaders = this.getCorsHeaders(req);
211
216
  Object.assign(proxyRes.headers, corsHeaders);
212
217
  }
213
- getTarget() {
214
- return this.target;
215
- }
216
218
  /**
217
219
  * Extract recording ID from custom HTTP header
218
220
  * Used for concurrent replay session routing, especially with Next.js
@@ -248,13 +250,7 @@ var ProxyServer = class {
248
250
  getRecordingIdFromRequest(req) {
249
251
  const fromHeader = this.getRecordingIdFromHeader(req);
250
252
  const fromCookie = this.getRecordingIdFromCookie(req);
251
- if (fromHeader) {
252
- return fromHeader;
253
- }
254
- if (fromCookie) {
255
- return fromCookie;
256
- }
257
- return null;
253
+ return fromHeader ?? fromCookie ?? null;
258
254
  }
259
255
  /**
260
256
  * Get or create a replay session state for a given recording ID
@@ -274,6 +270,7 @@ var ProxyServer = class {
274
270
  sortedRecordingsByKey: /* @__PURE__ */ new Map()
275
271
  };
276
272
  this.replaySessions.set(recordingId, session);
273
+ this.startSessionEvictionTimer();
277
274
  console.log(
278
275
  `[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
279
276
  );
@@ -285,8 +282,9 @@ var ProxyServer = class {
285
282
  * @param sessionId The session ID to clean up
286
283
  */
287
284
  async cleanupSession(sessionId) {
288
- if (this.replaySessions.has(sessionId)) {
289
- this.replaySessions.delete(sessionId);
285
+ this.replaySessions.delete(sessionId);
286
+ if (this.replaySessions.size === 0) {
287
+ this.stopSessionEvictionTimer();
290
288
  }
291
289
  if (this.recordingId === sessionId) {
292
290
  await this.saveCurrentSession();
@@ -298,29 +296,44 @@ var ProxyServer = class {
298
296
  }
299
297
  console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
300
298
  }
301
- parseGetParams(req) {
302
- const url = new URL(req.url || "", `http://${req.headers.host}`);
303
- const mode = url.searchParams.get("mode");
304
- const id = url.searchParams.get("id") || void 0;
305
- const timeoutParam = url.searchParams.get("timeout");
306
- const timeout = timeoutParam ? Number.parseInt(timeoutParam, 10) : void 0;
307
- if (!mode) {
308
- throw new Error("Mode parameter is required");
299
+ startSessionEvictionTimer() {
300
+ if (this.sessionEvictionTimer) {
301
+ return;
309
302
  }
310
- return { mode, id, timeout };
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();
311
319
  }
312
- async parseControlRequest(req) {
313
- if (req.method === "GET") {
314
- return this.parseGetParams(req);
315
- }
316
- if (req.method === "POST") {
317
- const body = await readRequestBody(req);
318
- console.log(`MODE CHANGE (${req.method})`, body);
319
- return JSON.parse(body);
320
+ stopSessionEvictionTimer() {
321
+ if (this.sessionEvictionTimer) {
322
+ clearInterval(this.sessionEvictionTimer);
323
+ this.sessionEvictionTimer = null;
320
324
  }
321
- throw new Error("Unsupported control method");
325
+ }
326
+ async parseControlBody(req) {
327
+ const body = await readRequestBody(req);
328
+ console.log(`MODE CHANGE (${req.method})`, body);
329
+ return JSON.parse(body);
322
330
  }
323
331
  async handleControlRequest(req, res) {
332
+ if (req.method === "HEAD") {
333
+ res.writeHead(HTTP_STATUS_OK);
334
+ res.end();
335
+ return;
336
+ }
324
337
  if (req.method === "GET") {
325
338
  sendJsonResponse(res, HTTP_STATUS_OK, {
326
339
  recordingsDir: this.recordingsDir,
@@ -329,8 +342,11 @@ var ProxyServer = class {
329
342
  });
330
343
  return;
331
344
  }
345
+ await this.handleControlPost(req, res);
346
+ }
347
+ async handleControlPost(req, res) {
332
348
  try {
333
- const data = await this.parseControlRequest(req);
349
+ const data = await this.parseControlBody(req);
334
350
  const { mode, id, timeout: requestTimeout, cleanup } = data;
335
351
  if (cleanup && id) {
336
352
  await this.cleanupSession(id);
@@ -341,29 +357,7 @@ var ProxyServer = class {
341
357
  });
342
358
  return;
343
359
  }
344
- if (!mode) {
345
- throw new Error(
346
- "Mode parameter is required when cleanup is not specified"
347
- );
348
- }
349
- const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
350
- this.clearModeTimeout();
351
- await this.switchMode(mode, id);
352
- this.setupModeTimeout(timeout);
353
- if (mode === Modes.replay && id) {
354
- res.setHeader(
355
- "Set-Cookie",
356
- `proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
357
- );
358
- console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
359
- }
360
- sendJsonResponse(res, HTTP_STATUS_OK, {
361
- success: true,
362
- mode: this.mode,
363
- id: this.recordingId || this.replayId,
364
- timeout,
365
- recordingsDir: this.recordingsDir
366
- });
360
+ await this.applyModeChange(res, mode, id, requestTimeout);
367
361
  } catch (error) {
368
362
  console.error("Control request error:", error);
369
363
  sendJsonResponse(res, HTTP_STATUS_BAD_REQUEST, {
@@ -371,6 +365,31 @@ var ProxyServer = class {
371
365
  });
372
366
  }
373
367
  }
368
+ async applyModeChange(res, mode, id, requestTimeout) {
369
+ if (!mode) {
370
+ throw new Error(
371
+ "Mode parameter is required when cleanup is not specified"
372
+ );
373
+ }
374
+ const timeout = requestTimeout ?? this.timeoutMs;
375
+ this.clearModeTimeout();
376
+ await this.switchMode(mode, id);
377
+ this.setupModeTimeout(timeout);
378
+ if (mode === Modes.replay && id) {
379
+ res.setHeader(
380
+ "Set-Cookie",
381
+ `proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
382
+ );
383
+ console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
384
+ }
385
+ sendJsonResponse(res, HTTP_STATUS_OK, {
386
+ success: true,
387
+ mode: this.mode,
388
+ id: this.recordingId || this.replayId,
389
+ timeout,
390
+ recordingsDir: this.recordingsDir
391
+ });
392
+ }
374
393
  clearModeTimeout() {
375
394
  clearTimeout(this.modeTimeout || 0);
376
395
  this.modeTimeout = null;
@@ -410,7 +429,7 @@ var ProxyServer = class {
410
429
  this.recordingId = null;
411
430
  this.replayId = null;
412
431
  this.currentSession = null;
413
- clearTimeout(this.modeTimeout || 0);
432
+ this.clearModeTimeout();
414
433
  console.log("Switched to transparent mode");
415
434
  }
416
435
  switchToRecordMode(id) {
@@ -441,7 +460,7 @@ var ProxyServer = class {
441
460
  console.log(`Switched to replay mode with ID: ${id}`);
442
461
  }
443
462
  setupModeTimeout(timeout) {
444
- clearTimeout(this.modeTimeout || 0);
463
+ this.clearModeTimeout();
445
464
  this.modeTimeout = setTimeout(async () => {
446
465
  console.log("Timeout reached, switching back to transparent mode");
447
466
  await this.saveCurrentSession();
@@ -566,11 +585,10 @@ var ProxyServer = class {
566
585
  try {
567
586
  const sessionState = this.getOrCreateReplaySession(recordingId);
568
587
  if (!sessionState.loadedSession) {
569
- const error = new Error(
570
- `Recording session file not found: ${filePath}`
588
+ throw Object.assign(
589
+ new Error(`Recording session file not found: ${filePath}`),
590
+ { code: "ENOENT" }
571
591
  );
572
- error.code = "ENOENT";
573
- throw error;
574
592
  }
575
593
  const servedForThisKey = this.getServedTracker(sessionState, key);
576
594
  const host = req.headers.host || "unknown";
@@ -665,7 +683,7 @@ var ProxyServer = class {
665
683
  res.end();
666
684
  }
667
685
  async handleProxyRequest(req, res) {
668
- const target = this.getTarget();
686
+ const target = this.target;
669
687
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
670
688
  if (this.mode === Modes.record) {
671
689
  await this.recordAndProxyRequest(req, res, target);
@@ -791,7 +809,7 @@ var ProxyServer = class {
791
809
  this.handleReplayWebSocket(req, socket);
792
810
  return;
793
811
  }
794
- const target = this.getTarget();
812
+ const target = this.target;
795
813
  console.log(`[${this.mode}] WebSocket upgrade ${req.url} -> ${target}`);
796
814
  if (this.mode === Modes.record) {
797
815
  this.handleRecordWebSocket(req, socket, head, target);
@@ -864,11 +882,12 @@ var ProxyServer = class {
864
882
  console.error("WebSocket server error:", err);
865
883
  });
866
884
  }
867
- handleReplayWebSocket(req, socket) {
885
+ async handleReplayWebSocket(req, socket) {
868
886
  const url = req.url || "/";
869
887
  const key = `WS_${url.replaceAll("/", "_")}`;
870
888
  const filePath = getRecordingPath(this.recordingsDir, this.replayId);
871
- loadRecordingSession(filePath).then((session) => {
889
+ try {
890
+ const session = await loadRecordingSession(filePath);
872
891
  const wsRecording = session.websocketRecordings.find(
873
892
  (r) => r.key === key
874
893
  );
@@ -934,11 +953,11 @@ var ProxyServer = class {
934
953
  console.log("Replay WebSocket closed");
935
954
  });
936
955
  });
937
- }).catch((error) => {
956
+ } catch (error) {
938
957
  console.error("Replay error:", error);
939
958
  socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
940
959
  socket.destroy();
941
- });
960
+ }
942
961
  }
943
962
  logServerStartup(port) {
944
963
  console.log(`Proxy server running on http://localhost:${port}`);
@@ -949,6 +968,7 @@ var ProxyServer = class {
949
968
  );
950
969
  }
951
970
  };
971
+ var registeredContexts = /* @__PURE__ */ new WeakSet();
952
972
  function getProxyPort() {
953
973
  const envPort = process.env.TEST_PROXY_RECORDER_PORT;
954
974
  if (envPort) {
@@ -967,7 +987,7 @@ async function setProxyMode(mode, sessionId, timeout) {
967
987
  id: sessionId,
968
988
  ...timeout && { timeout }
969
989
  };
970
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
990
+ const response = await fetch(`http://localhost:${proxyPort}/__control`, {
971
991
  method: "POST",
972
992
  headers: { "Content-Type": "application/json" },
973
993
  body: JSON.stringify(body)
@@ -990,7 +1010,7 @@ async function cleanupSession(sessionId) {
990
1010
  cleanup: true,
991
1011
  id: sessionId
992
1012
  };
993
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
1013
+ const response = await fetch(`http://localhost:${proxyPort}/__control`, {
994
1014
  method: "POST",
995
1015
  headers: { "Content-Type": "application/json" },
996
1016
  body: JSON.stringify(body)
@@ -1054,7 +1074,7 @@ async function getRecordingsDir() {
1054
1074
  }
1055
1075
  const proxyPort = getProxyPort();
1056
1076
  try {
1057
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`);
1077
+ const response = await fetch(`http://localhost:${proxyPort}/__control`);
1058
1078
  if (response.ok) {
1059
1079
  const data = await response.json();
1060
1080
  if (data.recordingsDir) {
@@ -1141,10 +1161,8 @@ var playwrightProxy = {
1141
1161
  // Ensure the handler applies to all matching requests
1142
1162
  );
1143
1163
  const context = page.context();
1144
- const contextId = context._guid || "default";
1145
- const handlerKey = `cleanup_${contextId}`;
1146
- if (!globalThis[handlerKey]) {
1147
- globalThis[handlerKey] = true;
1164
+ if (!registeredContexts.has(context)) {
1165
+ registeredContexts.add(context);
1148
1166
  context.on("close", async () => {
1149
1167
  try {
1150
1168
  await cleanupSession(sessionId);
@@ -1154,7 +1172,7 @@ var playwrightProxy = {
1154
1172
  error
1155
1173
  );
1156
1174
  } finally {
1157
- delete globalThis[handlerKey];
1175
+ registeredContexts.delete(context);
1158
1176
  }
1159
1177
  });
1160
1178
  }
@@ -17,6 +17,7 @@ var Modes = {
17
17
  };
18
18
 
19
19
  // src/playwright/index.ts
20
+ var registeredContexts = /* @__PURE__ */ new WeakSet();
20
21
  function getProxyPort() {
21
22
  const envPort = process.env.TEST_PROXY_RECORDER_PORT;
22
23
  if (envPort) {
@@ -35,7 +36,7 @@ async function setProxyMode(mode, sessionId, timeout) {
35
36
  id: sessionId,
36
37
  ...timeout && { timeout }
37
38
  };
38
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
39
+ const response = await fetch(`http://localhost:${proxyPort}/__control`, {
39
40
  method: "POST",
40
41
  headers: { "Content-Type": "application/json" },
41
42
  body: JSON.stringify(body)
@@ -58,7 +59,7 @@ async function cleanupSession(sessionId) {
58
59
  cleanup: true,
59
60
  id: sessionId
60
61
  };
61
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
62
+ const response = await fetch(`http://localhost:${proxyPort}/__control`, {
62
63
  method: "POST",
63
64
  headers: { "Content-Type": "application/json" },
64
65
  body: JSON.stringify(body)
@@ -122,7 +123,7 @@ async function getRecordingsDir() {
122
123
  }
123
124
  const proxyPort = getProxyPort();
124
125
  try {
125
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`);
126
+ const response = await fetch(`http://localhost:${proxyPort}/__control`);
126
127
  if (response.ok) {
127
128
  const data = await response.json();
128
129
  if (data.recordingsDir) {
@@ -209,10 +210,8 @@ var playwrightProxy = {
209
210
  // Ensure the handler applies to all matching requests
210
211
  );
211
212
  const context = page.context();
212
- const contextId = context._guid || "default";
213
- const handlerKey = `cleanup_${contextId}`;
214
- if (!globalThis[handlerKey]) {
215
- globalThis[handlerKey] = true;
213
+ if (!registeredContexts.has(context)) {
214
+ registeredContexts.add(context);
216
215
  context.on("close", async () => {
217
216
  try {
218
217
  await cleanupSession(sessionId);
@@ -222,7 +221,7 @@ var playwrightProxy = {
222
221
  error
223
222
  );
224
223
  } finally {
225
- delete globalThis[handlerKey];
224
+ registeredContexts.delete(context);
226
225
  }
227
226
  });
228
227
  }
@@ -11,6 +11,7 @@ var Modes = {
11
11
  };
12
12
 
13
13
  // src/playwright/index.ts
14
+ var registeredContexts = /* @__PURE__ */ new WeakSet();
14
15
  function getProxyPort() {
15
16
  const envPort = process.env.TEST_PROXY_RECORDER_PORT;
16
17
  if (envPort) {
@@ -29,7 +30,7 @@ async function setProxyMode(mode, sessionId, timeout) {
29
30
  id: sessionId,
30
31
  ...timeout && { timeout }
31
32
  };
32
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
33
+ const response = await fetch(`http://localhost:${proxyPort}/__control`, {
33
34
  method: "POST",
34
35
  headers: { "Content-Type": "application/json" },
35
36
  body: JSON.stringify(body)
@@ -52,7 +53,7 @@ async function cleanupSession(sessionId) {
52
53
  cleanup: true,
53
54
  id: sessionId
54
55
  };
55
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
56
+ const response = await fetch(`http://localhost:${proxyPort}/__control`, {
56
57
  method: "POST",
57
58
  headers: { "Content-Type": "application/json" },
58
59
  body: JSON.stringify(body)
@@ -116,7 +117,7 @@ async function getRecordingsDir() {
116
117
  }
117
118
  const proxyPort = getProxyPort();
118
119
  try {
119
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`);
120
+ const response = await fetch(`http://localhost:${proxyPort}/__control`);
120
121
  if (response.ok) {
121
122
  const data = await response.json();
122
123
  if (data.recordingsDir) {
@@ -203,10 +204,8 @@ var playwrightProxy = {
203
204
  // Ensure the handler applies to all matching requests
204
205
  );
205
206
  const context = page.context();
206
- const contextId = context._guid || "default";
207
- const handlerKey = `cleanup_${contextId}`;
208
- if (!globalThis[handlerKey]) {
209
- globalThis[handlerKey] = true;
207
+ if (!registeredContexts.has(context)) {
208
+ registeredContexts.add(context);
210
209
  context.on("close", async () => {
211
210
  try {
212
211
  await cleanupSession(sessionId);
@@ -216,7 +215,7 @@ var playwrightProxy = {
216
215
  error
217
216
  );
218
217
  } finally {
219
- delete globalThis[handlerKey];
218
+ registeredContexts.delete(context);
220
219
  }
221
220
  });
222
221
  }