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.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;
@@ -119,8 +119,7 @@ function sendJsonResponse(res, statusCode, data) {
119
119
 
120
120
  // src/ProxyServer.ts
121
121
  var ProxyServer = class {
122
- targets;
123
- currentTargetIndex;
122
+ target;
124
123
  mode;
125
124
  recordingId;
126
125
  replayId;
@@ -128,19 +127,22 @@ var ProxyServer = class {
128
127
  proxy;
129
128
  currentSession;
130
129
  recordingsDir;
130
+ timeoutMs;
131
131
  recordingIdCounter;
132
132
  // Unique ID for each recording entry
133
133
  sequenceCounterByKey;
134
134
  // Sequence counter per key (endpoint)
135
135
  replaySessions;
136
136
  // Track multiple concurrent replay sessions by recording ID
137
+ sessionEvictionTimer;
138
+ // Periodic timer to evict idle replay sessions
137
139
  recordingPromises;
138
140
  // Stack of promises that resolve to completed recordings
139
141
  flushPromise;
140
142
  // Promise for in-progress flush operation
141
- constructor(targets, recordingsDir) {
142
- this.targets = targets;
143
- this.currentTargetIndex = 0;
143
+ constructor(target, recordingsDir, timeoutMs) {
144
+ this.target = target;
145
+ this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
144
146
  this.mode = Modes.transparent;
145
147
  this.recordingId = null;
146
148
  this.recordingIdCounter = 0;
@@ -150,6 +152,7 @@ var ProxyServer = class {
150
152
  this.currentSession = null;
151
153
  this.recordingsDir = recordingsDir;
152
154
  this.replaySessions = /* @__PURE__ */ new Map();
155
+ this.sessionEvictionTimer = null;
153
156
  this.recordingPromises = [];
154
157
  this.flushPromise = null;
155
158
  this.proxy = httpProxy.createProxyServer({
@@ -212,11 +215,6 @@ var ProxyServer = class {
212
215
  const corsHeaders = this.getCorsHeaders(req);
213
216
  Object.assign(proxyRes.headers, corsHeaders);
214
217
  }
215
- getTarget() {
216
- const target = this.targets[this.currentTargetIndex];
217
- this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
218
- return target;
219
- }
220
218
  /**
221
219
  * Extract recording ID from custom HTTP header
222
220
  * Used for concurrent replay session routing, especially with Next.js
@@ -252,13 +250,7 @@ var ProxyServer = class {
252
250
  getRecordingIdFromRequest(req) {
253
251
  const fromHeader = this.getRecordingIdFromHeader(req);
254
252
  const fromCookie = this.getRecordingIdFromCookie(req);
255
- if (fromHeader) {
256
- return fromHeader;
257
- }
258
- if (fromCookie) {
259
- return fromCookie;
260
- }
261
- return null;
253
+ return fromHeader ?? fromCookie ?? null;
262
254
  }
263
255
  /**
264
256
  * Get or create a replay session state for a given recording ID
@@ -278,6 +270,7 @@ var ProxyServer = class {
278
270
  sortedRecordingsByKey: /* @__PURE__ */ new Map()
279
271
  };
280
272
  this.replaySessions.set(recordingId, session);
273
+ this.startSessionEvictionTimer();
281
274
  console.log(
282
275
  `[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
283
276
  );
@@ -289,8 +282,9 @@ var ProxyServer = class {
289
282
  * @param sessionId The session ID to clean up
290
283
  */
291
284
  async cleanupSession(sessionId) {
292
- if (this.replaySessions.has(sessionId)) {
293
- this.replaySessions.delete(sessionId);
285
+ this.replaySessions.delete(sessionId);
286
+ if (this.replaySessions.size === 0) {
287
+ this.stopSessionEvictionTimer();
294
288
  }
295
289
  if (this.recordingId === sessionId) {
296
290
  await this.saveCurrentSession();
@@ -302,29 +296,44 @@ var ProxyServer = class {
302
296
  }
303
297
  console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
304
298
  }
305
- parseGetParams(req) {
306
- const url = new URL(req.url || "", `http://${req.headers.host}`);
307
- const mode = url.searchParams.get("mode");
308
- const id = url.searchParams.get("id") || void 0;
309
- const timeoutParam = url.searchParams.get("timeout");
310
- const timeout = timeoutParam ? Number.parseInt(timeoutParam, 10) : void 0;
311
- if (!mode) {
312
- throw new Error("Mode parameter is required");
299
+ startSessionEvictionTimer() {
300
+ if (this.sessionEvictionTimer) {
301
+ return;
313
302
  }
314
- 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();
315
319
  }
316
- async parseControlRequest(req) {
317
- if (req.method === "GET") {
318
- return this.parseGetParams(req);
319
- }
320
- if (req.method === "POST") {
321
- const body = await readRequestBody(req);
322
- console.log(`MODE CHANGE (${req.method})`, body);
323
- return JSON.parse(body);
320
+ stopSessionEvictionTimer() {
321
+ if (this.sessionEvictionTimer) {
322
+ clearInterval(this.sessionEvictionTimer);
323
+ this.sessionEvictionTimer = null;
324
324
  }
325
- 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);
326
330
  }
327
331
  async handleControlRequest(req, res) {
332
+ if (req.method === "HEAD") {
333
+ res.writeHead(HTTP_STATUS_OK);
334
+ res.end();
335
+ return;
336
+ }
328
337
  if (req.method === "GET") {
329
338
  sendJsonResponse(res, HTTP_STATUS_OK, {
330
339
  recordingsDir: this.recordingsDir,
@@ -333,8 +342,11 @@ var ProxyServer = class {
333
342
  });
334
343
  return;
335
344
  }
345
+ await this.handleControlPost(req, res);
346
+ }
347
+ async handleControlPost(req, res) {
336
348
  try {
337
- const data = await this.parseControlRequest(req);
349
+ const data = await this.parseControlBody(req);
338
350
  const { mode, id, timeout: requestTimeout, cleanup } = data;
339
351
  if (cleanup && id) {
340
352
  await this.cleanupSession(id);
@@ -345,29 +357,7 @@ var ProxyServer = class {
345
357
  });
346
358
  return;
347
359
  }
348
- if (!mode) {
349
- throw new Error(
350
- "Mode parameter is required when cleanup is not specified"
351
- );
352
- }
353
- const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
354
- this.clearModeTimeout();
355
- await this.switchMode(mode, id);
356
- this.setupModeTimeout(timeout);
357
- if (mode === Modes.replay && id) {
358
- res.setHeader(
359
- "Set-Cookie",
360
- `proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
361
- );
362
- console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
363
- }
364
- sendJsonResponse(res, HTTP_STATUS_OK, {
365
- success: true,
366
- mode: this.mode,
367
- id: this.recordingId || this.replayId,
368
- timeout,
369
- recordingsDir: this.recordingsDir
370
- });
360
+ await this.applyModeChange(res, mode, id, requestTimeout);
371
361
  } catch (error) {
372
362
  console.error("Control request error:", error);
373
363
  sendJsonResponse(res, HTTP_STATUS_BAD_REQUEST, {
@@ -375,6 +365,31 @@ var ProxyServer = class {
375
365
  });
376
366
  }
377
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
+ }
378
393
  clearModeTimeout() {
379
394
  clearTimeout(this.modeTimeout || 0);
380
395
  this.modeTimeout = null;
@@ -414,7 +429,7 @@ var ProxyServer = class {
414
429
  this.recordingId = null;
415
430
  this.replayId = null;
416
431
  this.currentSession = null;
417
- clearTimeout(this.modeTimeout || 0);
432
+ this.clearModeTimeout();
418
433
  console.log("Switched to transparent mode");
419
434
  }
420
435
  switchToRecordMode(id) {
@@ -445,7 +460,7 @@ var ProxyServer = class {
445
460
  console.log(`Switched to replay mode with ID: ${id}`);
446
461
  }
447
462
  setupModeTimeout(timeout) {
448
- clearTimeout(this.modeTimeout || 0);
463
+ this.clearModeTimeout();
449
464
  this.modeTimeout = setTimeout(async () => {
450
465
  console.log("Timeout reached, switching back to transparent mode");
451
466
  await this.saveCurrentSession();
@@ -570,11 +585,10 @@ var ProxyServer = class {
570
585
  try {
571
586
  const sessionState = this.getOrCreateReplaySession(recordingId);
572
587
  if (!sessionState.loadedSession) {
573
- const error = new Error(
574
- `Recording session file not found: ${filePath}`
588
+ throw Object.assign(
589
+ new Error(`Recording session file not found: ${filePath}`),
590
+ { code: "ENOENT" }
575
591
  );
576
- error.code = "ENOENT";
577
- throw error;
578
592
  }
579
593
  const servedForThisKey = this.getServedTracker(sessionState, key);
580
594
  const host = req.headers.host || "unknown";
@@ -669,7 +683,7 @@ var ProxyServer = class {
669
683
  res.end();
670
684
  }
671
685
  async handleProxyRequest(req, res) {
672
- const target = this.getTarget();
686
+ const target = this.target;
673
687
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
674
688
  if (this.mode === Modes.record) {
675
689
  await this.recordAndProxyRequest(req, res, target);
@@ -795,7 +809,7 @@ var ProxyServer = class {
795
809
  this.handleReplayWebSocket(req, socket);
796
810
  return;
797
811
  }
798
- const target = this.getTarget();
812
+ const target = this.target;
799
813
  console.log(`[${this.mode}] WebSocket upgrade ${req.url} -> ${target}`);
800
814
  if (this.mode === Modes.record) {
801
815
  this.handleRecordWebSocket(req, socket, head, target);
@@ -868,11 +882,12 @@ var ProxyServer = class {
868
882
  console.error("WebSocket server error:", err);
869
883
  });
870
884
  }
871
- handleReplayWebSocket(req, socket) {
885
+ async handleReplayWebSocket(req, socket) {
872
886
  const url = req.url || "/";
873
887
  const key = `WS_${url.replaceAll("/", "_")}`;
874
888
  const filePath = getRecordingPath(this.recordingsDir, this.replayId);
875
- loadRecordingSession(filePath).then((session) => {
889
+ try {
890
+ const session = await loadRecordingSession(filePath);
876
891
  const wsRecording = session.websocketRecordings.find(
877
892
  (r) => r.key === key
878
893
  );
@@ -938,21 +953,22 @@ var ProxyServer = class {
938
953
  console.log("Replay WebSocket closed");
939
954
  });
940
955
  });
941
- }).catch((error) => {
956
+ } catch (error) {
942
957
  console.error("Replay error:", error);
943
958
  socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
944
959
  socket.destroy();
945
- });
960
+ }
946
961
  }
947
962
  logServerStartup(port) {
948
963
  console.log(`Proxy server running on http://localhost:${port}`);
949
964
  console.log(`Mode: ${this.mode}`);
950
- console.log(`Targets: ${this.targets.join(", ")}`);
965
+ console.log(`Target: ${this.target}`);
951
966
  console.log(
952
967
  `Control endpoint: http://localhost:${port}${CONTROL_ENDPOINT}`
953
968
  );
954
969
  }
955
970
  };
971
+ var registeredContexts = /* @__PURE__ */ new WeakSet();
956
972
  function getProxyPort() {
957
973
  const envPort = process.env.TEST_PROXY_RECORDER_PORT;
958
974
  if (envPort) {
@@ -971,7 +987,7 @@ async function setProxyMode(mode, sessionId, timeout) {
971
987
  id: sessionId,
972
988
  ...timeout && { timeout }
973
989
  };
974
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
990
+ const response = await fetch(`http://localhost:${proxyPort}/__control`, {
975
991
  method: "POST",
976
992
  headers: { "Content-Type": "application/json" },
977
993
  body: JSON.stringify(body)
@@ -994,7 +1010,7 @@ async function cleanupSession(sessionId) {
994
1010
  cleanup: true,
995
1011
  id: sessionId
996
1012
  };
997
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`, {
1013
+ const response = await fetch(`http://localhost:${proxyPort}/__control`, {
998
1014
  method: "POST",
999
1015
  headers: { "Content-Type": "application/json" },
1000
1016
  body: JSON.stringify(body)
@@ -1058,7 +1074,7 @@ async function getRecordingsDir() {
1058
1074
  }
1059
1075
  const proxyPort = getProxyPort();
1060
1076
  try {
1061
- const response = await fetch(`http://127.0.0.1:${proxyPort}/__control`);
1077
+ const response = await fetch(`http://localhost:${proxyPort}/__control`);
1062
1078
  if (response.ok) {
1063
1079
  const data = await response.json();
1064
1080
  if (data.recordingsDir) {
@@ -1145,10 +1161,8 @@ var playwrightProxy = {
1145
1161
  // Ensure the handler applies to all matching requests
1146
1162
  );
1147
1163
  const context = page.context();
1148
- const contextId = context._guid || "default";
1149
- const handlerKey = `cleanup_${contextId}`;
1150
- if (!globalThis[handlerKey]) {
1151
- globalThis[handlerKey] = true;
1164
+ if (!registeredContexts.has(context)) {
1165
+ registeredContexts.add(context);
1152
1166
  context.on("close", async () => {
1153
1167
  try {
1154
1168
  await cleanupSession(sessionId);
@@ -1158,7 +1172,7 @@ var playwrightProxy = {
1158
1172
  error
1159
1173
  );
1160
1174
  } finally {
1161
- delete globalThis[handlerKey];
1175
+ registeredContexts.delete(context);
1162
1176
  }
1163
1177
  });
1164
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
  }