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