test-proxy-recorder 0.3.0 → 0.3.1
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/README.md +4 -4
- package/dist/index.cjs +214 -243
- package/dist/index.d.cts +9 -6
- package/dist/index.d.ts +9 -6
- package/dist/index.mjs +214 -243
- package/dist/playwright/index.cjs +1 -1
- package/dist/playwright/index.mjs +1 -1
- package/dist/proxy.js +215 -244
- package/package.json +1 -1
package/dist/proxy.js
CHANGED
|
@@ -23,7 +23,7 @@ function parseCliArgs() {
|
|
|
23
23
|
"Port number for the proxy server",
|
|
24
24
|
String(DEFAULT_PORT)
|
|
25
25
|
).option(
|
|
26
|
-
"-
|
|
26
|
+
"-d, --dir <path>",
|
|
27
27
|
"Directory to store recordings (relative to CWD)",
|
|
28
28
|
DEFAULT_RECORDINGS_DIR
|
|
29
29
|
).action(() => {
|
|
@@ -39,7 +39,7 @@ function parseCliArgs() {
|
|
|
39
39
|
if (targets2.length === 0) {
|
|
40
40
|
program.help();
|
|
41
41
|
}
|
|
42
|
-
const recordingsDir2 = path.resolve(process.cwd(), options.
|
|
42
|
+
const recordingsDir2 = path.resolve(process.cwd(), options.dir);
|
|
43
43
|
return { targets: targets2, port: port2, recordingsDir: recordingsDir2 };
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -84,13 +84,23 @@ async function loadRecordingSession(filePath) {
|
|
|
84
84
|
return JSON.parse(fileContent);
|
|
85
85
|
}
|
|
86
86
|
function processRecordings(recordings) {
|
|
87
|
-
const
|
|
88
|
-
|
|
87
|
+
const recordingsByKey = /* @__PURE__ */ new Map();
|
|
88
|
+
for (const recording of recordings) {
|
|
89
89
|
const key = recording.key;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
if (!recordingsByKey.has(key)) {
|
|
91
|
+
recordingsByKey.set(key, []);
|
|
92
|
+
}
|
|
93
|
+
recordingsByKey.get(key).push(recording);
|
|
94
|
+
}
|
|
95
|
+
const processedRecordings = [];
|
|
96
|
+
for (const [_key, keyRecordings] of recordingsByKey) {
|
|
97
|
+
keyRecordings.sort((a, b) => a.recordingId - b.recordingId);
|
|
98
|
+
keyRecordings.forEach((recording, index) => {
|
|
99
|
+
processedRecordings.push({ ...recording, sequence: index });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
processedRecordings.sort((a, b) => a.recordingId - b.recordingId);
|
|
103
|
+
return processedRecordings;
|
|
94
104
|
}
|
|
95
105
|
async function saveRecordingSession(recordingsDir2, session) {
|
|
96
106
|
const filePath = getRecordingPath(recordingsDir2, session.id);
|
|
@@ -153,19 +163,25 @@ var ProxyServer = class {
|
|
|
153
163
|
recordingsDir;
|
|
154
164
|
recordingIdCounter;
|
|
155
165
|
// Unique ID for each recording entry
|
|
166
|
+
sequenceCounterByKey;
|
|
167
|
+
// Sequence counter per key (endpoint)
|
|
156
168
|
replaySessions;
|
|
157
169
|
// Track multiple concurrent replay sessions by recording ID
|
|
170
|
+
recordingPromises;
|
|
171
|
+
// Stack of promises that resolve to completed recordings
|
|
158
172
|
constructor(targets2, recordingsDir2) {
|
|
159
173
|
this.targets = targets2;
|
|
160
174
|
this.currentTargetIndex = 0;
|
|
161
175
|
this.mode = Modes.transparent;
|
|
162
176
|
this.recordingId = null;
|
|
163
177
|
this.recordingIdCounter = 0;
|
|
178
|
+
this.sequenceCounterByKey = /* @__PURE__ */ new Map();
|
|
164
179
|
this.replayId = null;
|
|
165
180
|
this.modeTimeout = null;
|
|
166
181
|
this.currentSession = null;
|
|
167
182
|
this.recordingsDir = recordingsDir2;
|
|
168
183
|
this.replaySessions = /* @__PURE__ */ new Map();
|
|
184
|
+
this.recordingPromises = [];
|
|
169
185
|
this.proxy = httpProxy.createProxyServer({
|
|
170
186
|
secure: false,
|
|
171
187
|
changeOrigin: true,
|
|
@@ -191,7 +207,7 @@ var ProxyServer = class {
|
|
|
191
207
|
}
|
|
192
208
|
setupProxyEventHandlers() {
|
|
193
209
|
this.proxy.on("error", this.handleProxyError.bind(this));
|
|
194
|
-
this.proxy.on("proxyRes", this.
|
|
210
|
+
this.proxy.on("proxyRes", this.addCorsHeaders.bind(this));
|
|
195
211
|
}
|
|
196
212
|
handleProxyError(err, req, res) {
|
|
197
213
|
console.error("Proxy error:", err);
|
|
@@ -207,12 +223,6 @@ var ProxyServer = class {
|
|
|
207
223
|
}
|
|
208
224
|
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
209
225
|
}
|
|
210
|
-
handleProxyResponse(proxyRes, req) {
|
|
211
|
-
this.addCorsHeaders(proxyRes, req);
|
|
212
|
-
if (this.mode === Modes.record && this.recordingId) {
|
|
213
|
-
this.recordResponse(req, proxyRes);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
226
|
/**
|
|
217
227
|
* Get CORS headers for a given request
|
|
218
228
|
* @param req The incoming HTTP request
|
|
@@ -306,18 +316,20 @@ var ProxyServer = class {
|
|
|
306
316
|
}
|
|
307
317
|
return { mode, id, timeout };
|
|
308
318
|
}
|
|
319
|
+
async parseControlRequest(req) {
|
|
320
|
+
if (req.method === "GET") {
|
|
321
|
+
return this.parseGetParams(req);
|
|
322
|
+
}
|
|
323
|
+
if (req.method === "POST") {
|
|
324
|
+
const body = await readRequestBody(req);
|
|
325
|
+
console.log(`MODE CHANGE (${req.method})`, body);
|
|
326
|
+
return JSON.parse(body);
|
|
327
|
+
}
|
|
328
|
+
throw new Error("Unsupported control method");
|
|
329
|
+
}
|
|
309
330
|
async handleControlRequest(req, res) {
|
|
310
331
|
try {
|
|
311
|
-
|
|
312
|
-
if (req.method === "GET") {
|
|
313
|
-
data = this.parseGetParams(req);
|
|
314
|
-
} else if (req.method === "POST") {
|
|
315
|
-
const body = await readRequestBody(req);
|
|
316
|
-
console.log(`MODE CHANGE (${req.method})`, body);
|
|
317
|
-
data = JSON.parse(body);
|
|
318
|
-
} else {
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
332
|
+
const data = await this.parseControlRequest(req);
|
|
321
333
|
const { mode, id, timeout: requestTimeout } = data;
|
|
322
334
|
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
323
335
|
this.clearModeTimeout();
|
|
@@ -350,7 +362,7 @@ var ProxyServer = class {
|
|
|
350
362
|
async switchMode(mode, id) {
|
|
351
363
|
console.log(`Switching to ${mode.toUpperCase()} mode`);
|
|
352
364
|
if (this.currentSession && this.mode === Modes.record) {
|
|
353
|
-
await this.saveCurrentSession(
|
|
365
|
+
await this.saveCurrentSession();
|
|
354
366
|
console.log("Session saved, continuing with mode switch");
|
|
355
367
|
}
|
|
356
368
|
switch (mode) {
|
|
@@ -390,6 +402,8 @@ var ProxyServer = class {
|
|
|
390
402
|
this.recordingId = id;
|
|
391
403
|
this.replayId = null;
|
|
392
404
|
this.currentSession = { id, recordings: [], websocketRecordings: [] };
|
|
405
|
+
this.recordingIdCounter = 0;
|
|
406
|
+
this.sequenceCounterByKey.clear();
|
|
393
407
|
console.log(`Switched to record mode with ID: ${id}`);
|
|
394
408
|
}
|
|
395
409
|
async switchToReplayMode(id) {
|
|
@@ -409,146 +423,39 @@ var ProxyServer = class {
|
|
|
409
423
|
setupModeTimeout(timeout) {
|
|
410
424
|
this.modeTimeout = setTimeout(async () => {
|
|
411
425
|
console.log("Timeout reached, switching back to transparent mode");
|
|
412
|
-
await this.saveCurrentSession(
|
|
426
|
+
await this.saveCurrentSession();
|
|
413
427
|
this.switchToTransparentMode();
|
|
414
428
|
this.modeTimeout = null;
|
|
415
429
|
}, timeout);
|
|
416
430
|
}
|
|
417
|
-
async
|
|
418
|
-
if (
|
|
431
|
+
async flushPendingRecordings() {
|
|
432
|
+
if (this.recordingPromises.length === 0) {
|
|
419
433
|
return;
|
|
420
434
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
(r) => r.response
|
|
428
|
-
);
|
|
435
|
+
const results = await Promise.allSettled(this.recordingPromises);
|
|
436
|
+
if (this.currentSession) {
|
|
437
|
+
for (const result of results) {
|
|
438
|
+
if (result.status === "fulfilled" && result.value) {
|
|
439
|
+
this.currentSession.recordings.push(result.value);
|
|
440
|
+
}
|
|
429
441
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
`Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
|
|
433
|
-
);
|
|
434
|
-
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
435
|
-
}
|
|
436
|
-
saveRequestRecordSync(req, body) {
|
|
437
|
-
if (!this.currentSession) {
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
const key = getReqID(req);
|
|
441
|
-
const recordingId = this.recordingIdCounter++;
|
|
442
|
-
req.__recordingId = recordingId;
|
|
443
|
-
const record = {
|
|
444
|
-
request: {
|
|
445
|
-
method: req.method,
|
|
446
|
-
url: req.url,
|
|
447
|
-
headers: req.headers,
|
|
448
|
-
body: body || null
|
|
449
|
-
},
|
|
450
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
451
|
-
key,
|
|
452
|
-
recordingId
|
|
453
|
-
};
|
|
454
|
-
this.currentSession.recordings.push(record);
|
|
455
|
-
console.log(
|
|
456
|
-
// eslint-disable-next-line sonarjs/no-nested-template-literals
|
|
457
|
-
`saveRequestRecordSync: Saved ${req.method} ${req.url} (key: ${key}, recordingId: ${recordingId}, body: ${body ? `${body.length} chars` : "null"}, total: ${this.currentSession.recordings.length}, sessionId: ${this.currentSession.id})`
|
|
458
|
-
);
|
|
459
|
-
}
|
|
460
|
-
updateRequestBodySync(req, body) {
|
|
461
|
-
if (!this.currentSession) {
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
const recordingId = req.__recordingId;
|
|
465
|
-
if (recordingId === void 0) {
|
|
466
|
-
console.error(
|
|
467
|
-
`updateRequestBodySync: No recording ID found on request ${req.method} ${req.url}`
|
|
468
|
-
);
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
471
|
-
const record = this.currentSession.recordings.find(
|
|
472
|
-
(r) => r.recordingId === recordingId
|
|
473
|
-
);
|
|
474
|
-
if (!record) {
|
|
475
|
-
console.error(
|
|
476
|
-
`updateRequestBodySync: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
|
|
442
|
+
console.log(
|
|
443
|
+
`Flushed ${results.length} recordings to session (total: ${this.currentSession.recordings.length})`
|
|
477
444
|
);
|
|
478
|
-
return;
|
|
479
445
|
}
|
|
480
|
-
|
|
481
|
-
console.log(
|
|
482
|
-
`updateRequestBodySync: Updated body for ${req.method} ${req.url} (${body.length} chars, recordingId: ${recordingId})`
|
|
483
|
-
);
|
|
446
|
+
this.recordingPromises = [];
|
|
484
447
|
}
|
|
485
|
-
async
|
|
448
|
+
async saveCurrentSession() {
|
|
486
449
|
if (!this.currentSession) {
|
|
487
450
|
return;
|
|
488
451
|
}
|
|
489
|
-
|
|
490
|
-
if (recordingId === void 0) {
|
|
491
|
-
console.error(
|
|
492
|
-
`recordResponse: No recording ID found on request ${req.method} ${req.url}`
|
|
493
|
-
);
|
|
494
|
-
return;
|
|
495
|
-
}
|
|
496
|
-
const record = this.currentSession.recordings.find(
|
|
497
|
-
(r) => r.recordingId === recordingId
|
|
498
|
-
);
|
|
499
|
-
if (!record) {
|
|
500
|
-
console.error(
|
|
501
|
-
`recordResponse: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
|
|
502
|
-
);
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
const chunks = [];
|
|
506
|
-
proxyRes.on("data", (chunk) => {
|
|
507
|
-
chunks.push(chunk);
|
|
508
|
-
});
|
|
509
|
-
proxyRes.on("end", async () => {
|
|
510
|
-
const body = Buffer.concat(chunks).toString("utf8");
|
|
511
|
-
record.response = {
|
|
512
|
-
statusCode: proxyRes.statusCode,
|
|
513
|
-
headers: proxyRes.headers,
|
|
514
|
-
body: body || null
|
|
515
|
-
};
|
|
516
|
-
console.log(
|
|
517
|
-
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
518
|
-
);
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
async recordResponseData(req, proxyRes, body) {
|
|
522
|
-
if (!this.currentSession) {
|
|
523
|
-
return false;
|
|
524
|
-
}
|
|
525
|
-
const recordingId = req.__recordingId;
|
|
526
|
-
if (recordingId === void 0) {
|
|
527
|
-
console.error(
|
|
528
|
-
`recordResponseData: No recording ID found on request ${req.method} ${req.url}`
|
|
529
|
-
);
|
|
530
|
-
return false;
|
|
531
|
-
}
|
|
532
|
-
const record = this.currentSession.recordings.find(
|
|
533
|
-
(r) => r.recordingId === recordingId
|
|
534
|
-
);
|
|
535
|
-
if (!record) {
|
|
536
|
-
console.error(
|
|
537
|
-
`recordResponseData: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
|
|
538
|
-
);
|
|
539
|
-
return false;
|
|
540
|
-
}
|
|
541
|
-
record.response = {
|
|
542
|
-
statusCode: proxyRes.statusCode,
|
|
543
|
-
headers: proxyRes.headers,
|
|
544
|
-
body: body || null
|
|
545
|
-
};
|
|
452
|
+
await this.flushPendingRecordings();
|
|
546
453
|
console.log(
|
|
547
|
-
`
|
|
454
|
+
`Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
|
|
548
455
|
);
|
|
549
|
-
|
|
456
|
+
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
550
457
|
}
|
|
551
|
-
|
|
458
|
+
getRecordingIdOrError(req, res) {
|
|
552
459
|
const recordingId = this.getRecordingIdFromRequest(req) || this.replayId;
|
|
553
460
|
if (!recordingId) {
|
|
554
461
|
const corsHeaders = this.getCorsHeaders(req);
|
|
@@ -557,21 +464,50 @@ var ProxyServer = class {
|
|
|
557
464
|
...corsHeaders
|
|
558
465
|
});
|
|
559
466
|
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
560
|
-
return;
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
return recordingId;
|
|
470
|
+
}
|
|
471
|
+
async ensureSessionLoaded(recordingId, filePath) {
|
|
472
|
+
const sessionState = this.getOrCreateReplaySession(recordingId);
|
|
473
|
+
if (!sessionState.loadedSession) {
|
|
474
|
+
sessionState.loadedSession = await loadRecordingSession(filePath);
|
|
475
|
+
console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
|
|
476
|
+
}
|
|
477
|
+
return sessionState;
|
|
478
|
+
}
|
|
479
|
+
getServedTracker(sessionState, key) {
|
|
480
|
+
if (!sessionState.servedRecordingIdsByKey.has(key)) {
|
|
481
|
+
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
482
|
+
}
|
|
483
|
+
return sessionState.servedRecordingIdsByKey.get(key);
|
|
484
|
+
}
|
|
485
|
+
selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
|
|
486
|
+
for (const rec of recordsWithKey) {
|
|
487
|
+
if (!servedForThisKey.has(rec.recordingId)) {
|
|
488
|
+
return rec;
|
|
489
|
+
}
|
|
561
490
|
}
|
|
491
|
+
if (recordsWithKey.length > 0) {
|
|
492
|
+
console.log(
|
|
493
|
+
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
494
|
+
);
|
|
495
|
+
return recordsWithKey[recordsWithKey.length - 1];
|
|
496
|
+
}
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
async handleReplayRequest(req, res) {
|
|
500
|
+
const recordingId = this.getRecordingIdOrError(req, res);
|
|
501
|
+
if (!recordingId) return;
|
|
562
502
|
const key = getReqID(req);
|
|
563
503
|
const filePath = getRecordingPath(this.recordingsDir, recordingId);
|
|
564
504
|
try {
|
|
565
|
-
const sessionState = this.
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
}
|
|
505
|
+
const sessionState = await this.ensureSessionLoaded(
|
|
506
|
+
recordingId,
|
|
507
|
+
filePath
|
|
508
|
+
);
|
|
570
509
|
const session = sessionState.loadedSession;
|
|
571
|
-
|
|
572
|
-
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
573
|
-
}
|
|
574
|
-
const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
|
|
510
|
+
const servedForThisKey = this.getServedTracker(sessionState, key);
|
|
575
511
|
const host = req.headers.host || "unknown";
|
|
576
512
|
const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
|
|
577
513
|
const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
|
|
@@ -600,30 +536,23 @@ var ProxyServer = class {
|
|
|
600
536
|
}
|
|
601
537
|
const requestCount = servedForThisKey.size + 1;
|
|
602
538
|
console.log(
|
|
603
|
-
`[replay request #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
539
|
+
`[replay request #${requestCount}] ${req.method} ${req.url} (key: ${key}, session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
604
540
|
);
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
541
|
+
const record = this.selectReplayRecord(
|
|
542
|
+
recordsWithKey,
|
|
543
|
+
servedForThisKey,
|
|
544
|
+
key,
|
|
545
|
+
recordingId
|
|
546
|
+
);
|
|
547
|
+
if (!record || !record.response) {
|
|
548
|
+
throw new Error(
|
|
549
|
+
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
615
550
|
);
|
|
616
|
-
record = recordsWithKey[recordsWithKey.length - 1];
|
|
617
551
|
}
|
|
618
552
|
servedForThisKey.add(record.recordingId);
|
|
619
553
|
console.log(
|
|
620
554
|
`[replay serving] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
|
|
621
555
|
);
|
|
622
|
-
if (!record.response) {
|
|
623
|
-
throw new Error(
|
|
624
|
-
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
625
|
-
);
|
|
626
|
-
}
|
|
627
556
|
const { statusCode, headers, body } = record.response;
|
|
628
557
|
const responseHeaders = {
|
|
629
558
|
...headers,
|
|
@@ -678,82 +607,124 @@ var ProxyServer = class {
|
|
|
678
607
|
const target = this.getTarget();
|
|
679
608
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
|
|
680
609
|
if (this.mode === Modes.record) {
|
|
681
|
-
this.
|
|
682
|
-
await this.bufferAndProxyRequest(req, res, target);
|
|
610
|
+
await this.recordAndProxyRequest(req, res, target);
|
|
683
611
|
} else {
|
|
684
612
|
this.proxy.web(req, res, { target });
|
|
685
613
|
}
|
|
686
614
|
}
|
|
687
|
-
//
|
|
688
|
-
async
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
chunks.push(chunk);
|
|
692
|
-
});
|
|
693
|
-
try {
|
|
694
|
-
await new Promise((resolve, reject) => {
|
|
695
|
-
req.on("end", () => resolve());
|
|
696
|
-
req.on("error", (err) => reject(err));
|
|
697
|
-
setTimeout(
|
|
698
|
-
() => reject(new Error("Request buffering timeout")),
|
|
699
|
-
3e4
|
|
700
|
-
);
|
|
701
|
-
});
|
|
702
|
-
} catch (error) {
|
|
703
|
-
console.error("Error buffering request:", error);
|
|
615
|
+
// Note: streaming requests are buffered before proxying; streaming passthrough is not yet implemented
|
|
616
|
+
async recordAndProxyRequest(req, res, target) {
|
|
617
|
+
if (!this.currentSession) {
|
|
618
|
+
return;
|
|
704
619
|
}
|
|
705
|
-
const
|
|
706
|
-
this.
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
const recorded = await this.recordResponseData(
|
|
728
|
-
req,
|
|
729
|
-
proxyRes,
|
|
730
|
-
responseBody.toString("utf8")
|
|
731
|
-
);
|
|
732
|
-
const responseHeaders = {
|
|
733
|
-
...proxyRes.headers,
|
|
734
|
-
...this.getCorsHeaders(req)
|
|
735
|
-
};
|
|
736
|
-
res.writeHead(proxyRes.statusCode || 200, responseHeaders);
|
|
737
|
-
res.end(responseBody);
|
|
738
|
-
if (recorded) {
|
|
739
|
-
console.log(`Recorded: ${req.method} ${req.url}`);
|
|
620
|
+
const key = getReqID(req);
|
|
621
|
+
const recordingId = this.recordingIdCounter++;
|
|
622
|
+
const sequence = this.sequenceCounterByKey.get(key) || 0;
|
|
623
|
+
this.sequenceCounterByKey.set(key, sequence + 1);
|
|
624
|
+
const recordingPromise = new Promise((resolve) => {
|
|
625
|
+
(async () => {
|
|
626
|
+
try {
|
|
627
|
+
const chunks = [];
|
|
628
|
+
req.on("data", (chunk) => {
|
|
629
|
+
chunks.push(chunk);
|
|
630
|
+
});
|
|
631
|
+
try {
|
|
632
|
+
await new Promise((resolveBuffer, rejectBuffer) => {
|
|
633
|
+
req.on("end", () => resolveBuffer());
|
|
634
|
+
req.on("error", (err) => rejectBuffer(err));
|
|
635
|
+
setTimeout(
|
|
636
|
+
() => rejectBuffer(new Error("Request buffering timeout")),
|
|
637
|
+
3e4
|
|
638
|
+
);
|
|
639
|
+
});
|
|
640
|
+
} catch (error) {
|
|
641
|
+
console.error("Error buffering request:", error);
|
|
740
642
|
}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
643
|
+
const requestBody = Buffer.concat(chunks).toString("utf8");
|
|
644
|
+
const targetUrl = new URL(target);
|
|
645
|
+
const isHttps = targetUrl.protocol === "https:";
|
|
646
|
+
const requestModule = isHttps ? https : http;
|
|
647
|
+
const defaultPort = isHttps ? 443 : 80;
|
|
648
|
+
const proxyReq = requestModule.request(
|
|
649
|
+
{
|
|
650
|
+
hostname: targetUrl.hostname,
|
|
651
|
+
port: targetUrl.port || defaultPort,
|
|
652
|
+
path: req.url,
|
|
653
|
+
method: req.method,
|
|
654
|
+
headers: req.headers
|
|
655
|
+
},
|
|
656
|
+
(proxyRes) => {
|
|
657
|
+
this.addCorsHeaders(proxyRes, req);
|
|
658
|
+
const responseChunks = [];
|
|
659
|
+
proxyRes.on("data", (chunk) => {
|
|
660
|
+
responseChunks.push(chunk);
|
|
661
|
+
});
|
|
662
|
+
proxyRes.on("end", async () => {
|
|
663
|
+
try {
|
|
664
|
+
const responseBody = Buffer.concat(responseChunks);
|
|
665
|
+
const responseBodyStr = responseBody.toString("utf8");
|
|
666
|
+
const recording = {
|
|
667
|
+
request: {
|
|
668
|
+
method: req.method,
|
|
669
|
+
url: req.url,
|
|
670
|
+
headers: req.headers,
|
|
671
|
+
body: requestBody || null
|
|
672
|
+
},
|
|
673
|
+
response: {
|
|
674
|
+
statusCode: proxyRes.statusCode,
|
|
675
|
+
headers: proxyRes.headers,
|
|
676
|
+
body: responseBodyStr || null
|
|
677
|
+
},
|
|
678
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
679
|
+
key,
|
|
680
|
+
recordingId,
|
|
681
|
+
sequence
|
|
682
|
+
};
|
|
683
|
+
const responseHeaders = {
|
|
684
|
+
...proxyRes.headers,
|
|
685
|
+
...this.getCorsHeaders(req)
|
|
686
|
+
};
|
|
687
|
+
res.writeHead(proxyRes.statusCode || 200, responseHeaders);
|
|
688
|
+
res.end(responseBody);
|
|
689
|
+
console.log(
|
|
690
|
+
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId}, sequence: ${sequence})`
|
|
691
|
+
);
|
|
692
|
+
resolve(recording);
|
|
693
|
+
} catch (error) {
|
|
694
|
+
console.error("Error completing recording:", error);
|
|
695
|
+
resolve(null);
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
proxyRes.on("error", (err) => {
|
|
699
|
+
console.error("Proxy response error:", err);
|
|
700
|
+
if (!res.headersSent) {
|
|
701
|
+
this.handleProxyError(err, req, res);
|
|
702
|
+
}
|
|
703
|
+
resolve(null);
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
);
|
|
707
|
+
proxyReq.on("error", (err) => {
|
|
745
708
|
this.handleProxyError(err, req, res);
|
|
709
|
+
resolve(null);
|
|
710
|
+
});
|
|
711
|
+
if (chunks.length > 0) {
|
|
712
|
+
proxyReq.write(Buffer.concat(chunks));
|
|
746
713
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
714
|
+
proxyReq.end();
|
|
715
|
+
} catch (error) {
|
|
716
|
+
console.error("Error in recordAndProxyRequest:", error);
|
|
717
|
+
try {
|
|
718
|
+
this.handleProxyError(error, req, res);
|
|
719
|
+
} catch (error_) {
|
|
720
|
+
console.error("Failed to handle proxy error:", error_);
|
|
721
|
+
}
|
|
722
|
+
resolve(null);
|
|
723
|
+
}
|
|
724
|
+
})();
|
|
752
725
|
});
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
}
|
|
756
|
-
proxyReq.end();
|
|
726
|
+
this.recordingPromises.push(recordingPromise);
|
|
727
|
+
await recordingPromise;
|
|
757
728
|
}
|
|
758
729
|
handleUpgrade(req, socket, head) {
|
|
759
730
|
if (this.mode === Modes.replay) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "test-proxy-recorder",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|