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.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",
|
|
@@ -59,9 +297,9 @@ function processRecordings(recordings) {
|
|
|
59
297
|
const processedRecordings = [];
|
|
60
298
|
for (const [_key, keyRecordings] of recordingsByKey) {
|
|
61
299
|
keyRecordings.sort((a, b) => a.recordingId - b.recordingId);
|
|
62
|
-
keyRecordings.
|
|
300
|
+
for (const [index, recording] of keyRecordings.entries()) {
|
|
63
301
|
processedRecordings.push({ ...recording, sequence: index });
|
|
64
|
-
}
|
|
302
|
+
}
|
|
65
303
|
}
|
|
66
304
|
processedRecordings.sort((a, b) => a.recordingId - b.recordingId);
|
|
67
305
|
return processedRecordings;
|
|
@@ -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;
|
|
@@ -127,6 +539,7 @@ var ProxyServer = class {
|
|
|
127
539
|
proxy;
|
|
128
540
|
currentSession;
|
|
129
541
|
recordingsDir;
|
|
542
|
+
timeoutMs;
|
|
130
543
|
recordingIdCounter;
|
|
131
544
|
// Unique ID for each recording entry
|
|
132
545
|
sequenceCounterByKey;
|
|
@@ -137,8 +550,9 @@ var ProxyServer = class {
|
|
|
137
550
|
// Stack of promises that resolve to completed recordings
|
|
138
551
|
flushPromise;
|
|
139
552
|
// Promise for in-progress flush operation
|
|
140
|
-
constructor(target, recordingsDir) {
|
|
553
|
+
constructor(target, recordingsDir, timeoutMs) {
|
|
141
554
|
this.target = target;
|
|
555
|
+
this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
142
556
|
this.mode = Modes.transparent;
|
|
143
557
|
this.recordingId = null;
|
|
144
558
|
this.recordingIdCounter = 0;
|
|
@@ -147,7 +561,7 @@ var ProxyServer = class {
|
|
|
147
561
|
this.modeTimeout = null;
|
|
148
562
|
this.currentSession = null;
|
|
149
563
|
this.recordingsDir = recordingsDir;
|
|
150
|
-
this.replaySessions =
|
|
564
|
+
this.replaySessions = new ReplaySessionManager(this.timeoutMs);
|
|
151
565
|
this.recordingPromises = [];
|
|
152
566
|
this.flushPromise = null;
|
|
153
567
|
this.proxy = httpProxy.createProxyServer({
|
|
@@ -161,7 +575,7 @@ var ProxyServer = class {
|
|
|
161
575
|
await fs.mkdir(this.recordingsDir, { recursive: true });
|
|
162
576
|
}
|
|
163
577
|
listen(port) {
|
|
164
|
-
const server =
|
|
578
|
+
const server = http2.createServer((req, res) => {
|
|
165
579
|
this.handleRequest(req, res);
|
|
166
580
|
});
|
|
167
581
|
server.on("upgrade", (req, socket, head) => {
|
|
@@ -175,15 +589,15 @@ var ProxyServer = class {
|
|
|
175
589
|
}
|
|
176
590
|
setupProxyEventHandlers() {
|
|
177
591
|
this.proxy.on("error", this.handleProxyError.bind(this));
|
|
178
|
-
this.proxy.on("proxyRes",
|
|
592
|
+
this.proxy.on("proxyRes", addCorsHeaders);
|
|
179
593
|
}
|
|
180
594
|
handleProxyError(err, req, res) {
|
|
181
595
|
console.error("Proxy error:", err);
|
|
182
|
-
if (!(res instanceof
|
|
596
|
+
if (!(res instanceof http2.ServerResponse)) {
|
|
183
597
|
return;
|
|
184
598
|
}
|
|
185
599
|
if (!res.headersSent) {
|
|
186
|
-
const corsHeaders =
|
|
600
|
+
const corsHeaders = getCorsHeaders(req);
|
|
187
601
|
res.writeHead(HTTP_STATUS_BAD_GATEWAY, {
|
|
188
602
|
"Content-Type": "application/json",
|
|
189
603
|
...corsHeaders
|
|
@@ -191,103 +605,12 @@ var ProxyServer = class {
|
|
|
191
605
|
}
|
|
192
606
|
res.end(JSON.stringify({ error: "Proxy error", message: err.message }));
|
|
193
607
|
}
|
|
194
|
-
/**
|
|
195
|
-
* Get CORS headers for a given request
|
|
196
|
-
* @param req The incoming HTTP request
|
|
197
|
-
* @returns An object containing CORS headers
|
|
198
|
-
*/
|
|
199
|
-
getCorsHeaders(req) {
|
|
200
|
-
const origin = req.headers.origin;
|
|
201
|
-
return {
|
|
202
|
-
"access-control-allow-origin": origin || "*",
|
|
203
|
-
"access-control-allow-credentials": "true",
|
|
204
|
-
"access-control-allow-headers": req.headers["access-control-request-headers"] || `Origin, X-Requested-With, Content-Type, Accept, Authorization, ${RECORDING_ID_HEADER}`,
|
|
205
|
-
"access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
206
|
-
"access-control-expose-headers": "*"
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
addCorsHeaders(proxyRes, req) {
|
|
210
|
-
const corsHeaders = this.getCorsHeaders(req);
|
|
211
|
-
Object.assign(proxyRes.headers, corsHeaders);
|
|
212
|
-
}
|
|
213
|
-
getTarget() {
|
|
214
|
-
return this.target;
|
|
215
|
-
}
|
|
216
|
-
/**
|
|
217
|
-
* Extract recording ID from custom HTTP header
|
|
218
|
-
* Used for concurrent replay session routing, especially with Next.js
|
|
219
|
-
* @param req The incoming HTTP request
|
|
220
|
-
* @returns The recording ID from header, or null if not found
|
|
221
|
-
*/
|
|
222
|
-
getRecordingIdFromHeader(req) {
|
|
223
|
-
const headerValue = req.headers[RECORDING_ID_HEADER];
|
|
224
|
-
if (!headerValue) {
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
return Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
228
|
-
}
|
|
229
|
-
/**
|
|
230
|
-
* Extract recording ID from request cookie
|
|
231
|
-
* Used for concurrent replay session routing (fallback method)
|
|
232
|
-
* @param req The incoming HTTP request
|
|
233
|
-
* @returns The recording ID from cookie, or null if not found
|
|
234
|
-
*/
|
|
235
|
-
getRecordingIdFromCookie(req) {
|
|
236
|
-
const cookies = req.headers.cookie;
|
|
237
|
-
if (!cookies) {
|
|
238
|
-
return null;
|
|
239
|
-
}
|
|
240
|
-
const match = cookies.match(/proxy-recording-id=([^;]+)/);
|
|
241
|
-
return match ? decodeURIComponent(match[1]) : null;
|
|
242
|
-
}
|
|
243
|
-
/**
|
|
244
|
-
* Extract recording ID from request using custom header (preferred) or cookie (fallback)
|
|
245
|
-
* @param req The incoming HTTP request
|
|
246
|
-
* @returns The recording ID, or null if not found
|
|
247
|
-
*/
|
|
248
|
-
getRecordingIdFromRequest(req) {
|
|
249
|
-
const fromHeader = this.getRecordingIdFromHeader(req);
|
|
250
|
-
const fromCookie = this.getRecordingIdFromCookie(req);
|
|
251
|
-
if (fromHeader) {
|
|
252
|
-
return fromHeader;
|
|
253
|
-
}
|
|
254
|
-
if (fromCookie) {
|
|
255
|
-
return fromCookie;
|
|
256
|
-
}
|
|
257
|
-
return null;
|
|
258
|
-
}
|
|
259
|
-
/**
|
|
260
|
-
* Get or create a replay session state for a given recording ID
|
|
261
|
-
* @param recordingId The recording ID to get/create session for
|
|
262
|
-
* @returns The replay session state
|
|
263
|
-
*/
|
|
264
|
-
getOrCreateReplaySession(recordingId) {
|
|
265
|
-
let session = this.replaySessions.get(recordingId);
|
|
266
|
-
if (session) {
|
|
267
|
-
session.lastAccessTime = Date.now();
|
|
268
|
-
} else {
|
|
269
|
-
session = {
|
|
270
|
-
recordingId,
|
|
271
|
-
servedRecordingIdsByKey: /* @__PURE__ */ new Map(),
|
|
272
|
-
loadedSession: null,
|
|
273
|
-
lastAccessTime: Date.now(),
|
|
274
|
-
sortedRecordingsByKey: /* @__PURE__ */ new Map()
|
|
275
|
-
};
|
|
276
|
-
this.replaySessions.set(recordingId, session);
|
|
277
|
-
console.log(
|
|
278
|
-
`[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
|
-
return session;
|
|
282
|
-
}
|
|
283
608
|
/**
|
|
284
609
|
* Clean up a session - removes it from memory and resets counters
|
|
285
610
|
* @param sessionId The session ID to clean up
|
|
286
611
|
*/
|
|
287
612
|
async cleanupSession(sessionId) {
|
|
288
|
-
|
|
289
|
-
this.replaySessions.delete(sessionId);
|
|
290
|
-
}
|
|
613
|
+
this.replaySessions.delete(sessionId);
|
|
291
614
|
if (this.recordingId === sessionId) {
|
|
292
615
|
await this.saveCurrentSession();
|
|
293
616
|
this.currentSession = null;
|
|
@@ -298,29 +621,17 @@ var ProxyServer = class {
|
|
|
298
621
|
}
|
|
299
622
|
console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
|
|
300
623
|
}
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const timeoutParam = url.searchParams.get("timeout");
|
|
306
|
-
const timeout = timeoutParam ? Number.parseInt(timeoutParam, 10) : void 0;
|
|
307
|
-
if (!mode) {
|
|
308
|
-
throw new Error("Mode parameter is required");
|
|
309
|
-
}
|
|
310
|
-
return { mode, id, timeout };
|
|
311
|
-
}
|
|
312
|
-
async parseControlRequest(req) {
|
|
313
|
-
if (req.method === "GET") {
|
|
314
|
-
return this.parseGetParams(req);
|
|
315
|
-
}
|
|
316
|
-
if (req.method === "POST") {
|
|
317
|
-
const body = await readRequestBody(req);
|
|
318
|
-
console.log(`MODE CHANGE (${req.method})`, body);
|
|
319
|
-
return JSON.parse(body);
|
|
320
|
-
}
|
|
321
|
-
throw new Error("Unsupported control method");
|
|
624
|
+
async parseControlBody(req) {
|
|
625
|
+
const body = await readRequestBody(req);
|
|
626
|
+
console.log(`MODE CHANGE (${req.method})`, body);
|
|
627
|
+
return JSON.parse(body);
|
|
322
628
|
}
|
|
323
629
|
async handleControlRequest(req, res) {
|
|
630
|
+
if (req.method === "HEAD") {
|
|
631
|
+
res.writeHead(HTTP_STATUS_OK);
|
|
632
|
+
res.end();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
324
635
|
if (req.method === "GET") {
|
|
325
636
|
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
326
637
|
recordingsDir: this.recordingsDir,
|
|
@@ -329,8 +640,11 @@ var ProxyServer = class {
|
|
|
329
640
|
});
|
|
330
641
|
return;
|
|
331
642
|
}
|
|
643
|
+
await this.handleControlPost(req, res);
|
|
644
|
+
}
|
|
645
|
+
async handleControlPost(req, res) {
|
|
332
646
|
try {
|
|
333
|
-
const data = await this.
|
|
647
|
+
const data = await this.parseControlBody(req);
|
|
334
648
|
const { mode, id, timeout: requestTimeout, cleanup } = data;
|
|
335
649
|
if (cleanup && id) {
|
|
336
650
|
await this.cleanupSession(id);
|
|
@@ -341,29 +655,7 @@ var ProxyServer = class {
|
|
|
341
655
|
});
|
|
342
656
|
return;
|
|
343
657
|
}
|
|
344
|
-
|
|
345
|
-
throw new Error(
|
|
346
|
-
"Mode parameter is required when cleanup is not specified"
|
|
347
|
-
);
|
|
348
|
-
}
|
|
349
|
-
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
350
|
-
this.clearModeTimeout();
|
|
351
|
-
await this.switchMode(mode, id);
|
|
352
|
-
this.setupModeTimeout(timeout);
|
|
353
|
-
if (mode === Modes.replay && id) {
|
|
354
|
-
res.setHeader(
|
|
355
|
-
"Set-Cookie",
|
|
356
|
-
`proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
|
|
357
|
-
);
|
|
358
|
-
console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
|
|
359
|
-
}
|
|
360
|
-
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
361
|
-
success: true,
|
|
362
|
-
mode: this.mode,
|
|
363
|
-
id: this.recordingId || this.replayId,
|
|
364
|
-
timeout,
|
|
365
|
-
recordingsDir: this.recordingsDir
|
|
366
|
-
});
|
|
658
|
+
await this.applyModeChange(res, mode, id, requestTimeout);
|
|
367
659
|
} catch (error) {
|
|
368
660
|
console.error("Control request error:", error);
|
|
369
661
|
sendJsonResponse(res, HTTP_STATUS_BAD_REQUEST, {
|
|
@@ -371,6 +663,31 @@ var ProxyServer = class {
|
|
|
371
663
|
});
|
|
372
664
|
}
|
|
373
665
|
}
|
|
666
|
+
async applyModeChange(res, mode, id, requestTimeout) {
|
|
667
|
+
if (!mode) {
|
|
668
|
+
throw new Error(
|
|
669
|
+
"Mode parameter is required when cleanup is not specified"
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
const timeout = requestTimeout ?? this.timeoutMs;
|
|
673
|
+
this.clearModeTimeout();
|
|
674
|
+
await this.switchMode(mode, id);
|
|
675
|
+
this.setupModeTimeout(timeout);
|
|
676
|
+
if (mode === Modes.replay && id) {
|
|
677
|
+
res.setHeader(
|
|
678
|
+
"Set-Cookie",
|
|
679
|
+
`proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
|
|
680
|
+
);
|
|
681
|
+
console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
|
|
682
|
+
}
|
|
683
|
+
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
684
|
+
success: true,
|
|
685
|
+
mode: this.mode,
|
|
686
|
+
id: this.recordingId || this.replayId,
|
|
687
|
+
timeout,
|
|
688
|
+
recordingsDir: this.recordingsDir
|
|
689
|
+
});
|
|
690
|
+
}
|
|
374
691
|
clearModeTimeout() {
|
|
375
692
|
clearTimeout(this.modeTimeout || 0);
|
|
376
693
|
this.modeTimeout = null;
|
|
@@ -410,7 +727,7 @@ var ProxyServer = class {
|
|
|
410
727
|
this.recordingId = null;
|
|
411
728
|
this.replayId = null;
|
|
412
729
|
this.currentSession = null;
|
|
413
|
-
|
|
730
|
+
this.clearModeTimeout();
|
|
414
731
|
console.log("Switched to transparent mode");
|
|
415
732
|
}
|
|
416
733
|
switchToRecordMode(id) {
|
|
@@ -427,7 +744,7 @@ var ProxyServer = class {
|
|
|
427
744
|
this.replayId = id;
|
|
428
745
|
this.recordingId = null;
|
|
429
746
|
this.currentSession = null;
|
|
430
|
-
const sessionState = this.
|
|
747
|
+
const sessionState = this.replaySessions.getOrCreate(id);
|
|
431
748
|
sessionState.servedRecordingIdsByKey.clear();
|
|
432
749
|
sessionState.sortedRecordingsByKey.clear();
|
|
433
750
|
const filePath = getRecordingPath(this.recordingsDir, id);
|
|
@@ -441,7 +758,7 @@ var ProxyServer = class {
|
|
|
441
758
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
442
759
|
}
|
|
443
760
|
setupModeTimeout(timeout) {
|
|
444
|
-
|
|
761
|
+
this.clearModeTimeout();
|
|
445
762
|
this.modeTimeout = setTimeout(async () => {
|
|
446
763
|
console.log("Timeout reached, switching back to transparent mode");
|
|
447
764
|
await this.saveCurrentSession();
|
|
@@ -488,7 +805,7 @@ var ProxyServer = class {
|
|
|
488
805
|
await saveRecordingSession(this.recordingsDir, this.currentSession);
|
|
489
806
|
}
|
|
490
807
|
getRecordingIdOrError(req, res) {
|
|
491
|
-
const recordingIdFromRequest =
|
|
808
|
+
const recordingIdFromRequest = getRecordingIdFromRequest(req);
|
|
492
809
|
if (recordingIdFromRequest) {
|
|
493
810
|
return recordingIdFromRequest;
|
|
494
811
|
}
|
|
@@ -496,7 +813,7 @@ var ProxyServer = class {
|
|
|
496
813
|
console.warn(
|
|
497
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)`
|
|
498
815
|
);
|
|
499
|
-
const corsHeaders =
|
|
816
|
+
const corsHeaders = getCorsHeaders(req);
|
|
500
817
|
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
501
818
|
"Content-Type": "application/json",
|
|
502
819
|
...corsHeaders
|
|
@@ -512,7 +829,7 @@ var ProxyServer = class {
|
|
|
512
829
|
}
|
|
513
830
|
const recordingId = this.replayId;
|
|
514
831
|
if (!recordingId) {
|
|
515
|
-
const corsHeaders =
|
|
832
|
+
const corsHeaders = getCorsHeaders(req);
|
|
516
833
|
res.writeHead(HTTP_STATUS_BAD_REQUEST, {
|
|
517
834
|
"Content-Type": "application/json",
|
|
518
835
|
...corsHeaders
|
|
@@ -525,56 +842,22 @@ var ProxyServer = class {
|
|
|
525
842
|
);
|
|
526
843
|
return recordingId;
|
|
527
844
|
}
|
|
528
|
-
getServedTracker(sessionState, key) {
|
|
529
|
-
if (!sessionState.servedRecordingIdsByKey.has(key)) {
|
|
530
|
-
sessionState.servedRecordingIdsByKey.set(key, /* @__PURE__ */ new Set());
|
|
531
|
-
}
|
|
532
|
-
return sessionState.servedRecordingIdsByKey.get(key);
|
|
533
|
-
}
|
|
534
|
-
getSortedRecordings(sessionState, key) {
|
|
535
|
-
if (sessionState.sortedRecordingsByKey.has(key)) {
|
|
536
|
-
return sessionState.sortedRecordingsByKey.get(key);
|
|
537
|
-
}
|
|
538
|
-
const session = sessionState.loadedSession;
|
|
539
|
-
const sortedRecords = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
|
|
540
|
-
const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
|
|
541
|
-
const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
|
|
542
|
-
return aSeq - bSeq;
|
|
543
|
-
});
|
|
544
|
-
sessionState.sortedRecordingsByKey.set(key, sortedRecords);
|
|
545
|
-
return sortedRecords;
|
|
546
|
-
}
|
|
547
|
-
selectReplayRecord(recordsWithKey, servedForThisKey, key, recordingId) {
|
|
548
|
-
for (const rec of recordsWithKey) {
|
|
549
|
-
if (!servedForThisKey.has(rec.recordingId)) {
|
|
550
|
-
return rec;
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
if (recordsWithKey.length > 0) {
|
|
554
|
-
console.log(
|
|
555
|
-
`[REPLAY WARNING] All ${recordsWithKey.length} recordings already served for ${key} (session: ${recordingId}), reusing last one`
|
|
556
|
-
);
|
|
557
|
-
return recordsWithKey[recordsWithKey.length - 1];
|
|
558
|
-
}
|
|
559
|
-
return null;
|
|
560
|
-
}
|
|
561
845
|
async handleReplayRequest(req, res) {
|
|
562
846
|
const recordingId = this.getRecordingIdOrError(req, res);
|
|
563
847
|
if (!recordingId) return;
|
|
564
848
|
const key = getReqID(req);
|
|
565
849
|
const filePath = getRecordingPath(this.recordingsDir, recordingId);
|
|
566
850
|
try {
|
|
567
|
-
const sessionState = this.
|
|
851
|
+
const sessionState = this.replaySessions.getOrCreate(recordingId);
|
|
568
852
|
if (!sessionState.loadedSession) {
|
|
569
|
-
|
|
570
|
-
`Recording session file not found: ${filePath}`
|
|
853
|
+
throw Object.assign(
|
|
854
|
+
new Error(`Recording session file not found: ${filePath}`),
|
|
855
|
+
{ code: "ENOENT" }
|
|
571
856
|
);
|
|
572
|
-
error.code = "ENOENT";
|
|
573
|
-
throw error;
|
|
574
857
|
}
|
|
575
|
-
const servedForThisKey =
|
|
858
|
+
const servedForThisKey = getServedTracker(sessionState, key);
|
|
576
859
|
const host = req.headers.host || "unknown";
|
|
577
|
-
const recordsWithKey =
|
|
860
|
+
const recordsWithKey = getSortedRecordings(sessionState, key);
|
|
578
861
|
if (recordsWithKey.length === 0) {
|
|
579
862
|
const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
|
|
580
863
|
console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
|
|
@@ -587,7 +870,7 @@ var ProxyServer = class {
|
|
|
587
870
|
key,
|
|
588
871
|
sessionId: recordingId
|
|
589
872
|
};
|
|
590
|
-
const corsHeaders =
|
|
873
|
+
const corsHeaders = getCorsHeaders(req);
|
|
591
874
|
res.writeHead(HTTP_STATUS_NOT_FOUND, {
|
|
592
875
|
"Content-Type": "application/json",
|
|
593
876
|
...corsHeaders
|
|
@@ -599,7 +882,7 @@ var ProxyServer = class {
|
|
|
599
882
|
console.log(
|
|
600
883
|
`[replay request #${requestCount}] ${req.method} ${req.url} (key: ${key}, session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
|
|
601
884
|
);
|
|
602
|
-
const record =
|
|
885
|
+
const record = selectReplayRecord(
|
|
603
886
|
recordsWithKey,
|
|
604
887
|
servedForThisKey,
|
|
605
888
|
key,
|
|
@@ -617,7 +900,7 @@ var ProxyServer = class {
|
|
|
617
900
|
const { statusCode, headers, body } = record.response;
|
|
618
901
|
const responseHeaders = {
|
|
619
902
|
...headers,
|
|
620
|
-
...
|
|
903
|
+
...getCorsHeaders(req)
|
|
621
904
|
};
|
|
622
905
|
res.writeHead(statusCode, responseHeaders);
|
|
623
906
|
res.end(body);
|
|
@@ -628,7 +911,7 @@ var ProxyServer = class {
|
|
|
628
911
|
handleReplayError(req, res, err, key, filePath) {
|
|
629
912
|
const isFileNotFound = err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
630
913
|
console.error("Replay error:", err);
|
|
631
|
-
const corsHeaders =
|
|
914
|
+
const corsHeaders = getCorsHeaders(req);
|
|
632
915
|
res.writeHead(HTTP_STATUS_NOT_FOUND, {
|
|
633
916
|
"Content-Type": "application/json",
|
|
634
917
|
...corsHeaders
|
|
@@ -656,7 +939,7 @@ var ProxyServer = class {
|
|
|
656
939
|
await this.handleProxyRequest(req, res);
|
|
657
940
|
}
|
|
658
941
|
handleCorsPreflightRequest(req, res) {
|
|
659
|
-
const corsHeaders =
|
|
942
|
+
const corsHeaders = getCorsHeaders(req);
|
|
660
943
|
res.writeHead(HTTP_STATUS_OK, {
|
|
661
944
|
...corsHeaders,
|
|
662
945
|
"Access-Control-Max-Age": "86400"
|
|
@@ -665,16 +948,15 @@ var ProxyServer = class {
|
|
|
665
948
|
res.end();
|
|
666
949
|
}
|
|
667
950
|
async handleProxyRequest(req, res) {
|
|
668
|
-
const target = this.
|
|
951
|
+
const target = this.target;
|
|
669
952
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
|
|
670
953
|
if (this.mode === Modes.record) {
|
|
671
|
-
|
|
954
|
+
this.recordAndProxy(req, res, target);
|
|
672
955
|
} else {
|
|
673
956
|
this.proxy.web(req, res, { target });
|
|
674
957
|
}
|
|
675
958
|
}
|
|
676
|
-
|
|
677
|
-
async recordAndProxyRequest(req, res, target) {
|
|
959
|
+
recordAndProxy(req, res, target) {
|
|
678
960
|
if (!this.currentSession) {
|
|
679
961
|
return;
|
|
680
962
|
}
|
|
@@ -682,194 +964,66 @@ var ProxyServer = class {
|
|
|
682
964
|
const recordingId = this.recordingIdCounter++;
|
|
683
965
|
const sequence = this.sequenceCounterByKey.get(key) || 0;
|
|
684
966
|
this.sequenceCounterByKey.set(key, sequence + 1);
|
|
685
|
-
|
|
686
|
-
(
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
setTimeout(
|
|
697
|
-
() => rejectBuffer(new Error("Request buffering timeout")),
|
|
698
|
-
3e4
|
|
699
|
-
);
|
|
700
|
-
});
|
|
701
|
-
} catch (error) {
|
|
702
|
-
console.error("Error buffering request:", error);
|
|
703
|
-
}
|
|
704
|
-
const requestBody = Buffer.concat(chunks).toString("utf8");
|
|
705
|
-
const targetUrl = new URL(target);
|
|
706
|
-
const isHttps = targetUrl.protocol === "https:";
|
|
707
|
-
const requestModule = isHttps ? https : http;
|
|
708
|
-
const defaultPort = isHttps ? 443 : 80;
|
|
709
|
-
const proxyReq = requestModule.request(
|
|
710
|
-
{
|
|
711
|
-
hostname: targetUrl.hostname,
|
|
712
|
-
port: targetUrl.port || defaultPort,
|
|
713
|
-
path: req.url,
|
|
714
|
-
method: req.method,
|
|
715
|
-
headers: req.headers
|
|
716
|
-
},
|
|
717
|
-
(proxyRes) => {
|
|
718
|
-
this.addCorsHeaders(proxyRes, req);
|
|
719
|
-
const responseChunks = [];
|
|
720
|
-
proxyRes.on("data", (chunk) => {
|
|
721
|
-
responseChunks.push(chunk);
|
|
722
|
-
});
|
|
723
|
-
proxyRes.on("end", async () => {
|
|
724
|
-
try {
|
|
725
|
-
const responseBody = Buffer.concat(responseChunks);
|
|
726
|
-
const responseBodyStr = responseBody.toString("utf8");
|
|
727
|
-
const recording = {
|
|
728
|
-
request: {
|
|
729
|
-
method: req.method,
|
|
730
|
-
url: req.url,
|
|
731
|
-
headers: req.headers,
|
|
732
|
-
body: requestBody || null
|
|
733
|
-
},
|
|
734
|
-
response: {
|
|
735
|
-
statusCode: proxyRes.statusCode,
|
|
736
|
-
headers: proxyRes.headers,
|
|
737
|
-
body: responseBodyStr || null
|
|
738
|
-
},
|
|
739
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
740
|
-
key,
|
|
741
|
-
recordingId,
|
|
742
|
-
sequence
|
|
743
|
-
};
|
|
744
|
-
const responseHeaders = {
|
|
745
|
-
...proxyRes.headers,
|
|
746
|
-
...this.getCorsHeaders(req)
|
|
747
|
-
};
|
|
748
|
-
res.writeHead(proxyRes.statusCode || 200, responseHeaders);
|
|
749
|
-
res.end(responseBody);
|
|
750
|
-
console.log(
|
|
751
|
-
`Recorded: ${req.method} ${req.url} (recordingId: ${recordingId}, sequence: ${sequence})`
|
|
752
|
-
);
|
|
753
|
-
resolve(recording);
|
|
754
|
-
} catch (error) {
|
|
755
|
-
console.error("Error completing recording:", error);
|
|
756
|
-
resolve(null);
|
|
757
|
-
}
|
|
758
|
-
});
|
|
759
|
-
proxyRes.on("error", (err) => {
|
|
760
|
-
console.error("Proxy response error:", err);
|
|
761
|
-
if (!res.headersSent) {
|
|
762
|
-
this.handleProxyError(err, req, res);
|
|
763
|
-
}
|
|
764
|
-
resolve(null);
|
|
765
|
-
});
|
|
766
|
-
}
|
|
767
|
-
);
|
|
768
|
-
proxyReq.on("error", (err) => {
|
|
769
|
-
this.handleProxyError(err, req, res);
|
|
770
|
-
resolve(null);
|
|
771
|
-
});
|
|
772
|
-
if (chunks.length > 0) {
|
|
773
|
-
proxyReq.write(Buffer.concat(chunks));
|
|
774
|
-
}
|
|
775
|
-
proxyReq.end();
|
|
776
|
-
} catch (error) {
|
|
777
|
-
console.error("Error in recordAndProxyRequest:", error);
|
|
778
|
-
try {
|
|
779
|
-
this.handleProxyError(error, req, res);
|
|
780
|
-
} catch (error_) {
|
|
781
|
-
console.error("Failed to handle proxy error:", error_);
|
|
782
|
-
}
|
|
783
|
-
resolve(null);
|
|
784
|
-
}
|
|
785
|
-
})();
|
|
786
|
-
});
|
|
787
|
-
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
|
+
);
|
|
788
978
|
}
|
|
789
979
|
handleUpgrade(req, socket, head) {
|
|
790
980
|
if (this.mode === Modes.replay) {
|
|
791
981
|
this.handleReplayWebSocket(req, socket);
|
|
792
982
|
return;
|
|
793
983
|
}
|
|
794
|
-
const target = this.
|
|
984
|
+
const target = this.target;
|
|
795
985
|
console.log(`[${this.mode}] WebSocket upgrade ${req.url} -> ${target}`);
|
|
796
986
|
if (this.mode === Modes.record) {
|
|
797
|
-
|
|
987
|
+
recordWebSocket(req, socket, head, target, this.currentSession);
|
|
798
988
|
} else {
|
|
799
989
|
this.proxy.ws(req, socket, head, { target });
|
|
800
990
|
}
|
|
801
991
|
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
if (
|
|
812
|
-
|
|
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;
|
|
813
1003
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
const message = data.toString();
|
|
822
|
-
wsRecording.messages.push({
|
|
823
|
-
direction: "client-to-server",
|
|
824
|
-
data: message,
|
|
825
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
826
|
-
});
|
|
827
|
-
if (backendWs.readyState === WebSocket.OPEN) {
|
|
828
|
-
backendWs.send(message);
|
|
829
|
-
}
|
|
830
|
-
});
|
|
831
|
-
backendWs.on("message", (data) => {
|
|
832
|
-
const message = data.toString();
|
|
833
|
-
wsRecording.messages.push({
|
|
834
|
-
direction: "server-to-client",
|
|
835
|
-
data: message,
|
|
836
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
837
|
-
});
|
|
838
|
-
if (clientWs.readyState === WebSocket.OPEN) {
|
|
839
|
-
clientWs.send(message);
|
|
840
|
-
}
|
|
841
|
-
});
|
|
842
|
-
clientWs.on("error", (err) => {
|
|
843
|
-
console.error("Client WebSocket error:", err);
|
|
844
|
-
});
|
|
845
|
-
backendWs.on("error", (err) => {
|
|
846
|
-
console.error("Backend WebSocket error:", err);
|
|
847
|
-
});
|
|
848
|
-
clientWs.on("close", () => {
|
|
849
|
-
backendWs.close();
|
|
850
|
-
console.log("Client WebSocket closed");
|
|
851
|
-
});
|
|
852
|
-
backendWs.on("close", () => {
|
|
853
|
-
clientWs.close();
|
|
854
|
-
console.log("Backend WebSocket closed");
|
|
855
|
-
});
|
|
856
|
-
});
|
|
857
|
-
});
|
|
858
|
-
backendWs.on("error", (err) => {
|
|
859
|
-
console.error("Backend WebSocket connection error:", err);
|
|
860
|
-
clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
|
861
|
-
clientSocket.destroy();
|
|
862
|
-
});
|
|
863
|
-
wss.on("error", (err) => {
|
|
864
|
-
console.error("WebSocket server error:", err);
|
|
865
|
-
});
|
|
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;
|
|
866
1011
|
}
|
|
867
|
-
handleReplayWebSocket(req, socket) {
|
|
868
|
-
const
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1012
|
+
async handleReplayWebSocket(req, socket) {
|
|
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
|
+
}
|
|
1020
|
+
try {
|
|
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(
|
|
873
1027
|
(r) => r.key === key
|
|
874
1028
|
);
|
|
875
1029
|
if (!wsRecording) {
|
|
@@ -878,67 +1032,12 @@ var ProxyServer = class {
|
|
|
878
1032
|
console.log(`No WebSocket recording found for ${key}`);
|
|
879
1033
|
return;
|
|
880
1034
|
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
headers: {
|
|
884
|
-
...req.headers,
|
|
885
|
-
"sec-websocket-key": req.headers["sec-websocket-key"] || "replay-key",
|
|
886
|
-
"sec-websocket-version": "13"
|
|
887
|
-
}
|
|
888
|
-
});
|
|
889
|
-
wss.handleUpgrade(fakeReq, socket, Buffer.alloc(0), (ws) => {
|
|
890
|
-
console.log(`Replaying WebSocket: ${url}`);
|
|
891
|
-
const serverMessages = wsRecording.messages.filter(
|
|
892
|
-
(m) => m.direction === "server-to-client"
|
|
893
|
-
);
|
|
894
|
-
let messageIndex = 0;
|
|
895
|
-
ws.on("message", (data) => {
|
|
896
|
-
const clientMessage = data.toString();
|
|
897
|
-
console.log(`Replay: Client sent: ${clientMessage}`);
|
|
898
|
-
if (messageIndex < serverMessages.length) {
|
|
899
|
-
setTimeout(() => {
|
|
900
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
901
|
-
ws.send(serverMessages[messageIndex].data);
|
|
902
|
-
console.log(`Replay: Sent server message ${messageIndex}`);
|
|
903
|
-
messageIndex++;
|
|
904
|
-
}
|
|
905
|
-
}, 10);
|
|
906
|
-
}
|
|
907
|
-
});
|
|
908
|
-
let initialMessagesSent = 0;
|
|
909
|
-
for (let i = 0; i < wsRecording.messages.length; i++) {
|
|
910
|
-
const msg = wsRecording.messages[i];
|
|
911
|
-
if (msg.direction === "client-to-server") {
|
|
912
|
-
break;
|
|
913
|
-
}
|
|
914
|
-
if (msg.direction === "server-to-client") {
|
|
915
|
-
setTimeout(
|
|
916
|
-
() => {
|
|
917
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
918
|
-
ws.send(msg.data);
|
|
919
|
-
console.log(
|
|
920
|
-
`Replay: Sent initial server message: ${msg.data}`
|
|
921
|
-
);
|
|
922
|
-
messageIndex++;
|
|
923
|
-
initialMessagesSent++;
|
|
924
|
-
}
|
|
925
|
-
},
|
|
926
|
-
10 * (initialMessagesSent + 1)
|
|
927
|
-
);
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
ws.on("error", (err) => {
|
|
931
|
-
console.error("Replay WebSocket error:", err);
|
|
932
|
-
});
|
|
933
|
-
ws.on("close", () => {
|
|
934
|
-
console.log("Replay WebSocket closed");
|
|
935
|
-
});
|
|
936
|
-
});
|
|
937
|
-
}).catch((error) => {
|
|
1035
|
+
replayWebSocket(req, socket, wsRecording, recordingId);
|
|
1036
|
+
} catch (error) {
|
|
938
1037
|
console.error("Replay error:", error);
|
|
939
1038
|
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
940
1039
|
socket.destroy();
|
|
941
|
-
}
|
|
1040
|
+
}
|
|
942
1041
|
}
|
|
943
1042
|
logServerStartup(port) {
|
|
944
1043
|
console.log(`Proxy server running on http://localhost:${port}`);
|
|
@@ -949,6 +1048,7 @@ var ProxyServer = class {
|
|
|
949
1048
|
);
|
|
950
1049
|
}
|
|
951
1050
|
};
|
|
1051
|
+
var registeredContexts = /* @__PURE__ */ new WeakSet();
|
|
952
1052
|
function getProxyPort() {
|
|
953
1053
|
const envPort = process.env.TEST_PROXY_RECORDER_PORT;
|
|
954
1054
|
if (envPort) {
|
|
@@ -967,7 +1067,7 @@ async function setProxyMode(mode, sessionId, timeout) {
|
|
|
967
1067
|
id: sessionId,
|
|
968
1068
|
...timeout && { timeout }
|
|
969
1069
|
};
|
|
970
|
-
const response = await fetch(`http://
|
|
1070
|
+
const response = await fetch(`http://localhost:${proxyPort}/__control`, {
|
|
971
1071
|
method: "POST",
|
|
972
1072
|
headers: { "Content-Type": "application/json" },
|
|
973
1073
|
body: JSON.stringify(body)
|
|
@@ -990,7 +1090,7 @@ async function cleanupSession(sessionId) {
|
|
|
990
1090
|
cleanup: true,
|
|
991
1091
|
id: sessionId
|
|
992
1092
|
};
|
|
993
|
-
const response = await fetch(`http://
|
|
1093
|
+
const response = await fetch(`http://localhost:${proxyPort}/__control`, {
|
|
994
1094
|
method: "POST",
|
|
995
1095
|
headers: { "Content-Type": "application/json" },
|
|
996
1096
|
body: JSON.stringify(body)
|
|
@@ -1054,7 +1154,7 @@ async function getRecordingsDir() {
|
|
|
1054
1154
|
}
|
|
1055
1155
|
const proxyPort = getProxyPort();
|
|
1056
1156
|
try {
|
|
1057
|
-
const response = await fetch(`http://
|
|
1157
|
+
const response = await fetch(`http://localhost:${proxyPort}/__control`);
|
|
1058
1158
|
if (response.ok) {
|
|
1059
1159
|
const data = await response.json();
|
|
1060
1160
|
if (data.recordingsDir) {
|
|
@@ -1107,6 +1207,14 @@ var playwrightProxy = {
|
|
|
1107
1207
|
await page.setExtraHTTPHeaders({
|
|
1108
1208
|
[RECORDING_ID_HEADER]: sessionId
|
|
1109
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
|
+
]);
|
|
1110
1218
|
await setProxyMode(mode, sessionId, timeout);
|
|
1111
1219
|
if (clientSideOptions?.url) {
|
|
1112
1220
|
await setupClientSideRecording(
|
|
@@ -1141,10 +1249,8 @@ var playwrightProxy = {
|
|
|
1141
1249
|
// Ensure the handler applies to all matching requests
|
|
1142
1250
|
);
|
|
1143
1251
|
const context = page.context();
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
if (!globalThis[handlerKey]) {
|
|
1147
|
-
globalThis[handlerKey] = true;
|
|
1252
|
+
if (!registeredContexts.has(context)) {
|
|
1253
|
+
registeredContexts.add(context);
|
|
1148
1254
|
context.on("close", async () => {
|
|
1149
1255
|
try {
|
|
1150
1256
|
await cleanupSession(sessionId);
|
|
@@ -1154,7 +1260,7 @@ var playwrightProxy = {
|
|
|
1154
1260
|
error
|
|
1155
1261
|
);
|
|
1156
1262
|
} finally {
|
|
1157
|
-
delete
|
|
1263
|
+
registeredContexts.delete(context);
|
|
1158
1264
|
}
|
|
1159
1265
|
});
|
|
1160
1266
|
}
|