test-proxy-recorder 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +334 -483
- package/dist/{index-BlBWqSE4.d.cts → index-BloXCw69.d.cts} +4 -0
- package/dist/{index-BlBWqSE4.d.ts → index-BloXCw69.d.ts} +4 -0
- package/dist/index.cjs +551 -445
- package/dist/index.d.cts +15 -43
- package/dist/index.d.ts +15 -43
- package/dist/index.mjs +549 -443
- package/dist/playwright/index.cjs +15 -8
- package/dist/playwright/index.d.cts +1 -1
- package/dist/playwright/index.d.ts +1 -1
- package/dist/playwright/index.mjs +15 -8
- package/dist/proxy.js +560 -450
- package/package.json +14 -12
- package/skills/nextjs-ssr/SKILL.md +377 -0
- package/skills/proxy-setup/SKILL.md +458 -0
package/dist/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",
|
|
@@ -71,9 +309,9 @@ function processRecordings(recordings) {
|
|
|
71
309
|
const processedRecordings = [];
|
|
72
310
|
for (const [_key, keyRecordings] of recordingsByKey) {
|
|
73
311
|
keyRecordings.sort((a, b) => a.recordingId - b.recordingId);
|
|
74
|
-
keyRecordings.
|
|
312
|
+
for (const [index, recording] of keyRecordings.entries()) {
|
|
75
313
|
processedRecordings.push({ ...recording, sequence: index });
|
|
76
|
-
}
|
|
314
|
+
}
|
|
77
315
|
}
|
|
78
316
|
processedRecordings.sort((a, b) => a.recordingId - b.recordingId);
|
|
79
317
|
return processedRecordings;
|
|
@@ -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;
|
|
@@ -139,6 +551,7 @@ var ProxyServer = class {
|
|
|
139
551
|
proxy;
|
|
140
552
|
currentSession;
|
|
141
553
|
recordingsDir;
|
|
554
|
+
timeoutMs;
|
|
142
555
|
recordingIdCounter;
|
|
143
556
|
// Unique ID for each recording entry
|
|
144
557
|
sequenceCounterByKey;
|
|
@@ -149,8 +562,9 @@ var ProxyServer = class {
|
|
|
149
562
|
// Stack of promises that resolve to completed recordings
|
|
150
563
|
flushPromise;
|
|
151
564
|
// Promise for in-progress flush operation
|
|
152
|
-
constructor(target, recordingsDir) {
|
|
565
|
+
constructor(target, recordingsDir, timeoutMs) {
|
|
153
566
|
this.target = target;
|
|
567
|
+
this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
154
568
|
this.mode = Modes.transparent;
|
|
155
569
|
this.recordingId = null;
|
|
156
570
|
this.recordingIdCounter = 0;
|
|
@@ -159,7 +573,7 @@ var ProxyServer = class {
|
|
|
159
573
|
this.modeTimeout = null;
|
|
160
574
|
this.currentSession = null;
|
|
161
575
|
this.recordingsDir = recordingsDir;
|
|
162
|
-
this.replaySessions =
|
|
576
|
+
this.replaySessions = new ReplaySessionManager(this.timeoutMs);
|
|
163
577
|
this.recordingPromises = [];
|
|
164
578
|
this.flushPromise = null;
|
|
165
579
|
this.proxy = httpProxy__default.default.createProxyServer({
|
|
@@ -173,7 +587,7 @@ var ProxyServer = class {
|
|
|
173
587
|
await fs__default.default.mkdir(this.recordingsDir, { recursive: true });
|
|
174
588
|
}
|
|
175
589
|
listen(port) {
|
|
176
|
-
const server =
|
|
590
|
+
const server = http2__default.default.createServer((req, res) => {
|
|
177
591
|
this.handleRequest(req, res);
|
|
178
592
|
});
|
|
179
593
|
server.on("upgrade", (req, socket, head) => {
|
|
@@ -187,15 +601,15 @@ var ProxyServer = class {
|
|
|
187
601
|
}
|
|
188
602
|
setupProxyEventHandlers() {
|
|
189
603
|
this.proxy.on("error", this.handleProxyError.bind(this));
|
|
190
|
-
this.proxy.on("proxyRes",
|
|
604
|
+
this.proxy.on("proxyRes", addCorsHeaders);
|
|
191
605
|
}
|
|
192
606
|
handleProxyError(err, req, res) {
|
|
193
607
|
console.error("Proxy error:", err);
|
|
194
|
-
if (!(res instanceof
|
|
608
|
+
if (!(res instanceof http2__default.default.ServerResponse)) {
|
|
195
609
|
return;
|
|
196
610
|
}
|
|
197
611
|
if (!res.headersSent) {
|
|
198
|
-
const corsHeaders =
|
|
612
|
+
const corsHeaders = getCorsHeaders(req);
|
|
199
613
|
res.writeHead(HTTP_STATUS_BAD_GATEWAY, {
|
|
200
614
|
"Content-Type": "application/json",
|
|
201
615
|
...corsHeaders
|
|
@@ -203,103 +617,12 @@ var ProxyServer = class {
|
|
|
203
617
|
}
|
|
204
618
|
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
205
619
|
}
|
|
206
|
-
/**
|
|
207
|
-
* Get CORS headers for a given request
|
|
208
|
-
* @param req The incoming HTTP request
|
|
209
|
-
* @returns An object containing CORS headers
|
|
210
|
-
*/
|
|
211
|
-
getCorsHeaders(req) {
|
|
212
|
-
const origin = req.headers.origin;
|
|
213
|
-
return {
|
|
214
|
-
"access-control-allow-origin": origin || "*",
|
|
215
|
-
"access-control-allow-credentials": "true",
|
|
216
|
-
"access-control-allow-headers": req.headers["access-control-request-headers"] || `Origin, X-Requested-With, Content-Type, Accept, Authorization, ${RECORDING_ID_HEADER}`,
|
|
217
|
-
"access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
218
|
-
"access-control-expose-headers": "*"
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
addCorsHeaders(proxyRes, req) {
|
|
222
|
-
const corsHeaders = this.getCorsHeaders(req);
|
|
223
|
-
Object.assign(proxyRes.headers, corsHeaders);
|
|
224
|
-
}
|
|
225
|
-
getTarget() {
|
|
226
|
-
return this.target;
|
|
227
|
-
}
|
|
228
|
-
/**
|
|
229
|
-
* Extract recording ID from custom HTTP header
|
|
230
|
-
* Used for concurrent replay session routing, especially with Next.js
|
|
231
|
-
* @param req The incoming HTTP request
|
|
232
|
-
* @returns The recording ID from header, or null if not found
|
|
233
|
-
*/
|
|
234
|
-
getRecordingIdFromHeader(req) {
|
|
235
|
-
const headerValue = req.headers[RECORDING_ID_HEADER];
|
|
236
|
-
if (!headerValue) {
|
|
237
|
-
return null;
|
|
238
|
-
}
|
|
239
|
-
return Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
240
|
-
}
|
|
241
|
-
/**
|
|
242
|
-
* Extract recording ID from request cookie
|
|
243
|
-
* Used for concurrent replay session routing (fallback method)
|
|
244
|
-
* @param req The incoming HTTP request
|
|
245
|
-
* @returns The recording ID from cookie, or null if not found
|
|
246
|
-
*/
|
|
247
|
-
getRecordingIdFromCookie(req) {
|
|
248
|
-
const cookies = req.headers.cookie;
|
|
249
|
-
if (!cookies) {
|
|
250
|
-
return null;
|
|
251
|
-
}
|
|
252
|
-
const match = cookies.match(/proxy-recording-id=([^;]+)/);
|
|
253
|
-
return match ? decodeURIComponent(match[1]) : null;
|
|
254
|
-
}
|
|
255
|
-
/**
|
|
256
|
-
* Extract recording ID from request using custom header (preferred) or cookie (fallback)
|
|
257
|
-
* @param req The incoming HTTP request
|
|
258
|
-
* @returns The recording ID, or null if not found
|
|
259
|
-
*/
|
|
260
|
-
getRecordingIdFromRequest(req) {
|
|
261
|
-
const fromHeader = this.getRecordingIdFromHeader(req);
|
|
262
|
-
const fromCookie = this.getRecordingIdFromCookie(req);
|
|
263
|
-
if (fromHeader) {
|
|
264
|
-
return fromHeader;
|
|
265
|
-
}
|
|
266
|
-
if (fromCookie) {
|
|
267
|
-
return fromCookie;
|
|
268
|
-
}
|
|
269
|
-
return null;
|
|
270
|
-
}
|
|
271
|
-
/**
|
|
272
|
-
* Get or create a replay session state for a given recording ID
|
|
273
|
-
* @param recordingId The recording ID to get/create session for
|
|
274
|
-
* @returns The replay session state
|
|
275
|
-
*/
|
|
276
|
-
getOrCreateReplaySession(recordingId) {
|
|
277
|
-
let session = this.replaySessions.get(recordingId);
|
|
278
|
-
if (session) {
|
|
279
|
-
session.lastAccessTime = Date.now();
|
|
280
|
-
} else {
|
|
281
|
-
session = {
|
|
282
|
-
recordingId,
|
|
283
|
-
servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
|
|
284
|
-
loadedSession: null,
|
|
285
|
-
lastAccessTime: Date.now(),
|
|
286
|
-
sortedRecordingsByKey: /* @__PURE__ */ new Map()
|
|
287
|
-
};
|
|
288
|
-
this.replaySessions.set(recordingId, session);
|
|
289
|
-
console.log(
|
|
290
|
-
`[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
|
|
291
|
-
);
|
|
292
|
-
}
|
|
293
|
-
return session;
|
|
294
|
-
}
|
|
295
620
|
/**
|
|
296
621
|
* Clean up a session - removes it from memory and resets counters
|
|
297
622
|
* @param sessionId The session ID to clean up
|
|
298
623
|
*/
|
|
299
624
|
async cleanupSession(sessionId) {
|
|
300
|
-
|
|
301
|
-
this.replaySessions.delete(sessionId);
|
|
302
|
-
}
|
|
625
|
+
this.replaySessions.delete(sessionId);
|
|
303
626
|
if (this.recordingId === sessionId) {
|
|
304
627
|
await this.saveCurrentSession();
|
|
305
628
|
this.currentSession = null;
|
|
@@ -310,29 +633,17 @@ var ProxyServer = class {
|
|
|
310
633
|
}
|
|
311
634
|
console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
|
|
312
635
|
}
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const timeoutParam = url.searchParams.get("timeout");
|
|
318
|
-
const timeout = timeoutParam ? Number.parseInt(timeoutParam, 10) : void 0;
|
|
319
|
-
if (!mode) {
|
|
320
|
-
throw new Error("Mode parameter is required");
|
|
321
|
-
}
|
|
322
|
-
return { mode, id, timeout };
|
|
323
|
-
}
|
|
324
|
-
async parseControlRequest(req) {
|
|
325
|
-
if (req.method === "GET") {
|
|
326
|
-
return this.parseGetParams(req);
|
|
327
|
-
}
|
|
328
|
-
if (req.method === "POST") {
|
|
329
|
-
const body = await readRequestBody(req);
|
|
330
|
-
console.log(`MODE CHANGE (${req.method})`, body);
|
|
331
|
-
return JSON.parse(body);
|
|
332
|
-
}
|
|
333
|
-
throw new Error("Unsupported control method");
|
|
636
|
+
async parseControlBody(req) {
|
|
637
|
+
const body = await readRequestBody(req);
|
|
638
|
+
console.log(`MODE CHANGE (${req.method})`, body);
|
|
639
|
+
return JSON.parse(body);
|
|
334
640
|
}
|
|
335
641
|
async handleControlRequest(req, res) {
|
|
642
|
+
if (req.method === "HEAD") {
|
|
643
|
+
res.writeHead(HTTP_STATUS_OK);
|
|
644
|
+
res.end();
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
336
647
|
if (req.method === "GET") {
|
|
337
648
|
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
338
649
|
recordingsDir: this.recordingsDir,
|
|
@@ -341,8 +652,11 @@ var ProxyServer = class {
|
|
|
341
652
|
});
|
|
342
653
|
return;
|
|
343
654
|
}
|
|
655
|
+
await this.handleControlPost(req, res);
|
|
656
|
+
}
|
|
657
|
+
async handleControlPost(req, res) {
|
|
344
658
|
try {
|
|
345
|
-
const data = await this.
|
|
659
|
+
const data = await this.parseControlBody(req);
|
|
346
660
|
const { mode, id, timeout: requestTimeout, cleanup } = data;
|
|
347
661
|
if (cleanup && id) {
|
|
348
662
|
await this.cleanupSession(id);
|
|
@@ -353,29 +667,7 @@ var ProxyServer = class {
|
|
|
353
667
|
});
|
|
354
668
|
return;
|
|
355
669
|
}
|
|
356
|
-
|
|
357
|
-
throw new Error(
|
|
358
|
-
"Mode parameter is required when cleanup is not specified"
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
362
|
-
this.clearModeTimeout();
|
|
363
|
-
await this.switchMode(mode, id);
|
|
364
|
-
this.setupModeTimeout(timeout);
|
|
365
|
-
if (mode === Modes.replay && id) {
|
|
366
|
-
res.setHeader(
|
|
367
|
-
"Set-Cookie",
|
|
368
|
-
`proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
|
|
369
|
-
);
|
|
370
|
-
console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
|
|
371
|
-
}
|
|
372
|
-
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
373
|
-
success: true,
|
|
374
|
-
mode: this.mode,
|
|
375
|
-
id: this.recordingId || this.replayId,
|
|
376
|
-
timeout,
|
|
377
|
-
recordingsDir: this.recordingsDir
|
|
378
|
-
});
|
|
670
|
+
await this.applyModeChange(res, mode, id, requestTimeout);
|
|
379
671
|
} catch (error) {
|
|
380
672
|
console.error("Control request error:", error);
|
|
381
673
|
sendJsonResponse(res, HTTP_STATUS_BAD_REQUEST, {
|
|
@@ -383,6 +675,31 @@ var ProxyServer = class {
|
|
|
383
675
|
});
|
|
384
676
|
}
|
|
385
677
|
}
|
|
678
|
+
async applyModeChange(res, mode, id, requestTimeout) {
|
|
679
|
+
if (!mode) {
|
|
680
|
+
throw new Error(
|
|
681
|
+
"Mode parameter is required when cleanup is not specified"
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
const timeout = requestTimeout ?? this.timeoutMs;
|
|
685
|
+
this.clearModeTimeout();
|
|
686
|
+
await this.switchMode(mode, id);
|
|
687
|
+
this.setupModeTimeout(timeout);
|
|
688
|
+
if (mode === Modes.replay && id) {
|
|
689
|
+
res.setHeader(
|
|
690
|
+
"Set-Cookie",
|
|
691
|
+
`proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
|
|
692
|
+
);
|
|
693
|
+
console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
|
|
694
|
+
}
|
|
695
|
+
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
696
|
+
success: true,
|
|
697
|
+
mode: this.mode,
|
|
698
|
+
id: this.recordingId || this.replayId,
|
|
699
|
+
timeout,
|
|
700
|
+
recordingsDir: this.recordingsDir
|
|
701
|
+
});
|
|
702
|
+
}
|
|
386
703
|
clearModeTimeout() {
|
|
387
704
|
clearTimeout(this.modeTimeout || 0);
|
|
388
705
|
this.modeTimeout = null;
|
|
@@ -422,7 +739,7 @@ var ProxyServer = class {
|
|
|
422
739
|
this.recordingId = null;
|
|
423
740
|
this.replayId = null;
|
|
424
741
|
this.currentSession = null;
|
|
425
|
-
|
|
742
|
+
this.clearModeTimeout();
|
|
426
743
|
console.log("Switched to transparent mode");
|
|
427
744
|
}
|
|
428
745
|
switchToRecordMode(id) {
|
|
@@ -439,7 +756,7 @@ var ProxyServer = class {
|
|
|
439
756
|
this.replayId = id;
|
|
440
757
|
this.recordingId = null;
|
|
441
758
|
this.currentSession = null;
|
|
442
|
-
const sessionState = this.
|
|
759
|
+
const sessionState = this.replaySessions.getOrCreate(id);
|
|
443
760
|
sessionState.servedRecordingIdsByKey.clear();
|
|
444
761
|
sessionState.sortedRecordingsByKey.clear();
|
|
445
762
|
const filePath = getRecordingPath(this.recordingsDir, id);
|
|
@@ -453,7 +770,7 @@ var ProxyServer = class {
|
|
|
453
770
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
454
771
|
}
|
|
455
772
|
setupModeTimeout(timeout) {
|
|
456
|
-
|
|
773
|
+
this.clearModeTimeout();
|
|
457
774
|
this.modeTimeout = setTimeout(async () => {
|
|
458
775
|
console.log("Timeout reached, switching back to transparent mode");
|
|
459
776
|
await this.saveCurrentSession();
|
|
@@ -500,7 +817,7 @@ var ProxyServer = class {
|
|
|
500
817
|
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
501
818
|
}
|
|
502
819
|
getRecordingIdOrError(req, res) {
|
|
503
|
-
const recordingIdFromRequest =
|
|
820
|
+
const recordingIdFromRequest = getRecordingIdFromRequest(req);
|
|
504
821
|
if (recordingIdFromRequest) {
|
|
505
822
|
return recordingIdFromRequest;
|
|
506
823
|
}
|
|
@@ -508,7 +825,7 @@ var ProxyServer = class {
|
|
|
508
825
|
console.warn(
|
|
509
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)`
|
|
510
827
|
);
|
|
511
|
-
const corsHeaders =
|
|
828
|
+
const corsHeaders = getCorsHeaders(req);
|
|
512
829
|
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
513
830
|
"Content-Type": "application/json",
|
|
514
831
|
...corsHeaders
|
|
@@ -524,7 +841,7 @@ var ProxyServer = class {
|
|
|
524
841
|
}
|
|
525
842
|
const recordingId = this.replayId;
|
|
526
843
|
if (!recordingId) {
|
|
527
|
-
const corsHeaders =
|
|
844
|
+
const corsHeaders = getCorsHeaders(req);
|
|
528
845
|
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
529
846
|
"Content-Type": "application/json",
|
|
530
847
|
...corsHeaders
|
|
@@ -537,56 +854,22 @@ var ProxyServer = class {
|
|
|
537
854
|
);
|
|
538
855
|
return recordingId;
|
|
539
856
|
}
|
|
540
|
-
getServedTracker(sessionState, key) {
|
|
541
|
-
if (!sessionState.servedRecordingIdsByKey.has(key)) {
|
|
542
|
-
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
543
|
-
}
|
|
544
|
-
return sessionState.servedRecordingIdsByKey.get(key);
|
|
545
|
-
}
|
|
546
|
-
getSortedRecordings(sessionState, key) {
|
|
547
|
-
if (sessionState.sortedRecordingsByKey.has(key)) {
|
|
548
|
-
return sessionState.sortedRecordingsByKey.get(key);
|
|
549
|
-
}
|
|
550
|
-
const session = sessionState.loadedSession;
|
|
551
|
-
const sortedRecords = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
|
|
552
|
-
const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
|
|
553
|
-
const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
|
|
554
|
-
return aSeq - bSeq;
|
|
555
|
-
});
|
|
556
|
-
sessionState.sortedRecordingsByKey.set(key, sortedRecords);
|
|
557
|
-
return sortedRecords;
|
|
558
|
-
}
|
|
559
|
-
selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
|
|
560
|
-
for (const rec of recordsWithKey) {
|
|
561
|
-
if (!servedForThisKey.has(rec.recordingId)) {
|
|
562
|
-
return rec;
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
if (recordsWithKey.length > 0) {
|
|
566
|
-
console.log(
|
|
567
|
-
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
568
|
-
);
|
|
569
|
-
return recordsWithKey[recordsWithKey.length - 1];
|
|
570
|
-
}
|
|
571
|
-
return null;
|
|
572
|
-
}
|
|
573
857
|
async handleReplayRequest(req, res) {
|
|
574
858
|
const recordingId = this.getRecordingIdOrError(req, res);
|
|
575
859
|
if (!recordingId) return;
|
|
576
860
|
const key = getReqID(req);
|
|
577
861
|
const filePath = getRecordingPath(this.recordingsDir, recordingId);
|
|
578
862
|
try {
|
|
579
|
-
const sessionState = this.
|
|
863
|
+
const sessionState = this.replaySessions.getOrCreate(recordingId);
|
|
580
864
|
if (!sessionState.loadedSession) {
|
|
581
|
-
|
|
582
|
-
`Recording session file not found: ${filePath}`
|
|
865
|
+
throw Object.assign(
|
|
866
|
+
new Error(`Recording session file not found: ${filePath}`),
|
|
867
|
+
{ code: "ENOENT" }
|
|
583
868
|
);
|
|
584
|
-
error.code = "ENOENT";
|
|
585
|
-
throw error;
|
|
586
869
|
}
|
|
587
|
-
const servedForThisKey =
|
|
870
|
+
const servedForThisKey = getServedTracker(sessionState, key);
|
|
588
871
|
const host = req.headers.host || "unknown";
|
|
589
|
-
const recordsWithKey =
|
|
872
|
+
const recordsWithKey = getSortedRecordings(sessionState, key);
|
|
590
873
|
if (recordsWithKey.length === 0) {
|
|
591
874
|
const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
|
|
592
875
|
console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
|
|
@@ -599,7 +882,7 @@ var ProxyServer = class {
|
|
|
599
882
|
key,
|
|
600
883
|
sessionId: recordingId
|
|
601
884
|
};
|
|
602
|
-
const corsHeaders =
|
|
885
|
+
const corsHeaders = getCorsHeaders(req);
|
|
603
886
|
res.writeHead(HTTP_STATUS_NOT_FOUND, {
|
|
604
887
|
"Content-Type": "application/json",
|
|
605
888
|
...corsHeaders
|
|
@@ -611,7 +894,7 @@ var ProxyServer = class {
|
|
|
611
894
|
console.log(
|
|
612
895
|
`[replay request #${requestCount}] ${req.method} ${req.url} (key: ${key}, session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
613
896
|
);
|
|
614
|
-
const record =
|
|
897
|
+
const record = selectReplayRecord(
|
|
615
898
|
recordsWithKey,
|
|
616
899
|
servedForThisKey,
|
|
617
900
|
key,
|
|
@@ -629,7 +912,7 @@ var ProxyServer = class {
|
|
|
629
912
|
const { statusCode, headers, body } = record.response;
|
|
630
913
|
const responseHeaders = {
|
|
631
914
|
...headers,
|
|
632
|
-
...
|
|
915
|
+
...getCorsHeaders(req)
|
|
633
916
|
};
|
|
634
917
|
res.writeHead(statusCode, responseHeaders);
|
|
635
918
|
res.end(body);
|
|
@@ -640,7 +923,7 @@ var ProxyServer = class {
|
|
|
640
923
|
handleReplayError(req, res, err, key, filePath) {
|
|
641
924
|
const isFileNotFound = err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
642
925
|
console.error("Replay error:", err);
|
|
643
|
-
const corsHeaders =
|
|
926
|
+
const corsHeaders = getCorsHeaders(req);
|
|
644
927
|
res.writeHead(HTTP_STATUS_NOT_FOUND, {
|
|
645
928
|
"Content-Type": "application/json",
|
|
646
929
|
...corsHeaders
|
|
@@ -668,7 +951,7 @@ var ProxyServer = class {
|
|
|
668
951
|
await this.handleProxyRequest(req, res);
|
|
669
952
|
}
|
|
670
953
|
handleCorsPreflightRequest(req, res) {
|
|
671
|
-
const corsHeaders =
|
|
954
|
+
const corsHeaders = getCorsHeaders(req);
|
|
672
955
|
res.writeHead(HTTP_STATUS_OK, {
|
|
673
956
|
...corsHeaders,
|
|
674
957
|
"Access-Control-Max-Age": "86400"
|
|
@@ -677,16 +960,15 @@ var ProxyServer = class {
|
|
|
677
960
|
res.end();
|
|
678
961
|
}
|
|
679
962
|
async handleProxyRequest(req, res) {
|
|
680
|
-
const target = this.
|
|
963
|
+
const target = this.target;
|
|
681
964
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
|
|
682
965
|
if (this.mode === Modes.record) {
|
|
683
|
-
|
|
966
|
+
this.recordAndProxy(req, res, target);
|
|
684
967
|
} else {
|
|
685
968
|
this.proxy.web(req, res, { target });
|
|
686
969
|
}
|
|
687
970
|
}
|
|
688
|
-
|
|
689
|
-
async recordAndProxyRequest(req, res, target) {
|
|
971
|
+
recordAndProxy(req, res, target) {
|
|
690
972
|
if (!this.currentSession) {
|
|
691
973
|
return;
|
|
692
974
|
}
|
|
@@ -694,194 +976,66 @@ var ProxyServer = class {
|
|
|
694
976
|
const recordingId = this.recordingIdCounter++;
|
|
695
977
|
const sequence = this.sequenceCounterByKey.get(key) || 0;
|
|
696
978
|
this.sequenceCounterByKey.set(key, sequence + 1);
|
|
697
|
-
|
|
698
|
-
(
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
setTimeout(
|
|
709
|
-
() => rejectBuffer(new Error("Request buffering timeout")),
|
|
710
|
-
3e4
|
|
711
|
-
);
|
|
712
|
-
});
|
|
713
|
-
} catch (error) {
|
|
714
|
-
console.error("Error buffering request:", error);
|
|
715
|
-
}
|
|
716
|
-
const requestBody = Buffer.concat(chunks).toString("utf8");
|
|
717
|
-
const targetUrl = new URL(target);
|
|
718
|
-
const isHttps = targetUrl.protocol === "https:";
|
|
719
|
-
const requestModule = isHttps ? https__default.default : http__default.default;
|
|
720
|
-
const defaultPort = isHttps ? 443 : 80;
|
|
721
|
-
const proxyReq = requestModule.request(
|
|
722
|
-
{
|
|
723
|
-
hostname: targetUrl.hostname,
|
|
724
|
-
port: targetUrl.port || defaultPort,
|
|
725
|
-
path: req.url,
|
|
726
|
-
method: req.method,
|
|
727
|
-
headers: req.headers
|
|
728
|
-
},
|
|
729
|
-
(proxyRes) => {
|
|
730
|
-
this.addCorsHeaders(proxyRes, req);
|
|
731
|
-
const responseChunks = [];
|
|
732
|
-
proxyRes.on("data", (chunk) => {
|
|
733
|
-
responseChunks.push(chunk);
|
|
734
|
-
});
|
|
735
|
-
proxyRes.on("end", async () => {
|
|
736
|
-
try {
|
|
737
|
-
const responseBody = Buffer.concat(responseChunks);
|
|
738
|
-
const responseBodyStr = responseBody.toString("utf8");
|
|
739
|
-
const recording = {
|
|
740
|
-
request: {
|
|
741
|
-
method: req.method,
|
|
742
|
-
url: req.url,
|
|
743
|
-
headers: req.headers,
|
|
744
|
-
body: requestBody || null
|
|
745
|
-
},
|
|
746
|
-
response: {
|
|
747
|
-
statusCode: proxyRes.statusCode,
|
|
748
|
-
headers: proxyRes.headers,
|
|
749
|
-
body: responseBodyStr || null
|
|
750
|
-
},
|
|
751
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
752
|
-
key,
|
|
753
|
-
recordingId,
|
|
754
|
-
sequence
|
|
755
|
-
};
|
|
756
|
-
const responseHeaders = {
|
|
757
|
-
...proxyRes.headers,
|
|
758
|
-
...this.getCorsHeaders(req)
|
|
759
|
-
};
|
|
760
|
-
res.writeHead(proxyRes.statusCode || 200, responseHeaders);
|
|
761
|
-
res.end(responseBody);
|
|
762
|
-
console.log(
|
|
763
|
-
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId}, sequence: ${sequence})`
|
|
764
|
-
);
|
|
765
|
-
resolve(recording);
|
|
766
|
-
} catch (error) {
|
|
767
|
-
console.error("Error completing recording:", error);
|
|
768
|
-
resolve(null);
|
|
769
|
-
}
|
|
770
|
-
});
|
|
771
|
-
proxyRes.on("error", (err) => {
|
|
772
|
-
console.error("Proxy response error:", err);
|
|
773
|
-
if (!res.headersSent) {
|
|
774
|
-
this.handleProxyError(err, req, res);
|
|
775
|
-
}
|
|
776
|
-
resolve(null);
|
|
777
|
-
});
|
|
778
|
-
}
|
|
779
|
-
);
|
|
780
|
-
proxyReq.on("error", (err) => {
|
|
781
|
-
this.handleProxyError(err, req, res);
|
|
782
|
-
resolve(null);
|
|
783
|
-
});
|
|
784
|
-
if (chunks.length > 0) {
|
|
785
|
-
proxyReq.write(Buffer.concat(chunks));
|
|
786
|
-
}
|
|
787
|
-
proxyReq.end();
|
|
788
|
-
} catch (error) {
|
|
789
|
-
console.error("Error in recordAndProxyRequest:", error);
|
|
790
|
-
try {
|
|
791
|
-
this.handleProxyError(error, req, res);
|
|
792
|
-
} catch (error_) {
|
|
793
|
-
console.error("Failed to handle proxy error:", error_);
|
|
794
|
-
}
|
|
795
|
-
resolve(null);
|
|
796
|
-
}
|
|
797
|
-
})();
|
|
798
|
-
});
|
|
799
|
-
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
|
+
);
|
|
800
990
|
}
|
|
801
991
|
handleUpgrade(req, socket, head) {
|
|
802
992
|
if (this.mode === Modes.replay) {
|
|
803
993
|
this.handleReplayWebSocket(req, socket);
|
|
804
994
|
return;
|
|
805
995
|
}
|
|
806
|
-
const target = this.
|
|
996
|
+
const target = this.target;
|
|
807
997
|
console.log(`[${this.mode}] WebSocket upgrade ${req.url} -> ${target}`);
|
|
808
998
|
if (this.mode === Modes.record) {
|
|
809
|
-
|
|
999
|
+
recordWebSocket(req, socket, head, target, this.currentSession);
|
|
810
1000
|
} else {
|
|
811
1001
|
this.proxy.ws(req, socket, head, { target });
|
|
812
1002
|
}
|
|
813
1003
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
if (
|
|
824
|
-
|
|
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;
|
|
825
1015
|
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
const message = data.toString();
|
|
834
|
-
wsRecording.messages.push({
|
|
835
|
-
direction: "client-to-server",
|
|
836
|
-
data: message,
|
|
837
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
838
|
-
});
|
|
839
|
-
if (backendWs.readyState === ws.WebSocket.OPEN) {
|
|
840
|
-
backendWs.send(message);
|
|
841
|
-
}
|
|
842
|
-
});
|
|
843
|
-
backendWs.on("message", (data) => {
|
|
844
|
-
const message = data.toString();
|
|
845
|
-
wsRecording.messages.push({
|
|
846
|
-
direction: "server-to-client",
|
|
847
|
-
data: message,
|
|
848
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
849
|
-
});
|
|
850
|
-
if (clientWs.readyState === ws.WebSocket.OPEN) {
|
|
851
|
-
clientWs.send(message);
|
|
852
|
-
}
|
|
853
|
-
});
|
|
854
|
-
clientWs.on("error", (err) => {
|
|
855
|
-
console.error("Client WebSocket error:", err);
|
|
856
|
-
});
|
|
857
|
-
backendWs.on("error", (err) => {
|
|
858
|
-
console.error("Backend WebSocket error:", err);
|
|
859
|
-
});
|
|
860
|
-
clientWs.on("close", () => {
|
|
861
|
-
backendWs.close();
|
|
862
|
-
console.log("Client WebSocket closed");
|
|
863
|
-
});
|
|
864
|
-
backendWs.on("close", () => {
|
|
865
|
-
clientWs.close();
|
|
866
|
-
console.log("Backend WebSocket closed");
|
|
867
|
-
});
|
|
868
|
-
});
|
|
869
|
-
});
|
|
870
|
-
backendWs.on("error", (err) => {
|
|
871
|
-
console.error("Backend WebSocket connection error:", err);
|
|
872
|
-
clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
|
873
|
-
clientSocket.destroy();
|
|
874
|
-
});
|
|
875
|
-
wss.on("error", (err) => {
|
|
876
|
-
console.error("WebSocket server error:", err);
|
|
877
|
-
});
|
|
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;
|
|
878
1023
|
}
|
|
879
|
-
handleReplayWebSocket(req, socket) {
|
|
880
|
-
const
|
|
881
|
-
const
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1024
|
+
async handleReplayWebSocket(req, socket) {
|
|
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
|
+
}
|
|
1032
|
+
try {
|
|
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(
|
|
885
1039
|
(r) => r.key === key
|
|
886
1040
|
);
|
|
887
1041
|
if (!wsRecording) {
|
|
@@ -890,67 +1044,12 @@ var ProxyServer = class {
|
|
|
890
1044
|
console.log(`No WebSocket recording found for ${key}`);
|
|
891
1045
|
return;
|
|
892
1046
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
headers: {
|
|
896
|
-
...req.headers,
|
|
897
|
-
"sec-websocket-key": req.headers["sec-websocket-key"] || "replay-key",
|
|
898
|
-
"sec-websocket-version": "13"
|
|
899
|
-
}
|
|
900
|
-
});
|
|
901
|
-
wss.handleUpgrade(fakeReq, socket, Buffer.alloc(0), (ws$1) => {
|
|
902
|
-
console.log(`Replaying WebSocket: ${url}`);
|
|
903
|
-
const serverMessages = wsRecording.messages.filter(
|
|
904
|
-
(m) => m.direction === "server-to-client"
|
|
905
|
-
);
|
|
906
|
-
let messageIndex = 0;
|
|
907
|
-
ws$1.on("message", (data) => {
|
|
908
|
-
const clientMessage = data.toString();
|
|
909
|
-
console.log(`Replay: Client sent: ${clientMessage}`);
|
|
910
|
-
if (messageIndex < serverMessages.length) {
|
|
911
|
-
setTimeout(() => {
|
|
912
|
-
if (ws$1.readyState === ws.WebSocket.OPEN) {
|
|
913
|
-
ws$1.send(serverMessages[messageIndex].data);
|
|
914
|
-
console.log(`Replay: Sent server message ${messageIndex}`);
|
|
915
|
-
messageIndex++;
|
|
916
|
-
}
|
|
917
|
-
}, 10);
|
|
918
|
-
}
|
|
919
|
-
});
|
|
920
|
-
let initialMessagesSent = 0;
|
|
921
|
-
for (let i = 0; i < wsRecording.messages.length; i++) {
|
|
922
|
-
const msg = wsRecording.messages[i];
|
|
923
|
-
if (msg.direction === "client-to-server") {
|
|
924
|
-
break;
|
|
925
|
-
}
|
|
926
|
-
if (msg.direction === "server-to-client") {
|
|
927
|
-
setTimeout(
|
|
928
|
-
() => {
|
|
929
|
-
if (ws$1.readyState === ws.WebSocket.OPEN) {
|
|
930
|
-
ws$1.send(msg.data);
|
|
931
|
-
console.log(
|
|
932
|
-
`Replay: Sent initial server message: ${msg.data}`
|
|
933
|
-
);
|
|
934
|
-
messageIndex++;
|
|
935
|
-
initialMessagesSent++;
|
|
936
|
-
}
|
|
937
|
-
},
|
|
938
|
-
10 * (initialMessagesSent + 1)
|
|
939
|
-
);
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
ws$1.on("error", (err) => {
|
|
943
|
-
console.error("Replay WebSocket error:", err);
|
|
944
|
-
});
|
|
945
|
-
ws$1.on("close", () => {
|
|
946
|
-
console.log("Replay WebSocket closed");
|
|
947
|
-
});
|
|
948
|
-
});
|
|
949
|
-
}).catch((error) => {
|
|
1047
|
+
replayWebSocket(req, socket, wsRecording, recordingId);
|
|
1048
|
+
} catch (error) {
|
|
950
1049
|
console.error("Replay error:", error);
|
|
951
1050
|
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
952
1051
|
socket.destroy();
|
|
953
|
-
}
|
|
1052
|
+
}
|
|
954
1053
|
}
|
|
955
1054
|
logServerStartup(port) {
|
|
956
1055
|
console.log(`Proxy server running on http://localhost:${port}`);
|
|
@@ -961,6 +1060,7 @@ var ProxyServer = class {
|
|
|
961
1060
|
);
|
|
962
1061
|
}
|
|
963
1062
|
};
|
|
1063
|
+
var registeredContexts = /* @__PURE__ */ new WeakSet();
|
|
964
1064
|
function getProxyPort() {
|
|
965
1065
|
const envPort = process.env.TEST_PROXY_RECORDER_PORT;
|
|
966
1066
|
if (envPort) {
|
|
@@ -979,7 +1079,7 @@ async function setProxyMode(mode, sessionId, timeout) {
|
|
|
979
1079
|
id: sessionId,
|
|
980
1080
|
...timeout && { timeout }
|
|
981
1081
|
};
|
|
982
|
-
const response = await fetch(`http://
|
|
1082
|
+
const response = await fetch(`http://localhost:${proxyPort}/__control`, {
|
|
983
1083
|
method: "POST",
|
|
984
1084
|
headers: { "Content-Type": "application/json" },
|
|
985
1085
|
body: JSON.stringify(body)
|
|
@@ -1002,7 +1102,7 @@ async function cleanupSession(sessionId) {
|
|
|
1002
1102
|
cleanup: true,
|
|
1003
1103
|
id: sessionId
|
|
1004
1104
|
};
|
|
1005
|
-
const response = await fetch(`http://
|
|
1105
|
+
const response = await fetch(`http://localhost:${proxyPort}/__control`, {
|
|
1006
1106
|
method: "POST",
|
|
1007
1107
|
headers: { "Content-Type": "application/json" },
|
|
1008
1108
|
body: JSON.stringify(body)
|
|
@@ -1066,7 +1166,7 @@ async function getRecordingsDir() {
|
|
|
1066
1166
|
}
|
|
1067
1167
|
const proxyPort = getProxyPort();
|
|
1068
1168
|
try {
|
|
1069
|
-
const response = await fetch(`http://
|
|
1169
|
+
const response = await fetch(`http://localhost:${proxyPort}/__control`);
|
|
1070
1170
|
if (response.ok) {
|
|
1071
1171
|
const data = await response.json();
|
|
1072
1172
|
if (data.recordingsDir) {
|
|
@@ -1119,6 +1219,14 @@ var playwrightProxy = {
|
|
|
1119
1219
|
await page.setExtraHTTPHeaders({
|
|
1120
1220
|
[RECORDING_ID_HEADER]: sessionId
|
|
1121
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
|
+
]);
|
|
1122
1230
|
await setProxyMode(mode, sessionId, timeout);
|
|
1123
1231
|
if (clientSideOptions?.url) {
|
|
1124
1232
|
await setupClientSideRecording(
|
|
@@ -1153,10 +1261,8 @@ var playwrightProxy = {
|
|
|
1153
1261
|
// Ensure the handler applies to all matching requests
|
|
1154
1262
|
);
|
|
1155
1263
|
const context = page.context();
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
if (!globalThis[handlerKey]) {
|
|
1159
|
-
globalThis[handlerKey] = true;
|
|
1264
|
+
if (!registeredContexts.has(context)) {
|
|
1265
|
+
registeredContexts.add(context);
|
|
1160
1266
|
context.on("close", async () => {
|
|
1161
1267
|
try {
|
|
1162
1268
|
await cleanupSession(sessionId);
|
|
@@ -1166,7 +1272,7 @@ var playwrightProxy = {
|
|
|
1166
1272
|
error
|
|
1167
1273
|
);
|
|
1168
1274
|
} finally {
|
|
1169
|
-
delete
|
|
1275
|
+
registeredContexts.delete(context);
|
|
1170
1276
|
}
|
|
1171
1277
|
});
|
|
1172
1278
|
}
|