test-proxy-recorder 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index-De4mgziH.d.cts +96 -0
- package/dist/index-De4mgziH.d.ts +96 -0
- package/dist/index.cjs +121 -41
- package/dist/index.d.cts +7 -48
- package/dist/index.d.ts +7 -48
- package/dist/index.mjs +119 -41
- package/dist/playwright/index.cjs +11 -4
- package/dist/playwright/index.d.cts +3 -50
- package/dist/playwright/index.d.ts +3 -50
- package/dist/playwright/index.mjs +11 -4
- package/dist/proxy.js +115 -37
- package/package.json +1 -1
package/dist/proxy.js
CHANGED
|
@@ -2,8 +2,10 @@ import path from 'path';
|
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
4
|
import http from 'http';
|
|
5
|
+
import https from 'https';
|
|
5
6
|
import httpProxy from 'http-proxy';
|
|
6
7
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
8
|
+
import filenamify from 'filenamify';
|
|
7
9
|
|
|
8
10
|
// src/cli.ts
|
|
9
11
|
var DEFAULT_PORT = 8e3;
|
|
@@ -72,6 +74,24 @@ async function saveRecordingSession(recordingsDir2, session) {
|
|
|
72
74
|
`Saved ${session.recordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
|
|
73
75
|
);
|
|
74
76
|
}
|
|
77
|
+
var QUERY_HASH_LENGTH = 8;
|
|
78
|
+
function getReqID(req) {
|
|
79
|
+
const urlParts = req.url.split("?");
|
|
80
|
+
const pathname = urlParts[0];
|
|
81
|
+
const query = urlParts[1] || "";
|
|
82
|
+
const pathPart = pathname === "/" ? "root" : pathname.slice(1);
|
|
83
|
+
const normalizedPath = filenamify(pathPart, { replacement: "_" });
|
|
84
|
+
const queryHash = generateQueryHash(query);
|
|
85
|
+
const filename = `${req.method}_${normalizedPath}${queryHash}.json`;
|
|
86
|
+
return filenamify(filename, { replacement: "_" });
|
|
87
|
+
}
|
|
88
|
+
function generateQueryHash(query) {
|
|
89
|
+
if (!query) {
|
|
90
|
+
return "";
|
|
91
|
+
}
|
|
92
|
+
const hash = Buffer.from(query).toString("base64").replaceAll(/[^a-zA-Z0-9]/g, "").slice(0, Math.max(0, QUERY_HASH_LENGTH));
|
|
93
|
+
return `_${hash}`;
|
|
94
|
+
}
|
|
75
95
|
|
|
76
96
|
// src/utils/httpHelpers.ts
|
|
77
97
|
var CONTENT_TYPE_JSON = "application/json";
|
|
@@ -87,28 +107,6 @@ function sendJsonResponse(res, statusCode, data) {
|
|
|
87
107
|
res.end(JSON.stringify(data));
|
|
88
108
|
}
|
|
89
109
|
|
|
90
|
-
// src/utils/requestKeyGenerator.ts
|
|
91
|
-
var QUERY_HASH_LENGTH = 8;
|
|
92
|
-
function generateRequestKey(req) {
|
|
93
|
-
const urlParts = req.url.split("?");
|
|
94
|
-
const pathname = urlParts[0];
|
|
95
|
-
const query = urlParts[1] || "";
|
|
96
|
-
const normalizedPath = normalizePathname(pathname);
|
|
97
|
-
const queryHash = generateQueryHash(query);
|
|
98
|
-
return `${req.method}_${normalizedPath}${queryHash}.json`;
|
|
99
|
-
}
|
|
100
|
-
function normalizePathname(pathname) {
|
|
101
|
-
const normalized = pathname.replaceAll("/", "_").replace(/^_/, "");
|
|
102
|
-
return normalized || "root";
|
|
103
|
-
}
|
|
104
|
-
function generateQueryHash(query) {
|
|
105
|
-
if (!query) {
|
|
106
|
-
return "";
|
|
107
|
-
}
|
|
108
|
-
const hash = Buffer.from(query).toString("base64").replaceAll(/[^a-zA-Z0-9]/g, "").slice(0, Math.max(0, QUERY_HASH_LENGTH));
|
|
109
|
-
return `_${hash}`;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
110
|
// src/ProxyServer.ts
|
|
113
111
|
var ProxyServer = class {
|
|
114
112
|
targets;
|
|
@@ -120,6 +118,10 @@ var ProxyServer = class {
|
|
|
120
118
|
proxy;
|
|
121
119
|
currentSession;
|
|
122
120
|
recordingsDir;
|
|
121
|
+
requestSequenceMap;
|
|
122
|
+
// Track sequence per request key
|
|
123
|
+
replaySequenceMap;
|
|
124
|
+
// Track replay position per request key
|
|
123
125
|
constructor(targets2, recordingsDir2) {
|
|
124
126
|
this.targets = targets2;
|
|
125
127
|
this.currentTargetIndex = 0;
|
|
@@ -129,6 +131,8 @@ var ProxyServer = class {
|
|
|
129
131
|
this.modeTimeout = null;
|
|
130
132
|
this.currentSession = null;
|
|
131
133
|
this.recordingsDir = recordingsDir2;
|
|
134
|
+
this.requestSequenceMap = /* @__PURE__ */ new Map();
|
|
135
|
+
this.replaySequenceMap = /* @__PURE__ */ new Map();
|
|
132
136
|
this.proxy = httpProxy.createProxyServer({
|
|
133
137
|
secure: false,
|
|
134
138
|
changeOrigin: true
|
|
@@ -167,10 +171,19 @@ var ProxyServer = class {
|
|
|
167
171
|
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
168
172
|
}
|
|
169
173
|
handleProxyResponse(proxyRes, req) {
|
|
174
|
+
this.addCorsHeaders(proxyRes, req);
|
|
170
175
|
if (this.mode === Modes.record && this.recordingId) {
|
|
171
176
|
this.recordResponse(req, proxyRes);
|
|
172
177
|
}
|
|
173
178
|
}
|
|
179
|
+
addCorsHeaders(proxyRes, req) {
|
|
180
|
+
const origin = req.headers.origin;
|
|
181
|
+
proxyRes.headers["access-control-allow-origin"] = origin || "*";
|
|
182
|
+
proxyRes.headers["access-control-allow-credentials"] = "true";
|
|
183
|
+
proxyRes.headers["access-control-allow-headers"] = req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization";
|
|
184
|
+
proxyRes.headers["access-control-allow-methods"] = "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
|
185
|
+
proxyRes.headers["access-control-expose-headers"] = "*";
|
|
186
|
+
}
|
|
174
187
|
getTarget() {
|
|
175
188
|
const target = this.targets[this.currentTargetIndex];
|
|
176
189
|
this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
|
|
@@ -234,6 +247,7 @@ var ProxyServer = class {
|
|
|
234
247
|
this.recordingId = null;
|
|
235
248
|
this.replayId = null;
|
|
236
249
|
this.currentSession = null;
|
|
250
|
+
clearTimeout(this.modeTimeout || 0);
|
|
237
251
|
console.log("Switched to transparent mode");
|
|
238
252
|
}
|
|
239
253
|
switchToRecordMode(id) {
|
|
@@ -244,6 +258,7 @@ var ProxyServer = class {
|
|
|
244
258
|
this.recordingId = id;
|
|
245
259
|
this.replayId = null;
|
|
246
260
|
this.currentSession = { id, recordings: [], websocketRecordings: [] };
|
|
261
|
+
this.requestSequenceMap.clear();
|
|
247
262
|
console.log(`Switched to record mode with ID: ${id}`);
|
|
248
263
|
}
|
|
249
264
|
switchToReplayMode(id) {
|
|
@@ -254,6 +269,7 @@ var ProxyServer = class {
|
|
|
254
269
|
this.replayId = id;
|
|
255
270
|
this.recordingId = null;
|
|
256
271
|
this.currentSession = null;
|
|
272
|
+
this.replaySequenceMap.clear();
|
|
257
273
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
258
274
|
}
|
|
259
275
|
setupModeTimeout(timeout) {
|
|
@@ -284,7 +300,9 @@ var ProxyServer = class {
|
|
|
284
300
|
if (!this.currentSession) {
|
|
285
301
|
return;
|
|
286
302
|
}
|
|
287
|
-
const key =
|
|
303
|
+
const key = getReqID(req);
|
|
304
|
+
const currentSequence = this.requestSequenceMap.get(key) || 0;
|
|
305
|
+
this.requestSequenceMap.set(key, currentSequence + 1);
|
|
288
306
|
const record = {
|
|
289
307
|
request: {
|
|
290
308
|
method: req.method,
|
|
@@ -293,7 +311,8 @@ var ProxyServer = class {
|
|
|
293
311
|
body: body || null
|
|
294
312
|
},
|
|
295
313
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
296
|
-
key
|
|
314
|
+
key,
|
|
315
|
+
sequence: currentSequence
|
|
297
316
|
};
|
|
298
317
|
this.currentSession.recordings.push(record);
|
|
299
318
|
}
|
|
@@ -301,8 +320,10 @@ var ProxyServer = class {
|
|
|
301
320
|
if (!this.currentSession) {
|
|
302
321
|
return;
|
|
303
322
|
}
|
|
304
|
-
const key =
|
|
305
|
-
const record = this.currentSession.recordings.
|
|
323
|
+
const key = getReqID(req);
|
|
324
|
+
const record = this.currentSession.recordings.findLast(
|
|
325
|
+
(r) => r.key === key && !r.response
|
|
326
|
+
);
|
|
306
327
|
if (!record) {
|
|
307
328
|
console.error("Request record not found for response:", key);
|
|
308
329
|
return;
|
|
@@ -323,21 +344,35 @@ var ProxyServer = class {
|
|
|
323
344
|
});
|
|
324
345
|
}
|
|
325
346
|
async handleReplayRequest(req, res) {
|
|
326
|
-
const key =
|
|
347
|
+
const key = getReqID(req);
|
|
327
348
|
const filePath = getRecordingPath(this.recordingsDir, this.replayId);
|
|
328
349
|
try {
|
|
329
350
|
const session = await loadRecordingSession(filePath);
|
|
330
|
-
const
|
|
351
|
+
const currentSequence = this.replaySequenceMap.get(key) || 0;
|
|
352
|
+
const record = session.recordings.find(
|
|
353
|
+
(r) => r.key === key && r.sequence === currentSequence
|
|
354
|
+
);
|
|
331
355
|
if (!record) {
|
|
332
|
-
throw new Error(
|
|
356
|
+
throw new Error(
|
|
357
|
+
`No recording found for ${key} with sequence ${currentSequence}`
|
|
358
|
+
);
|
|
333
359
|
}
|
|
334
360
|
if (!record.response) {
|
|
335
361
|
throw new Error("No response recorded for this request");
|
|
336
362
|
}
|
|
363
|
+
this.replaySequenceMap.set(key, currentSequence + 1);
|
|
337
364
|
const { statusCode, headers, body } = record.response;
|
|
338
|
-
|
|
365
|
+
const origin = req.headers.origin;
|
|
366
|
+
const responseHeaders = {
|
|
367
|
+
...headers,
|
|
368
|
+
"access-control-allow-origin": origin || "*",
|
|
369
|
+
"access-control-allow-credentials": "true"
|
|
370
|
+
};
|
|
371
|
+
res.writeHead(statusCode, responseHeaders);
|
|
339
372
|
res.end(body);
|
|
340
|
-
console.log(
|
|
373
|
+
console.log(
|
|
374
|
+
`Replayed: ${req.method} ${req.url} (sequence: ${currentSequence})`
|
|
375
|
+
);
|
|
341
376
|
} catch (error) {
|
|
342
377
|
this.handleReplayError(res, error, key, filePath);
|
|
343
378
|
}
|
|
@@ -353,6 +388,9 @@ var ProxyServer = class {
|
|
|
353
388
|
});
|
|
354
389
|
}
|
|
355
390
|
async handleRequest(req, res) {
|
|
391
|
+
if (req.method === "OPTIONS") {
|
|
392
|
+
return this.handleCorsPreflightRequest(req, res);
|
|
393
|
+
}
|
|
356
394
|
if (req.url === CONTROL_ENDPOINT) {
|
|
357
395
|
return this.handleControlRequest(req, res);
|
|
358
396
|
}
|
|
@@ -361,23 +399,63 @@ var ProxyServer = class {
|
|
|
361
399
|
}
|
|
362
400
|
await this.handleProxyRequest(req, res);
|
|
363
401
|
}
|
|
402
|
+
handleCorsPreflightRequest(req, res) {
|
|
403
|
+
const origin = req.headers.origin;
|
|
404
|
+
res.writeHead(HTTP_STATUS_OK, {
|
|
405
|
+
"Access-Control-Allow-Origin": origin || "*",
|
|
406
|
+
"Access-Control-Allow-Credentials": "true",
|
|
407
|
+
"Access-Control-Allow-Headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
|
|
408
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
409
|
+
"Access-Control-Max-Age": "86400"
|
|
410
|
+
// 24 hours
|
|
411
|
+
});
|
|
412
|
+
res.end();
|
|
413
|
+
}
|
|
364
414
|
async handleProxyRequest(req, res) {
|
|
365
415
|
const target = this.getTarget();
|
|
366
416
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
|
|
367
417
|
if (this.mode === Modes.record) {
|
|
368
|
-
await this.
|
|
418
|
+
await this.bufferAndProxyRequest(req, res, target);
|
|
419
|
+
} else {
|
|
420
|
+
this.proxy.web(req, res, { target });
|
|
369
421
|
}
|
|
370
|
-
this.proxy.web(req, res, { target });
|
|
371
422
|
}
|
|
372
|
-
async
|
|
423
|
+
async bufferAndProxyRequest(req, res, target) {
|
|
373
424
|
const chunks = [];
|
|
374
425
|
req.on("data", (chunk) => {
|
|
375
426
|
chunks.push(chunk);
|
|
376
427
|
});
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
await this.saveRequestRecord(req, body);
|
|
428
|
+
await new Promise((resolve) => {
|
|
429
|
+
req.on("end", () => resolve());
|
|
380
430
|
});
|
|
431
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
432
|
+
await this.saveRequestRecord(req, body);
|
|
433
|
+
const targetUrl = new URL(target);
|
|
434
|
+
const isHttps = targetUrl.protocol === "https:";
|
|
435
|
+
const requestModule = isHttps ? https : http;
|
|
436
|
+
const defaultPort = isHttps ? 443 : 80;
|
|
437
|
+
const proxyReq = requestModule.request(
|
|
438
|
+
{
|
|
439
|
+
hostname: targetUrl.hostname,
|
|
440
|
+
port: targetUrl.port || defaultPort,
|
|
441
|
+
path: req.url,
|
|
442
|
+
method: req.method,
|
|
443
|
+
headers: req.headers
|
|
444
|
+
},
|
|
445
|
+
(proxyRes) => {
|
|
446
|
+
this.addCorsHeaders(proxyRes, req);
|
|
447
|
+
this.recordResponse(req, proxyRes);
|
|
448
|
+
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
449
|
+
proxyRes.pipe(res);
|
|
450
|
+
}
|
|
451
|
+
);
|
|
452
|
+
proxyReq.on("error", (err) => {
|
|
453
|
+
this.handleProxyError(err, req, res);
|
|
454
|
+
});
|
|
455
|
+
if (chunks.length > 0) {
|
|
456
|
+
proxyReq.write(Buffer.concat(chunks));
|
|
457
|
+
}
|
|
458
|
+
proxyReq.end();
|
|
381
459
|
}
|
|
382
460
|
handleUpgrade(req, socket, head) {
|
|
383
461
|
if (this.mode === Modes.replay) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "test-proxy-recorder",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|