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/proxy.js CHANGED
@@ -8,6 +8,17 @@ import { WebSocket, WebSocketServer } from 'ws';
8
8
  import crypto from 'crypto';
9
9
  import filenamify2 from 'filenamify';
10
10
 
11
+ // src/cli.ts
12
+
13
+ // src/constants.ts
14
+ var DEFAULT_TIMEOUT_MS = 120 * 1e3;
15
+ var HTTP_STATUS_BAD_GATEWAY = 502;
16
+ var HTTP_STATUS_OK = 200;
17
+ var HTTP_STATUS_BAD_REQUEST = 400;
18
+ var HTTP_STATUS_NOT_FOUND = 404;
19
+ var CONTROL_ENDPOINT = "/__control";
20
+ var RECORDING_ID_HEADER = "x-test-rcrd-id";
21
+
11
22
  // src/cli.ts
12
23
  var DEFAULT_PORT = 8e3;
13
24
  var DEFAULT_RECORDINGS_DIR = "./recordings";
@@ -26,6 +37,10 @@ function parseCliArgs() {
26
37
  "-d, --dir <path>",
27
38
  "Directory to store recordings (relative to CWD)",
28
39
  DEFAULT_RECORDINGS_DIR
40
+ ).option(
41
+ "-t, --timeout <ms>",
42
+ "Session timeout in milliseconds",
43
+ String(DEFAULT_TIMEOUT_MS)
29
44
  ).action(() => {
30
45
  });
31
46
  program.parse();
@@ -36,22 +51,18 @@ function parseCliArgs() {
36
51
  console.error("Error: Invalid port number. Must be between 1 and 65535");
37
52
  process.exit(1);
38
53
  }
54
+ const timeout2 = Number.parseInt(options.timeout, 10);
55
+ if (Number.isNaN(timeout2) || timeout2 < 0) {
56
+ console.error("Error: Invalid timeout. Must be a non-negative number");
57
+ process.exit(1);
58
+ }
39
59
  if (!target2) {
40
60
  program.help();
41
61
  }
42
62
  const recordingsDir2 = path.resolve(process.cwd(), options.dir);
43
- return { target: target2, port: port2, recordingsDir: recordingsDir2 };
63
+ return { target: target2, port: port2, recordingsDir: recordingsDir2, timeout: timeout2 };
44
64
  }
45
65
 
46
- // src/constants.ts
47
- var DEFAULT_TIMEOUT_MS = 120 * 1e3;
48
- var HTTP_STATUS_BAD_GATEWAY = 502;
49
- var HTTP_STATUS_OK = 200;
50
- var HTTP_STATUS_BAD_REQUEST = 400;
51
- var HTTP_STATUS_NOT_FOUND = 404;
52
- var CONTROL_ENDPOINT = "/__control";
53
- var RECORDING_ID_HEADER = "x-test-rcrd-id";
54
-
55
66
  // src/types.ts
56
67
  var Modes = {
57
68
  transparent: "transparent",
@@ -95,9 +106,9 @@ function processRecordings(recordings) {
95
106
  const processedRecordings = [];
96
107
  for (const [_key, keyRecordings] of recordingsByKey) {
97
108
  keyRecordings.sort((a, b) => a.recordingId - b.recordingId);
98
- keyRecordings.forEach((recording, index) => {
109
+ for (const [index, recording] of keyRecordings.entries()) {
99
110
  processedRecordings.push({ ...recording, sequence: index });
100
- });
111
+ }
101
112
  }
102
113
  processedRecordings.sort((a, b) => a.recordingId - b.recordingId);
103
114
  return processedRecordings;
@@ -163,18 +174,22 @@ var ProxyServer = class {
163
174
  proxy;
164
175
  currentSession;
165
176
  recordingsDir;
177
+ timeoutMs;
166
178
  recordingIdCounter;
167
179
  // Unique ID for each recording entry
168
180
  sequenceCounterByKey;
169
181
  // Sequence counter per key (endpoint)
170
182
  replaySessions;
171
183
  // Track multiple concurrent replay sessions by recording ID
184
+ sessionEvictionTimer;
185
+ // Periodic timer to evict idle replay sessions
172
186
  recordingPromises;
173
187
  // Stack of promises that resolve to completed recordings
174
188
  flushPromise;
175
189
  // Promise for in-progress flush operation
176
- constructor(target2, recordingsDir2) {
190
+ constructor(target2, recordingsDir2, timeoutMs) {
177
191
  this.target = target2;
192
+ this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
178
193
  this.mode = Modes.transparent;
179
194
  this.recordingId = null;
180
195
  this.recordingIdCounter = 0;
@@ -184,6 +199,7 @@ var ProxyServer = class {
184
199
  this.currentSession = null;
185
200
  this.recordingsDir = recordingsDir2;
186
201
  this.replaySessions = /* @__PURE__ */ new Map();
202
+ this.sessionEvictionTimer = null;
187
203
  this.recordingPromises = [];
188
204
  this.flushPromise = null;
189
205
  this.proxy = httpProxy.createProxyServer({
@@ -246,9 +262,6 @@ var ProxyServer = class {
246
262
  const corsHeaders = this.getCorsHeaders(req);
247
263
  Object.assign(proxyRes.headers, corsHeaders);
248
264
  }
249
- getTarget() {
250
- return this.target;
251
- }
252
265
  /**
253
266
  * Extract recording ID from custom HTTP header
254
267
  * Used for concurrent replay session routing, especially with Next.js
@@ -284,13 +297,7 @@ var ProxyServer = class {
284
297
  getRecordingIdFromRequest(req) {
285
298
  const fromHeader = this.getRecordingIdFromHeader(req);
286
299
  const fromCookie = this.getRecordingIdFromCookie(req);
287
- if (fromHeader) {
288
- return fromHeader;
289
- }
290
- if (fromCookie) {
291
- return fromCookie;
292
- }
293
- return null;
300
+ return fromHeader ?? fromCookie ?? null;
294
301
  }
295
302
  /**
296
303
  * Get or create a replay session state for a given recording ID
@@ -310,6 +317,7 @@ var ProxyServer = class {
310
317
  sortedRecordingsByKey: /* @__PURE__ */ new Map()
311
318
  };
312
319
  this.replaySessions.set(recordingId, session);
320
+ this.startSessionEvictionTimer();
313
321
  console.log(
314
322
  `[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
315
323
  );
@@ -321,8 +329,9 @@ var ProxyServer = class {
321
329
  * @param sessionId The session ID to clean up
322
330
  */
323
331
  async cleanupSession(sessionId) {
324
- if (this.replaySessions.has(sessionId)) {
325
- this.replaySessions.delete(sessionId);
332
+ this.replaySessions.delete(sessionId);
333
+ if (this.replaySessions.size === 0) {
334
+ this.stopSessionEvictionTimer();
326
335
  }
327
336
  if (this.recordingId === sessionId) {
328
337
  await this.saveCurrentSession();
@@ -334,29 +343,44 @@ var ProxyServer = class {
334
343
  }
335
344
  console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
336
345
  }
337
- parseGetParams(req) {
338
- const url = new URL(req.url || "", `http://${req.headers.host}`);
339
- const mode = url.searchParams.get("mode");
340
- const id = url.searchParams.get("id") || void 0;
341
- const timeoutParam = url.searchParams.get("timeout");
342
- const timeout = timeoutParam ? Number.parseInt(timeoutParam, 10) : void 0;
343
- if (!mode) {
344
- throw new Error("Mode parameter is required");
346
+ startSessionEvictionTimer() {
347
+ if (this.sessionEvictionTimer) {
348
+ return;
345
349
  }
346
- return { mode, id, timeout };
350
+ const CHECK_INTERVAL_MS = 3e4;
351
+ this.sessionEvictionTimer = setInterval(() => {
352
+ const now = Date.now();
353
+ for (const [id, session] of this.replaySessions) {
354
+ if (now - session.lastAccessTime >= this.timeoutMs) {
355
+ console.log(
356
+ `[EVICTION] Evicting idle replay session: ${id} (idle for ${Math.round((now - session.lastAccessTime) / 1e3)}s)`
357
+ );
358
+ this.replaySessions.delete(id);
359
+ }
360
+ }
361
+ if (this.replaySessions.size === 0) {
362
+ this.stopSessionEvictionTimer();
363
+ }
364
+ }, CHECK_INTERVAL_MS);
365
+ this.sessionEvictionTimer.unref();
347
366
  }
348
- async parseControlRequest(req) {
349
- if (req.method === "GET") {
350
- return this.parseGetParams(req);
351
- }
352
- if (req.method === "POST") {
353
- const body = await readRequestBody(req);
354
- console.log(`MODE CHANGE (${req.method})`, body);
355
- return JSON.parse(body);
367
+ stopSessionEvictionTimer() {
368
+ if (this.sessionEvictionTimer) {
369
+ clearInterval(this.sessionEvictionTimer);
370
+ this.sessionEvictionTimer = null;
356
371
  }
357
- throw new Error("Unsupported control method");
372
+ }
373
+ async parseControlBody(req) {
374
+ const body = await readRequestBody(req);
375
+ console.log(`MODE CHANGE (${req.method})`, body);
376
+ return JSON.parse(body);
358
377
  }
359
378
  async handleControlRequest(req, res) {
379
+ if (req.method === "HEAD") {
380
+ res.writeHead(HTTP_STATUS_OK);
381
+ res.end();
382
+ return;
383
+ }
360
384
  if (req.method === "GET") {
361
385
  sendJsonResponse(res, HTTP_STATUS_OK, {
362
386
  recordingsDir: this.recordingsDir,
@@ -365,8 +389,11 @@ var ProxyServer = class {
365
389
  });
366
390
  return;
367
391
  }
392
+ await this.handleControlPost(req, res);
393
+ }
394
+ async handleControlPost(req, res) {
368
395
  try {
369
- const data = await this.parseControlRequest(req);
396
+ const data = await this.parseControlBody(req);
370
397
  const { mode, id, timeout: requestTimeout, cleanup } = data;
371
398
  if (cleanup && id) {
372
399
  await this.cleanupSession(id);
@@ -377,29 +404,7 @@ var ProxyServer = class {
377
404
  });
378
405
  return;
379
406
  }
380
- if (!mode) {
381
- throw new Error(
382
- "Mode parameter is required when cleanup is not specified"
383
- );
384
- }
385
- const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
386
- this.clearModeTimeout();
387
- await this.switchMode(mode, id);
388
- this.setupModeTimeout(timeout);
389
- if (mode === Modes.replay && id) {
390
- res.setHeader(
391
- "Set-Cookie",
392
- `proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
393
- );
394
- console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
395
- }
396
- sendJsonResponse(res, HTTP_STATUS_OK, {
397
- success: true,
398
- mode: this.mode,
399
- id: this.recordingId || this.replayId,
400
- timeout,
401
- recordingsDir: this.recordingsDir
402
- });
407
+ await this.applyModeChange(res, mode, id, requestTimeout);
403
408
  } catch (error) {
404
409
  console.error("Control request error:", error);
405
410
  sendJsonResponse(res, HTTP_STATUS_BAD_REQUEST, {
@@ -407,6 +412,31 @@ var ProxyServer = class {
407
412
  });
408
413
  }
409
414
  }
415
+ async applyModeChange(res, mode, id, requestTimeout) {
416
+ if (!mode) {
417
+ throw new Error(
418
+ "Mode parameter is required when cleanup is not specified"
419
+ );
420
+ }
421
+ const timeout2 = requestTimeout ?? this.timeoutMs;
422
+ this.clearModeTimeout();
423
+ await this.switchMode(mode, id);
424
+ this.setupModeTimeout(timeout2);
425
+ if (mode === Modes.replay && id) {
426
+ res.setHeader(
427
+ "Set-Cookie",
428
+ `proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
429
+ );
430
+ console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
431
+ }
432
+ sendJsonResponse(res, HTTP_STATUS_OK, {
433
+ success: true,
434
+ mode: this.mode,
435
+ id: this.recordingId || this.replayId,
436
+ timeout: timeout2,
437
+ recordingsDir: this.recordingsDir
438
+ });
439
+ }
410
440
  clearModeTimeout() {
411
441
  clearTimeout(this.modeTimeout || 0);
412
442
  this.modeTimeout = null;
@@ -446,7 +476,7 @@ var ProxyServer = class {
446
476
  this.recordingId = null;
447
477
  this.replayId = null;
448
478
  this.currentSession = null;
449
- clearTimeout(this.modeTimeout || 0);
479
+ this.clearModeTimeout();
450
480
  console.log("Switched to transparent mode");
451
481
  }
452
482
  switchToRecordMode(id) {
@@ -476,14 +506,14 @@ var ProxyServer = class {
476
506
  }
477
507
  console.log(`Switched to replay mode with ID: ${id}`);
478
508
  }
479
- setupModeTimeout(timeout) {
480
- clearTimeout(this.modeTimeout || 0);
509
+ setupModeTimeout(timeout2) {
510
+ this.clearModeTimeout();
481
511
  this.modeTimeout = setTimeout(async () => {
482
512
  console.log("Timeout reached, switching back to transparent mode");
483
513
  await this.saveCurrentSession();
484
514
  this.switchToTransparentMode();
485
515
  this.modeTimeout = null;
486
- }, timeout);
516
+ }, timeout2);
487
517
  }
488
518
  async flushPendingRecordings() {
489
519
  if (this.flushPromise) {
@@ -602,11 +632,10 @@ var ProxyServer = class {
602
632
  try {
603
633
  const sessionState = this.getOrCreateReplaySession(recordingId);
604
634
  if (!sessionState.loadedSession) {
605
- const error = new Error(
606
- `Recording session file not found: ${filePath}`
635
+ throw Object.assign(
636
+ new Error(`Recording session file not found: ${filePath}`),
637
+ { code: "ENOENT" }
607
638
  );
608
- error.code = "ENOENT";
609
- throw error;
610
639
  }
611
640
  const servedForThisKey = this.getServedTracker(sessionState, key);
612
641
  const host = req.headers.host || "unknown";
@@ -701,7 +730,7 @@ var ProxyServer = class {
701
730
  res.end();
702
731
  }
703
732
  async handleProxyRequest(req, res) {
704
- const target2 = this.getTarget();
733
+ const target2 = this.target;
705
734
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target2}`);
706
735
  if (this.mode === Modes.record) {
707
736
  await this.recordAndProxyRequest(req, res, target2);
@@ -827,7 +856,7 @@ var ProxyServer = class {
827
856
  this.handleReplayWebSocket(req, socket);
828
857
  return;
829
858
  }
830
- const target2 = this.getTarget();
859
+ const target2 = this.target;
831
860
  console.log(`[${this.mode}] WebSocket upgrade ${req.url} -> ${target2}`);
832
861
  if (this.mode === Modes.record) {
833
862
  this.handleRecordWebSocket(req, socket, head, target2);
@@ -900,11 +929,12 @@ var ProxyServer = class {
900
929
  console.error("WebSocket server error:", err);
901
930
  });
902
931
  }
903
- handleReplayWebSocket(req, socket) {
932
+ async handleReplayWebSocket(req, socket) {
904
933
  const url = req.url || "/";
905
934
  const key = `WS_${url.replaceAll("/", "_")}`;
906
935
  const filePath = getRecordingPath(this.recordingsDir, this.replayId);
907
- loadRecordingSession(filePath).then((session) => {
936
+ try {
937
+ const session = await loadRecordingSession(filePath);
908
938
  const wsRecording = session.websocketRecordings.find(
909
939
  (r) => r.key === key
910
940
  );
@@ -970,11 +1000,11 @@ var ProxyServer = class {
970
1000
  console.log("Replay WebSocket closed");
971
1001
  });
972
1002
  });
973
- }).catch((error) => {
1003
+ } catch (error) {
974
1004
  console.error("Replay error:", error);
975
1005
  socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
976
1006
  socket.destroy();
977
- });
1007
+ }
978
1008
  }
979
1009
  logServerStartup(port2) {
980
1010
  console.log(`Proxy server running on http://localhost:${port2}`);
@@ -986,9 +1016,9 @@ var ProxyServer = class {
986
1016
  }
987
1017
  };
988
1018
 
989
- // src/proxy.ts
990
- var { target, port, recordingsDir } = parseCliArgs();
991
- var proxy = new ProxyServer(target, recordingsDir);
1019
+ // src/proxy-cli.ts
1020
+ var { target, port, recordingsDir, timeout } = parseCliArgs();
1021
+ var proxy = new ProxyServer(target, recordingsDir, timeout);
992
1022
  await proxy.init();
993
1023
  proxy.listen(port);
994
1024
  console.log(`Recordings will be saved to: ${recordingsDir}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-proxy-recorder",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "HTTP proxy server for recording and replaying network requests in testing. Works seamlessly with Playwright testing framework.",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -38,13 +38,18 @@
38
38
  "LICENSE"
39
39
  ],
40
40
  "pnpm": {
41
- "onlyBuiltDependencies": ["esbuild"]
41
+ "onlyBuiltDependencies": ["esbuild", "sharp"]
42
42
  },
43
43
  "packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd",
44
44
  "scripts": {
45
45
  "start": "node dist/proxy.js",
46
46
  "dev": "tsx src/proxy.ts",
47
47
  "build": "tsup",
48
+ "example:dev": "pnpm --filter example-nextjs16 dev",
49
+ "example:build": "pnpm --filter example-nextjs16 build",
50
+ "example:services": "pnpm build && pnpm --filter example-nextjs16 start:all",
51
+ "example:test:e2e": "pnpm --filter example-nextjs16 test:e2e",
52
+ "example:test:e2e:record": "pnpm --filter example-nextjs16 test:e2e:record",
48
53
  "prepublish": "pnpm run build && pnpm run test:run && pnpm run lint",
49
54
  "lint": "eslint src --ext .ts",
50
55
  "lint:fix": "eslint src --ext .ts --fix",
@@ -95,6 +100,7 @@
95
100
  "@playwright/test": ">=1.0.0"
96
101
  },
97
102
  "devDependencies": {
103
+ "@playwright/test": "^1.59.1",
98
104
  "@types/http-proxy": "^1.17.15",
99
105
  "@types/node": "^22.0.0",
100
106
  "@types/ws": "^8.18.1",