test-proxy-recorder 0.1.2 → 0.1.4

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
@@ -2,8 +2,10 @@ import path from 'path';
2
2
  import { Command } from 'commander';
3
3
  import fs from 'fs/promises';
4
4
  import http from 'http';
5
+ import https from 'https';
5
6
  import httpProxy from 'http-proxy';
6
7
  import { WebSocket, WebSocketServer } from 'ws';
8
+ import filenamify from 'filenamify';
7
9
 
8
10
  // src/cli.ts
9
11
  var DEFAULT_PORT = 8e3;
@@ -72,6 +74,24 @@ async function saveRecordingSession(recordingsDir2, session) {
72
74
  `Saved ${session.recordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
73
75
  );
74
76
  }
77
+ var QUERY_HASH_LENGTH = 8;
78
+ function getReqID(req) {
79
+ const urlParts = req.url.split("?");
80
+ const pathname = urlParts[0];
81
+ const query = urlParts[1] || "";
82
+ const pathPart = pathname === "/" ? "root" : pathname.slice(1);
83
+ const normalizedPath = filenamify(pathPart, { replacement: "_" });
84
+ const queryHash = generateQueryHash(query);
85
+ const filename = `${req.method}_${normalizedPath}${queryHash}.json`;
86
+ return filenamify(filename, { replacement: "_" });
87
+ }
88
+ function generateQueryHash(query) {
89
+ if (!query) {
90
+ return "";
91
+ }
92
+ const hash = Buffer.from(query).toString("base64").replaceAll(/[^a-zA-Z0-9]/g, "").slice(0, Math.max(0, QUERY_HASH_LENGTH));
93
+ return `_${hash}`;
94
+ }
75
95
 
76
96
  // src/utils/httpHelpers.ts
77
97
  var CONTENT_TYPE_JSON = "application/json";
@@ -87,28 +107,6 @@ function sendJsonResponse(res, statusCode, data) {
87
107
  res.end(JSON.stringify(data));
88
108
  }
89
109
 
90
- // src/utils/requestKeyGenerator.ts
91
- var QUERY_HASH_LENGTH = 8;
92
- function generateRequestKey(req) {
93
- const urlParts = req.url.split("?");
94
- const pathname = urlParts[0];
95
- const query = urlParts[1] || "";
96
- const normalizedPath = normalizePathname(pathname);
97
- const queryHash = generateQueryHash(query);
98
- return `${req.method}_${normalizedPath}${queryHash}.json`;
99
- }
100
- function normalizePathname(pathname) {
101
- const normalized = pathname.replaceAll("/", "_").replace(/^_/, "");
102
- return normalized || "root";
103
- }
104
- function generateQueryHash(query) {
105
- if (!query) {
106
- return "";
107
- }
108
- const hash = Buffer.from(query).toString("base64").replaceAll(/[^a-zA-Z0-9]/g, "").slice(0, Math.max(0, QUERY_HASH_LENGTH));
109
- return `_${hash}`;
110
- }
111
-
112
110
  // src/ProxyServer.ts
113
111
  var ProxyServer = class {
114
112
  targets;
@@ -120,6 +118,10 @@ var ProxyServer = class {
120
118
  proxy;
121
119
  currentSession;
122
120
  recordingsDir;
121
+ requestSequenceMap;
122
+ // Track sequence per request key
123
+ replaySequenceMap;
124
+ // Track replay position per request key
123
125
  constructor(targets2, recordingsDir2) {
124
126
  this.targets = targets2;
125
127
  this.currentTargetIndex = 0;
@@ -129,6 +131,8 @@ var ProxyServer = class {
129
131
  this.modeTimeout = null;
130
132
  this.currentSession = null;
131
133
  this.recordingsDir = recordingsDir2;
134
+ this.requestSequenceMap = /* @__PURE__ */ new Map();
135
+ this.replaySequenceMap = /* @__PURE__ */ new Map();
132
136
  this.proxy = httpProxy.createProxyServer({
133
137
  secure: false,
134
138
  changeOrigin: true
@@ -167,10 +171,19 @@ var ProxyServer = class {
167
171
  res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
168
172
  }
169
173
  handleProxyResponse(proxyRes, req) {
174
+ this.addCorsHeaders(proxyRes, req);
170
175
  if (this.mode === Modes.record && this.recordingId) {
171
176
  this.recordResponse(req, proxyRes);
172
177
  }
173
178
  }
179
+ addCorsHeaders(proxyRes, req) {
180
+ const origin = req.headers.origin;
181
+ proxyRes.headers["access-control-allow-origin"] = origin || "*";
182
+ proxyRes.headers["access-control-allow-credentials"] = "true";
183
+ proxyRes.headers["access-control-allow-headers"] = req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization";
184
+ proxyRes.headers["access-control-allow-methods"] = "GET, POST, PUT, DELETE, PATCH, OPTIONS";
185
+ proxyRes.headers["access-control-expose-headers"] = "*";
186
+ }
174
187
  getTarget() {
175
188
  const target = this.targets[this.currentTargetIndex];
176
189
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
@@ -234,6 +247,7 @@ var ProxyServer = class {
234
247
  this.recordingId = null;
235
248
  this.replayId = null;
236
249
  this.currentSession = null;
250
+ clearTimeout(this.modeTimeout || 0);
237
251
  console.log("Switched to transparent mode");
238
252
  }
239
253
  switchToRecordMode(id) {
@@ -244,6 +258,7 @@ var ProxyServer = class {
244
258
  this.recordingId = id;
245
259
  this.replayId = null;
246
260
  this.currentSession = { id, recordings: [], websocketRecordings: [] };
261
+ this.requestSequenceMap.clear();
247
262
  console.log(`Switched to record mode with ID: ${id}`);
248
263
  }
249
264
  switchToReplayMode(id) {
@@ -254,6 +269,7 @@ var ProxyServer = class {
254
269
  this.replayId = id;
255
270
  this.recordingId = null;
256
271
  this.currentSession = null;
272
+ this.replaySequenceMap.clear();
257
273
  console.log(`Switched to replay mode with ID: ${id}`);
258
274
  }
259
275
  setupModeTimeout(timeout) {
@@ -284,7 +300,9 @@ var ProxyServer = class {
284
300
  if (!this.currentSession) {
285
301
  return;
286
302
  }
287
- const key = generateRequestKey(req);
303
+ const key = getReqID(req);
304
+ const currentSequence = this.requestSequenceMap.get(key) || 0;
305
+ this.requestSequenceMap.set(key, currentSequence + 1);
288
306
  const record = {
289
307
  request: {
290
308
  method: req.method,
@@ -293,7 +311,8 @@ var ProxyServer = class {
293
311
  body: body || null
294
312
  },
295
313
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
296
- key
314
+ key,
315
+ sequence: currentSequence
297
316
  };
298
317
  this.currentSession.recordings.push(record);
299
318
  }
@@ -301,8 +320,10 @@ var ProxyServer = class {
301
320
  if (!this.currentSession) {
302
321
  return;
303
322
  }
304
- const key = generateRequestKey(req);
305
- const record = this.currentSession.recordings.find((r) => r.key === key);
323
+ const key = getReqID(req);
324
+ const record = this.currentSession.recordings.findLast(
325
+ (r) => r.key === key && !r.response
326
+ );
306
327
  if (!record) {
307
328
  console.error("Request record not found for response:", key);
308
329
  return;
@@ -323,21 +344,35 @@ var ProxyServer = class {
323
344
  });
324
345
  }
325
346
  async handleReplayRequest(req, res) {
326
- const key = generateRequestKey(req);
347
+ const key = getReqID(req);
327
348
  const filePath = getRecordingPath(this.recordingsDir, this.replayId);
328
349
  try {
329
350
  const session = await loadRecordingSession(filePath);
330
- const record = session.recordings.find((r) => r.key === key);
351
+ const currentSequence = this.replaySequenceMap.get(key) || 0;
352
+ const record = session.recordings.find(
353
+ (r) => r.key === key && r.sequence === currentSequence
354
+ );
331
355
  if (!record) {
332
- throw new Error(`No recording found for ${key}`);
356
+ throw new Error(
357
+ `No recording found for ${key} with sequence ${currentSequence}`
358
+ );
333
359
  }
334
360
  if (!record.response) {
335
361
  throw new Error("No response recorded for this request");
336
362
  }
363
+ this.replaySequenceMap.set(key, currentSequence + 1);
337
364
  const { statusCode, headers, body } = record.response;
338
- res.writeHead(statusCode, headers);
365
+ const origin = req.headers.origin;
366
+ const responseHeaders = {
367
+ ...headers,
368
+ "access-control-allow-origin": origin || "*",
369
+ "access-control-allow-credentials": "true"
370
+ };
371
+ res.writeHead(statusCode, responseHeaders);
339
372
  res.end(body);
340
- console.log(`Replayed: ${req.method} ${req.url}`);
373
+ console.log(
374
+ `Replayed: ${req.method} ${req.url} (sequence: ${currentSequence})`
375
+ );
341
376
  } catch (error) {
342
377
  this.handleReplayError(res, error, key, filePath);
343
378
  }
@@ -353,6 +388,9 @@ var ProxyServer = class {
353
388
  });
354
389
  }
355
390
  async handleRequest(req, res) {
391
+ if (req.method === "OPTIONS") {
392
+ return this.handleCorsPreflightRequest(req, res);
393
+ }
356
394
  if (req.url === CONTROL_ENDPOINT) {
357
395
  return this.handleControlRequest(req, res);
358
396
  }
@@ -361,23 +399,63 @@ var ProxyServer = class {
361
399
  }
362
400
  await this.handleProxyRequest(req, res);
363
401
  }
402
+ handleCorsPreflightRequest(req, res) {
403
+ const origin = req.headers.origin;
404
+ res.writeHead(HTTP_STATUS_OK, {
405
+ "Access-Control-Allow-Origin": origin || "*",
406
+ "Access-Control-Allow-Credentials": "true",
407
+ "Access-Control-Allow-Headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
408
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
409
+ "Access-Control-Max-Age": "86400"
410
+ // 24 hours
411
+ });
412
+ res.end();
413
+ }
364
414
  async handleProxyRequest(req, res) {
365
415
  const target = this.getTarget();
366
416
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
367
417
  if (this.mode === Modes.record) {
368
- await this.bufferRequestForRecord(req);
418
+ await this.bufferAndProxyRequest(req, res, target);
419
+ } else {
420
+ this.proxy.web(req, res, { target });
369
421
  }
370
- this.proxy.web(req, res, { target });
371
422
  }
372
- async bufferRequestForRecord(req) {
423
+ async bufferAndProxyRequest(req, res, target) {
373
424
  const chunks = [];
374
425
  req.on("data", (chunk) => {
375
426
  chunks.push(chunk);
376
427
  });
377
- req.on("end", async () => {
378
- const body = Buffer.concat(chunks).toString("utf8");
379
- await this.saveRequestRecord(req, body);
428
+ await new Promise((resolve) => {
429
+ req.on("end", () => resolve());
380
430
  });
431
+ const body = Buffer.concat(chunks).toString("utf8");
432
+ await this.saveRequestRecord(req, body);
433
+ const targetUrl = new URL(target);
434
+ const isHttps = targetUrl.protocol === "https:";
435
+ const requestModule = isHttps ? https : http;
436
+ const defaultPort = isHttps ? 443 : 80;
437
+ const proxyReq = requestModule.request(
438
+ {
439
+ hostname: targetUrl.hostname,
440
+ port: targetUrl.port || defaultPort,
441
+ path: req.url,
442
+ method: req.method,
443
+ headers: req.headers
444
+ },
445
+ (proxyRes) => {
446
+ this.addCorsHeaders(proxyRes, req);
447
+ this.recordResponse(req, proxyRes);
448
+ res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
449
+ proxyRes.pipe(res);
450
+ }
451
+ );
452
+ proxyReq.on("error", (err) => {
453
+ this.handleProxyError(err, req, res);
454
+ });
455
+ if (chunks.length > 0) {
456
+ proxyReq.write(Buffer.concat(chunks));
457
+ }
458
+ proxyReq.end();
381
459
  }
382
460
  handleUpgrade(req, socket, head) {
383
461
  if (this.mode === Modes.replay) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-proxy-recorder",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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",