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/README.md +334 -207
- package/dist/index.cjs +205 -62
- package/dist/index.d.cts +10 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.mjs +205 -62
- package/dist/proxy.js +205 -62
- package/package.json +8 -3
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,
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 (
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
321
|
-
const
|
|
322
|
-
|
|
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}
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
|
490
|
+
const corsHeaders = this.getCorsHeaders(req);
|
|
373
491
|
res.writeHead(HTTP_STATUS_OK, {
|
|
374
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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);
|