test-proxy-recorder 0.3.4 → 0.3.6
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 -483
- package/dist/{index-BlBWqSE4.d.cts → index-BloXCw69.d.cts} +4 -0
- package/dist/{index-BlBWqSE4.d.ts → index-BloXCw69.d.ts} +4 -0
- package/dist/index.cjs +551 -445
- package/dist/index.d.cts +15 -43
- package/dist/index.d.ts +15 -43
- package/dist/index.mjs +549 -443
- package/dist/playwright/index.cjs +15 -8
- package/dist/playwright/index.d.cts +1 -1
- package/dist/playwright/index.d.ts +1 -1
- package/dist/playwright/index.mjs +15 -8
- package/dist/proxy.js +560 -450
- package/package.json +14 -12
- package/skills/nextjs-ssr/SKILL.md +377 -0
- package/skills/proxy-setup/SKILL.md +458 -0
package/dist/proxy.js
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
|
-
import
|
|
5
|
-
import https from 'https';
|
|
4
|
+
import http2 from 'http';
|
|
6
5
|
import httpProxy from 'http-proxy';
|
|
7
|
-
import
|
|
6
|
+
import https from 'https';
|
|
8
7
|
import crypto from 'crypto';
|
|
9
8
|
import filenamify2 from 'filenamify';
|
|
9
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
10
|
+
|
|
11
|
+
// src/cli.ts
|
|
12
|
+
|
|
13
|
+
// src/constants.ts
|
|
14
|
+
var DEFAULT_TIMEOUT_MS = 120 * 1e3;
|
|
15
|
+
var HTTP_STATUS_BAD_GATEWAY = 502;
|
|
16
|
+
var HTTP_STATUS_OK = 200;
|
|
17
|
+
var HTTP_STATUS_BAD_REQUEST = 400;
|
|
18
|
+
var HTTP_STATUS_NOT_FOUND = 404;
|
|
19
|
+
var CONTROL_ENDPOINT = "/__control";
|
|
20
|
+
var RECORDING_ID_HEADER = "x-test-rcrd-id";
|
|
10
21
|
|
|
11
22
|
// src/cli.ts
|
|
12
23
|
var DEFAULT_PORT = 8e3;
|
|
@@ -26,6 +37,10 @@ function parseCliArgs() {
|
|
|
26
37
|
"-d, --dir <path>",
|
|
27
38
|
"Directory to store recordings (relative to CWD)",
|
|
28
39
|
DEFAULT_RECORDINGS_DIR
|
|
40
|
+
).option(
|
|
41
|
+
"-t, --timeout <ms>",
|
|
42
|
+
"Session timeout in milliseconds",
|
|
43
|
+
String(DEFAULT_TIMEOUT_MS)
|
|
29
44
|
).action(() => {
|
|
30
45
|
});
|
|
31
46
|
program.parse();
|
|
@@ -36,21 +51,255 @@ function parseCliArgs() {
|
|
|
36
51
|
console.error("Error: Invalid port number. Must be between 1 and 65535");
|
|
37
52
|
process.exit(1);
|
|
38
53
|
}
|
|
54
|
+
const timeout2 = Number.parseInt(options.timeout, 10);
|
|
55
|
+
if (Number.isNaN(timeout2) || timeout2 < 0) {
|
|
56
|
+
console.error("Error: Invalid timeout. Must be a non-negative number");
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
39
59
|
if (!target2) {
|
|
40
60
|
program.help();
|
|
41
61
|
}
|
|
42
62
|
const recordingsDir2 = path.resolve(process.cwd(), options.dir);
|
|
43
|
-
return { target: target2, port: port2, recordingsDir: recordingsDir2 };
|
|
63
|
+
return { target: target2, port: port2, recordingsDir: recordingsDir2, timeout: timeout2 };
|
|
44
64
|
}
|
|
45
65
|
|
|
46
|
-
// src/
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
66
|
+
// src/utils/cors.ts
|
|
67
|
+
function getCorsHeaders(req) {
|
|
68
|
+
const origin = req.headers.origin;
|
|
69
|
+
return {
|
|
70
|
+
"access-control-allow-origin": origin || "*",
|
|
71
|
+
"access-control-allow-credentials": "true",
|
|
72
|
+
"access-control-allow-headers": req.headers["access-control-request-headers"] || `Origin, X-Requested-With, Content-Type, Accept, Authorization, ${RECORDING_ID_HEADER}`,
|
|
73
|
+
"access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
74
|
+
"access-control-expose-headers": "*"
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function addCorsHeaders(proxyRes, req) {
|
|
78
|
+
Object.assign(proxyRes.headers, getCorsHeaders(req));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/httpRecorder.ts
|
|
82
|
+
async function bufferRequestBody(req) {
|
|
83
|
+
const chunks = [];
|
|
84
|
+
req.on("data", (chunk) => {
|
|
85
|
+
chunks.push(chunk);
|
|
86
|
+
});
|
|
87
|
+
try {
|
|
88
|
+
await new Promise((resolveBuffer, rejectBuffer) => {
|
|
89
|
+
req.on("end", () => resolveBuffer());
|
|
90
|
+
req.on("error", (err) => rejectBuffer(err));
|
|
91
|
+
setTimeout(
|
|
92
|
+
() => rejectBuffer(new Error("Request buffering timeout")),
|
|
93
|
+
3e4
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error("Error buffering request:", error);
|
|
98
|
+
}
|
|
99
|
+
return chunks;
|
|
100
|
+
}
|
|
101
|
+
function handleProxyResponse(proxyRes, context) {
|
|
102
|
+
const { options, requestBody, resolve } = context;
|
|
103
|
+
const { req, res, key, recordingId, sequence, onProxyError } = options;
|
|
104
|
+
addCorsHeaders(proxyRes, req);
|
|
105
|
+
const responseChunks = [];
|
|
106
|
+
proxyRes.on("data", (chunk) => {
|
|
107
|
+
responseChunks.push(chunk);
|
|
108
|
+
});
|
|
109
|
+
proxyRes.on("end", () => {
|
|
110
|
+
try {
|
|
111
|
+
const responseBody = Buffer.concat(responseChunks);
|
|
112
|
+
const responseBodyStr = responseBody.toString("utf8");
|
|
113
|
+
const recording = {
|
|
114
|
+
request: {
|
|
115
|
+
method: req.method,
|
|
116
|
+
url: req.url,
|
|
117
|
+
headers: req.headers,
|
|
118
|
+
body: requestBody || null
|
|
119
|
+
},
|
|
120
|
+
response: {
|
|
121
|
+
statusCode: proxyRes.statusCode,
|
|
122
|
+
headers: proxyRes.headers,
|
|
123
|
+
body: responseBodyStr || null
|
|
124
|
+
},
|
|
125
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
126
|
+
key,
|
|
127
|
+
recordingId,
|
|
128
|
+
sequence
|
|
129
|
+
};
|
|
130
|
+
const responseHeaders = {
|
|
131
|
+
...proxyRes.headers,
|
|
132
|
+
...getCorsHeaders(req)
|
|
133
|
+
};
|
|
134
|
+
res.writeHead(proxyRes.statusCode || 200, responseHeaders);
|
|
135
|
+
res.end(responseBody);
|
|
136
|
+
console.log(
|
|
137
|
+
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId}, sequence: ${sequence})`
|
|
138
|
+
);
|
|
139
|
+
resolve(recording);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error("Error completing recording:", error);
|
|
142
|
+
resolve(null);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
proxyRes.on("error", (err) => {
|
|
146
|
+
console.error("Proxy response error:", err);
|
|
147
|
+
if (!res.headersSent) {
|
|
148
|
+
onProxyError(err, req, res);
|
|
149
|
+
}
|
|
150
|
+
resolve(null);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function proxyWithBufferedBody(options, chunks) {
|
|
154
|
+
const { req, res, target: target2, onProxyError } = options;
|
|
155
|
+
const requestBody = Buffer.concat(chunks).toString("utf8");
|
|
156
|
+
const targetUrl = new URL(target2);
|
|
157
|
+
const isHttps = targetUrl.protocol === "https:";
|
|
158
|
+
const requestModule = isHttps ? https : http2;
|
|
159
|
+
const defaultPort = isHttps ? 443 : 80;
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
const proxyReq = requestModule.request(
|
|
162
|
+
{
|
|
163
|
+
hostname: targetUrl.hostname,
|
|
164
|
+
port: targetUrl.port || defaultPort,
|
|
165
|
+
path: req.url,
|
|
166
|
+
method: req.method,
|
|
167
|
+
headers: req.headers
|
|
168
|
+
},
|
|
169
|
+
(proxyRes) => {
|
|
170
|
+
handleProxyResponse(proxyRes, { options, requestBody, resolve });
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
proxyReq.on("error", (err) => {
|
|
174
|
+
onProxyError(err, req, res);
|
|
175
|
+
resolve(null);
|
|
176
|
+
});
|
|
177
|
+
if (chunks.length > 0) {
|
|
178
|
+
proxyReq.write(Buffer.concat(chunks));
|
|
179
|
+
}
|
|
180
|
+
proxyReq.end();
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
async function recordAndProxyRequest(options) {
|
|
184
|
+
const { req, res, onProxyError } = options;
|
|
185
|
+
try {
|
|
186
|
+
const chunks = await bufferRequestBody(req);
|
|
187
|
+
return await proxyWithBufferedBody(options, chunks);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.error("Error in recordAndProxyRequest:", error);
|
|
190
|
+
try {
|
|
191
|
+
onProxyError(error, req, res);
|
|
192
|
+
} catch (error_) {
|
|
193
|
+
console.error("Failed to handle proxy error:", error_);
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// src/replaySessions.ts
|
|
200
|
+
var ReplaySessionManager = class {
|
|
201
|
+
sessions = /* @__PURE__ */ new Map();
|
|
202
|
+
evictionTimer = null;
|
|
203
|
+
timeoutMs;
|
|
204
|
+
constructor(timeoutMs) {
|
|
205
|
+
this.timeoutMs = timeoutMs;
|
|
206
|
+
}
|
|
207
|
+
get size() {
|
|
208
|
+
return this.sessions.size;
|
|
209
|
+
}
|
|
210
|
+
keys() {
|
|
211
|
+
return this.sessions.keys();
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Get or create a replay session state for a given recording ID
|
|
215
|
+
*/
|
|
216
|
+
getOrCreate(recordingId) {
|
|
217
|
+
let session = this.sessions.get(recordingId);
|
|
218
|
+
if (session) {
|
|
219
|
+
session.lastAccessTime = Date.now();
|
|
220
|
+
} else {
|
|
221
|
+
session = {
|
|
222
|
+
recordingId,
|
|
223
|
+
servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
|
|
224
|
+
loadedSession: null,
|
|
225
|
+
lastAccessTime: Date.now(),
|
|
226
|
+
sortedRecordingsByKey: /* @__PURE__ */ new Map()
|
|
227
|
+
};
|
|
228
|
+
this.sessions.set(recordingId, session);
|
|
229
|
+
this.startEvictionTimer();
|
|
230
|
+
console.log(
|
|
231
|
+
`[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return session;
|
|
235
|
+
}
|
|
236
|
+
delete(sessionId) {
|
|
237
|
+
this.sessions.delete(sessionId);
|
|
238
|
+
if (this.sessions.size === 0) {
|
|
239
|
+
this.stopEvictionTimer();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
startEvictionTimer() {
|
|
243
|
+
if (this.evictionTimer) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const CHECK_INTERVAL_MS = 3e4;
|
|
247
|
+
this.evictionTimer = setInterval(() => {
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
for (const [id, session] of this.sessions) {
|
|
250
|
+
if (now - session.lastAccessTime >= this.timeoutMs) {
|
|
251
|
+
console.log(
|
|
252
|
+
`[EVICTION] Evicting idle replay session: ${id} (idle for ${Math.round((now - session.lastAccessTime) / 1e3)}s)`
|
|
253
|
+
);
|
|
254
|
+
this.sessions.delete(id);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (this.sessions.size === 0) {
|
|
258
|
+
this.stopEvictionTimer();
|
|
259
|
+
}
|
|
260
|
+
}, CHECK_INTERVAL_MS);
|
|
261
|
+
this.evictionTimer.unref();
|
|
262
|
+
}
|
|
263
|
+
stopEvictionTimer() {
|
|
264
|
+
if (this.evictionTimer) {
|
|
265
|
+
clearInterval(this.evictionTimer);
|
|
266
|
+
this.evictionTimer = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
function getServedTracker(sessionState, key) {
|
|
271
|
+
if (!sessionState.servedRecordingIdsByKey.has(key)) {
|
|
272
|
+
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
273
|
+
}
|
|
274
|
+
return sessionState.servedRecordingIdsByKey.get(key);
|
|
275
|
+
}
|
|
276
|
+
function getSortedRecordings(sessionState, key) {
|
|
277
|
+
if (sessionState.sortedRecordingsByKey.has(key)) {
|
|
278
|
+
return sessionState.sortedRecordingsByKey.get(key);
|
|
279
|
+
}
|
|
280
|
+
const session = sessionState.loadedSession;
|
|
281
|
+
const sortedRecords = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
|
|
282
|
+
const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
|
|
283
|
+
const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
|
|
284
|
+
return aSeq - bSeq;
|
|
285
|
+
});
|
|
286
|
+
sessionState.sortedRecordingsByKey.set(key, sortedRecords);
|
|
287
|
+
return sortedRecords;
|
|
288
|
+
}
|
|
289
|
+
function selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
|
|
290
|
+
for (const rec of recordsWithKey) {
|
|
291
|
+
if (!servedForThisKey.has(rec.recordingId)) {
|
|
292
|
+
return rec;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (recordsWithKey.length > 0) {
|
|
296
|
+
console.log(
|
|
297
|
+
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
298
|
+
);
|
|
299
|
+
return recordsWithKey[recordsWithKey.length - 1];
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
54
303
|
|
|
55
304
|
// src/types.ts
|
|
56
305
|
var Modes = {
|
|
@@ -95,9 +344,9 @@ function processRecordings(recordings) {
|
|
|
95
344
|
const processedRecordings = [];
|
|
96
345
|
for (const [_key, keyRecordings] of recordingsByKey) {
|
|
97
346
|
keyRecordings.sort((a, b) => a.recordingId - b.recordingId);
|
|
98
|
-
keyRecordings.
|
|
347
|
+
for (const [index, recording] of keyRecordings.entries()) {
|
|
99
348
|
processedRecordings.push({ ...recording, sequence: index });
|
|
100
|
-
}
|
|
349
|
+
}
|
|
101
350
|
}
|
|
102
351
|
processedRecordings.sort((a, b) => a.recordingId - b.recordingId);
|
|
103
352
|
return processedRecordings;
|
|
@@ -153,6 +402,180 @@ function sendJsonResponse(res, statusCode, data) {
|
|
|
153
402
|
res.end(JSON.stringify(data));
|
|
154
403
|
}
|
|
155
404
|
|
|
405
|
+
// src/utils/recordingId.ts
|
|
406
|
+
function getRecordingIdFromHeader(req) {
|
|
407
|
+
const headerValue = req.headers[RECORDING_ID_HEADER];
|
|
408
|
+
if (!headerValue) {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
return Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
412
|
+
}
|
|
413
|
+
function getRecordingIdFromCookie(req) {
|
|
414
|
+
const cookies = req.headers.cookie;
|
|
415
|
+
if (!cookies) {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
const match = cookies.match(/proxy-recording-id=([^;]+)/);
|
|
419
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
420
|
+
}
|
|
421
|
+
function getRecordingIdFromRequest(req) {
|
|
422
|
+
const fromHeader = getRecordingIdFromHeader(req);
|
|
423
|
+
const fromCookie = getRecordingIdFromCookie(req);
|
|
424
|
+
return fromHeader ?? fromCookie ?? null;
|
|
425
|
+
}
|
|
426
|
+
function getWsRecordingKey(url) {
|
|
427
|
+
return `WS_${url.replaceAll("/", "_")}`;
|
|
428
|
+
}
|
|
429
|
+
var WS_INTERNAL_HEADERS = /* @__PURE__ */ new Set([
|
|
430
|
+
"host",
|
|
431
|
+
"connection",
|
|
432
|
+
"upgrade",
|
|
433
|
+
"sec-websocket-key",
|
|
434
|
+
"sec-websocket-version",
|
|
435
|
+
"sec-websocket-extensions"
|
|
436
|
+
]);
|
|
437
|
+
function getRecordableWsHeaders(req) {
|
|
438
|
+
const headers = {};
|
|
439
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
440
|
+
if (!WS_INTERNAL_HEADERS.has(name) && value !== void 0) {
|
|
441
|
+
headers[name] = value;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return headers;
|
|
445
|
+
}
|
|
446
|
+
function getForwardableWsHeaders(req) {
|
|
447
|
+
const headers = {};
|
|
448
|
+
for (const [name, value] of Object.entries(getRecordableWsHeaders(req))) {
|
|
449
|
+
if (name !== "sec-websocket-protocol" && value !== void 0) {
|
|
450
|
+
headers[name] = Array.isArray(value) ? value.join(", ") : value;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return headers;
|
|
454
|
+
}
|
|
455
|
+
function getClientSubprotocols(req) {
|
|
456
|
+
const header = req.headers["sec-websocket-protocol"];
|
|
457
|
+
if (!header) {
|
|
458
|
+
return [];
|
|
459
|
+
}
|
|
460
|
+
const raw = Array.isArray(header) ? header.join(",") : header;
|
|
461
|
+
return raw.split(",").map((p) => p.trim()).filter(Boolean);
|
|
462
|
+
}
|
|
463
|
+
function recordWebSocket(req, clientSocket, head, target2, session) {
|
|
464
|
+
const url = req.url || "/";
|
|
465
|
+
const wsRecording = {
|
|
466
|
+
url,
|
|
467
|
+
messages: [],
|
|
468
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
469
|
+
key: getWsRecordingKey(url),
|
|
470
|
+
headers: getRecordableWsHeaders(req)
|
|
471
|
+
};
|
|
472
|
+
if (session) {
|
|
473
|
+
session.websocketRecordings.push(wsRecording);
|
|
474
|
+
}
|
|
475
|
+
const backendWsUrl = `${target2.replace("http", "ws")}${url}`;
|
|
476
|
+
const backendWs = new WebSocket(backendWsUrl, getClientSubprotocols(req), {
|
|
477
|
+
headers: getForwardableWsHeaders(req)
|
|
478
|
+
});
|
|
479
|
+
const wss = new WebSocketServer({
|
|
480
|
+
noServer: true,
|
|
481
|
+
handleProtocols: (protocols) => backendWs.protocol && protocols.has(backendWs.protocol) ? backendWs.protocol : protocols.values().next().value ?? false
|
|
482
|
+
});
|
|
483
|
+
backendWs.on("open", () => {
|
|
484
|
+
console.log(`WebSocket recording: connected to backend ${backendWsUrl}`);
|
|
485
|
+
if (backendWs.protocol) {
|
|
486
|
+
wsRecording.protocol = backendWs.protocol;
|
|
487
|
+
}
|
|
488
|
+
wss.handleUpgrade(req, clientSocket, head, (clientWs) => {
|
|
489
|
+
clientWs.on("message", (data) => {
|
|
490
|
+
const message = data.toString();
|
|
491
|
+
wsRecording.messages.push({
|
|
492
|
+
direction: "client-to-server",
|
|
493
|
+
data: message,
|
|
494
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
495
|
+
});
|
|
496
|
+
if (backendWs.readyState === WebSocket.OPEN) {
|
|
497
|
+
backendWs.send(message);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
backendWs.on("message", (data) => {
|
|
501
|
+
const message = data.toString();
|
|
502
|
+
wsRecording.messages.push({
|
|
503
|
+
direction: "server-to-client",
|
|
504
|
+
data: message,
|
|
505
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
506
|
+
});
|
|
507
|
+
if (clientWs.readyState === WebSocket.OPEN) {
|
|
508
|
+
clientWs.send(message);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
clientWs.on("error", (err) => {
|
|
512
|
+
console.error("Client WebSocket error:", err);
|
|
513
|
+
});
|
|
514
|
+
backendWs.on("error", (err) => {
|
|
515
|
+
console.error("Backend WebSocket error:", err);
|
|
516
|
+
});
|
|
517
|
+
clientWs.on("close", () => {
|
|
518
|
+
backendWs.close();
|
|
519
|
+
console.log("Client WebSocket closed");
|
|
520
|
+
});
|
|
521
|
+
backendWs.on("close", () => {
|
|
522
|
+
clientWs.close();
|
|
523
|
+
console.log("Backend WebSocket closed");
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
backendWs.on("error", (err) => {
|
|
528
|
+
console.error("Backend WebSocket connection error:", err);
|
|
529
|
+
clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
|
530
|
+
clientSocket.destroy();
|
|
531
|
+
});
|
|
532
|
+
wss.on("error", (err) => {
|
|
533
|
+
console.error("WebSocket server error:", err);
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
function replayWebSocket(req, socket, wsRecording, recordingId) {
|
|
537
|
+
const url = req.url || "/";
|
|
538
|
+
const wss = new WebSocketServer({
|
|
539
|
+
noServer: true,
|
|
540
|
+
handleProtocols: (protocols) => wsRecording.protocol && protocols.has(wsRecording.protocol) ? wsRecording.protocol : protocols.values().next().value ?? false
|
|
541
|
+
});
|
|
542
|
+
const fakeReq = Object.assign(req, {
|
|
543
|
+
headers: {
|
|
544
|
+
...req.headers,
|
|
545
|
+
"sec-websocket-key": req.headers["sec-websocket-key"] || "replay-key",
|
|
546
|
+
"sec-websocket-version": "13"
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
wss.handleUpgrade(fakeReq, socket, Buffer.alloc(0), (ws) => {
|
|
550
|
+
console.log(`Replaying WebSocket: ${url} (session: ${recordingId})`);
|
|
551
|
+
const messages = wsRecording.messages;
|
|
552
|
+
let cursor = 0;
|
|
553
|
+
const flushServerMessages = () => {
|
|
554
|
+
while (cursor < messages.length && messages[cursor].direction === "server-to-client") {
|
|
555
|
+
const msg = messages[cursor];
|
|
556
|
+
cursor++;
|
|
557
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
558
|
+
ws.send(msg.data);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
ws.on("message", (data) => {
|
|
563
|
+
console.log(`Replay: Client sent: ${data.toString()}`);
|
|
564
|
+
if (cursor < messages.length && messages[cursor].direction === "client-to-server") {
|
|
565
|
+
cursor++;
|
|
566
|
+
}
|
|
567
|
+
flushServerMessages();
|
|
568
|
+
});
|
|
569
|
+
ws.on("error", (err) => {
|
|
570
|
+
console.error("Replay WebSocket error:", err);
|
|
571
|
+
});
|
|
572
|
+
ws.on("close", () => {
|
|
573
|
+
console.log("Replay WebSocket closed");
|
|
574
|
+
});
|
|
575
|
+
flushServerMessages();
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
156
579
|
// src/ProxyServer.ts
|
|
157
580
|
var ProxyServer = class {
|
|
158
581
|
target;
|
|
@@ -163,6 +586,7 @@ var ProxyServer = class {
|
|
|
163
586
|
proxy;
|
|
164
587
|
currentSession;
|
|
165
588
|
recordingsDir;
|
|
589
|
+
timeoutMs;
|
|
166
590
|
recordingIdCounter;
|
|
167
591
|
// Unique ID for each recording entry
|
|
168
592
|
sequenceCounterByKey;
|
|
@@ -173,8 +597,9 @@ var ProxyServer = class {
|
|
|
173
597
|
// Stack of promises that resolve to completed recordings
|
|
174
598
|
flushPromise;
|
|
175
599
|
// Promise for in-progress flush operation
|
|
176
|
-
constructor(target2, recordingsDir2) {
|
|
600
|
+
constructor(target2, recordingsDir2, timeoutMs) {
|
|
177
601
|
this.target = target2;
|
|
602
|
+
this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
178
603
|
this.mode = Modes.transparent;
|
|
179
604
|
this.recordingId = null;
|
|
180
605
|
this.recordingIdCounter = 0;
|
|
@@ -183,7 +608,7 @@ var ProxyServer = class {
|
|
|
183
608
|
this.modeTimeout = null;
|
|
184
609
|
this.currentSession = null;
|
|
185
610
|
this.recordingsDir = recordingsDir2;
|
|
186
|
-
this.replaySessions =
|
|
611
|
+
this.replaySessions = new ReplaySessionManager(this.timeoutMs);
|
|
187
612
|
this.recordingPromises = [];
|
|
188
613
|
this.flushPromise = null;
|
|
189
614
|
this.proxy = httpProxy.createProxyServer({
|
|
@@ -197,7 +622,7 @@ var ProxyServer = class {
|
|
|
197
622
|
await fs.mkdir(this.recordingsDir, { recursive: true });
|
|
198
623
|
}
|
|
199
624
|
listen(port2) {
|
|
200
|
-
const server =
|
|
625
|
+
const server = http2.createServer((req, res) => {
|
|
201
626
|
this.handleRequest(req, res);
|
|
202
627
|
});
|
|
203
628
|
server.on("upgrade", (req, socket, head) => {
|
|
@@ -211,15 +636,15 @@ var ProxyServer = class {
|
|
|
211
636
|
}
|
|
212
637
|
setupProxyEventHandlers() {
|
|
213
638
|
this.proxy.on("error", this.handleProxyError.bind(this));
|
|
214
|
-
this.proxy.on("proxyRes",
|
|
639
|
+
this.proxy.on("proxyRes", addCorsHeaders);
|
|
215
640
|
}
|
|
216
641
|
handleProxyError(err, req, res) {
|
|
217
642
|
console.error("Proxy error:", err);
|
|
218
|
-
if (!(res instanceof
|
|
643
|
+
if (!(res instanceof http2.ServerResponse)) {
|
|
219
644
|
return;
|
|
220
645
|
}
|
|
221
646
|
if (!res.headersSent) {
|
|
222
|
-
const corsHeaders =
|
|
647
|
+
const corsHeaders = getCorsHeaders(req);
|
|
223
648
|
res.writeHead(HTTP_STATUS_BAD_GATEWAY, {
|
|
224
649
|
"Content-Type": "application/json",
|
|
225
650
|
...corsHeaders
|
|
@@ -227,103 +652,12 @@ var ProxyServer = class {
|
|
|
227
652
|
}
|
|
228
653
|
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
229
654
|
}
|
|
230
|
-
/**
|
|
231
|
-
* Get CORS headers for a given request
|
|
232
|
-
* @param req The incoming HTTP request
|
|
233
|
-
* @returns An object containing CORS headers
|
|
234
|
-
*/
|
|
235
|
-
getCorsHeaders(req) {
|
|
236
|
-
const origin = req.headers.origin;
|
|
237
|
-
return {
|
|
238
|
-
"access-control-allow-origin": origin || "*",
|
|
239
|
-
"access-control-allow-credentials": "true",
|
|
240
|
-
"access-control-allow-headers": req.headers["access-control-request-headers"] || `Origin, X-Requested-With, Content-Type, Accept, Authorization, ${RECORDING_ID_HEADER}`,
|
|
241
|
-
"access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
242
|
-
"access-control-expose-headers": "*"
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
addCorsHeaders(proxyRes, req) {
|
|
246
|
-
const corsHeaders = this.getCorsHeaders(req);
|
|
247
|
-
Object.assign(proxyRes.headers, corsHeaders);
|
|
248
|
-
}
|
|
249
|
-
getTarget() {
|
|
250
|
-
return this.target;
|
|
251
|
-
}
|
|
252
|
-
/**
|
|
253
|
-
* Extract recording ID from custom HTTP header
|
|
254
|
-
* Used for concurrent replay session routing, especially with Next.js
|
|
255
|
-
* @param req The incoming HTTP request
|
|
256
|
-
* @returns The recording ID from header, or null if not found
|
|
257
|
-
*/
|
|
258
|
-
getRecordingIdFromHeader(req) {
|
|
259
|
-
const headerValue = req.headers[RECORDING_ID_HEADER];
|
|
260
|
-
if (!headerValue) {
|
|
261
|
-
return null;
|
|
262
|
-
}
|
|
263
|
-
return Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
264
|
-
}
|
|
265
|
-
/**
|
|
266
|
-
* Extract recording ID from request cookie
|
|
267
|
-
* Used for concurrent replay session routing (fallback method)
|
|
268
|
-
* @param req The incoming HTTP request
|
|
269
|
-
* @returns The recording ID from cookie, or null if not found
|
|
270
|
-
*/
|
|
271
|
-
getRecordingIdFromCookie(req) {
|
|
272
|
-
const cookies = req.headers.cookie;
|
|
273
|
-
if (!cookies) {
|
|
274
|
-
return null;
|
|
275
|
-
}
|
|
276
|
-
const match = cookies.match(/proxy-recording-id=([^;]+)/);
|
|
277
|
-
return match ? decodeURIComponent(match[1]) : null;
|
|
278
|
-
}
|
|
279
|
-
/**
|
|
280
|
-
* Extract recording ID from request using custom header (preferred) or cookie (fallback)
|
|
281
|
-
* @param req The incoming HTTP request
|
|
282
|
-
* @returns The recording ID, or null if not found
|
|
283
|
-
*/
|
|
284
|
-
getRecordingIdFromRequest(req) {
|
|
285
|
-
const fromHeader = this.getRecordingIdFromHeader(req);
|
|
286
|
-
const fromCookie = this.getRecordingIdFromCookie(req);
|
|
287
|
-
if (fromHeader) {
|
|
288
|
-
return fromHeader;
|
|
289
|
-
}
|
|
290
|
-
if (fromCookie) {
|
|
291
|
-
return fromCookie;
|
|
292
|
-
}
|
|
293
|
-
return null;
|
|
294
|
-
}
|
|
295
|
-
/**
|
|
296
|
-
* Get or create a replay session state for a given recording ID
|
|
297
|
-
* @param recordingId The recording ID to get/create session for
|
|
298
|
-
* @returns The replay session state
|
|
299
|
-
*/
|
|
300
|
-
getOrCreateReplaySession(recordingId) {
|
|
301
|
-
let session = this.replaySessions.get(recordingId);
|
|
302
|
-
if (session) {
|
|
303
|
-
session.lastAccessTime = Date.now();
|
|
304
|
-
} else {
|
|
305
|
-
session = {
|
|
306
|
-
recordingId,
|
|
307
|
-
servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
|
|
308
|
-
loadedSession: null,
|
|
309
|
-
lastAccessTime: Date.now(),
|
|
310
|
-
sortedRecordingsByKey: /* @__PURE__ */ new Map()
|
|
311
|
-
};
|
|
312
|
-
this.replaySessions.set(recordingId, session);
|
|
313
|
-
console.log(
|
|
314
|
-
`[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
return session;
|
|
318
|
-
}
|
|
319
655
|
/**
|
|
320
656
|
* Clean up a session - removes it from memory and resets counters
|
|
321
657
|
* @param sessionId The session ID to clean up
|
|
322
658
|
*/
|
|
323
659
|
async cleanupSession(sessionId) {
|
|
324
|
-
|
|
325
|
-
this.replaySessions.delete(sessionId);
|
|
326
|
-
}
|
|
660
|
+
this.replaySessions.delete(sessionId);
|
|
327
661
|
if (this.recordingId === sessionId) {
|
|
328
662
|
await this.saveCurrentSession();
|
|
329
663
|
this.currentSession = null;
|
|
@@ -334,29 +668,17 @@ var ProxyServer = class {
|
|
|
334
668
|
}
|
|
335
669
|
console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
|
|
336
670
|
}
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const timeoutParam = url.searchParams.get("timeout");
|
|
342
|
-
const timeout = timeoutParam ? Number.parseInt(timeoutParam, 10) : void 0;
|
|
343
|
-
if (!mode) {
|
|
344
|
-
throw new Error("Mode parameter is required");
|
|
345
|
-
}
|
|
346
|
-
return { mode, id, timeout };
|
|
347
|
-
}
|
|
348
|
-
async parseControlRequest(req) {
|
|
349
|
-
if (req.method === "GET") {
|
|
350
|
-
return this.parseGetParams(req);
|
|
351
|
-
}
|
|
352
|
-
if (req.method === "POST") {
|
|
353
|
-
const body = await readRequestBody(req);
|
|
354
|
-
console.log(`MODE CHANGE (${req.method})`, body);
|
|
355
|
-
return JSON.parse(body);
|
|
356
|
-
}
|
|
357
|
-
throw new Error("Unsupported control method");
|
|
671
|
+
async parseControlBody(req) {
|
|
672
|
+
const body = await readRequestBody(req);
|
|
673
|
+
console.log(`MODE CHANGE (${req.method})`, body);
|
|
674
|
+
return JSON.parse(body);
|
|
358
675
|
}
|
|
359
676
|
async handleControlRequest(req, res) {
|
|
677
|
+
if (req.method === "HEAD") {
|
|
678
|
+
res.writeHead(HTTP_STATUS_OK);
|
|
679
|
+
res.end();
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
360
682
|
if (req.method === "GET") {
|
|
361
683
|
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
362
684
|
recordingsDir: this.recordingsDir,
|
|
@@ -365,8 +687,11 @@ var ProxyServer = class {
|
|
|
365
687
|
});
|
|
366
688
|
return;
|
|
367
689
|
}
|
|
690
|
+
await this.handleControlPost(req, res);
|
|
691
|
+
}
|
|
692
|
+
async handleControlPost(req, res) {
|
|
368
693
|
try {
|
|
369
|
-
const data = await this.
|
|
694
|
+
const data = await this.parseControlBody(req);
|
|
370
695
|
const { mode, id, timeout: requestTimeout, cleanup } = data;
|
|
371
696
|
if (cleanup && id) {
|
|
372
697
|
await this.cleanupSession(id);
|
|
@@ -377,29 +702,7 @@ var ProxyServer = class {
|
|
|
377
702
|
});
|
|
378
703
|
return;
|
|
379
704
|
}
|
|
380
|
-
|
|
381
|
-
throw new Error(
|
|
382
|
-
"Mode parameter is required when cleanup is not specified"
|
|
383
|
-
);
|
|
384
|
-
}
|
|
385
|
-
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
386
|
-
this.clearModeTimeout();
|
|
387
|
-
await this.switchMode(mode, id);
|
|
388
|
-
this.setupModeTimeout(timeout);
|
|
389
|
-
if (mode === Modes.replay && id) {
|
|
390
|
-
res.setHeader(
|
|
391
|
-
"Set-Cookie",
|
|
392
|
-
`proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
|
|
393
|
-
);
|
|
394
|
-
console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
|
|
395
|
-
}
|
|
396
|
-
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
397
|
-
success: true,
|
|
398
|
-
mode: this.mode,
|
|
399
|
-
id: this.recordingId || this.replayId,
|
|
400
|
-
timeout,
|
|
401
|
-
recordingsDir: this.recordingsDir
|
|
402
|
-
});
|
|
705
|
+
await this.applyModeChange(res, mode, id, requestTimeout);
|
|
403
706
|
} catch (error) {
|
|
404
707
|
console.error("Control request error:", error);
|
|
405
708
|
sendJsonResponse(res, HTTP_STATUS_BAD_REQUEST, {
|
|
@@ -407,6 +710,31 @@ var ProxyServer = class {
|
|
|
407
710
|
});
|
|
408
711
|
}
|
|
409
712
|
}
|
|
713
|
+
async applyModeChange(res, mode, id, requestTimeout) {
|
|
714
|
+
if (!mode) {
|
|
715
|
+
throw new Error(
|
|
716
|
+
"Mode parameter is required when cleanup is not specified"
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
const timeout2 = requestTimeout ?? this.timeoutMs;
|
|
720
|
+
this.clearModeTimeout();
|
|
721
|
+
await this.switchMode(mode, id);
|
|
722
|
+
this.setupModeTimeout(timeout2);
|
|
723
|
+
if (mode === Modes.replay && id) {
|
|
724
|
+
res.setHeader(
|
|
725
|
+
"Set-Cookie",
|
|
726
|
+
`proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
|
|
727
|
+
);
|
|
728
|
+
console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
|
|
729
|
+
}
|
|
730
|
+
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
731
|
+
success: true,
|
|
732
|
+
mode: this.mode,
|
|
733
|
+
id: this.recordingId || this.replayId,
|
|
734
|
+
timeout: timeout2,
|
|
735
|
+
recordingsDir: this.recordingsDir
|
|
736
|
+
});
|
|
737
|
+
}
|
|
410
738
|
clearModeTimeout() {
|
|
411
739
|
clearTimeout(this.modeTimeout || 0);
|
|
412
740
|
this.modeTimeout = null;
|
|
@@ -446,7 +774,7 @@ var ProxyServer = class {
|
|
|
446
774
|
this.recordingId = null;
|
|
447
775
|
this.replayId = null;
|
|
448
776
|
this.currentSession = null;
|
|
449
|
-
|
|
777
|
+
this.clearModeTimeout();
|
|
450
778
|
console.log("Switched to transparent mode");
|
|
451
779
|
}
|
|
452
780
|
switchToRecordMode(id) {
|
|
@@ -463,7 +791,7 @@ var ProxyServer = class {
|
|
|
463
791
|
this.replayId = id;
|
|
464
792
|
this.recordingId = null;
|
|
465
793
|
this.currentSession = null;
|
|
466
|
-
const sessionState = this.
|
|
794
|
+
const sessionState = this.replaySessions.getOrCreate(id);
|
|
467
795
|
sessionState.servedRecordingIdsByKey.clear();
|
|
468
796
|
sessionState.sortedRecordingsByKey.clear();
|
|
469
797
|
const filePath = getRecordingPath(this.recordingsDir, id);
|
|
@@ -476,14 +804,14 @@ var ProxyServer = class {
|
|
|
476
804
|
}
|
|
477
805
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
478
806
|
}
|
|
479
|
-
setupModeTimeout(
|
|
480
|
-
|
|
807
|
+
setupModeTimeout(timeout2) {
|
|
808
|
+
this.clearModeTimeout();
|
|
481
809
|
this.modeTimeout = setTimeout(async () => {
|
|
482
810
|
console.log("Timeout reached, switching back to transparent mode");
|
|
483
811
|
await this.saveCurrentSession();
|
|
484
812
|
this.switchToTransparentMode();
|
|
485
813
|
this.modeTimeout = null;
|
|
486
|
-
},
|
|
814
|
+
}, timeout2);
|
|
487
815
|
}
|
|
488
816
|
async flushPendingRecordings() {
|
|
489
817
|
if (this.flushPromise) {
|
|
@@ -524,7 +852,7 @@ var ProxyServer = class {
|
|
|
524
852
|
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
525
853
|
}
|
|
526
854
|
getRecordingIdOrError(req, res) {
|
|
527
|
-
const recordingIdFromRequest =
|
|
855
|
+
const recordingIdFromRequest = getRecordingIdFromRequest(req);
|
|
528
856
|
if (recordingIdFromRequest) {
|
|
529
857
|
return recordingIdFromRequest;
|
|
530
858
|
}
|
|
@@ -532,7 +860,7 @@ var ProxyServer = class {
|
|
|
532
860
|
console.warn(
|
|
533
861
|
`[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)`
|
|
534
862
|
);
|
|
535
|
-
const corsHeaders =
|
|
863
|
+
const corsHeaders = getCorsHeaders(req);
|
|
536
864
|
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
537
865
|
"Content-Type": "application/json",
|
|
538
866
|
...corsHeaders
|
|
@@ -548,7 +876,7 @@ var ProxyServer = class {
|
|
|
548
876
|
}
|
|
549
877
|
const recordingId = this.replayId;
|
|
550
878
|
if (!recordingId) {
|
|
551
|
-
const corsHeaders =
|
|
879
|
+
const corsHeaders = getCorsHeaders(req);
|
|
552
880
|
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
553
881
|
"Content-Type": "application/json",
|
|
554
882
|
...corsHeaders
|
|
@@ -561,56 +889,22 @@ var ProxyServer = class {
|
|
|
561
889
|
);
|
|
562
890
|
return recordingId;
|
|
563
891
|
}
|
|
564
|
-
getServedTracker(sessionState, key) {
|
|
565
|
-
if (!sessionState.servedRecordingIdsByKey.has(key)) {
|
|
566
|
-
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
567
|
-
}
|
|
568
|
-
return sessionState.servedRecordingIdsByKey.get(key);
|
|
569
|
-
}
|
|
570
|
-
getSortedRecordings(sessionState, key) {
|
|
571
|
-
if (sessionState.sortedRecordingsByKey.has(key)) {
|
|
572
|
-
return sessionState.sortedRecordingsByKey.get(key);
|
|
573
|
-
}
|
|
574
|
-
const session = sessionState.loadedSession;
|
|
575
|
-
const sortedRecords = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
|
|
576
|
-
const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
|
|
577
|
-
const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
|
|
578
|
-
return aSeq - bSeq;
|
|
579
|
-
});
|
|
580
|
-
sessionState.sortedRecordingsByKey.set(key, sortedRecords);
|
|
581
|
-
return sortedRecords;
|
|
582
|
-
}
|
|
583
|
-
selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
|
|
584
|
-
for (const rec of recordsWithKey) {
|
|
585
|
-
if (!servedForThisKey.has(rec.recordingId)) {
|
|
586
|
-
return rec;
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
if (recordsWithKey.length > 0) {
|
|
590
|
-
console.log(
|
|
591
|
-
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
592
|
-
);
|
|
593
|
-
return recordsWithKey[recordsWithKey.length - 1];
|
|
594
|
-
}
|
|
595
|
-
return null;
|
|
596
|
-
}
|
|
597
892
|
async handleReplayRequest(req, res) {
|
|
598
893
|
const recordingId = this.getRecordingIdOrError(req, res);
|
|
599
894
|
if (!recordingId) return;
|
|
600
895
|
const key = getReqID(req);
|
|
601
896
|
const filePath = getRecordingPath(this.recordingsDir, recordingId);
|
|
602
897
|
try {
|
|
603
|
-
const sessionState = this.
|
|
898
|
+
const sessionState = this.replaySessions.getOrCreate(recordingId);
|
|
604
899
|
if (!sessionState.loadedSession) {
|
|
605
|
-
|
|
606
|
-
`Recording session file not found: ${filePath}`
|
|
900
|
+
throw Object.assign(
|
|
901
|
+
new Error(`Recording session file not found: ${filePath}`),
|
|
902
|
+
{ code: "ENOENT" }
|
|
607
903
|
);
|
|
608
|
-
error.code = "ENOENT";
|
|
609
|
-
throw error;
|
|
610
904
|
}
|
|
611
|
-
const servedForThisKey =
|
|
905
|
+
const servedForThisKey = getServedTracker(sessionState, key);
|
|
612
906
|
const host = req.headers.host || "unknown";
|
|
613
|
-
const recordsWithKey =
|
|
907
|
+
const recordsWithKey = getSortedRecordings(sessionState, key);
|
|
614
908
|
if (recordsWithKey.length === 0) {
|
|
615
909
|
const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
|
|
616
910
|
console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
|
|
@@ -623,7 +917,7 @@ var ProxyServer = class {
|
|
|
623
917
|
key,
|
|
624
918
|
sessionId: recordingId
|
|
625
919
|
};
|
|
626
|
-
const corsHeaders =
|
|
920
|
+
const corsHeaders = getCorsHeaders(req);
|
|
627
921
|
res.writeHead(HTTP_STATUS_NOT_FOUND, {
|
|
628
922
|
"Content-Type": "application/json",
|
|
629
923
|
...corsHeaders
|
|
@@ -635,7 +929,7 @@ var ProxyServer = class {
|
|
|
635
929
|
console.log(
|
|
636
930
|
`[replay request #${requestCount}] ${req.method} ${req.url} (key: ${key}, session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
637
931
|
);
|
|
638
|
-
const record =
|
|
932
|
+
const record = selectReplayRecord(
|
|
639
933
|
recordsWithKey,
|
|
640
934
|
servedForThisKey,
|
|
641
935
|
key,
|
|
@@ -653,7 +947,7 @@ var ProxyServer = class {
|
|
|
653
947
|
const { statusCode, headers, body } = record.response;
|
|
654
948
|
const responseHeaders = {
|
|
655
949
|
...headers,
|
|
656
|
-
...
|
|
950
|
+
...getCorsHeaders(req)
|
|
657
951
|
};
|
|
658
952
|
res.writeHead(statusCode, responseHeaders);
|
|
659
953
|
res.end(body);
|
|
@@ -664,7 +958,7 @@ var ProxyServer = class {
|
|
|
664
958
|
handleReplayError(req, res, err, key, filePath) {
|
|
665
959
|
const isFileNotFound = err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
666
960
|
console.error("Replay error:", err);
|
|
667
|
-
const corsHeaders =
|
|
961
|
+
const corsHeaders = getCorsHeaders(req);
|
|
668
962
|
res.writeHead(HTTP_STATUS_NOT_FOUND, {
|
|
669
963
|
"Content-Type": "application/json",
|
|
670
964
|
...corsHeaders
|
|
@@ -692,7 +986,7 @@ var ProxyServer = class {
|
|
|
692
986
|
await this.handleProxyRequest(req, res);
|
|
693
987
|
}
|
|
694
988
|
handleCorsPreflightRequest(req, res) {
|
|
695
|
-
const corsHeaders =
|
|
989
|
+
const corsHeaders = getCorsHeaders(req);
|
|
696
990
|
res.writeHead(HTTP_STATUS_OK, {
|
|
697
991
|
...corsHeaders,
|
|
698
992
|
"Access-Control-Max-Age": "86400"
|
|
@@ -701,16 +995,15 @@ var ProxyServer = class {
|
|
|
701
995
|
res.end();
|
|
702
996
|
}
|
|
703
997
|
async handleProxyRequest(req, res) {
|
|
704
|
-
const target2 = this.
|
|
998
|
+
const target2 = this.target;
|
|
705
999
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target2}`);
|
|
706
1000
|
if (this.mode === Modes.record) {
|
|
707
|
-
|
|
1001
|
+
this.recordAndProxy(req, res, target2);
|
|
708
1002
|
} else {
|
|
709
1003
|
this.proxy.web(req, res, { target: target2 });
|
|
710
1004
|
}
|
|
711
1005
|
}
|
|
712
|
-
|
|
713
|
-
async recordAndProxyRequest(req, res, target2) {
|
|
1006
|
+
recordAndProxy(req, res, target2) {
|
|
714
1007
|
if (!this.currentSession) {
|
|
715
1008
|
return;
|
|
716
1009
|
}
|
|
@@ -718,194 +1011,66 @@ var ProxyServer = class {
|
|
|
718
1011
|
const recordingId = this.recordingIdCounter++;
|
|
719
1012
|
const sequence = this.sequenceCounterByKey.get(key) || 0;
|
|
720
1013
|
this.sequenceCounterByKey.set(key, sequence + 1);
|
|
721
|
-
|
|
722
|
-
(
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
setTimeout(
|
|
733
|
-
() => rejectBuffer(new Error("Request buffering timeout")),
|
|
734
|
-
3e4
|
|
735
|
-
);
|
|
736
|
-
});
|
|
737
|
-
} catch (error) {
|
|
738
|
-
console.error("Error buffering request:", error);
|
|
739
|
-
}
|
|
740
|
-
const requestBody = Buffer.concat(chunks).toString("utf8");
|
|
741
|
-
const targetUrl = new URL(target2);
|
|
742
|
-
const isHttps = targetUrl.protocol === "https:";
|
|
743
|
-
const requestModule = isHttps ? https : http;
|
|
744
|
-
const defaultPort = isHttps ? 443 : 80;
|
|
745
|
-
const proxyReq = requestModule.request(
|
|
746
|
-
{
|
|
747
|
-
hostname: targetUrl.hostname,
|
|
748
|
-
port: targetUrl.port || defaultPort,
|
|
749
|
-
path: req.url,
|
|
750
|
-
method: req.method,
|
|
751
|
-
headers: req.headers
|
|
752
|
-
},
|
|
753
|
-
(proxyRes) => {
|
|
754
|
-
this.addCorsHeaders(proxyRes, req);
|
|
755
|
-
const responseChunks = [];
|
|
756
|
-
proxyRes.on("data", (chunk) => {
|
|
757
|
-
responseChunks.push(chunk);
|
|
758
|
-
});
|
|
759
|
-
proxyRes.on("end", async () => {
|
|
760
|
-
try {
|
|
761
|
-
const responseBody = Buffer.concat(responseChunks);
|
|
762
|
-
const responseBodyStr = responseBody.toString("utf8");
|
|
763
|
-
const recording = {
|
|
764
|
-
request: {
|
|
765
|
-
method: req.method,
|
|
766
|
-
url: req.url,
|
|
767
|
-
headers: req.headers,
|
|
768
|
-
body: requestBody || null
|
|
769
|
-
},
|
|
770
|
-
response: {
|
|
771
|
-
statusCode: proxyRes.statusCode,
|
|
772
|
-
headers: proxyRes.headers,
|
|
773
|
-
body: responseBodyStr || null
|
|
774
|
-
},
|
|
775
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
776
|
-
key,
|
|
777
|
-
recordingId,
|
|
778
|
-
sequence
|
|
779
|
-
};
|
|
780
|
-
const responseHeaders = {
|
|
781
|
-
...proxyRes.headers,
|
|
782
|
-
...this.getCorsHeaders(req)
|
|
783
|
-
};
|
|
784
|
-
res.writeHead(proxyRes.statusCode || 200, responseHeaders);
|
|
785
|
-
res.end(responseBody);
|
|
786
|
-
console.log(
|
|
787
|
-
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId}, sequence: ${sequence})`
|
|
788
|
-
);
|
|
789
|
-
resolve(recording);
|
|
790
|
-
} catch (error) {
|
|
791
|
-
console.error("Error completing recording:", error);
|
|
792
|
-
resolve(null);
|
|
793
|
-
}
|
|
794
|
-
});
|
|
795
|
-
proxyRes.on("error", (err) => {
|
|
796
|
-
console.error("Proxy response error:", err);
|
|
797
|
-
if (!res.headersSent) {
|
|
798
|
-
this.handleProxyError(err, req, res);
|
|
799
|
-
}
|
|
800
|
-
resolve(null);
|
|
801
|
-
});
|
|
802
|
-
}
|
|
803
|
-
);
|
|
804
|
-
proxyReq.on("error", (err) => {
|
|
805
|
-
this.handleProxyError(err, req, res);
|
|
806
|
-
resolve(null);
|
|
807
|
-
});
|
|
808
|
-
if (chunks.length > 0) {
|
|
809
|
-
proxyReq.write(Buffer.concat(chunks));
|
|
810
|
-
}
|
|
811
|
-
proxyReq.end();
|
|
812
|
-
} catch (error) {
|
|
813
|
-
console.error("Error in recordAndProxyRequest:", error);
|
|
814
|
-
try {
|
|
815
|
-
this.handleProxyError(error, req, res);
|
|
816
|
-
} catch (error_) {
|
|
817
|
-
console.error("Failed to handle proxy error:", error_);
|
|
818
|
-
}
|
|
819
|
-
resolve(null);
|
|
820
|
-
}
|
|
821
|
-
})();
|
|
822
|
-
});
|
|
823
|
-
this.recordingPromises.push(recordingPromise);
|
|
1014
|
+
this.recordingPromises.push(
|
|
1015
|
+
recordAndProxyRequest({
|
|
1016
|
+
req,
|
|
1017
|
+
res,
|
|
1018
|
+
target: target2,
|
|
1019
|
+
key,
|
|
1020
|
+
recordingId,
|
|
1021
|
+
sequence,
|
|
1022
|
+
onProxyError: this.handleProxyError.bind(this)
|
|
1023
|
+
})
|
|
1024
|
+
);
|
|
824
1025
|
}
|
|
825
1026
|
handleUpgrade(req, socket, head) {
|
|
826
1027
|
if (this.mode === Modes.replay) {
|
|
827
1028
|
this.handleReplayWebSocket(req, socket);
|
|
828
1029
|
return;
|
|
829
1030
|
}
|
|
830
|
-
const target2 = this.
|
|
1031
|
+
const target2 = this.target;
|
|
831
1032
|
console.log(`[${this.mode}] WebSocket upgrade ${req.url} -> ${target2}`);
|
|
832
1033
|
if (this.mode === Modes.record) {
|
|
833
|
-
|
|
1034
|
+
recordWebSocket(req, socket, head, target2, this.currentSession);
|
|
834
1035
|
} else {
|
|
835
1036
|
this.proxy.ws(req, socket, head, { target: target2 });
|
|
836
1037
|
}
|
|
837
1038
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
if (
|
|
848
|
-
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
const message = data.toString();
|
|
858
|
-
wsRecording.messages.push({
|
|
859
|
-
direction: "client-to-server",
|
|
860
|
-
data: message,
|
|
861
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
862
|
-
});
|
|
863
|
-
if (backendWs.readyState === WebSocket.OPEN) {
|
|
864
|
-
backendWs.send(message);
|
|
865
|
-
}
|
|
866
|
-
});
|
|
867
|
-
backendWs.on("message", (data) => {
|
|
868
|
-
const message = data.toString();
|
|
869
|
-
wsRecording.messages.push({
|
|
870
|
-
direction: "server-to-client",
|
|
871
|
-
data: message,
|
|
872
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
873
|
-
});
|
|
874
|
-
if (clientWs.readyState === WebSocket.OPEN) {
|
|
875
|
-
clientWs.send(message);
|
|
876
|
-
}
|
|
877
|
-
});
|
|
878
|
-
clientWs.on("error", (err) => {
|
|
879
|
-
console.error("Client WebSocket error:", err);
|
|
880
|
-
});
|
|
881
|
-
backendWs.on("error", (err) => {
|
|
882
|
-
console.error("Backend WebSocket error:", err);
|
|
883
|
-
});
|
|
884
|
-
clientWs.on("close", () => {
|
|
885
|
-
backendWs.close();
|
|
886
|
-
console.log("Client WebSocket closed");
|
|
887
|
-
});
|
|
888
|
-
backendWs.on("close", () => {
|
|
889
|
-
clientWs.close();
|
|
890
|
-
console.log("Backend WebSocket closed");
|
|
891
|
-
});
|
|
892
|
-
});
|
|
893
|
-
});
|
|
894
|
-
backendWs.on("error", (err) => {
|
|
895
|
-
console.error("Backend WebSocket connection error:", err);
|
|
896
|
-
clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
|
897
|
-
clientSocket.destroy();
|
|
898
|
-
});
|
|
899
|
-
wss.on("error", (err) => {
|
|
900
|
-
console.error("WebSocket server error:", err);
|
|
901
|
-
});
|
|
1039
|
+
/**
|
|
1040
|
+
* Resolve the recording ID for a WebSocket upgrade request.
|
|
1041
|
+
* Mirrors getRecordingIdOrError(): prefer the header/cookie from the request,
|
|
1042
|
+
* fall back to this.replayId only when there is at most one active session.
|
|
1043
|
+
* Browsers cannot set custom headers on WebSocket handshakes from JS, but
|
|
1044
|
+
* Playwright's setExtraHTTPHeaders / cookies still reach the upgrade request.
|
|
1045
|
+
*/
|
|
1046
|
+
getWsRecordingId(req) {
|
|
1047
|
+
const fromRequest = getRecordingIdFromRequest(req);
|
|
1048
|
+
if (fromRequest) {
|
|
1049
|
+
return fromRequest;
|
|
1050
|
+
}
|
|
1051
|
+
if (this.replaySessions.size > 1) {
|
|
1052
|
+
console.warn(
|
|
1053
|
+
`[CONCURRENT REPLAY WARNING] WebSocket upgrade ${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)`
|
|
1054
|
+
);
|
|
1055
|
+
return null;
|
|
1056
|
+
}
|
|
1057
|
+
return this.replayId;
|
|
902
1058
|
}
|
|
903
|
-
handleReplayWebSocket(req, socket) {
|
|
904
|
-
const
|
|
905
|
-
const
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1059
|
+
async handleReplayWebSocket(req, socket) {
|
|
1060
|
+
const key = getWsRecordingKey(req.url || "/");
|
|
1061
|
+
const recordingId = this.getWsRecordingId(req);
|
|
1062
|
+
if (!recordingId) {
|
|
1063
|
+
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
1064
|
+
socket.destroy();
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
try {
|
|
1068
|
+
const sessionState = this.replaySessions.getOrCreate(recordingId);
|
|
1069
|
+
if (!sessionState.loadedSession) {
|
|
1070
|
+
const filePath = getRecordingPath(this.recordingsDir, recordingId);
|
|
1071
|
+
sessionState.loadedSession = await loadRecordingSession(filePath);
|
|
1072
|
+
}
|
|
1073
|
+
const wsRecording = sessionState.loadedSession.websocketRecordings.find(
|
|
909
1074
|
(r) => r.key === key
|
|
910
1075
|
);
|
|
911
1076
|
if (!wsRecording) {
|
|
@@ -914,67 +1079,12 @@ var ProxyServer = class {
|
|
|
914
1079
|
console.log(`No WebSocket recording found for ${key}`);
|
|
915
1080
|
return;
|
|
916
1081
|
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
headers: {
|
|
920
|
-
...req.headers,
|
|
921
|
-
"sec-websocket-key": req.headers["sec-websocket-key"] || "replay-key",
|
|
922
|
-
"sec-websocket-version": "13"
|
|
923
|
-
}
|
|
924
|
-
});
|
|
925
|
-
wss.handleUpgrade(fakeReq, socket, Buffer.alloc(0), (ws) => {
|
|
926
|
-
console.log(`Replaying WebSocket: ${url}`);
|
|
927
|
-
const serverMessages = wsRecording.messages.filter(
|
|
928
|
-
(m) => m.direction === "server-to-client"
|
|
929
|
-
);
|
|
930
|
-
let messageIndex = 0;
|
|
931
|
-
ws.on("message", (data) => {
|
|
932
|
-
const clientMessage = data.toString();
|
|
933
|
-
console.log(`Replay: Client sent: ${clientMessage}`);
|
|
934
|
-
if (messageIndex < serverMessages.length) {
|
|
935
|
-
setTimeout(() => {
|
|
936
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
937
|
-
ws.send(serverMessages[messageIndex].data);
|
|
938
|
-
console.log(`Replay: Sent server message ${messageIndex}`);
|
|
939
|
-
messageIndex++;
|
|
940
|
-
}
|
|
941
|
-
}, 10);
|
|
942
|
-
}
|
|
943
|
-
});
|
|
944
|
-
let initialMessagesSent = 0;
|
|
945
|
-
for (let i = 0; i < wsRecording.messages.length; i++) {
|
|
946
|
-
const msg = wsRecording.messages[i];
|
|
947
|
-
if (msg.direction === "client-to-server") {
|
|
948
|
-
break;
|
|
949
|
-
}
|
|
950
|
-
if (msg.direction === "server-to-client") {
|
|
951
|
-
setTimeout(
|
|
952
|
-
() => {
|
|
953
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
954
|
-
ws.send(msg.data);
|
|
955
|
-
console.log(
|
|
956
|
-
`Replay: Sent initial server message: ${msg.data}`
|
|
957
|
-
);
|
|
958
|
-
messageIndex++;
|
|
959
|
-
initialMessagesSent++;
|
|
960
|
-
}
|
|
961
|
-
},
|
|
962
|
-
10 * (initialMessagesSent + 1)
|
|
963
|
-
);
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
ws.on("error", (err) => {
|
|
967
|
-
console.error("Replay WebSocket error:", err);
|
|
968
|
-
});
|
|
969
|
-
ws.on("close", () => {
|
|
970
|
-
console.log("Replay WebSocket closed");
|
|
971
|
-
});
|
|
972
|
-
});
|
|
973
|
-
}).catch((error) => {
|
|
1082
|
+
replayWebSocket(req, socket, wsRecording, recordingId);
|
|
1083
|
+
} catch (error) {
|
|
974
1084
|
console.error("Replay error:", error);
|
|
975
1085
|
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
976
1086
|
socket.destroy();
|
|
977
|
-
}
|
|
1087
|
+
}
|
|
978
1088
|
}
|
|
979
1089
|
logServerStartup(port2) {
|
|
980
1090
|
console.log(`Proxy server running on http://localhost:${port2}`);
|
|
@@ -986,9 +1096,9 @@ var ProxyServer = class {
|
|
|
986
1096
|
}
|
|
987
1097
|
};
|
|
988
1098
|
|
|
989
|
-
// src/proxy.ts
|
|
990
|
-
var { target, port, recordingsDir } = parseCliArgs();
|
|
991
|
-
var proxy = new ProxyServer(target, recordingsDir);
|
|
1099
|
+
// src/proxy-cli.ts
|
|
1100
|
+
var { target, port, recordingsDir, timeout } = parseCliArgs();
|
|
1101
|
+
var proxy = new ProxyServer(target, recordingsDir, timeout);
|
|
992
1102
|
await proxy.init();
|
|
993
1103
|
proxy.listen(port);
|
|
994
1104
|
console.log(`Recordings will be saved to: ${recordingsDir}`);
|