test-proxy-recorder 0.3.3 → 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;
@@ -131,8 +131,7 @@ function sendJsonResponse(res, statusCode, data) {
131
131
 
132
132
  // src/ProxyServer.ts
133
133
  var ProxyServer = class {
134
- targets;
135
- currentTargetIndex;
134
+ target;
136
135
  mode;
137
136
  recordingId;
138
137
  replayId;
@@ -140,19 +139,22 @@ var ProxyServer = class {
140
139
  proxy;
141
140
  currentSession;
142
141
  recordingsDir;
142
+ timeoutMs;
143
143
  recordingIdCounter;
144
144
  // Unique ID for each recording entry
145
145
  sequenceCounterByKey;
146
146
  // Sequence counter per key (endpoint)
147
147
  replaySessions;
148
148
  // Track multiple concurrent replay sessions by recording ID
149
+ sessionEvictionTimer;
150
+ // Periodic timer to evict idle replay sessions
149
151
  recordingPromises;
150
152
  // Stack of promises that resolve to completed recordings
151
153
  flushPromise;
152
154
  // Promise for in-progress flush operation
153
- constructor(targets, recordingsDir) {
154
- this.targets = targets;
155
- this.currentTargetIndex = 0;
155
+ constructor(target, recordingsDir, timeoutMs) {
156
+ this.target = target;
157
+ this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
156
158
  this.mode = Modes.transparent;
157
159
  this.recordingId = null;
158
160
  this.recordingIdCounter = 0;
@@ -162,6 +164,7 @@ var ProxyServer = class {
162
164
  this.currentSession = null;
163
165
  this.recordingsDir = recordingsDir;
164
166
  this.replaySessions = /* @__PURE__ */ new Map();
167
+ this.sessionEvictionTimer = null;
165
168
  this.recordingPromises = [];
166
169
  this.flushPromise = null;
167
170
  this.proxy = httpProxy__default.default.createProxyServer({
@@ -224,11 +227,6 @@ var ProxyServer = class {
224
227
  const corsHeaders = this.getCorsHeaders(req);
225
228
  Object.assign(proxyRes.headers, corsHeaders);
226
229
  }
227
- getTarget() {
228
- const target = this.targets[this.currentTargetIndex];
229
- this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
230
- return target;
231
- }
232
230
  /**
233
231
  * Extract recording ID from custom HTTP header
234
232
  * Used for concurrent replay session routing, especially with Next.js
@@ -264,13 +262,7 @@ var ProxyServer = class {
264
262
  getRecordingIdFromRequest(req) {
265
263
  const fromHeader = this.getRecordingIdFromHeader(req);
266
264
  const fromCookie = this.getRecordingIdFromCookie(req);
267
- if (fromHeader) {
268
- return fromHeader;
269
- }
270
- if (fromCookie) {
271
- return fromCookie;
272
- }
273
- return null;
265
+ return fromHeader ?? fromCookie ?? null;
274
266
  }
275
267
  /**
276
268
  * Get or create a replay session state for a given recording ID
@@ -290,6 +282,7 @@ var ProxyServer = class {
290
282
  sortedRecordingsByKey: /* @__PURE__ */ new Map()
291
283
  };
292
284
  this.replaySessions.set(recordingId, session);
285
+ this.startSessionEvictionTimer();
293
286
  console.log(
294
287
  `[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
295
288
  );
@@ -301,8 +294,9 @@ var ProxyServer = class {
301
294
  * @param sessionId The session ID to clean up
302
295
  */
303
296
  async cleanupSession(sessionId) {
304
- if (this.replaySessions.has(sessionId)) {
305
- this.replaySessions.delete(sessionId);
297
+ this.replaySessions.delete(sessionId);
298
+ if (this.replaySessions.size === 0) {
299
+ this.stopSessionEvictionTimer();
306
300
  }
307
301
  if (this.recordingId === sessionId) {
308
302
  await this.saveCurrentSession();
@@ -314,29 +308,44 @@ var ProxyServer = class {
314
308
  }
315
309
  console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
316
310
  }
317
- parseGetParams(req) {
318
- const url = new URL(req.url || "", `http://${req.headers.host}`);
319
- const mode = url.searchParams.get("mode");
320
- const id = url.searchParams.get("id") || void 0;
321
- const timeoutParam = url.searchParams.get("timeout");
322
- const timeout = timeoutParam ? Number.parseInt(timeoutParam, 10) : void 0;
323
- if (!mode) {
324
- throw new Error("Mode parameter is required");
311
+ startSessionEvictionTimer() {
312
+ if (this.sessionEvictionTimer) {
313
+ return;
325
314
  }
326
- 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();
327
331
  }
328
- async parseControlRequest(req) {
329
- if (req.method === "GET") {
330
- return this.parseGetParams(req);
331
- }
332
- if (req.method === "POST") {
333
- const body = await readRequestBody(req);
334
- console.log(`MODE CHANGE (${req.method})`, body);
335
- return JSON.parse(body);
332
+ stopSessionEvictionTimer() {
333
+ if (this.sessionEvictionTimer) {
334
+ clearInterval(this.sessionEvictionTimer);
335
+ this.sessionEvictionTimer = null;
336
336
  }
337
- 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);
338
342
  }
339
343
  async handleControlRequest(req, res) {
344
+ if (req.method === "HEAD") {
345
+ res.writeHead(HTTP_STATUS_OK);
346
+ res.end();
347
+ return;
348
+ }
340
349
  if (req.method === "GET") {
341
350
  sendJsonResponse(res, HTTP_STATUS_OK, {
342
351
  recordingsDir: this.recordingsDir,
@@ -345,8 +354,11 @@ var ProxyServer = class {
345
354
  });
346
355
  return;
347
356
  }
357
+ await this.handleControlPost(req, res);
358
+ }
359
+ async handleControlPost(req, res) {
348
360
  try {
349
- const data = await this.parseControlRequest(req);
361
+ const data = await this.parseControlBody(req);
350
362
  const { mode, id, timeout: requestTimeout, cleanup } = data;
351
363
  if (cleanup && id) {
352
364
  await this.cleanupSession(id);
@@ -357,29 +369,7 @@ var ProxyServer = class {
357
369
  });
358
370
  return;
359
371
  }
360
- if (!mode) {
361
- throw new Error(
362
- "Mode parameter is required when cleanup is not specified"
363
- );
364
- }
365
- const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
366
- this.clearModeTimeout();
367
- await this.switchMode(mode, id);
368
- this.setupModeTimeout(timeout);
369
- if (mode === Modes.replay && id) {
370
- res.setHeader(
371
- "Set-Cookie",
372
- `proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
373
- );
374
- console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
375
- }
376
- sendJsonResponse(res, HTTP_STATUS_OK, {
377
- success: true,
378
- mode: this.mode,
379
- id: this.recordingId || this.replayId,
380
- timeout,
381
- recordingsDir: this.recordingsDir
382
- });
372
+ await this.applyModeChange(res, mode, id, requestTimeout);
383
373
  } catch (error) {
384
374
  console.error("Control request error:", error);
385
375
  sendJsonResponse(res, HTTP_STATUS_BAD_REQUEST, {
@@ -387,6 +377,31 @@ var ProxyServer = class {
387
377
  });
388
378
  }
389
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
+ }
390
405
  clearModeTimeout() {
391
406
  clearTimeout(this.modeTimeout || 0);
392
407
  this.modeTimeout = null;
@@ -426,7 +441,7 @@ var ProxyServer = class {
426
441
  this.recordingId = null;
427
442
  this.replayId = null;
428
443
  this.currentSession = null;
429
- clearTimeout(this.modeTimeout || 0);
444
+ this.clearModeTimeout();
430
445
  console.log("Switched to transparent mode");
431
446
  }
432
447
  switchToRecordMode(id) {
@@ -457,7 +472,7 @@ var ProxyServer = class {
457
472
  console.log(`Switched to replay mode with ID: ${id}`);
458
473
  }
459
474
  setupModeTimeout(timeout) {
460
- clearTimeout(this.modeTimeout || 0);
475
+ this.clearModeTimeout();
461
476
  this.modeTimeout = setTimeout(async () => {
462
477
  console.log("Timeout reached, switching back to transparent mode");
463
478
  await this.saveCurrentSession();
@@ -582,11 +597,10 @@ var ProxyServer = class {
582
597
  try {
583
598
  const sessionState = this.getOrCreateReplaySession(recordingId);
584
599
  if (!sessionState.loadedSession) {
585
- const error = new Error(
586
- `Recording session file not found: ${filePath}`
600
+ throw Object.assign(
601
+ new Error(`Recording session file not found: ${filePath}`),
602
+ { code: "ENOENT" }
587
603
  );
588
- error.code = "ENOENT";
589
- throw error;
590
604
  }
591
605
  const servedForThisKey = this.getServedTracker(sessionState, key);
592
606
  const host = req.headers.host || "unknown";
@@ -681,7 +695,7 @@ var ProxyServer = class {
681
695
  res.end();
682
696
  }
683
697
  async handleProxyRequest(req, res) {
684
- const target = this.getTarget();
698
+ const target = this.target;
685
699
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
686
700
  if (this.mode === Modes.record) {
687
701
  await this.recordAndProxyRequest(req, res, target);
@@ -807,7 +821,7 @@ var ProxyServer = class {
807
821
  this.handleReplayWebSocket(req, socket);
808
822
  return;
809
823
  }
810
- const target = this.getTarget();
824
+ const target = this.target;
811
825
  console.log(`[${this.mode}] WebSocket upgrade ${req.url} -> ${target}`);
812
826
  if (this.mode === Modes.record) {
813
827
  this.handleRecordWebSocket(req, socket, head, target);
@@ -880,11 +894,12 @@ var ProxyServer = class {
880
894
  console.error("WebSocket server error:", err);
881
895
  });
882
896
  }
883
- handleReplayWebSocket(req, socket) {
897
+ async handleReplayWebSocket(req, socket) {
884
898
  const url = req.url || "/";
885
899
  const key = `WS_${url.replaceAll("/", "_")}`;
886
900
  const filePath = getRecordingPath(this.recordingsDir, this.replayId);
887
- loadRecordingSession(filePath).then((session) => {
901
+ try {
902
+ const session = await loadRecordingSession(filePath);
888
903
  const wsRecording = session.websocketRecordings.find(
889
904
  (r) => r.key === key
890
905
  );
@@ -950,21 +965,22 @@ var ProxyServer = class {
950
965
  console.log("Replay WebSocket closed");
951
966
  });
952
967
  });
953
- }).catch((error) => {
968
+ } catch (error) {
954
969
  console.error("Replay error:", error);
955
970
  socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
956
971
  socket.destroy();
957
- });
972
+ }
958
973
  }
959
974
  logServerStartup(port) {
960
975
  console.log(`Proxy server running on http://localhost:${port}`);
961
976
  console.log(`Mode: ${this.mode}`);
962
- console.log(`Targets: ${this.targets.join(", ")}`);
977
+ console.log(`Target: ${this.target}`);
963
978
  console.log(
964
979
  `Control endpoint: http://localhost:${port}${CONTROL_ENDPOINT}`
965
980
  );
966
981
  }
967
982
  };
983
+ var registeredContexts = /* @__PURE__ */ new WeakSet();
968
984
  function getProxyPort() {
969
985
  const envPort = process.env.TEST_PROXY_RECORDER_PORT;
970
986
  if (envPort) {
@@ -983,7 +999,7 @@ async function setProxyMode(mode, sessionId, timeout) {
983
999
  id: sessionId,
984
1000
  ...timeout && { timeout }
985
1001
  };
986
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
1002
+ const response = await fetch(`http://localhost:${proxyPort}/__control`, {
987
1003
  method: "POST",
988
1004
  headers: { "Content-Type": "application/json" },
989
1005
  body: JSON.stringify(body)
@@ -1006,7 +1022,7 @@ async function cleanupSession(sessionId) {
1006
1022
  cleanup: true,
1007
1023
  id: sessionId
1008
1024
  };
1009
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
1025
+ const response = await fetch(`http://localhost:${proxyPort}/__control`, {
1010
1026
  method: "POST",
1011
1027
  headers: { "Content-Type": "application/json" },
1012
1028
  body: JSON.stringify(body)
@@ -1070,7 +1086,7 @@ async function getRecordingsDir() {
1070
1086
  }
1071
1087
  const proxyPort = getProxyPort();
1072
1088
  try {
1073
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`);
1089
+ const response = await fetch(`http://localhost:${proxyPort}/__control`);
1074
1090
  if (response.ok) {
1075
1091
  const data = await response.json();
1076
1092
  if (data.recordingsDir) {
@@ -1157,10 +1173,8 @@ var playwrightProxy = {
1157
1173
  // Ensure the handler applies to all matching requests
1158
1174
  );
1159
1175
  const context = page.context();
1160
- const contextId = context._guid || "default";
1161
- const handlerKey = `cleanup_${contextId}`;
1162
- if (!globalThis[handlerKey]) {
1163
- globalThis[handlerKey] = true;
1176
+ if (!registeredContexts.has(context)) {
1177
+ registeredContexts.add(context);
1164
1178
  context.on("close", async () => {
1165
1179
  try {
1166
1180
  await cleanupSession(sessionId);
@@ -1170,7 +1184,7 @@ var playwrightProxy = {
1170
1184
  error
1171
1185
  );
1172
1186
  } finally {
1173
- delete globalThis[handlerKey];
1187
+ registeredContexts.delete(context);
1174
1188
  }
1175
1189
  });
1176
1190
  }
package/dist/index.d.cts CHANGED
@@ -4,8 +4,7 @@ export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording
4
4
  import '@playwright/test';
5
5
 
6
6
  declare class ProxyServer {
7
- private targets;
8
- private currentTargetIndex;
7
+ private target;
9
8
  private mode;
10
9
  private recordingId;
11
10
  private replayId;
@@ -13,12 +12,14 @@ declare class ProxyServer {
13
12
  private proxy;
14
13
  private currentSession;
15
14
  private recordingsDir;
15
+ private timeoutMs;
16
16
  private recordingIdCounter;
17
17
  private sequenceCounterByKey;
18
18
  private replaySessions;
19
+ private sessionEvictionTimer;
19
20
  private recordingPromises;
20
21
  private flushPromise;
21
- constructor(targets: string[], recordingsDir: string);
22
+ constructor(target: string, recordingsDir: string, timeoutMs?: number);
22
23
  init(): Promise<void>;
23
24
  listen(port: number): http.Server;
24
25
  private setupProxyEventHandlers;
@@ -30,7 +31,6 @@ declare class ProxyServer {
30
31
  */
31
32
  private getCorsHeaders;
32
33
  private addCorsHeaders;
33
- private getTarget;
34
34
  /**
35
35
  * Extract recording ID from custom HTTP header
36
36
  * Used for concurrent replay session routing, especially with Next.js
@@ -62,9 +62,12 @@ declare class ProxyServer {
62
62
  * @param sessionId The session ID to clean up
63
63
  */
64
64
  private cleanupSession;
65
- private parseGetParams;
66
- private parseControlRequest;
65
+ private startSessionEvictionTimer;
66
+ private stopSessionEvictionTimer;
67
+ private parseControlBody;
67
68
  private handleControlRequest;
69
+ private handleControlPost;
70
+ private applyModeChange;
68
71
  private clearModeTimeout;
69
72
  private switchMode;
70
73
  private switchToTransparentMode;
package/dist/index.d.ts CHANGED
@@ -4,8 +4,7 @@ export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording
4
4
  import '@playwright/test';
5
5
 
6
6
  declare class ProxyServer {
7
- private targets;
8
- private currentTargetIndex;
7
+ private target;
9
8
  private mode;
10
9
  private recordingId;
11
10
  private replayId;
@@ -13,12 +12,14 @@ declare class ProxyServer {
13
12
  private proxy;
14
13
  private currentSession;
15
14
  private recordingsDir;
15
+ private timeoutMs;
16
16
  private recordingIdCounter;
17
17
  private sequenceCounterByKey;
18
18
  private replaySessions;
19
+ private sessionEvictionTimer;
19
20
  private recordingPromises;
20
21
  private flushPromise;
21
- constructor(targets: string[], recordingsDir: string);
22
+ constructor(target: string, recordingsDir: string, timeoutMs?: number);
22
23
  init(): Promise<void>;
23
24
  listen(port: number): http.Server;
24
25
  private setupProxyEventHandlers;
@@ -30,7 +31,6 @@ declare class ProxyServer {
30
31
  */
31
32
  private getCorsHeaders;
32
33
  private addCorsHeaders;
33
- private getTarget;
34
34
  /**
35
35
  * Extract recording ID from custom HTTP header
36
36
  * Used for concurrent replay session routing, especially with Next.js
@@ -62,9 +62,12 @@ declare class ProxyServer {
62
62
  * @param sessionId The session ID to clean up
63
63
  */
64
64
  private cleanupSession;
65
- private parseGetParams;
66
- private parseControlRequest;
65
+ private startSessionEvictionTimer;
66
+ private stopSessionEvictionTimer;
67
+ private parseControlBody;
67
68
  private handleControlRequest;
69
+ private handleControlPost;
70
+ private applyModeChange;
68
71
  private clearModeTimeout;
69
72
  private switchMode;
70
73
  private switchToTransparentMode;