test-proxy-recorder 0.2.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 +134 -33
- package/dist/{index-nmNRt1WE.d.cts → index-CVuiglPk.d.cts} +6 -10
- package/dist/{index-nmNRt1WE.d.ts → index-CVuiglPk.d.ts} +6 -10
- package/dist/index.cjs +291 -258
- package/dist/index.d.cts +25 -8
- package/dist/index.d.ts +25 -8
- package/dist/index.mjs +288 -259
- package/dist/nextjs/index.cjs +43 -0
- package/dist/nextjs/index.d.cts +89 -0
- package/dist/nextjs/index.d.ts +89 -0
- package/dist/nextjs/index.mjs +38 -0
- package/dist/playwright/index.cjs +20 -11
- package/dist/playwright/index.d.cts +1 -1
- package/dist/playwright/index.d.ts +1 -1
- package/dist/playwright/index.mjs +20 -11
- package/dist/proxy.js +239 -246
- package/package.json +6 -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
|
|
|
@@ -50,6 +50,7 @@ var HTTP_STATUS_OK = 200;
|
|
|
50
50
|
var HTTP_STATUS_BAD_REQUEST = 400;
|
|
51
51
|
var HTTP_STATUS_NOT_FOUND = 404;
|
|
52
52
|
var CONTROL_ENDPOINT = "/__control";
|
|
53
|
+
var RECORDING_ID_HEADER = "x-test-rcrd-id";
|
|
53
54
|
|
|
54
55
|
// src/types.ts
|
|
55
56
|
var Modes = {
|
|
@@ -83,13 +84,23 @@ async function loadRecordingSession(filePath) {
|
|
|
83
84
|
return JSON.parse(fileContent);
|
|
84
85
|
}
|
|
85
86
|
function processRecordings(recordings) {
|
|
86
|
-
const
|
|
87
|
-
|
|
87
|
+
const recordingsByKey = /* @__PURE__ */ new Map();
|
|
88
|
+
for (const recording of recordings) {
|
|
88
89
|
const key = recording.key;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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;
|
|
93
104
|
}
|
|
94
105
|
async function saveRecordingSession(recordingsDir2, session) {
|
|
95
106
|
const filePath = getRecordingPath(recordingsDir2, session.id);
|
|
@@ -152,19 +163,25 @@ var ProxyServer = class {
|
|
|
152
163
|
recordingsDir;
|
|
153
164
|
recordingIdCounter;
|
|
154
165
|
// Unique ID for each recording entry
|
|
166
|
+
sequenceCounterByKey;
|
|
167
|
+
// Sequence counter per key (endpoint)
|
|
155
168
|
replaySessions;
|
|
156
169
|
// Track multiple concurrent replay sessions by recording ID
|
|
170
|
+
recordingPromises;
|
|
171
|
+
// Stack of promises that resolve to completed recordings
|
|
157
172
|
constructor(targets2, recordingsDir2) {
|
|
158
173
|
this.targets = targets2;
|
|
159
174
|
this.currentTargetIndex = 0;
|
|
160
175
|
this.mode = Modes.transparent;
|
|
161
176
|
this.recordingId = null;
|
|
162
177
|
this.recordingIdCounter = 0;
|
|
178
|
+
this.sequenceCounterByKey = /* @__PURE__ */ new Map();
|
|
163
179
|
this.replayId = null;
|
|
164
180
|
this.modeTimeout = null;
|
|
165
181
|
this.currentSession = null;
|
|
166
182
|
this.recordingsDir = recordingsDir2;
|
|
167
183
|
this.replaySessions = /* @__PURE__ */ new Map();
|
|
184
|
+
this.recordingPromises = [];
|
|
168
185
|
this.proxy = httpProxy.createProxyServer({
|
|
169
186
|
secure: false,
|
|
170
187
|
changeOrigin: true,
|
|
@@ -190,7 +207,7 @@ var ProxyServer = class {
|
|
|
190
207
|
}
|
|
191
208
|
setupProxyEventHandlers() {
|
|
192
209
|
this.proxy.on("error", this.handleProxyError.bind(this));
|
|
193
|
-
this.proxy.on("proxyRes", this.
|
|
210
|
+
this.proxy.on("proxyRes", this.addCorsHeaders.bind(this));
|
|
194
211
|
}
|
|
195
212
|
handleProxyError(err, req, res) {
|
|
196
213
|
console.error("Proxy error:", err);
|
|
@@ -206,12 +223,6 @@ var ProxyServer = class {
|
|
|
206
223
|
}
|
|
207
224
|
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
208
225
|
}
|
|
209
|
-
handleProxyResponse(proxyRes, req) {
|
|
210
|
-
this.addCorsHeaders(proxyRes, req);
|
|
211
|
-
if (this.mode === Modes.record && this.recordingId) {
|
|
212
|
-
this.recordResponse(req, proxyRes);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
226
|
/**
|
|
216
227
|
* Get CORS headers for a given request
|
|
217
228
|
* @param req The incoming HTTP request
|
|
@@ -222,7 +233,7 @@ var ProxyServer = class {
|
|
|
222
233
|
return {
|
|
223
234
|
"access-control-allow-origin": origin || "*",
|
|
224
235
|
"access-control-allow-credentials": "true",
|
|
225
|
-
"access-control-allow-headers": req.headers["access-control-request-headers"] ||
|
|
236
|
+
"access-control-allow-headers": req.headers["access-control-request-headers"] || `Origin, X-Requested-With, Content-Type, Accept, Authorization, ${RECORDING_ID_HEADER}`,
|
|
226
237
|
"access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
227
238
|
"access-control-expose-headers": "*"
|
|
228
239
|
};
|
|
@@ -236,9 +247,22 @@ var ProxyServer = class {
|
|
|
236
247
|
this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
|
|
237
248
|
return target;
|
|
238
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Extract recording ID from custom HTTP header
|
|
252
|
+
* Used for concurrent replay session routing, especially with Next.js
|
|
253
|
+
* @param req The incoming HTTP request
|
|
254
|
+
* @returns The recording ID from header, or null if not found
|
|
255
|
+
*/
|
|
256
|
+
getRecordingIdFromHeader(req) {
|
|
257
|
+
const headerValue = req.headers[RECORDING_ID_HEADER];
|
|
258
|
+
if (!headerValue) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
return Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
262
|
+
}
|
|
239
263
|
/**
|
|
240
264
|
* Extract recording ID from request cookie
|
|
241
|
-
* Used for concurrent replay session routing
|
|
265
|
+
* Used for concurrent replay session routing (fallback method)
|
|
242
266
|
* @param req The incoming HTTP request
|
|
243
267
|
* @returns The recording ID from cookie, or null if not found
|
|
244
268
|
*/
|
|
@@ -250,6 +274,14 @@ var ProxyServer = class {
|
|
|
250
274
|
const match = cookies.match(/proxy-recording-id=([^;]+)/);
|
|
251
275
|
return match ? decodeURIComponent(match[1]) : null;
|
|
252
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* Extract recording ID from request using custom header (preferred) or cookie (fallback)
|
|
279
|
+
* @param req The incoming HTTP request
|
|
280
|
+
* @returns The recording ID, or null if not found
|
|
281
|
+
*/
|
|
282
|
+
getRecordingIdFromRequest(req) {
|
|
283
|
+
return this.getRecordingIdFromHeader(req) || this.getRecordingIdFromCookie(req);
|
|
284
|
+
}
|
|
253
285
|
/**
|
|
254
286
|
* Get or create a replay session state for a given recording ID
|
|
255
287
|
* @param recordingId The recording ID to get/create session for
|
|
@@ -284,18 +316,20 @@ var ProxyServer = class {
|
|
|
284
316
|
}
|
|
285
317
|
return { mode, id, timeout };
|
|
286
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
|
+
}
|
|
287
330
|
async handleControlRequest(req, res) {
|
|
288
331
|
try {
|
|
289
|
-
|
|
290
|
-
if (req.method === "GET") {
|
|
291
|
-
data = this.parseGetParams(req);
|
|
292
|
-
} else if (req.method === "POST") {
|
|
293
|
-
const body = await readRequestBody(req);
|
|
294
|
-
console.log(`MODE CHANGE (${req.method})`, body);
|
|
295
|
-
data = JSON.parse(body);
|
|
296
|
-
} else {
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
332
|
+
const data = await this.parseControlRequest(req);
|
|
299
333
|
const { mode, id, timeout: requestTimeout } = data;
|
|
300
334
|
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
301
335
|
this.clearModeTimeout();
|
|
@@ -328,7 +362,7 @@ var ProxyServer = class {
|
|
|
328
362
|
async switchMode(mode, id) {
|
|
329
363
|
console.log(`Switching to ${mode.toUpperCase()} mode`);
|
|
330
364
|
if (this.currentSession && this.mode === Modes.record) {
|
|
331
|
-
await this.saveCurrentSession(
|
|
365
|
+
await this.saveCurrentSession();
|
|
332
366
|
console.log("Session saved, continuing with mode switch");
|
|
333
367
|
}
|
|
334
368
|
switch (mode) {
|
|
@@ -368,6 +402,8 @@ var ProxyServer = class {
|
|
|
368
402
|
this.recordingId = id;
|
|
369
403
|
this.replayId = null;
|
|
370
404
|
this.currentSession = { id, recordings: [], websocketRecordings: [] };
|
|
405
|
+
this.recordingIdCounter = 0;
|
|
406
|
+
this.sequenceCounterByKey.clear();
|
|
371
407
|
console.log(`Switched to record mode with ID: ${id}`);
|
|
372
408
|
}
|
|
373
409
|
async switchToReplayMode(id) {
|
|
@@ -387,169 +423,91 @@ var ProxyServer = class {
|
|
|
387
423
|
setupModeTimeout(timeout) {
|
|
388
424
|
this.modeTimeout = setTimeout(async () => {
|
|
389
425
|
console.log("Timeout reached, switching back to transparent mode");
|
|
390
|
-
await this.saveCurrentSession(
|
|
426
|
+
await this.saveCurrentSession();
|
|
391
427
|
this.switchToTransparentMode();
|
|
392
428
|
this.modeTimeout = null;
|
|
393
429
|
}, timeout);
|
|
394
430
|
}
|
|
395
|
-
async
|
|
396
|
-
if (
|
|
431
|
+
async flushPendingRecordings() {
|
|
432
|
+
if (this.recordingPromises.length === 0) {
|
|
397
433
|
return;
|
|
398
434
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
(r) => r.response
|
|
406
|
-
);
|
|
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
|
+
}
|
|
407
441
|
}
|
|
442
|
+
console.log(
|
|
443
|
+
`Flushed ${results.length} recordings to session (total: ${this.currentSession.recordings.length})`
|
|
444
|
+
);
|
|
408
445
|
}
|
|
409
|
-
|
|
410
|
-
`Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
|
|
411
|
-
);
|
|
412
|
-
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
446
|
+
this.recordingPromises = [];
|
|
413
447
|
}
|
|
414
|
-
|
|
448
|
+
async saveCurrentSession() {
|
|
415
449
|
if (!this.currentSession) {
|
|
416
450
|
return;
|
|
417
451
|
}
|
|
418
|
-
|
|
419
|
-
const recordingId = this.recordingIdCounter++;
|
|
420
|
-
req.__recordingId = recordingId;
|
|
421
|
-
const record = {
|
|
422
|
-
request: {
|
|
423
|
-
method: req.method,
|
|
424
|
-
url: req.url,
|
|
425
|
-
headers: req.headers,
|
|
426
|
-
body: body || null
|
|
427
|
-
},
|
|
428
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
429
|
-
key,
|
|
430
|
-
recordingId
|
|
431
|
-
};
|
|
432
|
-
this.currentSession.recordings.push(record);
|
|
452
|
+
await this.flushPendingRecordings();
|
|
433
453
|
console.log(
|
|
434
|
-
|
|
435
|
-
`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})`
|
|
454
|
+
`Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
|
|
436
455
|
);
|
|
456
|
+
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
437
457
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
);
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
const record = this.currentSession.recordings.find(
|
|
450
|
-
(r) => r.recordingId === recordingId
|
|
451
|
-
);
|
|
452
|
-
if (!record) {
|
|
453
|
-
console.error(
|
|
454
|
-
`updateRequestBodySync: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
|
|
455
|
-
);
|
|
456
|
-
return;
|
|
458
|
+
getRecordingIdOrError(req, res) {
|
|
459
|
+
const recordingId = this.getRecordingIdFromRequest(req) || this.replayId;
|
|
460
|
+
if (!recordingId) {
|
|
461
|
+
const corsHeaders = this.getCorsHeaders(req);
|
|
462
|
+
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
463
|
+
"Content-Type": "application/json",
|
|
464
|
+
...corsHeaders
|
|
465
|
+
});
|
|
466
|
+
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
467
|
+
return null;
|
|
457
468
|
}
|
|
458
|
-
|
|
459
|
-
console.log(
|
|
460
|
-
`updateRequestBodySync: Updated body for ${req.method} ${req.url} (${body.length} chars, recordingId: ${recordingId})`
|
|
461
|
-
);
|
|
469
|
+
return recordingId;
|
|
462
470
|
}
|
|
463
|
-
async
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if (recordingId === void 0) {
|
|
469
|
-
console.error(
|
|
470
|
-
`recordResponse: No recording ID found on request ${req.method} ${req.url}`
|
|
471
|
-
);
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
const record = this.currentSession.recordings.find(
|
|
475
|
-
(r) => r.recordingId === recordingId
|
|
476
|
-
);
|
|
477
|
-
if (!record) {
|
|
478
|
-
console.error(
|
|
479
|
-
`recordResponse: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
|
|
480
|
-
);
|
|
481
|
-
return;
|
|
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}`);
|
|
482
476
|
}
|
|
483
|
-
|
|
484
|
-
proxyRes.on("data", (chunk) => {
|
|
485
|
-
chunks.push(chunk);
|
|
486
|
-
});
|
|
487
|
-
proxyRes.on("end", async () => {
|
|
488
|
-
const body = Buffer.concat(chunks).toString("utf8");
|
|
489
|
-
record.response = {
|
|
490
|
-
statusCode: proxyRes.statusCode,
|
|
491
|
-
headers: proxyRes.headers,
|
|
492
|
-
body: body || null
|
|
493
|
-
};
|
|
494
|
-
console.log(
|
|
495
|
-
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
496
|
-
);
|
|
497
|
-
});
|
|
477
|
+
return sessionState;
|
|
498
478
|
}
|
|
499
|
-
|
|
500
|
-
if (!
|
|
501
|
-
|
|
479
|
+
getServedTracker(sessionState, key) {
|
|
480
|
+
if (!sessionState.servedRecordingIdsByKey.has(key)) {
|
|
481
|
+
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
502
482
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
)
|
|
508
|
-
|
|
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
|
+
}
|
|
509
490
|
}
|
|
510
|
-
|
|
511
|
-
(
|
|
512
|
-
|
|
513
|
-
if (!record) {
|
|
514
|
-
console.error(
|
|
515
|
-
`recordResponseData: Could not find recording with ID ${recordingId} for ${req.method} ${req.url}`
|
|
491
|
+
if (recordsWithKey.length > 0) {
|
|
492
|
+
console.log(
|
|
493
|
+
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
516
494
|
);
|
|
517
|
-
return
|
|
495
|
+
return recordsWithKey[recordsWithKey.length - 1];
|
|
518
496
|
}
|
|
519
|
-
|
|
520
|
-
statusCode: proxyRes.statusCode,
|
|
521
|
-
headers: proxyRes.headers,
|
|
522
|
-
body: body || null
|
|
523
|
-
};
|
|
524
|
-
console.log(
|
|
525
|
-
`recordResponseData: Recorded response for ${req.method} ${req.url} (recordingId: ${recordingId})`
|
|
526
|
-
);
|
|
527
|
-
return true;
|
|
497
|
+
return null;
|
|
528
498
|
}
|
|
529
499
|
async handleReplayRequest(req, res) {
|
|
530
|
-
const recordingId = this.
|
|
531
|
-
if (!recordingId)
|
|
532
|
-
const corsHeaders = this.getCorsHeaders(req);
|
|
533
|
-
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
534
|
-
"Content-Type": "application/json",
|
|
535
|
-
...corsHeaders
|
|
536
|
-
});
|
|
537
|
-
res.end(JSON.stringify({ error: "No replay session active" }));
|
|
538
|
-
return;
|
|
539
|
-
}
|
|
500
|
+
const recordingId = this.getRecordingIdOrError(req, res);
|
|
501
|
+
if (!recordingId) return;
|
|
540
502
|
const key = getReqID(req);
|
|
541
503
|
const filePath = getRecordingPath(this.recordingsDir, recordingId);
|
|
542
504
|
try {
|
|
543
|
-
const sessionState = this.
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
}
|
|
505
|
+
const sessionState = await this.ensureSessionLoaded(
|
|
506
|
+
recordingId,
|
|
507
|
+
filePath
|
|
508
|
+
);
|
|
548
509
|
const session = sessionState.loadedSession;
|
|
549
|
-
|
|
550
|
-
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
551
|
-
}
|
|
552
|
-
const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
|
|
510
|
+
const servedForThisKey = this.getServedTracker(sessionState, key);
|
|
553
511
|
const host = req.headers.host || "unknown";
|
|
554
512
|
const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
|
|
555
513
|
const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
|
|
@@ -578,30 +536,23 @@ var ProxyServer = class {
|
|
|
578
536
|
}
|
|
579
537
|
const requestCount = servedForThisKey.size + 1;
|
|
580
538
|
console.log(
|
|
581
|
-
`[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})`
|
|
582
540
|
);
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
`[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}`
|
|
593
550
|
);
|
|
594
|
-
record = recordsWithKey[recordsWithKey.length - 1];
|
|
595
551
|
}
|
|
596
552
|
servedForThisKey.add(record.recordingId);
|
|
597
553
|
console.log(
|
|
598
554
|
`[replay serving] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
|
|
599
555
|
);
|
|
600
|
-
if (!record.response) {
|
|
601
|
-
throw new Error(
|
|
602
|
-
`No response recorded for this request: ${req.method} ${host}${req.url}`
|
|
603
|
-
);
|
|
604
|
-
}
|
|
605
556
|
const { statusCode, headers, body } = record.response;
|
|
606
557
|
const responseHeaders = {
|
|
607
558
|
...headers,
|
|
@@ -656,82 +607,124 @@ var ProxyServer = class {
|
|
|
656
607
|
const target = this.getTarget();
|
|
657
608
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
|
|
658
609
|
if (this.mode === Modes.record) {
|
|
659
|
-
this.
|
|
660
|
-
await this.bufferAndProxyRequest(req, res, target);
|
|
610
|
+
await this.recordAndProxyRequest(req, res, target);
|
|
661
611
|
} else {
|
|
662
612
|
this.proxy.web(req, res, { target });
|
|
663
613
|
}
|
|
664
614
|
}
|
|
665
|
-
//
|
|
666
|
-
async
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
chunks.push(chunk);
|
|
670
|
-
});
|
|
671
|
-
try {
|
|
672
|
-
await new Promise((resolve, reject) => {
|
|
673
|
-
req.on("end", () => resolve());
|
|
674
|
-
req.on("error", (err) => reject(err));
|
|
675
|
-
setTimeout(
|
|
676
|
-
() => reject(new Error("Request buffering timeout")),
|
|
677
|
-
3e4
|
|
678
|
-
);
|
|
679
|
-
});
|
|
680
|
-
} catch (error) {
|
|
681
|
-
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;
|
|
682
619
|
}
|
|
683
|
-
const
|
|
684
|
-
this.
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
const
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
const recorded = await this.recordResponseData(
|
|
706
|
-
req,
|
|
707
|
-
proxyRes,
|
|
708
|
-
responseBody.toString("utf8")
|
|
709
|
-
);
|
|
710
|
-
const responseHeaders = {
|
|
711
|
-
...proxyRes.headers,
|
|
712
|
-
...this.getCorsHeaders(req)
|
|
713
|
-
};
|
|
714
|
-
res.writeHead(proxyRes.statusCode || 200, responseHeaders);
|
|
715
|
-
res.end(responseBody);
|
|
716
|
-
if (recorded) {
|
|
717
|
-
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);
|
|
718
642
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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) => {
|
|
723
708
|
this.handleProxyError(err, req, res);
|
|
709
|
+
resolve(null);
|
|
710
|
+
});
|
|
711
|
+
if (chunks.length > 0) {
|
|
712
|
+
proxyReq.write(Buffer.concat(chunks));
|
|
724
713
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
+
})();
|
|
730
725
|
});
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
}
|
|
734
|
-
proxyReq.end();
|
|
726
|
+
this.recordingPromises.push(recordingPromise);
|
|
727
|
+
await recordingPromise;
|
|
735
728
|
}
|
|
736
729
|
handleUpgrade(req, socket, head) {
|
|
737
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
|
+
"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",
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
"require": "./dist/playwright/index.cjs",
|
|
16
16
|
"types": "./dist/playwright/index.d.ts",
|
|
17
17
|
"default": "./dist/playwright/index.mjs"
|
|
18
|
+
},
|
|
19
|
+
"./nextjs": {
|
|
20
|
+
"require": "./dist/nextjs/index.cjs",
|
|
21
|
+
"types": "./dist/nextjs/index.d.ts",
|
|
22
|
+
"default": "./dist/nextjs/index.mjs"
|
|
18
23
|
}
|
|
19
24
|
},
|
|
20
25
|
"bin": {
|