@replayio-app-building/netlify-recorder 0.47.0 → 0.49.0
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/dist/index.d.ts +4 -0
- package/dist/index.js +200 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -331,6 +331,10 @@ interface EnsureRequestRecordingOptions {
|
|
|
331
331
|
* with the blob data URL from the stored request, updates the row to `"queued"`,
|
|
332
332
|
* and returns `null`.
|
|
333
333
|
*
|
|
334
|
+
* For warm-start requests, registers all preceding requests from the same
|
|
335
|
+
* module instance with the recorder service before triggering the recording,
|
|
336
|
+
* so the recorder can replay them to reconstruct module-level state.
|
|
337
|
+
*
|
|
334
338
|
* This function is idempotent — calling it multiple times for the same request
|
|
335
339
|
* is safe. Once the recording completes, subsequent calls return the recording ID.
|
|
336
340
|
*/
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,9 @@ function incrementCallIndex() {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// src/interceptors/network.ts
|
|
35
|
+
import http from "http";
|
|
36
|
+
import https from "https";
|
|
37
|
+
import { Readable, Writable } from "stream";
|
|
35
38
|
function isNeonQuery(obj) {
|
|
36
39
|
return typeof obj === "object" && obj !== null && "query" in obj && "params" in obj;
|
|
37
40
|
}
|
|
@@ -52,6 +55,159 @@ function buildSetConfigQueries(requestId, callIndex) {
|
|
|
52
55
|
{ query: "SELECT set_config('app.replay_call_index', $1, true)", params: [String(callIndex)] }
|
|
53
56
|
];
|
|
54
57
|
}
|
|
58
|
+
function patchHttpModules(mode, calls, consumed) {
|
|
59
|
+
const origHttpRequest = http.request;
|
|
60
|
+
const origHttpsRequest = https.request;
|
|
61
|
+
function makeInterceptedRequest(mod, origRequest, protocol, args) {
|
|
62
|
+
let options;
|
|
63
|
+
let callback;
|
|
64
|
+
if (typeof args[0] === "string" || args[0] instanceof URL) {
|
|
65
|
+
const urlStr2 = typeof args[0] === "string" ? args[0] : args[0].href;
|
|
66
|
+
options = typeof args[1] === "object" && args[1] !== null ? { ...args[1] } : {};
|
|
67
|
+
try {
|
|
68
|
+
const parsed = new URL(urlStr2);
|
|
69
|
+
options.hostname = options.hostname ?? parsed.hostname;
|
|
70
|
+
options.port = options.port ?? parsed.port;
|
|
71
|
+
options.path = options.path ?? parsed.pathname + parsed.search;
|
|
72
|
+
options.protocol = options.protocol ?? parsed.protocol;
|
|
73
|
+
} catch {
|
|
74
|
+
options.path = options.path ?? urlStr2;
|
|
75
|
+
}
|
|
76
|
+
callback = typeof args[2] === "function" ? args[2] : void 0;
|
|
77
|
+
if (!callback && typeof args[1] === "function") {
|
|
78
|
+
callback = args[1];
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
options = typeof args[0] === "object" && args[0] !== null ? { ...args[0] } : {};
|
|
82
|
+
callback = typeof args[1] === "function" ? args[1] : void 0;
|
|
83
|
+
}
|
|
84
|
+
const host = options.hostname || options.host || "localhost";
|
|
85
|
+
const port = options.port ? `:${options.port}` : "";
|
|
86
|
+
const path = options.path || "/";
|
|
87
|
+
const urlStr = `${protocol}//${host}${port}${path}`;
|
|
88
|
+
const method = (options.method || "GET").toUpperCase();
|
|
89
|
+
if (mode === "replay") {
|
|
90
|
+
return replayHttpRequest(urlStr, method, calls, consumed, callback);
|
|
91
|
+
}
|
|
92
|
+
const req = origRequest.call(mod, ...args);
|
|
93
|
+
const bodyChunks = [];
|
|
94
|
+
const origWrite = req.write.bind(req);
|
|
95
|
+
const origEnd = req.end.bind(req);
|
|
96
|
+
req.write = function(chunk, ...rest) {
|
|
97
|
+
if (chunk) bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
98
|
+
return origWrite(chunk, ...rest);
|
|
99
|
+
};
|
|
100
|
+
req.end = function(chunk, ...rest) {
|
|
101
|
+
if (chunk && typeof chunk !== "function") bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
102
|
+
return origEnd(chunk, ...rest);
|
|
103
|
+
};
|
|
104
|
+
const startTime = Date.now();
|
|
105
|
+
req.on("response", (res) => {
|
|
106
|
+
const resChunks = [];
|
|
107
|
+
res.on("data", (chunk) => resChunks.push(chunk));
|
|
108
|
+
res.on("end", () => {
|
|
109
|
+
const endTime = Date.now();
|
|
110
|
+
const requestHeaders = {};
|
|
111
|
+
if (options.headers) {
|
|
112
|
+
for (const [k, v] of Object.entries(options.headers)) {
|
|
113
|
+
if (v !== void 0) requestHeaders[k] = Array.isArray(v) ? v.join(", ") : String(v);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const responseHeaders = {};
|
|
117
|
+
if (res.headers) {
|
|
118
|
+
for (const [k, v] of Object.entries(res.headers)) {
|
|
119
|
+
if (v !== void 0) responseHeaders[k] = Array.isArray(v) ? v.join(", ") : String(v);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const store = getRequestStore();
|
|
123
|
+
const targetCalls = store?.networkCalls ?? calls;
|
|
124
|
+
targetCalls.push({
|
|
125
|
+
url: urlStr,
|
|
126
|
+
method,
|
|
127
|
+
requestHeaders,
|
|
128
|
+
requestBody: bodyChunks.length > 0 ? Buffer.concat(bodyChunks).toString("utf-8") : void 0,
|
|
129
|
+
responseStatus: res.statusCode ?? 0,
|
|
130
|
+
responseHeaders,
|
|
131
|
+
responseBody: Buffer.concat(resChunks).toString("utf-8"),
|
|
132
|
+
timestamp: endTime,
|
|
133
|
+
startTime,
|
|
134
|
+
endTime
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
return req;
|
|
139
|
+
}
|
|
140
|
+
http.request = function(...args) {
|
|
141
|
+
return makeInterceptedRequest(http, origHttpRequest, "http:", args);
|
|
142
|
+
};
|
|
143
|
+
https.request = function(...args) {
|
|
144
|
+
return makeInterceptedRequest(https, origHttpsRequest, "https:", args);
|
|
145
|
+
};
|
|
146
|
+
return () => {
|
|
147
|
+
http.request = origHttpRequest;
|
|
148
|
+
https.request = origHttpsRequest;
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function replayHttpRequest(url, _method, calls, consumed, callback) {
|
|
152
|
+
let matchIdx = -1;
|
|
153
|
+
for (let i = 0; i < calls.length; i++) {
|
|
154
|
+
if (consumed.has(i)) continue;
|
|
155
|
+
const c = calls[i];
|
|
156
|
+
if (c && c.url === url) {
|
|
157
|
+
matchIdx = i;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (matchIdx === -1) {
|
|
162
|
+
for (let i = 0; i < calls.length; i++) {
|
|
163
|
+
if (!consumed.has(i)) {
|
|
164
|
+
matchIdx = i;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const call = calls[matchIdx];
|
|
170
|
+
if (matchIdx === -1 || !call) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`No more recorded network calls to replay for http/https request (exhausted ${calls.length} calls)`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
consumed.add(matchIdx);
|
|
176
|
+
const duration = call.endTime - call.startTime;
|
|
177
|
+
console.log(
|
|
178
|
+
` [network-replay] Consumed call ${consumed.size}/${calls.length}: ${call.method} ${call.url} => ${call.responseStatus} (original: ${duration}ms)`
|
|
179
|
+
);
|
|
180
|
+
const body = call.responseBody ?? "";
|
|
181
|
+
const fakeRes = new Readable({
|
|
182
|
+
read() {
|
|
183
|
+
this.push(body);
|
|
184
|
+
this.push(null);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
fakeRes.statusCode = call.responseStatus;
|
|
188
|
+
fakeRes.statusMessage = "";
|
|
189
|
+
fakeRes.headers = {};
|
|
190
|
+
if (call.responseHeaders) {
|
|
191
|
+
for (const [k, v] of Object.entries(call.responseHeaders)) {
|
|
192
|
+
fakeRes.headers[k.toLowerCase()] = v;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const fakeReq = new Writable({
|
|
196
|
+
write(_chunk, _encoding, cb) {
|
|
197
|
+
cb();
|
|
198
|
+
},
|
|
199
|
+
final(cb) {
|
|
200
|
+
cb();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
fakeReq.aborted = false;
|
|
204
|
+
fakeReq.reusedSocket = false;
|
|
205
|
+
process.nextTick(() => {
|
|
206
|
+
if (callback) callback(fakeRes);
|
|
207
|
+
fakeReq.emit("response", fakeRes);
|
|
208
|
+
});
|
|
209
|
+
return fakeReq;
|
|
210
|
+
}
|
|
55
211
|
var _realOriginalFetch = null;
|
|
56
212
|
var _captureInterceptorInstalled = false;
|
|
57
213
|
function ensureCaptureInterceptor() {
|
|
@@ -117,10 +273,12 @@ function installNetworkInterceptor(mode, calls) {
|
|
|
117
273
|
if (store) {
|
|
118
274
|
ensureCaptureInterceptor();
|
|
119
275
|
store.networkCalls = calls;
|
|
276
|
+
const restoreHttp3 = patchHttpModules("capture", calls, null);
|
|
120
277
|
return {
|
|
121
278
|
restore() {
|
|
122
279
|
const s = getRequestStore();
|
|
123
280
|
if (s) s.networkCalls = null;
|
|
281
|
+
restoreHttp3();
|
|
124
282
|
},
|
|
125
283
|
consumedCount() {
|
|
126
284
|
return 0;
|
|
@@ -181,9 +339,11 @@ function installNetworkInterceptor(mode, calls) {
|
|
|
181
339
|
return response;
|
|
182
340
|
};
|
|
183
341
|
globalThis.fetch = captureFetch;
|
|
342
|
+
const restoreHttp2 = patchHttpModules("capture", calls, null);
|
|
184
343
|
return {
|
|
185
344
|
restore() {
|
|
186
345
|
globalThis.fetch = originalFetch2;
|
|
346
|
+
restoreHttp2();
|
|
187
347
|
},
|
|
188
348
|
consumedCount() {
|
|
189
349
|
return 0;
|
|
@@ -260,9 +420,11 @@ function installNetworkInterceptor(mode, calls) {
|
|
|
260
420
|
};
|
|
261
421
|
};
|
|
262
422
|
globalThis.fetch = replayFetch;
|
|
423
|
+
const restoreHttp = patchHttpModules("replay", calls, consumed);
|
|
263
424
|
return {
|
|
264
425
|
restore() {
|
|
265
426
|
globalThis.fetch = originalFetch;
|
|
427
|
+
restoreHttp();
|
|
266
428
|
},
|
|
267
429
|
consumedCount() {
|
|
268
430
|
return consumed.size;
|
|
@@ -1425,6 +1587,19 @@ function remoteCallbacks(recorderUrl) {
|
|
|
1425
1587
|
}
|
|
1426
1588
|
};
|
|
1427
1589
|
}
|
|
1590
|
+
async function backendRequestsListPreceding(sql, targetRequestId, originalRequestId) {
|
|
1591
|
+
const rows = await sql`
|
|
1592
|
+
SELECT id, blob_data_url, handler_path, commit_sha, branch_name, repository_url,
|
|
1593
|
+
original_request_id, status, recording_id, error_message, created_at, updated_at
|
|
1594
|
+
FROM backend_requests
|
|
1595
|
+
WHERE original_request_id = ${originalRequestId}
|
|
1596
|
+
AND id != ${targetRequestId}
|
|
1597
|
+
AND created_at <= (SELECT created_at FROM backend_requests WHERE id = ${targetRequestId})
|
|
1598
|
+
AND blob_data_url IS NOT NULL
|
|
1599
|
+
ORDER BY created_at ASC
|
|
1600
|
+
`;
|
|
1601
|
+
return rows;
|
|
1602
|
+
}
|
|
1428
1603
|
async function ensureRequestRecording(sql, requestId, options) {
|
|
1429
1604
|
const request = await backendRequestsGet(sql, requestId);
|
|
1430
1605
|
if (!request) {
|
|
@@ -1437,6 +1612,30 @@ async function ensureRequestRecording(sql, requestId, options) {
|
|
|
1437
1612
|
return null;
|
|
1438
1613
|
}
|
|
1439
1614
|
const recorderUrl = options.recorderUrl.replace(/\/+$/, "");
|
|
1615
|
+
const origReqId = request.original_request_id;
|
|
1616
|
+
if (origReqId && origReqId !== requestId) {
|
|
1617
|
+
const preceding = await backendRequestsListPreceding(sql, requestId, origReqId);
|
|
1618
|
+
if (preceding.length > 0) {
|
|
1619
|
+
await Promise.all(
|
|
1620
|
+
preceding.map(
|
|
1621
|
+
(req) => fetch(`${recorderUrl}/api/store-request`, {
|
|
1622
|
+
method: "POST",
|
|
1623
|
+
headers: { "Content-Type": "application/json" },
|
|
1624
|
+
body: JSON.stringify({
|
|
1625
|
+
requestId: req.id,
|
|
1626
|
+
blobDataUrl: req.blob_data_url,
|
|
1627
|
+
handlerPath: req.handler_path,
|
|
1628
|
+
commitSha: req.commit_sha,
|
|
1629
|
+
branchName: req.branch_name,
|
|
1630
|
+
repositoryUrl: req.repository_url ?? void 0,
|
|
1631
|
+
originalRequestId: req.original_request_id ?? void 0
|
|
1632
|
+
})
|
|
1633
|
+
}).catch(() => {
|
|
1634
|
+
})
|
|
1635
|
+
)
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1440
1639
|
const res = await fetch(`${recorderUrl}/api/create-recording`, {
|
|
1441
1640
|
method: "POST",
|
|
1442
1641
|
headers: { "Content-Type": "application/json" },
|
|
@@ -1448,7 +1647,7 @@ async function ensureRequestRecording(sql, requestId, options) {
|
|
|
1448
1647
|
branchName: request.branch_name,
|
|
1449
1648
|
repositoryUrl: request.repository_url ?? void 0,
|
|
1450
1649
|
webhookUrl: options.webhookUrl,
|
|
1451
|
-
originalRequestId:
|
|
1650
|
+
originalRequestId: origReqId ?? void 0
|
|
1452
1651
|
})
|
|
1453
1652
|
});
|
|
1454
1653
|
if (!res.ok) {
|