test-proxy-recorder 0.1.5 → 0.1.7

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
@@ -127,14 +127,16 @@ var ProxyServer = class {
127
127
  this.proxy.on("error", this.handleProxyError.bind(this));
128
128
  this.proxy.on("proxyRes", this.handleProxyResponse.bind(this));
129
129
  }
130
- handleProxyError(err, _req, res) {
130
+ handleProxyError(err, req, res) {
131
131
  console.error("Proxy error:", err);
132
132
  if (!(res instanceof http.ServerResponse)) {
133
133
  return;
134
134
  }
135
135
  if (!res.headersSent) {
136
+ const corsHeaders = this.getCorsHeaders(req);
136
137
  res.writeHead(HTTP_STATUS_BAD_GATEWAY, {
137
- "Content-Type": "application/json"
138
+ "Content-Type": "application/json",
139
+ ...corsHeaders
138
140
  });
139
141
  }
140
142
  res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
@@ -145,24 +147,51 @@ var ProxyServer = class {
145
147
  this.recordResponse(req, proxyRes);
146
148
  }
147
149
  }
148
- addCorsHeaders(proxyRes, req) {
150
+ /**
151
+ * Get CORS headers for a given request
152
+ * @param req The incoming HTTP request
153
+ * @returns An object containing CORS headers
154
+ */
155
+ getCorsHeaders(req) {
149
156
  const origin = req.headers.origin;
150
- proxyRes.headers["access-control-allow-origin"] = origin || "*";
151
- proxyRes.headers["access-control-allow-credentials"] = "true";
152
- proxyRes.headers["access-control-allow-headers"] = req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization";
153
- proxyRes.headers["access-control-allow-methods"] = "GET, POST, PUT, DELETE, PATCH, OPTIONS";
154
- proxyRes.headers["access-control-expose-headers"] = "*";
157
+ return {
158
+ "access-control-allow-origin": origin || "*",
159
+ "access-control-allow-credentials": "true",
160
+ "access-control-allow-headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
161
+ "access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
162
+ "access-control-expose-headers": "*"
163
+ };
164
+ }
165
+ addCorsHeaders(proxyRes, req) {
166
+ const corsHeaders = this.getCorsHeaders(req);
167
+ Object.assign(proxyRes.headers, corsHeaders);
155
168
  }
156
169
  getTarget() {
157
170
  const target = this.targets[this.currentTargetIndex];
158
171
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
159
172
  return target;
160
173
  }
174
+ parseGetParams(req) {
175
+ const url = new URL(req.url || "", `http://${req.headers.host}`);
176
+ const mode = url.searchParams.get("mode");
177
+ const id = url.searchParams.get("id") || void 0;
178
+ const timeoutParam = url.searchParams.get("timeout");
179
+ const timeout = timeoutParam ? Number.parseInt(timeoutParam, 10) : void 0;
180
+ if (!mode) {
181
+ throw new Error("Mode parameter is required");
182
+ }
183
+ return { mode, id, timeout };
184
+ }
161
185
  async handleControlRequest(req, res) {
162
186
  try {
163
- const body = await readRequestBody(req);
164
- console.log("MODE CHANGE", body);
165
- const data = JSON.parse(body);
187
+ let data;
188
+ if (req.method === "GET") {
189
+ data = this.parseGetParams(req);
190
+ } else {
191
+ const body = await readRequestBody(req);
192
+ console.log("MODE CHANGE (POST)", body);
193
+ data = JSON.parse(body);
194
+ }
166
195
  const { mode, id, timeout: requestTimeout } = data;
167
196
  const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
168
197
  this.clearModeTimeout();
@@ -190,7 +219,7 @@ var ProxyServer = class {
190
219
  async switchMode(mode, id) {
191
220
  if (this.currentSession) {
192
221
  console.log("Switching mode, saving current session first");
193
- await this.saveCurrentSession();
222
+ await this.saveCurrentSession(true);
194
223
  console.log("Session saved, continuing with mode switch");
195
224
  }
196
225
  switch (mode) {
@@ -245,33 +274,41 @@ var ProxyServer = class {
245
274
  if (timeout && timeout > 0) {
246
275
  this.modeTimeout = setTimeout(async () => {
247
276
  console.log("Timeout reached, switching back to transparent mode");
248
- await this.saveCurrentSession();
277
+ await this.saveCurrentSession(true);
249
278
  this.switchToTransparentMode();
250
279
  this.modeTimeout = null;
251
280
  }, timeout);
252
281
  }
253
282
  }
254
- async saveCurrentSession() {
283
+ async saveCurrentSession(filterIncomplete = false) {
255
284
  if (!this.currentSession) {
256
285
  console.log("No current session to save");
257
286
  return;
258
287
  }
259
- if (this.currentSession.recordings.length === 0 && this.currentSession.websocketRecordings.length === 0) {
260
- console.log("Session has no recordings, skipping save");
261
- return;
288
+ if (filterIncomplete) {
289
+ const incompleteCount = this.currentSession.recordings.filter(
290
+ (r) => !r.response
291
+ ).length;
292
+ if (incompleteCount > 0) {
293
+ console.log(
294
+ `Removing ${incompleteCount} incomplete recording(s) without responses`
295
+ );
296
+ this.currentSession.recordings = this.currentSession.recordings.filter(
297
+ (r) => r.response
298
+ );
299
+ }
262
300
  }
263
301
  console.log(
264
302
  `Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
265
303
  );
266
304
  await saveRecordingSession(this.recordingsDir, this.currentSession);
267
305
  }
268
- async saveRequestRecord(req, body) {
306
+ saveRequestRecordSync(req, body) {
269
307
  if (!this.currentSession) {
308
+ console.log("saveRequestRecordSync: No current session");
270
309
  return;
271
310
  }
272
311
  const key = getReqID(req);
273
- const currentSequence = this.requestSequenceMap.get(key) || 0;
274
- this.requestSequenceMap.set(key, currentSequence + 1);
275
312
  const record = {
276
313
  request: {
277
314
  method: req.method,
@@ -281,9 +318,34 @@ var ProxyServer = class {
281
318
  },
282
319
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
283
320
  key,
284
- sequence: currentSequence
321
+ sequence: -1
322
+ // Temporary, will be set when response arrives
285
323
  };
286
324
  this.currentSession.recordings.push(record);
325
+ console.log(
326
+ // eslint-disable-next-line sonarjs/no-nested-template-literals
327
+ `saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key}, body: ${body ? `${body.length} chars` : "null"}, total: ${this.currentSession.recordings.length}, sessionId: ${this.currentSession.id})`
328
+ );
329
+ }
330
+ updateRequestBodySync(req, body) {
331
+ if (!this.currentSession) {
332
+ console.log("updateRequestBodySync: No current session");
333
+ return;
334
+ }
335
+ const key = getReqID(req);
336
+ const record = this.currentSession.recordings.findLast(
337
+ (r) => r.key === key && !r.response
338
+ );
339
+ if (!record) {
340
+ console.error(
341
+ `updateRequestBodySync: Could not find request record for ${req.method} ${req.url}`
342
+ );
343
+ return;
344
+ }
345
+ record.request.body = body || null;
346
+ console.log(
347
+ `updateRequestBodySync: Updated body for ${req.method} ${req.url} (${body.length} chars)`
348
+ );
287
349
  }
288
350
  async recordResponse(req, proxyRes) {
289
351
  if (!this.currentSession) {
@@ -308,59 +370,115 @@ var ProxyServer = class {
308
370
  headers: proxyRes.headers,
309
371
  body: body || null
310
372
  };
311
- await this.saveCurrentSession();
312
373
  console.log(`Recorded: ${req.method} ${req.url}`);
313
374
  });
314
375
  }
376
+ async recordResponseData(req, proxyRes, body) {
377
+ if (!this.currentSession) {
378
+ console.log("recordResponseData: No current session");
379
+ return false;
380
+ }
381
+ const key = getReqID(req);
382
+ const record = this.currentSession.recordings.findLast(
383
+ (r) => r.key === key && !r.response
384
+ );
385
+ if (!record) {
386
+ const host = req.headers.host || "unknown";
387
+ const recordsWithKey = this.currentSession.recordings.filter(
388
+ (r) => r.key === key
389
+ );
390
+ console.error(
391
+ `Request record not found for response: ${key} at ${req.method} ${host}${req.url}`
392
+ );
393
+ console.error(
394
+ ` Total recordings: ${this.currentSession.recordings.length}, with this key: ${recordsWithKey.length}`
395
+ );
396
+ console.error(
397
+ ` Records with key:`,
398
+ recordsWithKey.map((r) => ({
399
+ seq: r.sequence,
400
+ hasResponse: !!r.response
401
+ }))
402
+ );
403
+ return false;
404
+ }
405
+ record.response = {
406
+ statusCode: proxyRes.statusCode,
407
+ headers: proxyRes.headers,
408
+ body: body || null
409
+ };
410
+ record.timestamp = (/* @__PURE__ */ new Date()).toISOString();
411
+ const currentSequence = this.requestSequenceMap.get(key) || 0;
412
+ record.sequence = currentSequence;
413
+ this.requestSequenceMap.set(key, currentSequence + 1);
414
+ console.log(
415
+ `recordResponseData: Recorded response for ${req.method} ${req.url} (seq: ${record.sequence})`
416
+ );
417
+ return true;
418
+ }
315
419
  async handleReplayRequest(req, res) {
316
420
  const key = getReqID(req);
317
421
  const filePath = getRecordingPath(this.recordingsDir, this.replayId);
318
422
  try {
319
423
  const session = await loadRecordingSession(filePath);
320
- const currentSequence = this.replaySequenceMap.get(key) || 0;
321
- const record = session.recordings.find(
322
- (r) => r.key === key && r.sequence === currentSequence
323
- );
324
- if (!record) {
424
+ const host = req.headers.host || "unknown";
425
+ const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.sequence - b.sequence);
426
+ if (recordsWithKey.length === 0) {
325
427
  throw new Error(
326
- `No recording found for ${key} with sequence ${currentSequence}`
428
+ `No recording found for ${key} at ${req.method} ${host}${req.url}`
327
429
  );
328
430
  }
431
+ const usageCount = this.replaySequenceMap.get(key) || 0;
432
+ let record;
433
+ if (recordsWithKey.length > 1) {
434
+ record = recordsWithKey[recordsWithKey.length - 1];
435
+ } else {
436
+ const recordIndex = usageCount % recordsWithKey.length;
437
+ record = recordsWithKey[recordIndex];
438
+ }
439
+ console.log(
440
+ `Replaying ${req.method} ${req.url} (usage: ${usageCount}, sequence: ${record.sequence}, body_len: ${record.response?.body?.length || 0})`
441
+ );
442
+ this.replaySequenceMap.set(key, usageCount + 1);
329
443
  if (!record.response) {
330
- throw new Error("No response recorded for this request");
444
+ throw new Error(
445
+ `No response recorded for this request: ${req.method} ${host}${req.url}`
446
+ );
331
447
  }
332
- this.replaySequenceMap.set(key, currentSequence + 1);
333
448
  const { statusCode, headers, body } = record.response;
334
- const origin = req.headers.origin;
335
449
  const responseHeaders = {
336
450
  ...headers,
337
- "access-control-allow-origin": origin || "*",
338
- "access-control-allow-credentials": "true"
451
+ ...this.getCorsHeaders(req)
339
452
  };
340
453
  res.writeHead(statusCode, responseHeaders);
341
454
  res.end(body);
342
- console.log(
343
- `Replayed: ${req.method} ${req.url} (sequence: ${currentSequence})`
344
- );
345
455
  } catch (error) {
346
- this.handleReplayError(res, error, key, filePath);
456
+ this.handleReplayError(req, res, error, key, filePath);
347
457
  }
348
458
  }
349
- handleReplayError(res, err, key, filePath) {
459
+ handleReplayError(req, res, err, key, filePath) {
350
460
  const isFileNotFound = err instanceof Error && "code" in err && err.code === "ENOENT";
351
461
  console.error("Replay error:", err);
352
- sendJsonResponse(res, HTTP_STATUS_NOT_FOUND, {
353
- error: isFileNotFound ? "Recording file not found" : "Recording not found",
354
- message: err instanceof Error ? err.message : "Unknown error",
355
- key,
356
- filePath
462
+ const corsHeaders = this.getCorsHeaders(req);
463
+ res.writeHead(HTTP_STATUS_NOT_FOUND, {
464
+ "Content-Type": "application/json",
465
+ ...corsHeaders
357
466
  });
467
+ res.end(
468
+ JSON.stringify({
469
+ error: isFileNotFound ? "Recording file not found" : "Recording not found",
470
+ message: err instanceof Error ? err.message : "Unknown error",
471
+ key,
472
+ filePath
473
+ })
474
+ );
358
475
  }
359
476
  async handleRequest(req, res) {
360
477
  if (req.method === "OPTIONS") {
361
478
  return this.handleCorsPreflightRequest(req, res);
362
479
  }
363
- if (req.url === CONTROL_ENDPOINT) {
480
+ const urlPath = req.url?.split("?")[0] || "";
481
+ if (urlPath === CONTROL_ENDPOINT) {
364
482
  return this.handleControlRequest(req, res);
365
483
  }
366
484
  if (this.mode === Modes.replay) {
@@ -369,12 +487,9 @@ var ProxyServer = class {
369
487
  await this.handleProxyRequest(req, res);
370
488
  }
371
489
  handleCorsPreflightRequest(req, res) {
372
- const origin = req.headers.origin;
490
+ const corsHeaders = this.getCorsHeaders(req);
373
491
  res.writeHead(HTTP_STATUS_OK, {
374
- "Access-Control-Allow-Origin": origin || "*",
375
- "Access-Control-Allow-Credentials": "true",
376
- "Access-Control-Allow-Headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
377
- "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
492
+ ...corsHeaders,
378
493
  "Access-Control-Max-Age": "86400"
379
494
  // 24 hours
380
495
  });
@@ -384,6 +499,7 @@ var ProxyServer = class {
384
499
  const target = this.getTarget();
385
500
  console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
386
501
  if (this.mode === Modes.record) {
502
+ this.saveRequestRecordSync(req, null);
387
503
  await this.bufferAndProxyRequest(req, res, target);
388
504
  } else {
389
505
  this.proxy.web(req, res, { target });
@@ -394,11 +510,20 @@ var ProxyServer = class {
394
510
  req.on("data", (chunk) => {
395
511
  chunks.push(chunk);
396
512
  });
397
- await new Promise((resolve) => {
398
- req.on("end", () => resolve());
399
- });
513
+ try {
514
+ await new Promise((resolve, reject) => {
515
+ req.on("end", () => resolve());
516
+ req.on("error", (err) => reject(err));
517
+ setTimeout(
518
+ () => reject(new Error("Request buffering timeout")),
519
+ 3e4
520
+ );
521
+ });
522
+ } catch (error) {
523
+ console.error("Error buffering request:", error);
524
+ }
400
525
  const body = Buffer.concat(chunks).toString("utf8");
401
- await this.saveRequestRecord(req, body);
526
+ this.updateRequestBodySync(req, body);
402
527
  const targetUrl = new URL(target);
403
528
  const isHttps = targetUrl.protocol === "https:";
404
529
  const requestModule = isHttps ? https : http;
@@ -413,9 +538,33 @@ var ProxyServer = class {
413
538
  },
414
539
  (proxyRes) => {
415
540
  this.addCorsHeaders(proxyRes, req);
416
- this.recordResponse(req, proxyRes);
417
- res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
418
- proxyRes.pipe(res);
541
+ const responseChunks = [];
542
+ proxyRes.on("data", (chunk) => {
543
+ responseChunks.push(chunk);
544
+ });
545
+ proxyRes.on("end", async () => {
546
+ const responseBody = Buffer.concat(responseChunks);
547
+ const recorded = await this.recordResponseData(
548
+ req,
549
+ proxyRes,
550
+ responseBody.toString("utf8")
551
+ );
552
+ const responseHeaders = {
553
+ ...proxyRes.headers,
554
+ ...this.getCorsHeaders(req)
555
+ };
556
+ res.writeHead(proxyRes.statusCode || 200, responseHeaders);
557
+ res.end(responseBody);
558
+ if (recorded) {
559
+ console.log(`Recorded: ${req.method} ${req.url}`);
560
+ }
561
+ });
562
+ proxyRes.on("error", (err) => {
563
+ console.error("Proxy response error:", err);
564
+ if (!res.headersSent) {
565
+ this.handleProxyError(err, req, res);
566
+ }
567
+ });
419
568
  }
420
569
  );
421
570
  proxyReq.on("error", (err) => {
@@ -467,9 +616,6 @@ var ProxyServer = class {
467
616
  if (backendWs.readyState === WebSocket.OPEN) {
468
617
  backendWs.send(message);
469
618
  }
470
- this.saveCurrentSession().catch((error) => {
471
- console.error("Failed to save WebSocket recording:", error);
472
- });
473
619
  });
474
620
  backendWs.on("message", (data) => {
475
621
  const message = data.toString();
@@ -481,9 +627,6 @@ var ProxyServer = class {
481
627
  if (clientWs.readyState === WebSocket.OPEN) {
482
628
  clientWs.send(message);
483
629
  }
484
- this.saveCurrentSession().catch((error) => {
485
- console.error("Failed to save WebSocket recording:", error);
486
- });
487
630
  });
488
631
  clientWs.on("error", (err) => {
489
632
  console.error("Client WebSocket error:", err);