test-proxy-recorder 0.1.0 → 0.1.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/dist/index.cjs +597 -0
- package/dist/index.d.cts +88 -0
- package/dist/index.d.ts +88 -5
- package/dist/index.mjs +582 -0
- package/dist/playwright/index.cjs +72 -0
- package/dist/playwright/index.d.cts +50 -0
- package/dist/playwright/index.d.ts +12 -10
- package/dist/playwright/index.mjs +65 -0
- package/dist/proxy.js +555 -4
- package/package.json +11 -5
- package/dist/ProxyServer.d.ts +0 -39
- package/dist/ProxyServer.d.ts.map +0 -1
- package/dist/ProxyServer.js +0 -464
- package/dist/cli.d.ts +0 -8
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -32
- package/dist/constants.d.ts +0 -7
- package/dist/constants.d.ts.map +0 -1
- package/dist/constants.js +0 -7
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -3
- package/dist/playwright/index.d.ts.map +0 -1
- package/dist/playwright/index.js +0 -92
- package/dist/proxy.d.ts +0 -2
- package/dist/proxy.d.ts.map +0 -1
- package/dist/types.d.ts +0 -46
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -6
- package/dist/utils/fileUtils.d.ts +0 -5
- package/dist/utils/fileUtils.d.ts.map +0 -1
- package/dist/utils/fileUtils.js +0 -16
- package/dist/utils/httpHelpers.d.ts +0 -4
- package/dist/utils/httpHelpers.d.ts.map +0 -1
- package/dist/utils/httpHelpers.js +0 -13
- package/dist/utils/index.d.ts +0 -4
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js +0 -4
- package/dist/utils/requestKeyGenerator.d.ts +0 -3
- package/dist/utils/requestKeyGenerator.d.ts.map +0 -1
- package/dist/utils/requestKeyGenerator.js +0 -24
package/dist/proxy.js
CHANGED
|
@@ -1,8 +1,559 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import http from 'http';
|
|
5
|
+
import httpProxy from 'http-proxy';
|
|
6
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
7
|
+
|
|
8
|
+
// src/cli.ts
|
|
9
|
+
var DEFAULT_PORT = 8e3;
|
|
10
|
+
var DEFAULT_RECORDINGS_DIR = "./recordings";
|
|
11
|
+
function parseCliArgs() {
|
|
12
|
+
const program = new Command();
|
|
13
|
+
program.name("dev-proxy").description(
|
|
14
|
+
"Development proxy server with recording and replay capabilities"
|
|
15
|
+
).argument(
|
|
16
|
+
"<targets...>",
|
|
17
|
+
"Target API service URLs (e.g., http://localhost:3000)"
|
|
18
|
+
).option(
|
|
19
|
+
"-p, --port <number>",
|
|
20
|
+
"Port number for the proxy server",
|
|
21
|
+
String(DEFAULT_PORT)
|
|
22
|
+
).option(
|
|
23
|
+
"-r, --recordings-dir <path>",
|
|
24
|
+
"Directory to store recordings (relative to CWD)",
|
|
25
|
+
DEFAULT_RECORDINGS_DIR
|
|
26
|
+
).action(() => {
|
|
27
|
+
});
|
|
28
|
+
program.parse();
|
|
29
|
+
const targets2 = program.args;
|
|
30
|
+
const options = program.opts();
|
|
31
|
+
const port2 = Number.parseInt(options.port, 10);
|
|
32
|
+
if (Number.isNaN(port2) || port2 < 1025 || port2 > 65535) {
|
|
33
|
+
console.error("Error: Invalid port number. Must be between 1 and 65535");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
if (targets2.length === 0) {
|
|
37
|
+
program.help();
|
|
38
|
+
}
|
|
39
|
+
const recordingsDir2 = path.resolve(process.cwd(), options.recordingsDir);
|
|
40
|
+
return { targets: targets2, port: port2, recordingsDir: recordingsDir2 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/constants.ts
|
|
44
|
+
var DEFAULT_TIMEOUT_MS = 120 * 1e3;
|
|
45
|
+
var HTTP_STATUS_BAD_GATEWAY = 502;
|
|
46
|
+
var HTTP_STATUS_OK = 200;
|
|
47
|
+
var HTTP_STATUS_BAD_REQUEST = 400;
|
|
48
|
+
var HTTP_STATUS_NOT_FOUND = 404;
|
|
49
|
+
var CONTROL_ENDPOINT = "/__control";
|
|
50
|
+
|
|
51
|
+
// src/types.ts
|
|
52
|
+
var Modes = {
|
|
53
|
+
transparent: "transparent",
|
|
54
|
+
record: "record",
|
|
55
|
+
replay: "replay"
|
|
56
|
+
};
|
|
57
|
+
var JSON_INDENT_SPACES = 2;
|
|
58
|
+
function getRecordingPath(recordingsDir2, id) {
|
|
59
|
+
return path.join(recordingsDir2, `${id}.json`);
|
|
60
|
+
}
|
|
61
|
+
async function loadRecordingSession(filePath) {
|
|
62
|
+
const fileContent = await fs.readFile(filePath, "utf8");
|
|
63
|
+
return JSON.parse(fileContent);
|
|
64
|
+
}
|
|
65
|
+
async function saveRecordingSession(recordingsDir2, session) {
|
|
66
|
+
const filePath = getRecordingPath(recordingsDir2, session.id);
|
|
67
|
+
await fs.writeFile(
|
|
68
|
+
filePath,
|
|
69
|
+
JSON.stringify(session, null, JSON_INDENT_SPACES)
|
|
70
|
+
);
|
|
71
|
+
console.log(
|
|
72
|
+
`Saved ${session.recordings.length} HTTP recordings and ${session.websocketRecordings?.length || 0} WebSocket recordings to ${filePath}`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/utils/httpHelpers.ts
|
|
77
|
+
var CONTENT_TYPE_JSON = "application/json";
|
|
78
|
+
async function readRequestBody(req) {
|
|
79
|
+
let body = "";
|
|
80
|
+
for await (const chunk of req) {
|
|
81
|
+
body += chunk.toString();
|
|
82
|
+
}
|
|
83
|
+
return body;
|
|
84
|
+
}
|
|
85
|
+
function sendJsonResponse(res, statusCode, data) {
|
|
86
|
+
res.writeHead(statusCode, { "Content-Type": CONTENT_TYPE_JSON });
|
|
87
|
+
res.end(JSON.stringify(data));
|
|
88
|
+
}
|
|
89
|
+
|
|
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
|
+
// src/ProxyServer.ts
|
|
113
|
+
var ProxyServer = class {
|
|
114
|
+
targets;
|
|
115
|
+
currentTargetIndex;
|
|
116
|
+
mode;
|
|
117
|
+
recordingId;
|
|
118
|
+
replayId;
|
|
119
|
+
modeTimeout;
|
|
120
|
+
proxy;
|
|
121
|
+
currentSession;
|
|
122
|
+
recordingsDir;
|
|
123
|
+
constructor(targets2, recordingsDir2) {
|
|
124
|
+
this.targets = targets2;
|
|
125
|
+
this.currentTargetIndex = 0;
|
|
126
|
+
this.mode = Modes.transparent;
|
|
127
|
+
this.recordingId = null;
|
|
128
|
+
this.replayId = null;
|
|
129
|
+
this.modeTimeout = null;
|
|
130
|
+
this.currentSession = null;
|
|
131
|
+
this.recordingsDir = recordingsDir2;
|
|
132
|
+
this.proxy = httpProxy.createProxyServer({
|
|
133
|
+
secure: false,
|
|
134
|
+
changeOrigin: true
|
|
135
|
+
});
|
|
136
|
+
this.setupProxyEventHandlers();
|
|
137
|
+
}
|
|
138
|
+
async init() {
|
|
139
|
+
await fs.mkdir(this.recordingsDir, { recursive: true });
|
|
140
|
+
}
|
|
141
|
+
listen(port2) {
|
|
142
|
+
const server = http.createServer((req, res) => {
|
|
143
|
+
this.handleRequest(req, res);
|
|
144
|
+
});
|
|
145
|
+
server.on("upgrade", (req, socket, head) => {
|
|
146
|
+
this.handleUpgrade(req, socket, head);
|
|
147
|
+
});
|
|
148
|
+
server.listen(port2, () => {
|
|
149
|
+
this.logServerStartup(port2);
|
|
150
|
+
});
|
|
151
|
+
return server;
|
|
152
|
+
}
|
|
153
|
+
setupProxyEventHandlers() {
|
|
154
|
+
this.proxy.on("error", this.handleProxyError.bind(this));
|
|
155
|
+
this.proxy.on("proxyRes", this.handleProxyResponse.bind(this));
|
|
156
|
+
}
|
|
157
|
+
handleProxyError(err, _req, res) {
|
|
158
|
+
console.error("Proxy error:", err);
|
|
159
|
+
if (!(res instanceof http.ServerResponse)) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (!res.headersSent) {
|
|
163
|
+
res.writeHead(HTTP_STATUS_BAD_GATEWAY, {
|
|
164
|
+
"Content-Type": "application/json"
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
168
|
+
}
|
|
169
|
+
handleProxyResponse(proxyRes, req) {
|
|
170
|
+
if (this.mode === Modes.record && this.recordingId) {
|
|
171
|
+
this.recordResponse(req, proxyRes);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
getTarget() {
|
|
175
|
+
const target = this.targets[this.currentTargetIndex];
|
|
176
|
+
this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
|
|
177
|
+
return target;
|
|
178
|
+
}
|
|
179
|
+
async handleControlRequest(req, res) {
|
|
180
|
+
try {
|
|
181
|
+
const body = await readRequestBody(req);
|
|
182
|
+
console.log("MODE CHANGE", body);
|
|
183
|
+
const data = JSON.parse(body);
|
|
184
|
+
const { mode, id, timeout: requestTimeout } = data;
|
|
185
|
+
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
186
|
+
this.clearModeTimeout();
|
|
187
|
+
await this.switchMode(mode, id);
|
|
188
|
+
this.setupModeTimeout(timeout);
|
|
189
|
+
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
190
|
+
success: true,
|
|
191
|
+
mode: this.mode,
|
|
192
|
+
id: this.recordingId || this.replayId,
|
|
193
|
+
timeout
|
|
194
|
+
});
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error("Control request error:", error);
|
|
197
|
+
sendJsonResponse(res, HTTP_STATUS_BAD_REQUEST, {
|
|
198
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
clearModeTimeout() {
|
|
203
|
+
if (this.modeTimeout) {
|
|
204
|
+
clearTimeout(this.modeTimeout);
|
|
205
|
+
this.modeTimeout = null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async switchMode(mode, id) {
|
|
209
|
+
if (this.currentSession) {
|
|
210
|
+
console.log("Switching mode, saving current session first");
|
|
211
|
+
await this.saveCurrentSession();
|
|
212
|
+
console.log("Session saved, continuing with mode switch");
|
|
213
|
+
}
|
|
214
|
+
switch (mode) {
|
|
215
|
+
case Modes.transparent: {
|
|
216
|
+
this.switchToTransparentMode();
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case Modes.record: {
|
|
220
|
+
this.switchToRecordMode(id);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case Modes.replay: {
|
|
224
|
+
this.switchToReplayMode(id);
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
default: {
|
|
228
|
+
throw new Error("Invalid mode. Use: transparent, record, or replay");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
switchToTransparentMode() {
|
|
233
|
+
this.mode = Modes.transparent;
|
|
234
|
+
this.recordingId = null;
|
|
235
|
+
this.replayId = null;
|
|
236
|
+
this.currentSession = null;
|
|
237
|
+
console.log("Switched to transparent mode");
|
|
238
|
+
}
|
|
239
|
+
switchToRecordMode(id) {
|
|
240
|
+
if (!id) {
|
|
241
|
+
throw new Error("Record ID is required");
|
|
242
|
+
}
|
|
243
|
+
this.mode = Modes.record;
|
|
244
|
+
this.recordingId = id;
|
|
245
|
+
this.replayId = null;
|
|
246
|
+
this.currentSession = { id, recordings: [], websocketRecordings: [] };
|
|
247
|
+
console.log(`Switched to record mode with ID: ${id}`);
|
|
248
|
+
}
|
|
249
|
+
switchToReplayMode(id) {
|
|
250
|
+
if (!id) {
|
|
251
|
+
throw new Error("Replay ID is required");
|
|
252
|
+
}
|
|
253
|
+
this.mode = Modes.replay;
|
|
254
|
+
this.replayId = id;
|
|
255
|
+
this.recordingId = null;
|
|
256
|
+
this.currentSession = null;
|
|
257
|
+
console.log(`Switched to replay mode with ID: ${id}`);
|
|
258
|
+
}
|
|
259
|
+
setupModeTimeout(timeout) {
|
|
260
|
+
if (timeout && timeout > 0) {
|
|
261
|
+
this.modeTimeout = setTimeout(async () => {
|
|
262
|
+
console.log("Timeout reached, switching back to transparent mode");
|
|
263
|
+
await this.saveCurrentSession();
|
|
264
|
+
this.switchToTransparentMode();
|
|
265
|
+
this.modeTimeout = null;
|
|
266
|
+
}, timeout);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async saveCurrentSession() {
|
|
270
|
+
if (!this.currentSession) {
|
|
271
|
+
console.log("No current session to save");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (this.currentSession.recordings.length === 0 && this.currentSession.websocketRecordings.length === 0) {
|
|
275
|
+
console.log("Session has no recordings, skipping save");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
console.log(
|
|
279
|
+
`Saving session with ${this.currentSession.recordings.length} HTTP and ${this.currentSession.websocketRecordings.length} WebSocket recordings`
|
|
280
|
+
);
|
|
281
|
+
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
282
|
+
}
|
|
283
|
+
async saveRequestRecord(req, body) {
|
|
284
|
+
if (!this.currentSession) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const key = generateRequestKey(req);
|
|
288
|
+
const record = {
|
|
289
|
+
request: {
|
|
290
|
+
method: req.method,
|
|
291
|
+
url: req.url,
|
|
292
|
+
headers: req.headers,
|
|
293
|
+
body: body || null
|
|
294
|
+
},
|
|
295
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
296
|
+
key
|
|
297
|
+
};
|
|
298
|
+
this.currentSession.recordings.push(record);
|
|
299
|
+
}
|
|
300
|
+
async recordResponse(req, proxyRes) {
|
|
301
|
+
if (!this.currentSession) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const key = generateRequestKey(req);
|
|
305
|
+
const record = this.currentSession.recordings.find((r) => r.key === key);
|
|
306
|
+
if (!record) {
|
|
307
|
+
console.error("Request record not found for response:", key);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const chunks = [];
|
|
311
|
+
proxyRes.on("data", (chunk) => {
|
|
312
|
+
chunks.push(chunk);
|
|
313
|
+
});
|
|
314
|
+
proxyRes.on("end", async () => {
|
|
315
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
316
|
+
record.response = {
|
|
317
|
+
statusCode: proxyRes.statusCode,
|
|
318
|
+
headers: proxyRes.headers,
|
|
319
|
+
body: body || null
|
|
320
|
+
};
|
|
321
|
+
await this.saveCurrentSession();
|
|
322
|
+
console.log(`Recorded: ${req.method} ${req.url}`);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
async handleReplayRequest(req, res) {
|
|
326
|
+
const key = generateRequestKey(req);
|
|
327
|
+
const filePath = getRecordingPath(this.recordingsDir, this.replayId);
|
|
328
|
+
try {
|
|
329
|
+
const session = await loadRecordingSession(filePath);
|
|
330
|
+
const record = session.recordings.find((r) => r.key === key);
|
|
331
|
+
if (!record) {
|
|
332
|
+
throw new Error(`No recording found for ${key}`);
|
|
333
|
+
}
|
|
334
|
+
if (!record.response) {
|
|
335
|
+
throw new Error("No response recorded for this request");
|
|
336
|
+
}
|
|
337
|
+
const { statusCode, headers, body } = record.response;
|
|
338
|
+
res.writeHead(statusCode, headers);
|
|
339
|
+
res.end(body);
|
|
340
|
+
console.log(`Replayed: ${req.method} ${req.url}`);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
this.handleReplayError(res, error, key, filePath);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
handleReplayError(res, err, key, filePath) {
|
|
346
|
+
const isFileNotFound = err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
347
|
+
console.error("Replay error:", err);
|
|
348
|
+
sendJsonResponse(res, HTTP_STATUS_NOT_FOUND, {
|
|
349
|
+
error: isFileNotFound ? "Recording file not found" : "Recording not found",
|
|
350
|
+
message: err instanceof Error ? err.message : "Unknown error",
|
|
351
|
+
key,
|
|
352
|
+
filePath
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
async handleRequest(req, res) {
|
|
356
|
+
if (req.url === CONTROL_ENDPOINT) {
|
|
357
|
+
return this.handleControlRequest(req, res);
|
|
358
|
+
}
|
|
359
|
+
if (this.mode === Modes.replay) {
|
|
360
|
+
return this.handleReplayRequest(req, res);
|
|
361
|
+
}
|
|
362
|
+
await this.handleProxyRequest(req, res);
|
|
363
|
+
}
|
|
364
|
+
async handleProxyRequest(req, res) {
|
|
365
|
+
const target = this.getTarget();
|
|
366
|
+
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
|
|
367
|
+
if (this.mode === Modes.record) {
|
|
368
|
+
await this.bufferRequestForRecord(req);
|
|
369
|
+
}
|
|
370
|
+
this.proxy.web(req, res, { target });
|
|
371
|
+
}
|
|
372
|
+
async bufferRequestForRecord(req) {
|
|
373
|
+
const chunks = [];
|
|
374
|
+
req.on("data", (chunk) => {
|
|
375
|
+
chunks.push(chunk);
|
|
376
|
+
});
|
|
377
|
+
req.on("end", async () => {
|
|
378
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
379
|
+
await this.saveRequestRecord(req, body);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
handleUpgrade(req, socket, head) {
|
|
383
|
+
if (this.mode === Modes.replay) {
|
|
384
|
+
this.handleReplayWebSocket(req, socket);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const target = this.getTarget();
|
|
388
|
+
console.log(`[${this.mode}] WebSocket upgrade ${req.url} -> ${target}`);
|
|
389
|
+
if (this.mode === Modes.record) {
|
|
390
|
+
this.handleRecordWebSocket(req, socket, head, target);
|
|
391
|
+
} else {
|
|
392
|
+
this.proxy.ws(req, socket, head, { target });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
handleRecordWebSocket(req, clientSocket, head, target) {
|
|
396
|
+
const url = req.url || "/";
|
|
397
|
+
const key = `WS_${url.replaceAll("/", "_")}`;
|
|
398
|
+
const wsRecording = {
|
|
399
|
+
url,
|
|
400
|
+
messages: [],
|
|
401
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
402
|
+
key
|
|
403
|
+
};
|
|
404
|
+
if (this.currentSession) {
|
|
405
|
+
this.currentSession.websocketRecordings.push(wsRecording);
|
|
406
|
+
}
|
|
407
|
+
const backendWsUrl = `${target.replace("http", "ws")}${url}`;
|
|
408
|
+
const backendWs = new WebSocket(backendWsUrl);
|
|
409
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
410
|
+
backendWs.on("open", () => {
|
|
411
|
+
console.log(`WebSocket recording: connected to backend ${backendWsUrl}`);
|
|
412
|
+
wss.handleUpgrade(req, clientSocket, head, (clientWs) => {
|
|
413
|
+
clientWs.on("message", (data) => {
|
|
414
|
+
const message = data.toString();
|
|
415
|
+
wsRecording.messages.push({
|
|
416
|
+
direction: "client-to-server",
|
|
417
|
+
data: message,
|
|
418
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
419
|
+
});
|
|
420
|
+
if (backendWs.readyState === WebSocket.OPEN) {
|
|
421
|
+
backendWs.send(message);
|
|
422
|
+
}
|
|
423
|
+
this.saveCurrentSession().catch((error) => {
|
|
424
|
+
console.error("Failed to save WebSocket recording:", error);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
backendWs.on("message", (data) => {
|
|
428
|
+
const message = data.toString();
|
|
429
|
+
wsRecording.messages.push({
|
|
430
|
+
direction: "server-to-client",
|
|
431
|
+
data: message,
|
|
432
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
433
|
+
});
|
|
434
|
+
if (clientWs.readyState === WebSocket.OPEN) {
|
|
435
|
+
clientWs.send(message);
|
|
436
|
+
}
|
|
437
|
+
this.saveCurrentSession().catch((error) => {
|
|
438
|
+
console.error("Failed to save WebSocket recording:", error);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
clientWs.on("error", (err) => {
|
|
442
|
+
console.error("Client WebSocket error:", err);
|
|
443
|
+
});
|
|
444
|
+
backendWs.on("error", (err) => {
|
|
445
|
+
console.error("Backend WebSocket error:", err);
|
|
446
|
+
});
|
|
447
|
+
clientWs.on("close", () => {
|
|
448
|
+
backendWs.close();
|
|
449
|
+
console.log("Client WebSocket closed");
|
|
450
|
+
});
|
|
451
|
+
backendWs.on("close", () => {
|
|
452
|
+
clientWs.close();
|
|
453
|
+
console.log("Backend WebSocket closed");
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
backendWs.on("error", (err) => {
|
|
458
|
+
console.error("Backend WebSocket connection error:", err);
|
|
459
|
+
clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
|
460
|
+
clientSocket.destroy();
|
|
461
|
+
});
|
|
462
|
+
wss.on("error", (err) => {
|
|
463
|
+
console.error("WebSocket server error:", err);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
handleReplayWebSocket(req, socket) {
|
|
467
|
+
const url = req.url || "/";
|
|
468
|
+
const key = `WS_${url.replaceAll("/", "_")}`;
|
|
469
|
+
const filePath = getRecordingPath(this.recordingsDir, this.replayId);
|
|
470
|
+
loadRecordingSession(filePath).then((session) => {
|
|
471
|
+
const wsRecording = session.websocketRecordings.find(
|
|
472
|
+
(r) => r.key === key
|
|
473
|
+
);
|
|
474
|
+
if (!wsRecording) {
|
|
475
|
+
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
476
|
+
socket.destroy();
|
|
477
|
+
console.log(`No WebSocket recording found for ${key}`);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
481
|
+
const fakeReq = Object.assign(req, {
|
|
482
|
+
headers: {
|
|
483
|
+
...req.headers,
|
|
484
|
+
"sec-websocket-key": req.headers["sec-websocket-key"] || "replay-key",
|
|
485
|
+
"sec-websocket-version": "13"
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
wss.handleUpgrade(fakeReq, socket, Buffer.alloc(0), (ws) => {
|
|
489
|
+
console.log(`Replaying WebSocket: ${url}`);
|
|
490
|
+
const serverMessages = wsRecording.messages.filter(
|
|
491
|
+
(m) => m.direction === "server-to-client"
|
|
492
|
+
);
|
|
493
|
+
let messageIndex = 0;
|
|
494
|
+
ws.on("message", (data) => {
|
|
495
|
+
const clientMessage = data.toString();
|
|
496
|
+
console.log(`Replay: Client sent: ${clientMessage}`);
|
|
497
|
+
if (messageIndex < serverMessages.length) {
|
|
498
|
+
setTimeout(() => {
|
|
499
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
500
|
+
ws.send(serverMessages[messageIndex].data);
|
|
501
|
+
console.log(`Replay: Sent server message ${messageIndex}`);
|
|
502
|
+
messageIndex++;
|
|
503
|
+
}
|
|
504
|
+
}, 10);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
let initialMessagesSent = 0;
|
|
508
|
+
for (let i = 0; i < wsRecording.messages.length; i++) {
|
|
509
|
+
const msg = wsRecording.messages[i];
|
|
510
|
+
if (msg.direction === "client-to-server") {
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
if (msg.direction === "server-to-client") {
|
|
514
|
+
setTimeout(
|
|
515
|
+
() => {
|
|
516
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
517
|
+
ws.send(msg.data);
|
|
518
|
+
console.log(
|
|
519
|
+
`Replay: Sent initial server message: ${msg.data}`
|
|
520
|
+
);
|
|
521
|
+
messageIndex++;
|
|
522
|
+
initialMessagesSent++;
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
10 * (initialMessagesSent + 1)
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
ws.on("error", (err) => {
|
|
530
|
+
console.error("Replay WebSocket error:", err);
|
|
531
|
+
});
|
|
532
|
+
ws.on("close", () => {
|
|
533
|
+
console.log("Replay WebSocket closed");
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
}).catch((error) => {
|
|
537
|
+
console.error("Replay error:", error);
|
|
538
|
+
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
539
|
+
socket.destroy();
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
logServerStartup(port2) {
|
|
543
|
+
console.log(`Proxy server running on http://localhost:${port2}`);
|
|
544
|
+
console.log(`Mode: ${this.mode}`);
|
|
545
|
+
console.log(`Targets: ${this.targets.join(", ")}`);
|
|
546
|
+
console.log(
|
|
547
|
+
`Control endpoint: http://localhost:${port2}${CONTROL_ENDPOINT}`
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
// src/proxy.ts
|
|
553
|
+
var { targets, port, recordingsDir } = parseCliArgs();
|
|
554
|
+
var proxy = new ProxyServer(targets, recordingsDir);
|
|
5
555
|
await proxy.init();
|
|
6
556
|
proxy.listen(port);
|
|
7
557
|
console.log(`Recordings will be saved to: ${recordingsDir}`);
|
|
558
|
+
//# sourceMappingURL=proxy.js.map
|
|
8
559
|
//# sourceMappingURL=proxy.js.map
|
package/package.json
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "test-proxy-recorder",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.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
|
-
"main": "dist/index.
|
|
6
|
+
"main": "dist/index.mjs",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
|
+
"require": "./dist/index.cjs",
|
|
10
11
|
"types": "./dist/index.d.ts",
|
|
11
|
-
"
|
|
12
|
+
"default": "./dist/index.mjs"
|
|
12
13
|
},
|
|
13
14
|
"./playwright": {
|
|
15
|
+
"require": "./dist/playwright/index.cjs",
|
|
14
16
|
"types": "./dist/playwright/index.d.ts",
|
|
15
|
-
"
|
|
17
|
+
"default": "./dist/playwright/index.mjs"
|
|
16
18
|
}
|
|
17
19
|
},
|
|
18
20
|
"bin": {
|
|
@@ -20,7 +22,10 @@
|
|
|
20
22
|
},
|
|
21
23
|
"files": [
|
|
22
24
|
"dist/**/*.js",
|
|
25
|
+
"dist/**/*.mjs",
|
|
26
|
+
"dist/**/*.cjs",
|
|
23
27
|
"dist/**/*.d.ts",
|
|
28
|
+
"dist/**/*.d.cts",
|
|
24
29
|
"dist/**/*.d.ts.map",
|
|
25
30
|
"!dist/**/*.test.*",
|
|
26
31
|
"!dist/**/*.integration.test.*",
|
|
@@ -31,7 +36,7 @@
|
|
|
31
36
|
"scripts": {
|
|
32
37
|
"start": "node dist/proxy.js",
|
|
33
38
|
"dev": "tsx src/proxy.ts",
|
|
34
|
-
"build": "
|
|
39
|
+
"build": "tsup",
|
|
35
40
|
"prepublish": "pnpm run build && pnpm run test:run && pnpm run lint",
|
|
36
41
|
"lint": "eslint src --ext .ts",
|
|
37
42
|
"lint:fix": "eslint src --ext .ts --fix",
|
|
@@ -92,6 +97,7 @@
|
|
|
92
97
|
"eslint-plugin-sonarjs": "^3.0.5",
|
|
93
98
|
"eslint-plugin-unicorn": "^62.0.0",
|
|
94
99
|
"prettier": "^3.6.2",
|
|
100
|
+
"tsup": "^8.5.0",
|
|
95
101
|
"tsx": "^4.19.0",
|
|
96
102
|
"typescript": "^5.6.0",
|
|
97
103
|
"vitest": "^3.2.4",
|
package/dist/ProxyServer.d.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import http from 'node:http';
|
|
2
|
-
export declare class ProxyServer {
|
|
3
|
-
private targets;
|
|
4
|
-
private currentTargetIndex;
|
|
5
|
-
private mode;
|
|
6
|
-
private recordingId;
|
|
7
|
-
private replayId;
|
|
8
|
-
private modeTimeout;
|
|
9
|
-
private proxy;
|
|
10
|
-
private currentSession;
|
|
11
|
-
private recordingsDir;
|
|
12
|
-
constructor(targets: string[], recordingsDir: string);
|
|
13
|
-
init(): Promise<void>;
|
|
14
|
-
listen(port: number): http.Server;
|
|
15
|
-
private setupProxyEventHandlers;
|
|
16
|
-
private handleProxyError;
|
|
17
|
-
private handleProxyResponse;
|
|
18
|
-
private getTarget;
|
|
19
|
-
private handleControlRequest;
|
|
20
|
-
private clearModeTimeout;
|
|
21
|
-
private switchMode;
|
|
22
|
-
private switchToTransparentMode;
|
|
23
|
-
private switchToRecordMode;
|
|
24
|
-
private switchToReplayMode;
|
|
25
|
-
private setupModeTimeout;
|
|
26
|
-
private saveCurrentSession;
|
|
27
|
-
private saveRequestRecord;
|
|
28
|
-
private recordResponse;
|
|
29
|
-
private handleReplayRequest;
|
|
30
|
-
private handleReplayError;
|
|
31
|
-
private handleRequest;
|
|
32
|
-
private handleProxyRequest;
|
|
33
|
-
private bufferRequestForRecord;
|
|
34
|
-
private handleUpgrade;
|
|
35
|
-
private handleRecordWebSocket;
|
|
36
|
-
private handleReplayWebSocket;
|
|
37
|
-
private logServerStartup;
|
|
38
|
-
}
|
|
39
|
-
//# sourceMappingURL=ProxyServer.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ProxyServer.d.ts","sourceRoot":"","sources":["../src/ProxyServer.ts"],"names":[],"mappings":"AACA,OAAO,IAAI,MAAM,WAAW,CAAC;AA8B7B,qBAAa,WAAW;IACtB,OAAO,CAAC,OAAO,CAAW;IAC1B,OAAO,CAAC,kBAAkB,CAAS;IACnC,OAAO,CAAC,IAAI,CAAO;IACnB,OAAO,CAAC,WAAW,CAAgB;IACnC,OAAO,CAAC,QAAQ,CAAgB;IAChC,OAAO,CAAC,WAAW,CAAwB;IAC3C,OAAO,CAAC,KAAK,CAAY;IACzB,OAAO,CAAC,cAAc,CAA0B;IAChD,OAAO,CAAC,aAAa,CAAS;gBAElB,OAAO,EAAE,MAAM,EAAE,EAAE,aAAa,EAAE,MAAM;IAiB9C,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAI3B,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC,MAAM;IAiBjC,OAAO,CAAC,uBAAuB;IAK/B,OAAO,CAAC,gBAAgB;IAoBxB,OAAO,CAAC,mBAAmB;IAS3B,OAAO,CAAC,SAAS;YAOH,oBAAoB;IA8BlC,OAAO,CAAC,gBAAgB;YAOV,UAAU;IA8BxB,OAAO,CAAC,uBAAuB;IAQ/B,OAAO,CAAC,kBAAkB;IAW1B,OAAO,CAAC,kBAAkB;IAW1B,OAAO,CAAC,gBAAgB;YAWV,kBAAkB;YAoBlB,iBAAiB;YAuBjB,cAAc;YAmCd,mBAAmB;IA6BjC,OAAO,CAAC,iBAAiB;YAoBX,aAAa;YAeb,kBAAkB;YAclB,sBAAsB;IAepC,OAAO,CAAC,aAAa;IAqBrB,OAAO,CAAC,qBAAqB;IA2G7B,OAAO,CAAC,qBAAqB;IAqG7B,OAAO,CAAC,gBAAgB;CAQzB"}
|