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.cjs CHANGED
@@ -71,9 +71,9 @@ function processRecordings(recordings) {
71
71
  const processedRecordings = [];
72
72
  for (const [_key, keyRecordings] of recordingsByKey) {
73
73
  keyRecordings.sort((a, b) => a.recordingId - b.recordingId);
74
- keyRecordings.forEach((recording, index) => {
74
+ for (const [index, recording] of keyRecordings.entries()) {
75
75
  processedRecordings.push({ ...recording, sequence: index });
76
- });
76
+ }
77
77
  }
78
78
  processedRecordings.sort((a, b) => a.recordingId - b.recordingId);
79
79
  return processedRecordings;
@@ -139,18 +139,22 @@ var ProxyServer = class {
139
139
  proxy;
140
140
  currentSession;
141
141
  recordingsDir;
142
+ timeoutMs;
142
143
  recordingIdCounter;
143
144
  // Unique ID for each recording entry
144
145
  sequenceCounterByKey;
145
146
  // Sequence counter per key (endpoint)
146
147
  replaySessions;
147
148
  // Track multiple concurrent replay sessions by recording ID
149
+ sessionEvictionTimer;
150
+ // Periodic timer to evict idle replay sessions
148
151
  recordingPromises;
149
152
  // Stack of promises that resolve to completed recordings
150
153
  flushPromise;
151
154
  // Promise for in-progress flush operation
152
- constructor(target, recordingsDir) {
155
+ constructor(target, recordingsDir, timeoutMs) {
153
156
  this.target = target;
157
+ this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
154
158
  this.mode = Modes.transparent;
155
159
  this.recordingId = null;
156
160
  this.recordingIdCounter = 0;
@@ -160,6 +164,7 @@ var ProxyServer = class {
160
164
  this.currentSession = null;
161
165
  this.recordingsDir = recordingsDir;
162
166
  this.replaySessions = /* @__PURE__ */ new Map();
167
+ this.sessionEvictionTimer = null;
163
168
  this.recordingPromises = [];
164
169
  this.flushPromise = null;
165
170
  this.proxy = httpProxy__default.default.createProxyServer({
@@ -222,9 +227,6 @@ var ProxyServer = class {
222
227
  const corsHeaders = this.getCorsHeaders(req);
223
228
  Object.assign(proxyRes.headers, corsHeaders);
224
229
  }
225
- getTarget() {
226
- return this.target;
227
- }
228
230
  /**
229
231
  * Extract recording ID from custom HTTP header
230
232
  * Used for concurrent replay session routing, especially with Next.js
@@ -260,13 +262,7 @@ var ProxyServer = class {
260
262
  getRecordingIdFromRequest(req) {
261
263
  const fromHeader = this.getRecordingIdFromHeader(req);
262
264
  const fromCookie = this.getRecordingIdFromCookie(req);
263
- if (fromHeader) {
264
- return fromHeader;
265
- }
266
- if (fromCookie) {
267
- return fromCookie;
268
- }
269
- return null;
265
+ return fromHeader ?? fromCookie ?? null;
270
266
  }
271
267
  /**
272
268
  * Get or create a replay session state for a given recording ID
@@ -286,6 +282,7 @@ var ProxyServer = class {
286
282
  sortedRecordingsByKey: /* @__PURE__ */ new Map()
287
283
  };
288
284
  this.replaySessions.set(recordingId, session);
285
+ this.startSessionEvictionTimer();
289
286
  console.log(
290
287
  `[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
291
288
  );
@@ -297,8 +294,9 @@ var ProxyServer = class {
297
294
  * @param sessionId The session ID to clean up
298
295
  */
299
296
  async cleanupSession(sessionId) {
300
- if (this.replaySessions.has(sessionId)) {
301
- this.replaySessions.delete(sessionId);
297
+ this.replaySessions.delete(sessionId);
298
+ if (this.replaySessions.size === 0) {
299
+ this.stopSessionEvictionTimer();
302
300
  }
303
301
  if (this.recordingId === sessionId) {
304
302
  await this.saveCurrentSession();
@@ -310,29 +308,44 @@ var ProxyServer = class {
310
308
  }
311
309
  console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
312
310
  }
313
- parseGetParams(req) {
314
- const url = new URL(req.url || "", `http://${req.headers.host}`);
315
- const mode = url.searchParams.get("mode");
316
- const id = url.searchParams.get("id") || void 0;
317
- const timeoutParam = url.searchParams.get("timeout");
318
- const timeout = timeoutParam ? Number.parseInt(timeoutParam, 10) : void 0;
319
- if (!mode) {
320
- throw new Error("Mode parameter is required");
311
+ startSessionEvictionTimer() {
312
+ if (this.sessionEvictionTimer) {
313
+ return;
321
314
  }
322
- return { mode, id, timeout };
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();
323
331
  }
324
- async parseControlRequest(req) {
325
- if (req.method === "GET") {
326
- return this.parseGetParams(req);
327
- }
328
- if (req.method === "POST") {
329
- const body = await readRequestBody(req);
330
- console.log(`MODE CHANGE (${req.method})`, body);
331
- return JSON.parse(body);
332
+ stopSessionEvictionTimer() {
333
+ if (this.sessionEvictionTimer) {
334
+ clearInterval(this.sessionEvictionTimer);
335
+ this.sessionEvictionTimer = null;
332
336
  }
333
- throw new Error("Unsupported control method");
337
+ }
338
+ async parseControlBody(req) {
339
+ const body = await readRequestBody(req);
340
+ console.log(`MODE CHANGE (${req.method})`, body);
341
+ return JSON.parse(body);
334
342
  }
335
343
  async handleControlRequest(req, res) {
344
+ if (req.method === "HEAD") {
345
+ res.writeHead(HTTP_STATUS_OK);
346
+ res.end();
347
+ return;
348
+ }
336
349
  if (req.method === "GET") {
337
350
  sendJsonResponse(res, HTTP_STATUS_OK, {
338
351
  recordingsDir: this.recordingsDir,
@@ -341,8 +354,11 @@ var ProxyServer = class {
341
354
  });
342
355
  return;
343
356
  }
357
+ await this.handleControlPost(req, res);
358
+ }
359
+ async handleControlPost(req, res) {
344
360
  try {
345
- const data = await this.parseControlRequest(req);
361
+ const data = await this.parseControlBody(req);
346
362
  const { mode, id, timeout: requestTimeout, cleanup } = data;
347
363
  if (cleanup && id) {
348
364
  await this.cleanupSession(id);
@@ -353,29 +369,7 @@ var ProxyServer = class {
353
369
  });
354
370
  return;
355
371
  }
356
- if (!mode) {
357
- throw new Error(
358
- "Mode parameter is required when cleanup is not specified"
359
- );
360
- }
361
- const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
362
- this.clearModeTimeout();
363
- await this.switchMode(mode, id);
364
- this.setupModeTimeout(timeout);
365
- if (mode === Modes.replay && id) {
366
- res.setHeader(
367
- "Set-Cookie",
368
- `proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
369
- );
370
- console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
371
- }
372
- sendJsonResponse(res, HTTP_STATUS_OK, {
373
- success: true,
374
- mode: this.mode,
375
- id: this.recordingId || this.replayId,
376
- timeout,
377
- recordingsDir: this.recordingsDir
378
- });
372
+ await this.applyModeChange(res, mode, id, requestTimeout);
379
373
  } catch (error) {
380
374
  console.error("Control request error:", error);
381
375
  sendJsonResponse(res, HTTP_STATUS_BAD_REQUEST, {
@@ -383,6 +377,31 @@ var ProxyServer = class {
383
377
  });
384
378
  }
385
379
  }
380
+ async applyModeChange(res, mode, id, requestTimeout) {
381
+ if (!mode) {
382
+ throw new Error(
383
+ "Mode parameter is required when cleanup is not specified"
384
+ );
385
+ }
386
+ const timeout = requestTimeout ?? this.timeoutMs;
387
+ this.clearModeTimeout();
388
+ await this.switchMode(mode, id);
389
+ this.setupModeTimeout(timeout);
390
+ if (mode === Modes.replay && id) {
391
+ res.setHeader(
392
+ "Set-Cookie",
393
+ `proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
394
+ );
395
+ console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
396
+ }
397
+ sendJsonResponse(res, HTTP_STATUS_OK, {
398
+ success: true,
399
+ mode: this.mode,
400
+ id: this.recordingId || this.replayId,
401
+ timeout,
402
+ recordingsDir: this.recordingsDir
403
+ });
404
+ }
386
405
  clearModeTimeout() {
387
406
  clearTimeout(this.modeTimeout || 0);
388
407
  this.modeTimeout = null;
@@ -422,7 +441,7 @@ var ProxyServer = class {
422
441
  this.recordingId = null;
423
442
  this.replayId = null;
424
443
  this.currentSession = null;
425
- clearTimeout(this.modeTimeout || 0);
444
+ this.clearModeTimeout();
426
445
  console.log("Switched to transparent mode");
427
446
  }
428
447
  switchToRecordMode(id) {
@@ -453,7 +472,7 @@ var ProxyServer = class {
453
472
  console.log(`Switched to replay mode with ID: ${id}`);
454
473
  }
455
474
  setupModeTimeout(timeout) {
456
- clearTimeout(this.modeTimeout || 0);
475
+ this.clearModeTimeout();
457
476
  this.modeTimeout = setTimeout(async () => {
458
477
  console.log("Timeout reached, switching back to transparent mode");
459
478
  await this.saveCurrentSession();
@@ -578,11 +597,10 @@ var ProxyServer = class {
578
597
  try {
579
598
  const sessionState = this.getOrCreateReplaySession(recordingId);
580
599
  if (!sessionState.loadedSession) {
581
- const error = new Error(
582
- `Recording session file not found: ${filePath}`
600
+ throw Object.assign(
601
+ new Error(`Recording session file not found: ${filePath}`),
602
+ { code: "ENOENT" }
583
603
  );
584
- error.code = "ENOENT";
585
- throw error;
586
604
  }
587
605
  const servedForThisKey = this.getServedTracker(sessionState, key);
588
606
  const host = req.headers.host || "unknown";
@@ -677,7 +695,7 @@ var ProxyServer = class {
677
695
  res.end();
678
696
  }
679
697
  async handleProxyRequest(req, res) {
680
- const target = this.getTarget();
698
+ const target = this.target;
681
699
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
682
700
  if (this.mode === Modes.record) {
683
701
  await this.recordAndProxyRequest(req, res, target);
@@ -803,7 +821,7 @@ var ProxyServer = class {
803
821
  this.handleReplayWebSocket(req, socket);
804
822
  return;
805
823
  }
806
- const target = this.getTarget();
824
+ const target = this.target;
807
825
  console.log(`[${this.mode}] WebSocket upgrade ${req.url} -> ${target}`);
808
826
  if (this.mode === Modes.record) {
809
827
  this.handleRecordWebSocket(req, socket, head, target);
@@ -876,11 +894,12 @@ var ProxyServer = class {
876
894
  console.error("WebSocket server error:", err);
877
895
  });
878
896
  }
879
- handleReplayWebSocket(req, socket) {
897
+ async handleReplayWebSocket(req, socket) {
880
898
  const url = req.url || "/";
881
899
  const key = `WS_${url.replaceAll("/", "_")}`;
882
900
  const filePath = getRecordingPath(this.recordingsDir, this.replayId);
883
- loadRecordingSession(filePath).then((session) => {
901
+ try {
902
+ const session = await loadRecordingSession(filePath);
884
903
  const wsRecording = session.websocketRecordings.find(
885
904
  (r) => r.key === key
886
905
  );
@@ -946,11 +965,11 @@ var ProxyServer = class {
946
965
  console.log("Replay WebSocket closed");
947
966
  });
948
967
  });
949
- }).catch((error) => {
968
+ } catch (error) {
950
969
  console.error("Replay error:", error);
951
970
  socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
952
971
  socket.destroy();
953
- });
972
+ }
954
973
  }
955
974
  logServerStartup(port) {
956
975
  console.log(`Proxy server running on http://localhost:${port}`);
@@ -961,6 +980,7 @@ var ProxyServer = class {
961
980
  );
962
981
  }
963
982
  };
983
+ var registeredContexts = /* @__PURE__ */ new WeakSet();
964
984
  function getProxyPort() {
965
985
  const envPort = process.env.TEST_PROXY_RECORDER_PORT;
966
986
  if (envPort) {
@@ -979,7 +999,7 @@ async function setProxyMode(mode, sessionId, timeout) {
979
999
  id: sessionId,
980
1000
  ...timeout && { timeout }
981
1001
  };
982
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
1002
+ const response = await fetch(`http://localhost:${proxyPort}/__control`, {
983
1003
  method: "POST",
984
1004
  headers: { "Content-Type": "application/json" },
985
1005
  body: JSON.stringify(body)
@@ -1002,7 +1022,7 @@ async function cleanupSession(sessionId) {
1002
1022
  cleanup: true,
1003
1023
  id: sessionId
1004
1024
  };
1005
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
1025
+ const response = await fetch(`http://localhost:${proxyPort}/__control`, {
1006
1026
  method: "POST",
1007
1027
  headers: { "Content-Type": "application/json" },
1008
1028
  body: JSON.stringify(body)
@@ -1066,7 +1086,7 @@ async function getRecordingsDir() {
1066
1086
  }
1067
1087
  const proxyPort = getProxyPort();
1068
1088
  try {
1069
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`);
1089
+ const response = await fetch(`http://localhost:${proxyPort}/__control`);
1070
1090
  if (response.ok) {
1071
1091
  const data = await response.json();
1072
1092
  if (data.recordingsDir) {
@@ -1153,10 +1173,8 @@ var playwrightProxy = {
1153
1173
  // Ensure the handler applies to all matching requests
1154
1174
  );
1155
1175
  const context = page.context();
1156
- const contextId = context._guid || "default";
1157
- const handlerKey = `cleanup_${contextId}`;
1158
- if (!globalThis[handlerKey]) {
1159
- globalThis[handlerKey] = true;
1176
+ if (!registeredContexts.has(context)) {
1177
+ registeredContexts.add(context);
1160
1178
  context.on("close", async () => {
1161
1179
  try {
1162
1180
  await cleanupSession(sessionId);
@@ -1166,7 +1184,7 @@ var playwrightProxy = {
1166
1184
  error
1167
1185
  );
1168
1186
  } finally {
1169
- delete globalThis[handlerKey];
1187
+ registeredContexts.delete(context);
1170
1188
  }
1171
1189
  });
1172
1190
  }
package/dist/index.d.cts CHANGED
@@ -12,12 +12,14 @@ declare class ProxyServer {
12
12
  private proxy;
13
13
  private currentSession;
14
14
  private recordingsDir;
15
+ private timeoutMs;
15
16
  private recordingIdCounter;
16
17
  private sequenceCounterByKey;
17
18
  private replaySessions;
19
+ private sessionEvictionTimer;
18
20
  private recordingPromises;
19
21
  private flushPromise;
20
- constructor(target: string, recordingsDir: string);
22
+ constructor(target: string, recordingsDir: string, timeoutMs?: number);
21
23
  init(): Promise<void>;
22
24
  listen(port: number): http.Server;
23
25
  private setupProxyEventHandlers;
@@ -29,7 +31,6 @@ declare class ProxyServer {
29
31
  */
30
32
  private getCorsHeaders;
31
33
  private addCorsHeaders;
32
- private getTarget;
33
34
  /**
34
35
  * Extract recording ID from custom HTTP header
35
36
  * Used for concurrent replay session routing, especially with Next.js
@@ -61,9 +62,12 @@ declare class ProxyServer {
61
62
  * @param sessionId The session ID to clean up
62
63
  */
63
64
  private cleanupSession;
64
- private parseGetParams;
65
- private parseControlRequest;
65
+ private startSessionEvictionTimer;
66
+ private stopSessionEvictionTimer;
67
+ private parseControlBody;
66
68
  private handleControlRequest;
69
+ private handleControlPost;
70
+ private applyModeChange;
67
71
  private clearModeTimeout;
68
72
  private switchMode;
69
73
  private switchToTransparentMode;
package/dist/index.d.ts CHANGED
@@ -12,12 +12,14 @@ declare class ProxyServer {
12
12
  private proxy;
13
13
  private currentSession;
14
14
  private recordingsDir;
15
+ private timeoutMs;
15
16
  private recordingIdCounter;
16
17
  private sequenceCounterByKey;
17
18
  private replaySessions;
19
+ private sessionEvictionTimer;
18
20
  private recordingPromises;
19
21
  private flushPromise;
20
- constructor(target: string, recordingsDir: string);
22
+ constructor(target: string, recordingsDir: string, timeoutMs?: number);
21
23
  init(): Promise<void>;
22
24
  listen(port: number): http.Server;
23
25
  private setupProxyEventHandlers;
@@ -29,7 +31,6 @@ declare class ProxyServer {
29
31
  */
30
32
  private getCorsHeaders;
31
33
  private addCorsHeaders;
32
- private getTarget;
33
34
  /**
34
35
  * Extract recording ID from custom HTTP header
35
36
  * Used for concurrent replay session routing, especially with Next.js
@@ -61,9 +62,12 @@ declare class ProxyServer {
61
62
  * @param sessionId The session ID to clean up
62
63
  */
63
64
  private cleanupSession;
64
- private parseGetParams;
65
- private parseControlRequest;
65
+ private startSessionEvictionTimer;
66
+ private stopSessionEvictionTimer;
67
+ private parseControlBody;
66
68
  private handleControlRequest;
69
+ private handleControlPost;
70
+ private applyModeChange;
67
71
  private clearModeTimeout;
68
72
  private switchMode;
69
73
  private switchToTransparentMode;