test-proxy-recorder 0.3.5 → 0.3.8
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 +295 -100
- 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 +488 -400
- package/dist/index.d.cts +10 -42
- package/dist/index.d.ts +10 -42
- package/dist/index.mjs +486 -398
- package/dist/playwright/index.cjs +8 -0
- package/dist/playwright/index.d.cts +1 -1
- package/dist/playwright/index.d.ts +1 -1
- package/dist/playwright/index.mjs +8 -0
- package/dist/proxy.js +478 -398
- package/package.json +13 -17
- package/skills/nextjs-ssr/SKILL.md +377 -0
- package/skills/proxy-setup/SKILL.md +458 -0
package/dist/proxy.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
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
10
|
|
|
11
11
|
// src/cli.ts
|
|
12
12
|
|
|
@@ -63,6 +63,244 @@ function parseCliArgs() {
|
|
|
63
63
|
return { target: target2, port: port2, recordingsDir: recordingsDir2, timeout: timeout2 };
|
|
64
64
|
}
|
|
65
65
|
|
|
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
|
+
}
|
|
303
|
+
|
|
66
304
|
// src/types.ts
|
|
67
305
|
var Modes = {
|
|
68
306
|
transparent: "transparent",
|
|
@@ -164,6 +402,180 @@ function sendJsonResponse(res, statusCode, data) {
|
|
|
164
402
|
res.end(JSON.stringify(data));
|
|
165
403
|
}
|
|
166
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
|
+
|
|
167
579
|
// src/ProxyServer.ts
|
|
168
580
|
var ProxyServer = class {
|
|
169
581
|
target;
|
|
@@ -181,8 +593,6 @@ var ProxyServer = class {
|
|
|
181
593
|
// Sequence counter per key (endpoint)
|
|
182
594
|
replaySessions;
|
|
183
595
|
// Track multiple concurrent replay sessions by recording ID
|
|
184
|
-
sessionEvictionTimer;
|
|
185
|
-
// Periodic timer to evict idle replay sessions
|
|
186
596
|
recordingPromises;
|
|
187
597
|
// Stack of promises that resolve to completed recordings
|
|
188
598
|
flushPromise;
|
|
@@ -198,8 +608,7 @@ var ProxyServer = class {
|
|
|
198
608
|
this.modeTimeout = null;
|
|
199
609
|
this.currentSession = null;
|
|
200
610
|
this.recordingsDir = recordingsDir2;
|
|
201
|
-
this.replaySessions =
|
|
202
|
-
this.sessionEvictionTimer = null;
|
|
611
|
+
this.replaySessions = new ReplaySessionManager(this.timeoutMs);
|
|
203
612
|
this.recordingPromises = [];
|
|
204
613
|
this.flushPromise = null;
|
|
205
614
|
this.proxy = httpProxy.createProxyServer({
|
|
@@ -213,7 +622,7 @@ var ProxyServer = class {
|
|
|
213
622
|
await fs.mkdir(this.recordingsDir, { recursive: true });
|
|
214
623
|
}
|
|
215
624
|
listen(port2) {
|
|
216
|
-
const server =
|
|
625
|
+
const server = http2.createServer((req, res) => {
|
|
217
626
|
this.handleRequest(req, res);
|
|
218
627
|
});
|
|
219
628
|
server.on("upgrade", (req, socket, head) => {
|
|
@@ -227,15 +636,15 @@ var ProxyServer = class {
|
|
|
227
636
|
}
|
|
228
637
|
setupProxyEventHandlers() {
|
|
229
638
|
this.proxy.on("error", this.handleProxyError.bind(this));
|
|
230
|
-
this.proxy.on("proxyRes",
|
|
639
|
+
this.proxy.on("proxyRes", addCorsHeaders);
|
|
231
640
|
}
|
|
232
641
|
handleProxyError(err, req, res) {
|
|
233
642
|
console.error("Proxy error:", err);
|
|
234
|
-
if (!(res instanceof
|
|
643
|
+
if (!(res instanceof http2.ServerResponse)) {
|
|
235
644
|
return;
|
|
236
645
|
}
|
|
237
646
|
if (!res.headersSent) {
|
|
238
|
-
const corsHeaders =
|
|
647
|
+
const corsHeaders = getCorsHeaders(req);
|
|
239
648
|
res.writeHead(HTTP_STATUS_BAD_GATEWAY, {
|
|
240
649
|
"Content-Type": "application/json",
|
|
241
650
|
...corsHeaders
|
|
@@ -243,96 +652,12 @@ var ProxyServer = class {
|
|
|
243
652
|
}
|
|
244
653
|
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
245
654
|
}
|
|
246
|
-
/**
|
|
247
|
-
* Get CORS headers for a given request
|
|
248
|
-
* @param req The incoming HTTP request
|
|
249
|
-
* @returns An object containing CORS headers
|
|
250
|
-
*/
|
|
251
|
-
getCorsHeaders(req) {
|
|
252
|
-
const origin = req.headers.origin;
|
|
253
|
-
return {
|
|
254
|
-
"access-control-allow-origin": origin || "*",
|
|
255
|
-
"access-control-allow-credentials": "true",
|
|
256
|
-
"access-control-allow-headers": req.headers["access-control-request-headers"] || `Origin, X-Requested-With, Content-Type, Accept, Authorization, ${RECORDING_ID_HEADER}`,
|
|
257
|
-
"access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
258
|
-
"access-control-expose-headers": "*"
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
addCorsHeaders(proxyRes, req) {
|
|
262
|
-
const corsHeaders = this.getCorsHeaders(req);
|
|
263
|
-
Object.assign(proxyRes.headers, corsHeaders);
|
|
264
|
-
}
|
|
265
|
-
/**
|
|
266
|
-
* Extract recording ID from custom HTTP header
|
|
267
|
-
* Used for concurrent replay session routing, especially with Next.js
|
|
268
|
-
* @param req The incoming HTTP request
|
|
269
|
-
* @returns The recording ID from header, or null if not found
|
|
270
|
-
*/
|
|
271
|
-
getRecordingIdFromHeader(req) {
|
|
272
|
-
const headerValue = req.headers[RECORDING_ID_HEADER];
|
|
273
|
-
if (!headerValue) {
|
|
274
|
-
return null;
|
|
275
|
-
}
|
|
276
|
-
return Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
277
|
-
}
|
|
278
|
-
/**
|
|
279
|
-
* Extract recording ID from request cookie
|
|
280
|
-
* Used for concurrent replay session routing (fallback method)
|
|
281
|
-
* @param req The incoming HTTP request
|
|
282
|
-
* @returns The recording ID from cookie, or null if not found
|
|
283
|
-
*/
|
|
284
|
-
getRecordingIdFromCookie(req) {
|
|
285
|
-
const cookies = req.headers.cookie;
|
|
286
|
-
if (!cookies) {
|
|
287
|
-
return null;
|
|
288
|
-
}
|
|
289
|
-
const match = cookies.match(/proxy-recording-id=([^;]+)/);
|
|
290
|
-
return match ? decodeURIComponent(match[1]) : null;
|
|
291
|
-
}
|
|
292
|
-
/**
|
|
293
|
-
* Extract recording ID from request using custom header (preferred) or cookie (fallback)
|
|
294
|
-
* @param req The incoming HTTP request
|
|
295
|
-
* @returns The recording ID, or null if not found
|
|
296
|
-
*/
|
|
297
|
-
getRecordingIdFromRequest(req) {
|
|
298
|
-
const fromHeader = this.getRecordingIdFromHeader(req);
|
|
299
|
-
const fromCookie = this.getRecordingIdFromCookie(req);
|
|
300
|
-
return fromHeader ?? fromCookie ?? null;
|
|
301
|
-
}
|
|
302
|
-
/**
|
|
303
|
-
* Get or create a replay session state for a given recording ID
|
|
304
|
-
* @param recordingId The recording ID to get/create session for
|
|
305
|
-
* @returns The replay session state
|
|
306
|
-
*/
|
|
307
|
-
getOrCreateReplaySession(recordingId) {
|
|
308
|
-
let session = this.replaySessions.get(recordingId);
|
|
309
|
-
if (session) {
|
|
310
|
-
session.lastAccessTime = Date.now();
|
|
311
|
-
} else {
|
|
312
|
-
session = {
|
|
313
|
-
recordingId,
|
|
314
|
-
servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
|
|
315
|
-
loadedSession: null,
|
|
316
|
-
lastAccessTime: Date.now(),
|
|
317
|
-
sortedRecordingsByKey: /* @__PURE__ */ new Map()
|
|
318
|
-
};
|
|
319
|
-
this.replaySessions.set(recordingId, session);
|
|
320
|
-
this.startSessionEvictionTimer();
|
|
321
|
-
console.log(
|
|
322
|
-
`[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
return session;
|
|
326
|
-
}
|
|
327
655
|
/**
|
|
328
656
|
* Clean up a session - removes it from memory and resets counters
|
|
329
657
|
* @param sessionId The session ID to clean up
|
|
330
658
|
*/
|
|
331
659
|
async cleanupSession(sessionId) {
|
|
332
660
|
this.replaySessions.delete(sessionId);
|
|
333
|
-
if (this.replaySessions.size === 0) {
|
|
334
|
-
this.stopSessionEvictionTimer();
|
|
335
|
-
}
|
|
336
661
|
if (this.recordingId === sessionId) {
|
|
337
662
|
await this.saveCurrentSession();
|
|
338
663
|
this.currentSession = null;
|
|
@@ -343,33 +668,6 @@ var ProxyServer = class {
|
|
|
343
668
|
}
|
|
344
669
|
console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
|
|
345
670
|
}
|
|
346
|
-
startSessionEvictionTimer() {
|
|
347
|
-
if (this.sessionEvictionTimer) {
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
const CHECK_INTERVAL_MS = 3e4;
|
|
351
|
-
this.sessionEvictionTimer = setInterval(() => {
|
|
352
|
-
const now = Date.now();
|
|
353
|
-
for (const [id, session] of this.replaySessions) {
|
|
354
|
-
if (now - session.lastAccessTime >= this.timeoutMs) {
|
|
355
|
-
console.log(
|
|
356
|
-
`[EVICTION] Evicting idle replay session: ${id} (idle for ${Math.round((now - session.lastAccessTime) / 1e3)}s)`
|
|
357
|
-
);
|
|
358
|
-
this.replaySessions.delete(id);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
if (this.replaySessions.size === 0) {
|
|
362
|
-
this.stopSessionEvictionTimer();
|
|
363
|
-
}
|
|
364
|
-
}, CHECK_INTERVAL_MS);
|
|
365
|
-
this.sessionEvictionTimer.unref();
|
|
366
|
-
}
|
|
367
|
-
stopSessionEvictionTimer() {
|
|
368
|
-
if (this.sessionEvictionTimer) {
|
|
369
|
-
clearInterval(this.sessionEvictionTimer);
|
|
370
|
-
this.sessionEvictionTimer = null;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
671
|
async parseControlBody(req) {
|
|
374
672
|
const body = await readRequestBody(req);
|
|
375
673
|
console.log(`MODE CHANGE (${req.method})`, body);
|
|
@@ -493,7 +791,7 @@ var ProxyServer = class {
|
|
|
493
791
|
this.replayId = id;
|
|
494
792
|
this.recordingId = null;
|
|
495
793
|
this.currentSession = null;
|
|
496
|
-
const sessionState = this.
|
|
794
|
+
const sessionState = this.replaySessions.getOrCreate(id);
|
|
497
795
|
sessionState.servedRecordingIdsByKey.clear();
|
|
498
796
|
sessionState.sortedRecordingsByKey.clear();
|
|
499
797
|
const filePath = getRecordingPath(this.recordingsDir, id);
|
|
@@ -554,7 +852,7 @@ var ProxyServer = class {
|
|
|
554
852
|
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
555
853
|
}
|
|
556
854
|
getRecordingIdOrError(req, res) {
|
|
557
|
-
const recordingIdFromRequest =
|
|
855
|
+
const recordingIdFromRequest = getRecordingIdFromRequest(req);
|
|
558
856
|
if (recordingIdFromRequest) {
|
|
559
857
|
return recordingIdFromRequest;
|
|
560
858
|
}
|
|
@@ -562,7 +860,7 @@ var ProxyServer = class {
|
|
|
562
860
|
console.warn(
|
|
563
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)`
|
|
564
862
|
);
|
|
565
|
-
const corsHeaders =
|
|
863
|
+
const corsHeaders = getCorsHeaders(req);
|
|
566
864
|
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
567
865
|
"Content-Type": "application/json",
|
|
568
866
|
...corsHeaders
|
|
@@ -578,7 +876,7 @@ var ProxyServer = class {
|
|
|
578
876
|
}
|
|
579
877
|
const recordingId = this.replayId;
|
|
580
878
|
if (!recordingId) {
|
|
581
|
-
const corsHeaders =
|
|
879
|
+
const corsHeaders = getCorsHeaders(req);
|
|
582
880
|
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
583
881
|
"Content-Type": "application/json",
|
|
584
882
|
...corsHeaders
|
|
@@ -591,55 +889,22 @@ var ProxyServer = class {
|
|
|
591
889
|
);
|
|
592
890
|
return recordingId;
|
|
593
891
|
}
|
|
594
|
-
getServedTracker(sessionState, key) {
|
|
595
|
-
if (!sessionState.servedRecordingIdsByKey.has(key)) {
|
|
596
|
-
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
597
|
-
}
|
|
598
|
-
return sessionState.servedRecordingIdsByKey.get(key);
|
|
599
|
-
}
|
|
600
|
-
getSortedRecordings(sessionState, key) {
|
|
601
|
-
if (sessionState.sortedRecordingsByKey.has(key)) {
|
|
602
|
-
return sessionState.sortedRecordingsByKey.get(key);
|
|
603
|
-
}
|
|
604
|
-
const session = sessionState.loadedSession;
|
|
605
|
-
const sortedRecords = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
|
|
606
|
-
const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
|
|
607
|
-
const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
|
|
608
|
-
return aSeq - bSeq;
|
|
609
|
-
});
|
|
610
|
-
sessionState.sortedRecordingsByKey.set(key, sortedRecords);
|
|
611
|
-
return sortedRecords;
|
|
612
|
-
}
|
|
613
|
-
selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
|
|
614
|
-
for (const rec of recordsWithKey) {
|
|
615
|
-
if (!servedForThisKey.has(rec.recordingId)) {
|
|
616
|
-
return rec;
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
if (recordsWithKey.length > 0) {
|
|
620
|
-
console.log(
|
|
621
|
-
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
622
|
-
);
|
|
623
|
-
return recordsWithKey[recordsWithKey.length - 1];
|
|
624
|
-
}
|
|
625
|
-
return null;
|
|
626
|
-
}
|
|
627
892
|
async handleReplayRequest(req, res) {
|
|
628
893
|
const recordingId = this.getRecordingIdOrError(req, res);
|
|
629
894
|
if (!recordingId) return;
|
|
630
895
|
const key = getReqID(req);
|
|
631
896
|
const filePath = getRecordingPath(this.recordingsDir, recordingId);
|
|
632
897
|
try {
|
|
633
|
-
const sessionState = this.
|
|
898
|
+
const sessionState = this.replaySessions.getOrCreate(recordingId);
|
|
634
899
|
if (!sessionState.loadedSession) {
|
|
635
900
|
throw Object.assign(
|
|
636
901
|
new Error(`Recording session file not found: ${filePath}`),
|
|
637
902
|
{ code: "ENOENT" }
|
|
638
903
|
);
|
|
639
904
|
}
|
|
640
|
-
const servedForThisKey =
|
|
905
|
+
const servedForThisKey = getServedTracker(sessionState, key);
|
|
641
906
|
const host = req.headers.host || "unknown";
|
|
642
|
-
const recordsWithKey =
|
|
907
|
+
const recordsWithKey = getSortedRecordings(sessionState, key);
|
|
643
908
|
if (recordsWithKey.length === 0) {
|
|
644
909
|
const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
|
|
645
910
|
console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
|
|
@@ -652,7 +917,7 @@ var ProxyServer = class {
|
|
|
652
917
|
key,
|
|
653
918
|
sessionId: recordingId
|
|
654
919
|
};
|
|
655
|
-
const corsHeaders =
|
|
920
|
+
const corsHeaders = getCorsHeaders(req);
|
|
656
921
|
res.writeHead(HTTP_STATUS_NOT_FOUND, {
|
|
657
922
|
"Content-Type": "application/json",
|
|
658
923
|
...corsHeaders
|
|
@@ -664,7 +929,7 @@ var ProxyServer = class {
|
|
|
664
929
|
console.log(
|
|
665
930
|
`[replay request #${requestCount}] ${req.method} ${req.url} (key: ${key}, session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
666
931
|
);
|
|
667
|
-
const record =
|
|
932
|
+
const record = selectReplayRecord(
|
|
668
933
|
recordsWithKey,
|
|
669
934
|
servedForThisKey,
|
|
670
935
|
key,
|
|
@@ -682,7 +947,7 @@ var ProxyServer = class {
|
|
|
682
947
|
const { statusCode, headers, body } = record.response;
|
|
683
948
|
const responseHeaders = {
|
|
684
949
|
...headers,
|
|
685
|
-
...
|
|
950
|
+
...getCorsHeaders(req)
|
|
686
951
|
};
|
|
687
952
|
res.writeHead(statusCode, responseHeaders);
|
|
688
953
|
res.end(body);
|
|
@@ -693,7 +958,7 @@ var ProxyServer = class {
|
|
|
693
958
|
handleReplayError(req, res, err, key, filePath) {
|
|
694
959
|
const isFileNotFound = err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
695
960
|
console.error("Replay error:", err);
|
|
696
|
-
const corsHeaders =
|
|
961
|
+
const corsHeaders = getCorsHeaders(req);
|
|
697
962
|
res.writeHead(HTTP_STATUS_NOT_FOUND, {
|
|
698
963
|
"Content-Type": "application/json",
|
|
699
964
|
...corsHeaders
|
|
@@ -721,7 +986,7 @@ var ProxyServer = class {
|
|
|
721
986
|
await this.handleProxyRequest(req, res);
|
|
722
987
|
}
|
|
723
988
|
handleCorsPreflightRequest(req, res) {
|
|
724
|
-
const corsHeaders =
|
|
989
|
+
const corsHeaders = getCorsHeaders(req);
|
|
725
990
|
res.writeHead(HTTP_STATUS_OK, {
|
|
726
991
|
...corsHeaders,
|
|
727
992
|
"Access-Control-Max-Age": "86400"
|
|
@@ -733,13 +998,12 @@ var ProxyServer = class {
|
|
|
733
998
|
const target2 = this.target;
|
|
734
999
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target2}`);
|
|
735
1000
|
if (this.mode === Modes.record) {
|
|
736
|
-
|
|
1001
|
+
this.recordAndProxy(req, res, target2);
|
|
737
1002
|
} else {
|
|
738
1003
|
this.proxy.web(req, res, { target: target2 });
|
|
739
1004
|
}
|
|
740
1005
|
}
|
|
741
|
-
|
|
742
|
-
async recordAndProxyRequest(req, res, target2) {
|
|
1006
|
+
recordAndProxy(req, res, target2) {
|
|
743
1007
|
if (!this.currentSession) {
|
|
744
1008
|
return;
|
|
745
1009
|
}
|
|
@@ -747,109 +1011,17 @@ var ProxyServer = class {
|
|
|
747
1011
|
const recordingId = this.recordingIdCounter++;
|
|
748
1012
|
const sequence = this.sequenceCounterByKey.get(key) || 0;
|
|
749
1013
|
this.sequenceCounterByKey.set(key, sequence + 1);
|
|
750
|
-
|
|
751
|
-
(
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
setTimeout(
|
|
762
|
-
() => rejectBuffer(new Error("Request buffering timeout")),
|
|
763
|
-
3e4
|
|
764
|
-
);
|
|
765
|
-
});
|
|
766
|
-
} catch (error) {
|
|
767
|
-
console.error("Error buffering request:", error);
|
|
768
|
-
}
|
|
769
|
-
const requestBody = Buffer.concat(chunks).toString("utf8");
|
|
770
|
-
const targetUrl = new URL(target2);
|
|
771
|
-
const isHttps = targetUrl.protocol === "https:";
|
|
772
|
-
const requestModule = isHttps ? https : http;
|
|
773
|
-
const defaultPort = isHttps ? 443 : 80;
|
|
774
|
-
const proxyReq = requestModule.request(
|
|
775
|
-
{
|
|
776
|
-
hostname: targetUrl.hostname,
|
|
777
|
-
port: targetUrl.port || defaultPort,
|
|
778
|
-
path: req.url,
|
|
779
|
-
method: req.method,
|
|
780
|
-
headers: req.headers
|
|
781
|
-
},
|
|
782
|
-
(proxyRes) => {
|
|
783
|
-
this.addCorsHeaders(proxyRes, req);
|
|
784
|
-
const responseChunks = [];
|
|
785
|
-
proxyRes.on("data", (chunk) => {
|
|
786
|
-
responseChunks.push(chunk);
|
|
787
|
-
});
|
|
788
|
-
proxyRes.on("end", async () => {
|
|
789
|
-
try {
|
|
790
|
-
const responseBody = Buffer.concat(responseChunks);
|
|
791
|
-
const responseBodyStr = responseBody.toString("utf8");
|
|
792
|
-
const recording = {
|
|
793
|
-
request: {
|
|
794
|
-
method: req.method,
|
|
795
|
-
url: req.url,
|
|
796
|
-
headers: req.headers,
|
|
797
|
-
body: requestBody || null
|
|
798
|
-
},
|
|
799
|
-
response: {
|
|
800
|
-
statusCode: proxyRes.statusCode,
|
|
801
|
-
headers: proxyRes.headers,
|
|
802
|
-
body: responseBodyStr || null
|
|
803
|
-
},
|
|
804
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
805
|
-
key,
|
|
806
|
-
recordingId,
|
|
807
|
-
sequence
|
|
808
|
-
};
|
|
809
|
-
const responseHeaders = {
|
|
810
|
-
...proxyRes.headers,
|
|
811
|
-
...this.getCorsHeaders(req)
|
|
812
|
-
};
|
|
813
|
-
res.writeHead(proxyRes.statusCode || 200, responseHeaders);
|
|
814
|
-
res.end(responseBody);
|
|
815
|
-
console.log(
|
|
816
|
-
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId}, sequence: ${sequence})`
|
|
817
|
-
);
|
|
818
|
-
resolve(recording);
|
|
819
|
-
} catch (error) {
|
|
820
|
-
console.error("Error completing recording:", error);
|
|
821
|
-
resolve(null);
|
|
822
|
-
}
|
|
823
|
-
});
|
|
824
|
-
proxyRes.on("error", (err) => {
|
|
825
|
-
console.error("Proxy response error:", err);
|
|
826
|
-
if (!res.headersSent) {
|
|
827
|
-
this.handleProxyError(err, req, res);
|
|
828
|
-
}
|
|
829
|
-
resolve(null);
|
|
830
|
-
});
|
|
831
|
-
}
|
|
832
|
-
);
|
|
833
|
-
proxyReq.on("error", (err) => {
|
|
834
|
-
this.handleProxyError(err, req, res);
|
|
835
|
-
resolve(null);
|
|
836
|
-
});
|
|
837
|
-
if (chunks.length > 0) {
|
|
838
|
-
proxyReq.write(Buffer.concat(chunks));
|
|
839
|
-
}
|
|
840
|
-
proxyReq.end();
|
|
841
|
-
} catch (error) {
|
|
842
|
-
console.error("Error in recordAndProxyRequest:", error);
|
|
843
|
-
try {
|
|
844
|
-
this.handleProxyError(error, req, res);
|
|
845
|
-
} catch (error_) {
|
|
846
|
-
console.error("Failed to handle proxy error:", error_);
|
|
847
|
-
}
|
|
848
|
-
resolve(null);
|
|
849
|
-
}
|
|
850
|
-
})();
|
|
851
|
-
});
|
|
852
|
-
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
|
+
);
|
|
853
1025
|
}
|
|
854
1026
|
handleUpgrade(req, socket, head) {
|
|
855
1027
|
if (this.mode === Modes.replay) {
|
|
@@ -859,83 +1031,46 @@ var ProxyServer = class {
|
|
|
859
1031
|
const target2 = this.target;
|
|
860
1032
|
console.log(`[${this.mode}] WebSocket upgrade ${req.url} -> ${target2}`);
|
|
861
1033
|
if (this.mode === Modes.record) {
|
|
862
|
-
|
|
1034
|
+
recordWebSocket(req, socket, head, target2, this.currentSession);
|
|
863
1035
|
} else {
|
|
864
1036
|
this.proxy.ws(req, socket, head, { target: target2 });
|
|
865
1037
|
}
|
|
866
1038
|
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
if (
|
|
877
|
-
|
|
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;
|
|
878
1050
|
}
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
const message = data.toString();
|
|
887
|
-
wsRecording.messages.push({
|
|
888
|
-
direction: "client-to-server",
|
|
889
|
-
data: message,
|
|
890
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
891
|
-
});
|
|
892
|
-
if (backendWs.readyState === WebSocket.OPEN) {
|
|
893
|
-
backendWs.send(message);
|
|
894
|
-
}
|
|
895
|
-
});
|
|
896
|
-
backendWs.on("message", (data) => {
|
|
897
|
-
const message = data.toString();
|
|
898
|
-
wsRecording.messages.push({
|
|
899
|
-
direction: "server-to-client",
|
|
900
|
-
data: message,
|
|
901
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
902
|
-
});
|
|
903
|
-
if (clientWs.readyState === WebSocket.OPEN) {
|
|
904
|
-
clientWs.send(message);
|
|
905
|
-
}
|
|
906
|
-
});
|
|
907
|
-
clientWs.on("error", (err) => {
|
|
908
|
-
console.error("Client WebSocket error:", err);
|
|
909
|
-
});
|
|
910
|
-
backendWs.on("error", (err) => {
|
|
911
|
-
console.error("Backend WebSocket error:", err);
|
|
912
|
-
});
|
|
913
|
-
clientWs.on("close", () => {
|
|
914
|
-
backendWs.close();
|
|
915
|
-
console.log("Client WebSocket closed");
|
|
916
|
-
});
|
|
917
|
-
backendWs.on("close", () => {
|
|
918
|
-
clientWs.close();
|
|
919
|
-
console.log("Backend WebSocket closed");
|
|
920
|
-
});
|
|
921
|
-
});
|
|
922
|
-
});
|
|
923
|
-
backendWs.on("error", (err) => {
|
|
924
|
-
console.error("Backend WebSocket connection error:", err);
|
|
925
|
-
clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
|
926
|
-
clientSocket.destroy();
|
|
927
|
-
});
|
|
928
|
-
wss.on("error", (err) => {
|
|
929
|
-
console.error("WebSocket server error:", err);
|
|
930
|
-
});
|
|
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;
|
|
931
1058
|
}
|
|
932
1059
|
async handleReplayWebSocket(req, socket) {
|
|
933
|
-
const
|
|
934
|
-
const
|
|
935
|
-
|
|
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
|
+
}
|
|
936
1067
|
try {
|
|
937
|
-
const
|
|
938
|
-
|
|
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(
|
|
939
1074
|
(r) => r.key === key
|
|
940
1075
|
);
|
|
941
1076
|
if (!wsRecording) {
|
|
@@ -944,62 +1079,7 @@ var ProxyServer = class {
|
|
|
944
1079
|
console.log(`No WebSocket recording found for ${key}`);
|
|
945
1080
|
return;
|
|
946
1081
|
}
|
|
947
|
-
|
|
948
|
-
const fakeReq = Object.assign(req, {
|
|
949
|
-
headers: {
|
|
950
|
-
...req.headers,
|
|
951
|
-
"sec-websocket-key": req.headers["sec-websocket-key"] || "replay-key",
|
|
952
|
-
"sec-websocket-version": "13"
|
|
953
|
-
}
|
|
954
|
-
});
|
|
955
|
-
wss.handleUpgrade(fakeReq, socket, Buffer.alloc(0), (ws) => {
|
|
956
|
-
console.log(`Replaying WebSocket: ${url}`);
|
|
957
|
-
const serverMessages = wsRecording.messages.filter(
|
|
958
|
-
(m) => m.direction === "server-to-client"
|
|
959
|
-
);
|
|
960
|
-
let messageIndex = 0;
|
|
961
|
-
ws.on("message", (data) => {
|
|
962
|
-
const clientMessage = data.toString();
|
|
963
|
-
console.log(`Replay: Client sent: ${clientMessage}`);
|
|
964
|
-
if (messageIndex < serverMessages.length) {
|
|
965
|
-
setTimeout(() => {
|
|
966
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
967
|
-
ws.send(serverMessages[messageIndex].data);
|
|
968
|
-
console.log(`Replay: Sent server message ${messageIndex}`);
|
|
969
|
-
messageIndex++;
|
|
970
|
-
}
|
|
971
|
-
}, 10);
|
|
972
|
-
}
|
|
973
|
-
});
|
|
974
|
-
let initialMessagesSent = 0;
|
|
975
|
-
for (let i = 0; i < wsRecording.messages.length; i++) {
|
|
976
|
-
const msg = wsRecording.messages[i];
|
|
977
|
-
if (msg.direction === "client-to-server") {
|
|
978
|
-
break;
|
|
979
|
-
}
|
|
980
|
-
if (msg.direction === "server-to-client") {
|
|
981
|
-
setTimeout(
|
|
982
|
-
() => {
|
|
983
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
984
|
-
ws.send(msg.data);
|
|
985
|
-
console.log(
|
|
986
|
-
`Replay: Sent initial server message: ${msg.data}`
|
|
987
|
-
);
|
|
988
|
-
messageIndex++;
|
|
989
|
-
initialMessagesSent++;
|
|
990
|
-
}
|
|
991
|
-
},
|
|
992
|
-
10 * (initialMessagesSent + 1)
|
|
993
|
-
);
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
ws.on("error", (err) => {
|
|
997
|
-
console.error("Replay WebSocket error:", err);
|
|
998
|
-
});
|
|
999
|
-
ws.on("close", () => {
|
|
1000
|
-
console.log("Replay WebSocket closed");
|
|
1001
|
-
});
|
|
1002
|
-
});
|
|
1082
|
+
replayWebSocket(req, socket, wsRecording, recordingId);
|
|
1003
1083
|
} catch (error) {
|
|
1004
1084
|
console.error("Replay error:", error);
|
|
1005
1085
|
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|