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