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/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";
@@ -16,8 +27,8 @@ function parseCliArgs() {
16
27
  program.name("dev-proxy").description(
17
28
  "Development proxy server with recording and replay capabilities"
18
29
  ).argument(
19
- "<targets...>",
20
- "Target API service URLs (e.g., http://localhost:3000)"
30
+ "<target>",
31
+ "Target API service URL (e.g., http://localhost:3000)"
21
32
  ).option(
22
33
  "-p, --port <number>",
23
34
  "Port number for the proxy server",
@@ -26,32 +37,32 @@ 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();
32
- const targets2 = program.args;
47
+ const target2 = program.args[0];
33
48
  const options = program.opts();
34
49
  const port2 = Number.parseInt(options.port, 10);
35
50
  if (Number.isNaN(port2) || port2 < 1025 || port2 > 65535) {
36
51
  console.error("Error: Invalid port number. Must be between 1 and 65535");
37
52
  process.exit(1);
38
53
  }
39
- if (targets2.length === 0) {
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
+ }
59
+ if (!target2) {
40
60
  program.help();
41
61
  }
42
62
  const recordingsDir2 = path.resolve(process.cwd(), options.dir);
43
- return { targets: targets2, 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;
@@ -155,8 +166,7 @@ function sendJsonResponse(res, statusCode, data) {
155
166
 
156
167
  // src/ProxyServer.ts
157
168
  var ProxyServer = class {
158
- targets;
159
- currentTargetIndex;
169
+ target;
160
170
  mode;
161
171
  recordingId;
162
172
  replayId;
@@ -164,19 +174,22 @@ var ProxyServer = class {
164
174
  proxy;
165
175
  currentSession;
166
176
  recordingsDir;
177
+ timeoutMs;
167
178
  recordingIdCounter;
168
179
  // Unique ID for each recording entry
169
180
  sequenceCounterByKey;
170
181
  // Sequence counter per key (endpoint)
171
182
  replaySessions;
172
183
  // Track multiple concurrent replay sessions by recording ID
184
+ sessionEvictionTimer;
185
+ // Periodic timer to evict idle replay sessions
173
186
  recordingPromises;
174
187
  // Stack of promises that resolve to completed recordings
175
188
  flushPromise;
176
189
  // Promise for in-progress flush operation
177
- constructor(targets2, recordingsDir2) {
178
- this.targets = targets2;
179
- this.currentTargetIndex = 0;
190
+ constructor(target2, recordingsDir2, timeoutMs) {
191
+ this.target = target2;
192
+ this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
180
193
  this.mode = Modes.transparent;
181
194
  this.recordingId = null;
182
195
  this.recordingIdCounter = 0;
@@ -186,6 +199,7 @@ var ProxyServer = class {
186
199
  this.currentSession = null;
187
200
  this.recordingsDir = recordingsDir2;
188
201
  this.replaySessions = /* @__PURE__ */ new Map();
202
+ this.sessionEvictionTimer = null;
189
203
  this.recordingPromises = [];
190
204
  this.flushPromise = null;
191
205
  this.proxy = httpProxy.createProxyServer({
@@ -248,11 +262,6 @@ var ProxyServer = class {
248
262
  const corsHeaders = this.getCorsHeaders(req);
249
263
  Object.assign(proxyRes.headers, corsHeaders);
250
264
  }
251
- getTarget() {
252
- const target = this.targets[this.currentTargetIndex];
253
- this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
254
- return target;
255
- }
256
265
  /**
257
266
  * Extract recording ID from custom HTTP header
258
267
  * Used for concurrent replay session routing, especially with Next.js
@@ -288,13 +297,7 @@ var ProxyServer = class {
288
297
  getRecordingIdFromRequest(req) {
289
298
  const fromHeader = this.getRecordingIdFromHeader(req);
290
299
  const fromCookie = this.getRecordingIdFromCookie(req);
291
- if (fromHeader) {
292
- return fromHeader;
293
- }
294
- if (fromCookie) {
295
- return fromCookie;
296
- }
297
- return null;
300
+ return fromHeader ?? fromCookie ?? null;
298
301
  }
299
302
  /**
300
303
  * Get or create a replay session state for a given recording ID
@@ -314,6 +317,7 @@ var ProxyServer = class {
314
317
  sortedRecordingsByKey: /* @__PURE__ */ new Map()
315
318
  };
316
319
  this.replaySessions.set(recordingId, session);
320
+ this.startSessionEvictionTimer();
317
321
  console.log(
318
322
  `[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
319
323
  );
@@ -325,8 +329,9 @@ var ProxyServer = class {
325
329
  * @param sessionId The session ID to clean up
326
330
  */
327
331
  async cleanupSession(sessionId) {
328
- if (this.replaySessions.has(sessionId)) {
329
- this.replaySessions.delete(sessionId);
332
+ this.replaySessions.delete(sessionId);
333
+ if (this.replaySessions.size === 0) {
334
+ this.stopSessionEvictionTimer();
330
335
  }
331
336
  if (this.recordingId === sessionId) {
332
337
  await this.saveCurrentSession();
@@ -338,29 +343,44 @@ var ProxyServer = class {
338
343
  }
339
344
  console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
340
345
  }
341
- parseGetParams(req) {
342
- const url = new URL(req.url || "", `http://${req.headers.host}`);
343
- const mode = url.searchParams.get("mode");
344
- const id = url.searchParams.get("id") || void 0;
345
- const timeoutParam = url.searchParams.get("timeout");
346
- const timeout = timeoutParam ? Number.parseInt(timeoutParam, 10) : void 0;
347
- if (!mode) {
348
- throw new Error("Mode parameter is required");
346
+ startSessionEvictionTimer() {
347
+ if (this.sessionEvictionTimer) {
348
+ return;
349
349
  }
350
- 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();
351
366
  }
352
- async parseControlRequest(req) {
353
- if (req.method === "GET") {
354
- return this.parseGetParams(req);
355
- }
356
- if (req.method === "POST") {
357
- const body = await readRequestBody(req);
358
- console.log(`MODE CHANGE (${req.method})`, body);
359
- return JSON.parse(body);
367
+ stopSessionEvictionTimer() {
368
+ if (this.sessionEvictionTimer) {
369
+ clearInterval(this.sessionEvictionTimer);
370
+ this.sessionEvictionTimer = null;
360
371
  }
361
- 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);
362
377
  }
363
378
  async handleControlRequest(req, res) {
379
+ if (req.method === "HEAD") {
380
+ res.writeHead(HTTP_STATUS_OK);
381
+ res.end();
382
+ return;
383
+ }
364
384
  if (req.method === "GET") {
365
385
  sendJsonResponse(res, HTTP_STATUS_OK, {
366
386
  recordingsDir: this.recordingsDir,
@@ -369,8 +389,11 @@ var ProxyServer = class {
369
389
  });
370
390
  return;
371
391
  }
392
+ await this.handleControlPost(req, res);
393
+ }
394
+ async handleControlPost(req, res) {
372
395
  try {
373
- const data = await this.parseControlRequest(req);
396
+ const data = await this.parseControlBody(req);
374
397
  const { mode, id, timeout: requestTimeout, cleanup } = data;
375
398
  if (cleanup && id) {
376
399
  await this.cleanupSession(id);
@@ -381,29 +404,7 @@ var ProxyServer = class {
381
404
  });
382
405
  return;
383
406
  }
384
- if (!mode) {
385
- throw new Error(
386
- "Mode parameter is required when cleanup is not specified"
387
- );
388
- }
389
- const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
390
- this.clearModeTimeout();
391
- await this.switchMode(mode, id);
392
- this.setupModeTimeout(timeout);
393
- if (mode === Modes.replay && id) {
394
- res.setHeader(
395
- "Set-Cookie",
396
- `proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
397
- );
398
- console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
399
- }
400
- sendJsonResponse(res, HTTP_STATUS_OK, {
401
- success: true,
402
- mode: this.mode,
403
- id: this.recordingId || this.replayId,
404
- timeout,
405
- recordingsDir: this.recordingsDir
406
- });
407
+ await this.applyModeChange(res, mode, id, requestTimeout);
407
408
  } catch (error) {
408
409
  console.error("Control request error:", error);
409
410
  sendJsonResponse(res, HTTP_STATUS_BAD_REQUEST, {
@@ -411,6 +412,31 @@ var ProxyServer = class {
411
412
  });
412
413
  }
413
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
+ }
414
440
  clearModeTimeout() {
415
441
  clearTimeout(this.modeTimeout || 0);
416
442
  this.modeTimeout = null;
@@ -450,7 +476,7 @@ var ProxyServer = class {
450
476
  this.recordingId = null;
451
477
  this.replayId = null;
452
478
  this.currentSession = null;
453
- clearTimeout(this.modeTimeout || 0);
479
+ this.clearModeTimeout();
454
480
  console.log("Switched to transparent mode");
455
481
  }
456
482
  switchToRecordMode(id) {
@@ -480,14 +506,14 @@ var ProxyServer = class {
480
506
  }
481
507
  console.log(`Switched to replay mode with ID: ${id}`);
482
508
  }
483
- setupModeTimeout(timeout) {
484
- clearTimeout(this.modeTimeout || 0);
509
+ setupModeTimeout(timeout2) {
510
+ this.clearModeTimeout();
485
511
  this.modeTimeout = setTimeout(async () => {
486
512
  console.log("Timeout reached, switching back to transparent mode");
487
513
  await this.saveCurrentSession();
488
514
  this.switchToTransparentMode();
489
515
  this.modeTimeout = null;
490
- }, timeout);
516
+ }, timeout2);
491
517
  }
492
518
  async flushPendingRecordings() {
493
519
  if (this.flushPromise) {
@@ -606,11 +632,10 @@ var ProxyServer = class {
606
632
  try {
607
633
  const sessionState = this.getOrCreateReplaySession(recordingId);
608
634
  if (!sessionState.loadedSession) {
609
- const error = new Error(
610
- `Recording session file not found: ${filePath}`
635
+ throw Object.assign(
636
+ new Error(`Recording session file not found: ${filePath}`),
637
+ { code: "ENOENT" }
611
638
  );
612
- error.code = "ENOENT";
613
- throw error;
614
639
  }
615
640
  const servedForThisKey = this.getServedTracker(sessionState, key);
616
641
  const host = req.headers.host || "unknown";
@@ -705,16 +730,16 @@ var ProxyServer = class {
705
730
  res.end();
706
731
  }
707
732
  async handleProxyRequest(req, res) {
708
- const target = this.getTarget();
709
- console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
733
+ const target2 = this.target;
734
+ console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target2}`);
710
735
  if (this.mode === Modes.record) {
711
- await this.recordAndProxyRequest(req, res, target);
736
+ await this.recordAndProxyRequest(req, res, target2);
712
737
  } else {
713
- this.proxy.web(req, res, { target });
738
+ this.proxy.web(req, res, { target: target2 });
714
739
  }
715
740
  }
716
741
  // Note: streaming requests are buffered before proxying; streaming passthrough is not yet implemented
717
- async recordAndProxyRequest(req, res, target) {
742
+ async recordAndProxyRequest(req, res, target2) {
718
743
  if (!this.currentSession) {
719
744
  return;
720
745
  }
@@ -742,7 +767,7 @@ var ProxyServer = class {
742
767
  console.error("Error buffering request:", error);
743
768
  }
744
769
  const requestBody = Buffer.concat(chunks).toString("utf8");
745
- const targetUrl = new URL(target);
770
+ const targetUrl = new URL(target2);
746
771
  const isHttps = targetUrl.protocol === "https:";
747
772
  const requestModule = isHttps ? https : http;
748
773
  const defaultPort = isHttps ? 443 : 80;
@@ -831,15 +856,15 @@ var ProxyServer = class {
831
856
  this.handleReplayWebSocket(req, socket);
832
857
  return;
833
858
  }
834
- const target = this.getTarget();
835
- console.log(`[${this.mode}] WebSocket upgrade ${req.url} -> ${target}`);
859
+ const target2 = this.target;
860
+ console.log(`[${this.mode}] WebSocket upgrade ${req.url} -> ${target2}`);
836
861
  if (this.mode === Modes.record) {
837
- this.handleRecordWebSocket(req, socket, head, target);
862
+ this.handleRecordWebSocket(req, socket, head, target2);
838
863
  } else {
839
- this.proxy.ws(req, socket, head, { target });
864
+ this.proxy.ws(req, socket, head, { target: target2 });
840
865
  }
841
866
  }
842
- handleRecordWebSocket(req, clientSocket, head, target) {
867
+ handleRecordWebSocket(req, clientSocket, head, target2) {
843
868
  const url = req.url || "/";
844
869
  const key = `WS_${url.replaceAll("/", "_")}`;
845
870
  const wsRecording = {
@@ -851,7 +876,7 @@ var ProxyServer = class {
851
876
  if (this.currentSession) {
852
877
  this.currentSession.websocketRecordings.push(wsRecording);
853
878
  }
854
- const backendWsUrl = `${target.replace("http", "ws")}${url}`;
879
+ const backendWsUrl = `${target2.replace("http", "ws")}${url}`;
855
880
  const backendWs = new WebSocket(backendWsUrl);
856
881
  const wss = new WebSocketServer({ noServer: true });
857
882
  backendWs.on("open", () => {
@@ -904,11 +929,12 @@ var ProxyServer = class {
904
929
  console.error("WebSocket server error:", err);
905
930
  });
906
931
  }
907
- handleReplayWebSocket(req, socket) {
932
+ async handleReplayWebSocket(req, socket) {
908
933
  const url = req.url || "/";
909
934
  const key = `WS_${url.replaceAll("/", "_")}`;
910
935
  const filePath = getRecordingPath(this.recordingsDir, this.replayId);
911
- loadRecordingSession(filePath).then((session) => {
936
+ try {
937
+ const session = await loadRecordingSession(filePath);
912
938
  const wsRecording = session.websocketRecordings.find(
913
939
  (r) => r.key === key
914
940
  );
@@ -974,25 +1000,25 @@ var ProxyServer = class {
974
1000
  console.log("Replay WebSocket closed");
975
1001
  });
976
1002
  });
977
- }).catch((error) => {
1003
+ } catch (error) {
978
1004
  console.error("Replay error:", error);
979
1005
  socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
980
1006
  socket.destroy();
981
- });
1007
+ }
982
1008
  }
983
1009
  logServerStartup(port2) {
984
1010
  console.log(`Proxy server running on http://localhost:${port2}`);
985
1011
  console.log(`Mode: ${this.mode}`);
986
- console.log(`Targets: ${this.targets.join(", ")}`);
1012
+ console.log(`Target: ${this.target}`);
987
1013
  console.log(
988
1014
  `Control endpoint: http://localhost:${port2}${CONTROL_ENDPOINT}`
989
1015
  );
990
1016
  }
991
1017
  };
992
1018
 
993
- // src/proxy.ts
994
- var { targets, port, recordingsDir } = parseCliArgs();
995
- var proxy = new ProxyServer(targets, recordingsDir);
1019
+ // src/proxy-cli.ts
1020
+ var { target, port, recordingsDir, timeout } = parseCliArgs();
1021
+ var proxy = new ProxyServer(target, recordingsDir, timeout);
996
1022
  await proxy.init();
997
1023
  proxy.listen(port);
998
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.3",
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",