test-proxy-recorder 0.3.0 → 0.3.2
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 +146 -21
- package/dist/{index-CVuiglPk.d.cts → index-BlBWqSE4.d.cts} +21 -4
- package/dist/{index-CVuiglPk.d.ts → index-BlBWqSE4.d.ts} +21 -4
- package/dist/index.cjs +439 -260
- package/dist/index.d.cts +15 -7
- package/dist/index.d.ts +15 -7
- package/dist/index.mjs +438 -259
- package/dist/playwright/index.cjs +151 -13
- package/dist/playwright/index.d.cts +1 -1
- package/dist/playwright/index.d.ts +1 -1
- package/dist/playwright/index.mjs +147 -14
- package/dist/proxy.js +296 -246
- 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);
|
|
@@ -108,16 +118,19 @@ async function saveRecordingSession(recordingsDir2, session) {
|
|
|
108
118
|
`Saved ${processedRecordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
|
|
109
119
|
);
|
|
110
120
|
}
|
|
111
|
-
function
|
|
112
|
-
const urlParts = req.url.split("?");
|
|
113
|
-
const pathname = urlParts[0];
|
|
114
|
-
const query = urlParts[1] || "";
|
|
121
|
+
function generateRecordingKey(pathname, query, method) {
|
|
115
122
|
const pathPart = pathname === "/" ? "root" : pathname.slice(1);
|
|
116
123
|
const normalizedPath = filenamify2(pathPart, { replacement: "_" });
|
|
117
124
|
const queryHash = generateQueryHash(query);
|
|
118
|
-
const filename = `${
|
|
125
|
+
const filename = `${method}_${normalizedPath}${queryHash}.json`;
|
|
119
126
|
return filenamify2(filename, { replacement: "_" });
|
|
120
127
|
}
|
|
128
|
+
function getReqID(req) {
|
|
129
|
+
const urlParts = req.url.split("?");
|
|
130
|
+
const pathname = urlParts[0];
|
|
131
|
+
const query = urlParts[1] || "";
|
|
132
|
+
return generateRecordingKey(pathname, query, req.method);
|
|
133
|
+
}
|
|
121
134
|
function generateQueryHash(query) {
|
|
122
135
|
if (!query) {
|
|
123
136
|
return "";
|
|
@@ -153,19 +166,25 @@ var ProxyServer = class {
|
|
|
153
166
|
recordingsDir;
|
|
154
167
|
recordingIdCounter;
|
|
155
168
|
// Unique ID for each recording entry
|
|
169
|
+
sequenceCounterByKey;
|
|
170
|
+
// Sequence counter per key (endpoint)
|
|
156
171
|
replaySessions;
|
|
157
172
|
// Track multiple concurrent replay sessions by recording ID
|
|
173
|
+
recordingPromises;
|
|
174
|
+
// Stack of promises that resolve to completed recordings
|
|
158
175
|
constructor(targets2, recordingsDir2) {
|
|
159
176
|
this.targets = targets2;
|
|
160
177
|
this.currentTargetIndex = 0;
|
|
161
178
|
this.mode = Modes.transparent;
|
|
162
179
|
this.recordingId = null;
|
|
163
180
|
this.recordingIdCounter = 0;
|
|
181
|
+
this.sequenceCounterByKey = /* @__PURE__ */ new Map();
|
|
164
182
|
this.replayId = null;
|
|
165
183
|
this.modeTimeout = null;
|
|
166
184
|
this.currentSession = null;
|
|
167
185
|
this.recordingsDir = recordingsDir2;
|
|
168
186
|
this.replaySessions = /* @__PURE__ */ new Map();
|
|
187
|
+
this.recordingPromises = [];
|
|
169
188
|
this.proxy = httpProxy.createProxyServer({
|
|
170
189
|
secure: false,
|
|
171
190
|
changeOrigin: true,
|
|
@@ -191,7 +210,7 @@ var ProxyServer = class {
|
|
|
191
210
|
}
|
|
192
211
|
setupProxyEventHandlers() {
|
|
193
212
|
this.proxy.on("error", this.handleProxyError.bind(this));
|
|
194
|
-
this.proxy.on("proxyRes", this.
|
|
213
|
+
this.proxy.on("proxyRes", this.addCorsHeaders.bind(this));
|
|
195
214
|
}
|
|
196
215
|
handleProxyError(err, req, res) {
|
|
197
216
|
console.error("Proxy error:", err);
|
|
@@ -207,12 +226,6 @@ var ProxyServer = class {
|
|
|
207
226
|
}
|
|
208
227
|
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
209
228
|
}
|
|
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
229
|
/**
|
|
217
230
|
* Get CORS headers for a given request
|
|
218
231
|
* @param req The incoming HTTP request
|
|
@@ -270,7 +283,15 @@ var ProxyServer = class {
|
|
|
270
283
|
* @returns The recording ID, or null if not found
|
|
271
284
|
*/
|
|
272
285
|
getRecordingIdFromRequest(req) {
|
|
273
|
-
|
|
286
|
+
const fromHeader = this.getRecordingIdFromHeader(req);
|
|
287
|
+
const fromCookie = this.getRecordingIdFromCookie(req);
|
|
288
|
+
if (fromHeader) {
|
|
289
|
+
return fromHeader;
|
|
290
|
+
}
|
|
291
|
+
if (fromCookie) {
|
|
292
|
+
return fromCookie;
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
274
295
|
}
|
|
275
296
|
/**
|
|
276
297
|
* Get or create a replay session state for a given recording ID
|
|
@@ -295,6 +316,27 @@ var ProxyServer = class {
|
|
|
295
316
|
}
|
|
296
317
|
return session;
|
|
297
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* Clean up a session - removes it from memory and resets counters
|
|
321
|
+
* @param sessionId The session ID to clean up
|
|
322
|
+
*/
|
|
323
|
+
async cleanupSession(sessionId) {
|
|
324
|
+
if (this.replaySessions.has(sessionId)) {
|
|
325
|
+
console.log(`[CLEANUP] Removing replay session: ${sessionId}`);
|
|
326
|
+
this.replaySessions.delete(sessionId);
|
|
327
|
+
}
|
|
328
|
+
if (this.recordingId === sessionId) {
|
|
329
|
+
console.log(`[CLEANUP] Saving and clearing active recording session: ${sessionId}`);
|
|
330
|
+
await this.saveCurrentSession();
|
|
331
|
+
this.currentSession = null;
|
|
332
|
+
this.recordingId = null;
|
|
333
|
+
}
|
|
334
|
+
if (this.replayId === sessionId) {
|
|
335
|
+
console.log(`[CLEANUP] Clearing active replay session: ${sessionId}`);
|
|
336
|
+
this.replayId = null;
|
|
337
|
+
}
|
|
338
|
+
console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
|
|
339
|
+
}
|
|
298
340
|
parseGetParams(req) {
|
|
299
341
|
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
300
342
|
const mode = url.searchParams.get("mode");
|
|
@@ -306,19 +348,41 @@ var ProxyServer = class {
|
|
|
306
348
|
}
|
|
307
349
|
return { mode, id, timeout };
|
|
308
350
|
}
|
|
351
|
+
async parseControlRequest(req) {
|
|
352
|
+
if (req.method === "GET") {
|
|
353
|
+
return this.parseGetParams(req);
|
|
354
|
+
}
|
|
355
|
+
if (req.method === "POST") {
|
|
356
|
+
const body = await readRequestBody(req);
|
|
357
|
+
console.log(`MODE CHANGE (${req.method})`, body);
|
|
358
|
+
return JSON.parse(body);
|
|
359
|
+
}
|
|
360
|
+
throw new Error("Unsupported control method");
|
|
361
|
+
}
|
|
309
362
|
async handleControlRequest(req, res) {
|
|
363
|
+
if (req.method === "GET") {
|
|
364
|
+
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
365
|
+
recordingsDir: this.recordingsDir,
|
|
366
|
+
mode: this.mode,
|
|
367
|
+
id: this.recordingId || this.replayId
|
|
368
|
+
});
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
310
371
|
try {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
372
|
+
const data = await this.parseControlRequest(req);
|
|
373
|
+
const { mode, id, timeout: requestTimeout, cleanup } = data;
|
|
374
|
+
if (cleanup && id) {
|
|
375
|
+
await this.cleanupSession(id);
|
|
376
|
+
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
377
|
+
success: true,
|
|
378
|
+
message: `Session ${id} cleaned up`,
|
|
379
|
+
mode: this.mode
|
|
380
|
+
});
|
|
319
381
|
return;
|
|
320
382
|
}
|
|
321
|
-
|
|
383
|
+
if (!mode) {
|
|
384
|
+
throw new Error("Mode parameter is required when cleanup is not specified");
|
|
385
|
+
}
|
|
322
386
|
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
323
387
|
this.clearModeTimeout();
|
|
324
388
|
await this.switchMode(mode, id);
|
|
@@ -334,7 +398,8 @@ var ProxyServer = class {
|
|
|
334
398
|
success: true,
|
|
335
399
|
mode: this.mode,
|
|
336
400
|
id: this.recordingId || this.replayId,
|
|
337
|
-
timeout
|
|
401
|
+
timeout,
|
|
402
|
+
recordingsDir: this.recordingsDir
|
|
338
403
|
});
|
|
339
404
|
} catch (error) {
|
|
340
405
|
console.error("Control request error:", error);
|
|
@@ -350,7 +415,7 @@ var ProxyServer = class {
|
|
|
350
415
|
async switchMode(mode, id) {
|
|
351
416
|
console.log(`Switching to ${mode.toUpperCase()} mode`);
|
|
352
417
|
if (this.currentSession && this.mode === Modes.record) {
|
|
353
|
-
await this.saveCurrentSession(
|
|
418
|
+
await this.saveCurrentSession();
|
|
354
419
|
console.log("Session saved, continuing with mode switch");
|
|
355
420
|
}
|
|
356
421
|
switch (mode) {
|
|
@@ -390,6 +455,8 @@ var ProxyServer = class {
|
|
|
390
455
|
this.recordingId = id;
|
|
391
456
|
this.replayId = null;
|
|
392
457
|
this.currentSession = { id, recordings: [], websocketRecordings: [] };
|
|
458
|
+
this.recordingIdCounter = 0;
|
|
459
|
+
this.sequenceCounterByKey.clear();
|
|
393
460
|
console.log(`Switched to record mode with ID: ${id}`);
|
|
394
461
|
}
|
|
395
462
|
async switchToReplayMode(id) {
|
|
@@ -407,171 +474,119 @@ var ProxyServer = class {
|
|
|
407
474
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
408
475
|
}
|
|
409
476
|
setupModeTimeout(timeout) {
|
|
477
|
+
clearTimeout(this.modeTimeout || 0);
|
|
410
478
|
this.modeTimeout = setTimeout(async () => {
|
|
411
479
|
console.log("Timeout reached, switching back to transparent mode");
|
|
412
|
-
await this.saveCurrentSession(
|
|
480
|
+
await this.saveCurrentSession();
|
|
413
481
|
this.switchToTransparentMode();
|
|
414
482
|
this.modeTimeout = null;
|
|
415
483
|
}, timeout);
|
|
416
484
|
}
|
|
417
|
-
async
|
|
418
|
-
if (
|
|
485
|
+
async flushPendingRecordings() {
|
|
486
|
+
if (this.recordingPromises.length === 0) {
|
|
419
487
|
return;
|
|
420
488
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
(r) => r.response
|
|
428
|
-
);
|
|
489
|
+
const results = await Promise.allSettled(this.recordingPromises);
|
|
490
|
+
if (this.currentSession) {
|
|
491
|
+
for (const result of results) {
|
|
492
|
+
if (result.status === "fulfilled" && result.value) {
|
|
493
|
+
this.currentSession.recordings.push(result.value);
|
|
494
|
+
}
|
|
429
495
|
}
|
|
496
|
+
console.log(
|
|
497
|
+
`Flushed ${results.length} recordings to session (total: ${this.currentSession.recordings.length})`
|
|
498
|
+
);
|
|
430
499
|
}
|
|
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);
|
|
500
|
+
this.recordingPromises = [];
|
|
435
501
|
}
|
|
436
|
-
|
|
502
|
+
async saveCurrentSession() {
|
|
437
503
|
if (!this.currentSession) {
|
|
438
504
|
return;
|
|
439
505
|
}
|
|
440
|
-
|
|
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);
|
|
506
|
+
await this.flushPendingRecordings();
|
|
455
507
|
console.log(
|
|
456
|
-
|
|
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})`
|
|
508
|
+
`Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
|
|
458
509
|
);
|
|
510
|
+
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
459
511
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
512
|
+
getRecordingIdOrError(req, res) {
|
|
513
|
+
const recordingIdFromRequest = this.getRecordingIdFromRequest(req);
|
|
514
|
+
if (recordingIdFromRequest) {
|
|
515
|
+
return recordingIdFromRequest;
|
|
463
516
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
`updateRequestBodySync: No recording ID found on request ${req.method} ${req.url}`
|
|
517
|
+
if (this.replaySessions.size > 1) {
|
|
518
|
+
console.warn(
|
|
519
|
+
`[CONCURRENT REPLAY WARNING] Request to ${req.method} ${req.url} is missing ${RECORDING_ID_HEADER} header/cookie. Active sessions: ${[...this.replaySessions.keys()].join(", ")}. this.replayId fallback would be: ${this.replayId} (NOT USING - could be wrong session)`
|
|
468
520
|
);
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
521
|
+
const corsHeaders = this.getCorsHeaders(req);
|
|
522
|
+
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
523
|
+
"Content-Type": "application/json",
|
|
524
|
+
...corsHeaders
|
|
525
|
+
});
|
|
526
|
+
res.end(
|
|
527
|
+
JSON.stringify({
|
|
528
|
+
error: "Missing recording ID in concurrent replay mode. Ensure x-test-rcrd-id header is set.",
|
|
529
|
+
activeSessions: [...this.replaySessions.keys()],
|
|
530
|
+
hint: "This usually means page.setExtraHTTPHeaders() did not apply to this request type"
|
|
531
|
+
})
|
|
477
532
|
);
|
|
478
|
-
return;
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
const recordingId = this.replayId;
|
|
536
|
+
if (!recordingId) {
|
|
537
|
+
const corsHeaders = this.getCorsHeaders(req);
|
|
538
|
+
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
539
|
+
"Content-Type": "application/json",
|
|
540
|
+
...corsHeaders
|
|
541
|
+
});
|
|
542
|
+
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
543
|
+
return null;
|
|
479
544
|
}
|
|
480
|
-
record.request.body = body || null;
|
|
481
545
|
console.log(
|
|
482
|
-
`
|
|
546
|
+
`[FALLBACK] Using replayId fallback for ${req.method} ${req.url} -> session: ${recordingId} (single session mode)`
|
|
483
547
|
);
|
|
548
|
+
return recordingId;
|
|
484
549
|
}
|
|
485
|
-
async
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if (recordingId === void 0) {
|
|
491
|
-
console.error(
|
|
492
|
-
`recordResponse: No recording ID found on request ${req.method} ${req.url}`
|
|
493
|
-
);
|
|
494
|
-
return;
|
|
550
|
+
async ensureSessionLoaded(recordingId, filePath) {
|
|
551
|
+
const sessionState = this.getOrCreateReplaySession(recordingId);
|
|
552
|
+
if (!sessionState.loadedSession) {
|
|
553
|
+
sessionState.loadedSession = await loadRecordingSession(filePath);
|
|
554
|
+
console.log(`[REPLAY] Loaded recording session: ${recordingId}`);
|
|
495
555
|
}
|
|
496
|
-
|
|
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
|
-
});
|
|
556
|
+
return sessionState;
|
|
520
557
|
}
|
|
521
|
-
|
|
522
|
-
if (!
|
|
523
|
-
|
|
558
|
+
getServedTracker(sessionState, key) {
|
|
559
|
+
if (!sessionState.servedRecordingIdsByKey.has(key)) {
|
|
560
|
+
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
524
561
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
)
|
|
530
|
-
|
|
562
|
+
return sessionState.servedRecordingIdsByKey.get(key);
|
|
563
|
+
}
|
|
564
|
+
selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
|
|
565
|
+
for (const rec of recordsWithKey) {
|
|
566
|
+
if (!servedForThisKey.has(rec.recordingId)) {
|
|
567
|
+
return rec;
|
|
568
|
+
}
|
|
531
569
|
}
|
|
532
|
-
|
|
533
|
-
(
|
|
534
|
-
|
|
535
|
-
if (!record) {
|
|
536
|
-
console.error(
|
|
537
|
-
`recordResponseData: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
|
|
570
|
+
if (recordsWithKey.length > 0) {
|
|
571
|
+
console.log(
|
|
572
|
+
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
538
573
|
);
|
|
539
|
-
return
|
|
574
|
+
return recordsWithKey[recordsWithKey.length - 1];
|
|
540
575
|
}
|
|
541
|
-
|
|
542
|
-
statusCode: proxyRes.statusCode,
|
|
543
|
-
headers: proxyRes.headers,
|
|
544
|
-
body: body || null
|
|
545
|
-
};
|
|
546
|
-
console.log(
|
|
547
|
-
`recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
548
|
-
);
|
|
549
|
-
return true;
|
|
576
|
+
return null;
|
|
550
577
|
}
|
|
551
578
|
async handleReplayRequest(req, res) {
|
|
552
|
-
const recordingId = this.
|
|
553
|
-
if (!recordingId)
|
|
554
|
-
const corsHeaders = this.getCorsHeaders(req);
|
|
555
|
-
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
556
|
-
"Content-Type": "application/json",
|
|
557
|
-
...corsHeaders
|
|
558
|
-
});
|
|
559
|
-
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
579
|
+
const recordingId = this.getRecordingIdOrError(req, res);
|
|
580
|
+
if (!recordingId) return;
|
|
562
581
|
const key = getReqID(req);
|
|
563
582
|
const filePath = getRecordingPath(this.recordingsDir, recordingId);
|
|
564
583
|
try {
|
|
565
|
-
const sessionState = this.
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
}
|
|
584
|
+
const sessionState = await this.ensureSessionLoaded(
|
|
585
|
+
recordingId,
|
|
586
|
+
filePath
|
|
587
|
+
);
|
|
570
588
|
const session = sessionState.loadedSession;
|
|
571
|
-
|
|
572
|
-
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
573
|
-
}
|
|
574
|
-
const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
|
|
589
|
+
const servedForThisKey = this.getServedTracker(sessionState, key);
|
|
575
590
|
const host = req.headers.host || "unknown";
|
|
576
591
|
const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
|
|
577
592
|
const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
|
|
@@ -600,30 +615,23 @@ var ProxyServer = class {
|
|
|
600
615
|
}
|
|
601
616
|
const requestCount = servedForThisKey.size + 1;
|
|
602
617
|
console.log(
|
|
603
|
-
`[replay request #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
618
|
+
`[replay request #${requestCount}] ${req.method} ${req.url} (key: ${key}, session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
604
619
|
);
|
|
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`
|
|
620
|
+
const record = this.selectReplayRecord(
|
|
621
|
+
recordsWithKey,
|
|
622
|
+
servedForThisKey,
|
|
623
|
+
key,
|
|
624
|
+
recordingId
|
|
625
|
+
);
|
|
626
|
+
if (!record || !record.response) {
|
|
627
|
+
throw new Error(
|
|
628
|
+
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
615
629
|
);
|
|
616
|
-
record = recordsWithKey[recordsWithKey.length - 1];
|
|
617
630
|
}
|
|
618
631
|
servedForThisKey.add(record.recordingId);
|
|
619
632
|
console.log(
|
|
620
633
|
`[replay serving] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
|
|
621
634
|
);
|
|
622
|
-
if (!record.response) {
|
|
623
|
-
throw new Error(
|
|
624
|
-
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
625
|
-
);
|
|
626
|
-
}
|
|
627
635
|
const { statusCode, headers, body } = record.response;
|
|
628
636
|
const responseHeaders = {
|
|
629
637
|
...headers,
|
|
@@ -678,82 +686,124 @@ var ProxyServer = class {
|
|
|
678
686
|
const target = this.getTarget();
|
|
679
687
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
|
|
680
688
|
if (this.mode === Modes.record) {
|
|
681
|
-
this.
|
|
682
|
-
await this.bufferAndProxyRequest(req, res, target);
|
|
689
|
+
await this.recordAndProxyRequest(req, res, target);
|
|
683
690
|
} else {
|
|
684
691
|
this.proxy.web(req, res, { target });
|
|
685
692
|
}
|
|
686
693
|
}
|
|
687
|
-
//
|
|
688
|
-
async
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
port: targetUrl.port || defaultPort,
|
|
715
|
-
path: req.url,
|
|
716
|
-
method: req.method,
|
|
717
|
-
headers: req.headers
|
|
718
|
-
},
|
|
719
|
-
(proxyRes) => {
|
|
720
|
-
this.addCorsHeaders(proxyRes, req);
|
|
721
|
-
const responseChunks = [];
|
|
722
|
-
proxyRes.on("data", (chunk) => {
|
|
723
|
-
responseChunks.push(chunk);
|
|
724
|
-
});
|
|
725
|
-
proxyRes.on("end", async () => {
|
|
726
|
-
const responseBody = Buffer.concat(responseChunks);
|
|
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}`);
|
|
694
|
+
// Note: streaming requests are buffered before proxying; streaming passthrough is not yet implemented
|
|
695
|
+
async recordAndProxyRequest(req, res, target) {
|
|
696
|
+
if (!this.currentSession) {
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const key = getReqID(req);
|
|
700
|
+
const recordingId = this.recordingIdCounter++;
|
|
701
|
+
const sequence = this.sequenceCounterByKey.get(key) || 0;
|
|
702
|
+
this.sequenceCounterByKey.set(key, sequence + 1);
|
|
703
|
+
const recordingPromise = new Promise((resolve) => {
|
|
704
|
+
(async () => {
|
|
705
|
+
try {
|
|
706
|
+
const chunks = [];
|
|
707
|
+
req.on("data", (chunk) => {
|
|
708
|
+
chunks.push(chunk);
|
|
709
|
+
});
|
|
710
|
+
try {
|
|
711
|
+
await new Promise((resolveBuffer, rejectBuffer) => {
|
|
712
|
+
req.on("end", () => resolveBuffer());
|
|
713
|
+
req.on("error", (err) => rejectBuffer(err));
|
|
714
|
+
setTimeout(
|
|
715
|
+
() => rejectBuffer(new Error("Request buffering timeout")),
|
|
716
|
+
3e4
|
|
717
|
+
);
|
|
718
|
+
});
|
|
719
|
+
} catch (error) {
|
|
720
|
+
console.error("Error buffering request:", error);
|
|
740
721
|
}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
722
|
+
const requestBody = Buffer.concat(chunks).toString("utf8");
|
|
723
|
+
const targetUrl = new URL(target);
|
|
724
|
+
const isHttps = targetUrl.protocol === "https:";
|
|
725
|
+
const requestModule = isHttps ? https : http;
|
|
726
|
+
const defaultPort = isHttps ? 443 : 80;
|
|
727
|
+
const proxyReq = requestModule.request(
|
|
728
|
+
{
|
|
729
|
+
hostname: targetUrl.hostname,
|
|
730
|
+
port: targetUrl.port || defaultPort,
|
|
731
|
+
path: req.url,
|
|
732
|
+
method: req.method,
|
|
733
|
+
headers: req.headers
|
|
734
|
+
},
|
|
735
|
+
(proxyRes) => {
|
|
736
|
+
this.addCorsHeaders(proxyRes, req);
|
|
737
|
+
const responseChunks = [];
|
|
738
|
+
proxyRes.on("data", (chunk) => {
|
|
739
|
+
responseChunks.push(chunk);
|
|
740
|
+
});
|
|
741
|
+
proxyRes.on("end", async () => {
|
|
742
|
+
try {
|
|
743
|
+
const responseBody = Buffer.concat(responseChunks);
|
|
744
|
+
const responseBodyStr = responseBody.toString("utf8");
|
|
745
|
+
const recording = {
|
|
746
|
+
request: {
|
|
747
|
+
method: req.method,
|
|
748
|
+
url: req.url,
|
|
749
|
+
headers: req.headers,
|
|
750
|
+
body: requestBody || null
|
|
751
|
+
},
|
|
752
|
+
response: {
|
|
753
|
+
statusCode: proxyRes.statusCode,
|
|
754
|
+
headers: proxyRes.headers,
|
|
755
|
+
body: responseBodyStr || null
|
|
756
|
+
},
|
|
757
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
758
|
+
key,
|
|
759
|
+
recordingId,
|
|
760
|
+
sequence
|
|
761
|
+
};
|
|
762
|
+
const responseHeaders = {
|
|
763
|
+
...proxyRes.headers,
|
|
764
|
+
...this.getCorsHeaders(req)
|
|
765
|
+
};
|
|
766
|
+
res.writeHead(proxyRes.statusCode || 200, responseHeaders);
|
|
767
|
+
res.end(responseBody);
|
|
768
|
+
console.log(
|
|
769
|
+
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId}, sequence: ${sequence})`
|
|
770
|
+
);
|
|
771
|
+
resolve(recording);
|
|
772
|
+
} catch (error) {
|
|
773
|
+
console.error("Error completing recording:", error);
|
|
774
|
+
resolve(null);
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
proxyRes.on("error", (err) => {
|
|
778
|
+
console.error("Proxy response error:", err);
|
|
779
|
+
if (!res.headersSent) {
|
|
780
|
+
this.handleProxyError(err, req, res);
|
|
781
|
+
}
|
|
782
|
+
resolve(null);
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
);
|
|
786
|
+
proxyReq.on("error", (err) => {
|
|
745
787
|
this.handleProxyError(err, req, res);
|
|
788
|
+
resolve(null);
|
|
789
|
+
});
|
|
790
|
+
if (chunks.length > 0) {
|
|
791
|
+
proxyReq.write(Buffer.concat(chunks));
|
|
746
792
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
793
|
+
proxyReq.end();
|
|
794
|
+
} catch (error) {
|
|
795
|
+
console.error("Error in recordAndProxyRequest:", error);
|
|
796
|
+
try {
|
|
797
|
+
this.handleProxyError(error, req, res);
|
|
798
|
+
} catch (error_) {
|
|
799
|
+
console.error("Failed to handle proxy error:", error_);
|
|
800
|
+
}
|
|
801
|
+
resolve(null);
|
|
802
|
+
}
|
|
803
|
+
})();
|
|
752
804
|
});
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
}
|
|
756
|
-
proxyReq.end();
|
|
805
|
+
this.recordingPromises.push(recordingPromise);
|
|
806
|
+
await recordingPromise;
|
|
757
807
|
}
|
|
758
808
|
handleUpgrade(req, socket, head) {
|
|
759
809
|
if (this.mode === Modes.replay) {
|