@replayio-app-building/netlify-recorder 0.59.0 → 0.61.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.js +107 -74
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -36,6 +36,16 @@ import http from "http";
|
|
|
36
36
|
import https from "https";
|
|
37
37
|
import zlib from "zlib";
|
|
38
38
|
import { Readable, Writable } from "stream";
|
|
39
|
+
var _lastReplayCallNs = null;
|
|
40
|
+
function replayGapMs() {
|
|
41
|
+
const now = process.hrtime.bigint();
|
|
42
|
+
const gapMs = _lastReplayCallNs === null ? 0 : Number(now - _lastReplayCallNs) / 1e6;
|
|
43
|
+
_lastReplayCallNs = now;
|
|
44
|
+
return Math.round(gapMs);
|
|
45
|
+
}
|
|
46
|
+
function resetReplayGap() {
|
|
47
|
+
_lastReplayCallNs = null;
|
|
48
|
+
}
|
|
39
49
|
function isNeonQuery(obj) {
|
|
40
50
|
return typeof obj === "object" && obj !== null && "query" in obj && "params" in obj;
|
|
41
51
|
}
|
|
@@ -56,7 +66,7 @@ function buildSetConfigQueries(requestId, callIndex) {
|
|
|
56
66
|
{ query: "SELECT set_config('app.replay_call_index', $1, true)", params: [String(callIndex)] }
|
|
57
67
|
];
|
|
58
68
|
}
|
|
59
|
-
function patchHttpModules(mode, calls, consumed, silent) {
|
|
69
|
+
function patchHttpModules(mode, calls, consumed, silent, matcher) {
|
|
60
70
|
const origHttpRequest = http.request;
|
|
61
71
|
const origHttpsRequest = https.request;
|
|
62
72
|
function makeInterceptedRequest(mod, origRequest, protocol, args) {
|
|
@@ -88,7 +98,7 @@ function patchHttpModules(mode, calls, consumed, silent) {
|
|
|
88
98
|
const urlStr = `${protocol}//${host}${port}${path}`;
|
|
89
99
|
const method = (options.method || "GET").toUpperCase();
|
|
90
100
|
if (mode === "replay") {
|
|
91
|
-
return replayHttpRequest(urlStr, method, calls, consumed, callback, silent);
|
|
101
|
+
return replayHttpRequest(urlStr, method, calls, consumed, matcher, callback, silent);
|
|
92
102
|
}
|
|
93
103
|
const req = origRequest.call(mod, ...args);
|
|
94
104
|
const bodyChunks = [];
|
|
@@ -172,24 +182,59 @@ function patchHttpModules(mode, calls, consumed, silent) {
|
|
|
172
182
|
https.request = origHttpsRequest;
|
|
173
183
|
};
|
|
174
184
|
}
|
|
175
|
-
function
|
|
176
|
-
let
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (c && c.url === url) {
|
|
181
|
-
matchIdx = i;
|
|
182
|
-
break;
|
|
183
|
-
}
|
|
185
|
+
function bucketPush(map, key, i) {
|
|
186
|
+
let b = map.get(key);
|
|
187
|
+
if (!b) {
|
|
188
|
+
b = { list: [], cur: 0 };
|
|
189
|
+
map.set(key, b);
|
|
184
190
|
}
|
|
185
|
-
|
|
191
|
+
b.list.push(i);
|
|
192
|
+
}
|
|
193
|
+
function urlBodyKey(url, body) {
|
|
194
|
+
return body === void 0 ? `${url}\0\0undef` : `${url}\0${body}`;
|
|
195
|
+
}
|
|
196
|
+
var ReplayCallMatcher = class {
|
|
197
|
+
constructor(calls, consumed) {
|
|
198
|
+
this.consumed = consumed;
|
|
186
199
|
for (let i = 0; i < calls.length; i++) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
200
|
+
this.all.list.push(i);
|
|
201
|
+
const c = calls[i];
|
|
202
|
+
if (!c) continue;
|
|
203
|
+
bucketPush(this.byUrl, c.url, i);
|
|
204
|
+
bucketPush(this.byUrlBody, urlBodyKey(c.url, c.requestBody), i);
|
|
205
|
+
if (c.requestBody !== void 0) bucketPush(this.byBody, c.requestBody, i);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
consumed;
|
|
209
|
+
byUrlBody = /* @__PURE__ */ new Map();
|
|
210
|
+
byUrl = /* @__PURE__ */ new Map();
|
|
211
|
+
byBody = /* @__PURE__ */ new Map();
|
|
212
|
+
all = { list: [], cur: 0 };
|
|
213
|
+
/** Lowest-index not-yet-consumed entry in a bucket, or -1. Amortized O(1). */
|
|
214
|
+
take(bucket) {
|
|
215
|
+
if (!bucket) return -1;
|
|
216
|
+
while (bucket.cur < bucket.list.length && this.consumed.has(bucket.list[bucket.cur])) {
|
|
217
|
+
bucket.cur++;
|
|
191
218
|
}
|
|
219
|
+
return bucket.cur < bucket.list.length ? bucket.list[bucket.cur] : -1;
|
|
192
220
|
}
|
|
221
|
+
/** fetch matching: exact url+body, then url, then body, then FIFO. */
|
|
222
|
+
matchFetch(url, requestBody) {
|
|
223
|
+
let idx = this.take(this.byUrlBody.get(urlBodyKey(url, requestBody)));
|
|
224
|
+
if (idx === -1) idx = this.take(this.byUrl.get(url));
|
|
225
|
+
if (idx === -1 && requestBody) idx = this.take(this.byBody.get(requestBody));
|
|
226
|
+
if (idx === -1) idx = this.take(this.all);
|
|
227
|
+
return idx;
|
|
228
|
+
}
|
|
229
|
+
/** http/https matching: exact url, then FIFO. */
|
|
230
|
+
matchHttp(url) {
|
|
231
|
+
let idx = this.take(this.byUrl.get(url));
|
|
232
|
+
if (idx === -1) idx = this.take(this.all);
|
|
233
|
+
return idx;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
function replayHttpRequest(url, _method, calls, consumed, matcher, callback, silent) {
|
|
237
|
+
const matchIdx = matcher.matchHttp(url);
|
|
193
238
|
const call = calls[matchIdx];
|
|
194
239
|
if (matchIdx === -1 || !call) {
|
|
195
240
|
throw new Error(
|
|
@@ -199,14 +244,15 @@ function replayHttpRequest(url, _method, calls, consumed, callback, silent) {
|
|
|
199
244
|
consumed.add(matchIdx);
|
|
200
245
|
const isPending = call.pending || call.endTime === 0 && call.responseStatus === 0;
|
|
201
246
|
if (!silent) {
|
|
247
|
+
const gap = replayGapMs();
|
|
202
248
|
if (isPending) {
|
|
203
249
|
console.log(
|
|
204
|
-
` [network-replay] Consumed call ${consumed.size}/${calls.length}: ${call.method} ${call.url} => (fire-and-forget, no response captured)`
|
|
250
|
+
` [network-replay] Consumed call ${consumed.size}/${calls.length}: ${call.method} ${call.url} => (fire-and-forget, no response captured) [replay gap: ${gap}ms]`
|
|
205
251
|
);
|
|
206
252
|
} else {
|
|
207
253
|
const duration = call.endTime - call.startTime;
|
|
208
254
|
console.log(
|
|
209
|
-
` [network-replay] Consumed call ${consumed.size}/${calls.length}: ${call.method} ${call.url} => ${call.responseStatus} (original: ${duration}ms)`
|
|
255
|
+
` [network-replay] Consumed call ${consumed.size}/${calls.length}: ${call.method} ${call.url} => ${call.responseStatus} (original: ${duration}ms, replay gap: ${gap}ms)`
|
|
210
256
|
);
|
|
211
257
|
}
|
|
212
258
|
}
|
|
@@ -411,46 +457,12 @@ function installNetworkInterceptor(mode, calls, options) {
|
|
|
411
457
|
}
|
|
412
458
|
const originalFetch = globalThis.fetch;
|
|
413
459
|
const consumed = /* @__PURE__ */ new Set();
|
|
460
|
+
const matcher = new ReplayCallMatcher(calls, consumed);
|
|
461
|
+
resetReplayGap();
|
|
414
462
|
const replayFetch = async (input, init) => {
|
|
415
463
|
const url = typeof input === "string" ? input : input instanceof URL ? input.href : typeof input === "object" && input !== null && "url" in input ? input.url : String(input);
|
|
416
464
|
const requestBody = typeof init?.body === "string" ? init.body : void 0;
|
|
417
|
-
|
|
418
|
-
for (let i = 0; i < calls.length; i++) {
|
|
419
|
-
if (consumed.has(i)) continue;
|
|
420
|
-
const c = calls[i];
|
|
421
|
-
if (c && c.url === url && c.requestBody === requestBody) {
|
|
422
|
-
matchIdx = i;
|
|
423
|
-
break;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
if (matchIdx === -1) {
|
|
427
|
-
for (let i = 0; i < calls.length; i++) {
|
|
428
|
-
if (consumed.has(i)) continue;
|
|
429
|
-
const c = calls[i];
|
|
430
|
-
if (c && c.url === url) {
|
|
431
|
-
matchIdx = i;
|
|
432
|
-
break;
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
if (matchIdx === -1 && requestBody) {
|
|
437
|
-
for (let i = 0; i < calls.length; i++) {
|
|
438
|
-
if (consumed.has(i)) continue;
|
|
439
|
-
const c = calls[i];
|
|
440
|
-
if (c && c.requestBody === requestBody) {
|
|
441
|
-
matchIdx = i;
|
|
442
|
-
break;
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
if (matchIdx === -1) {
|
|
447
|
-
for (let i = 0; i < calls.length; i++) {
|
|
448
|
-
if (!consumed.has(i)) {
|
|
449
|
-
matchIdx = i;
|
|
450
|
-
break;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
465
|
+
const matchIdx = matcher.matchFetch(url, requestBody);
|
|
454
466
|
const call = calls[matchIdx];
|
|
455
467
|
if (matchIdx === -1 || !call) {
|
|
456
468
|
throw new Error(
|
|
@@ -460,14 +472,15 @@ function installNetworkInterceptor(mode, calls, options) {
|
|
|
460
472
|
consumed.add(matchIdx);
|
|
461
473
|
const isPending = call.pending || call.endTime === 0 && call.responseStatus === 0;
|
|
462
474
|
if (!silent) {
|
|
475
|
+
const gap = replayGapMs();
|
|
463
476
|
if (isPending) {
|
|
464
477
|
console.log(
|
|
465
|
-
` [network-replay] Consumed call ${consumed.size}/${calls.length}: ${call.method} ${call.url} => (fire-and-forget, no response captured)`
|
|
478
|
+
` [network-replay] Consumed call ${consumed.size}/${calls.length}: ${call.method} ${call.url} => (fire-and-forget, no response captured) [replay gap: ${gap}ms]`
|
|
466
479
|
);
|
|
467
480
|
} else {
|
|
468
481
|
const duration = call.endTime - call.startTime;
|
|
469
482
|
console.log(
|
|
470
|
-
` [network-replay] Consumed call ${consumed.size}/${calls.length}: ${call.method} ${call.url} => ${call.responseStatus} (original: ${duration}ms)`
|
|
483
|
+
` [network-replay] Consumed call ${consumed.size}/${calls.length}: ${call.method} ${call.url} => ${call.responseStatus} (original: ${duration}ms, replay gap: ${gap}ms)`
|
|
471
484
|
);
|
|
472
485
|
}
|
|
473
486
|
}
|
|
@@ -509,7 +522,7 @@ function installNetworkInterceptor(mode, calls, options) {
|
|
|
509
522
|
};
|
|
510
523
|
};
|
|
511
524
|
globalThis.fetch = replayFetch;
|
|
512
|
-
const restoreHttp = patchHttpModules("replay", calls, consumed, silent);
|
|
525
|
+
const restoreHttp = patchHttpModules("replay", calls, consumed, silent, matcher);
|
|
513
526
|
return {
|
|
514
527
|
restore() {
|
|
515
528
|
globalThis.fetch = originalFetch;
|
|
@@ -541,6 +554,20 @@ async function handleNeonSqlRequest(originalFetch, input, init, url, method, req
|
|
|
541
554
|
body: JSON.stringify({ queries: txBody })
|
|
542
555
|
};
|
|
543
556
|
const startTime = Date.now();
|
|
557
|
+
const entry = {
|
|
558
|
+
url,
|
|
559
|
+
method,
|
|
560
|
+
requestHeaders,
|
|
561
|
+
requestBody,
|
|
562
|
+
responseStatus: 0,
|
|
563
|
+
responseHeaders: {},
|
|
564
|
+
responseBody: void 0,
|
|
565
|
+
timestamp: startTime,
|
|
566
|
+
startTime,
|
|
567
|
+
endTime: 0,
|
|
568
|
+
pending: true
|
|
569
|
+
};
|
|
570
|
+
calls.push(entry);
|
|
544
571
|
const response = await originalFetch(input, modifiedInit);
|
|
545
572
|
const endTime = Date.now();
|
|
546
573
|
const rawBody = await response.clone().text();
|
|
@@ -560,18 +587,12 @@ async function handleNeonSqlRequest(originalFetch, input, init, url, method, req
|
|
|
560
587
|
} catch {
|
|
561
588
|
}
|
|
562
589
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
responseHeaders,
|
|
570
|
-
responseBody: recordedBody,
|
|
571
|
-
timestamp: endTime,
|
|
572
|
-
startTime,
|
|
573
|
-
endTime
|
|
574
|
-
});
|
|
590
|
+
entry.responseStatus = response.status;
|
|
591
|
+
entry.responseHeaders = responseHeaders;
|
|
592
|
+
entry.responseBody = recordedBody;
|
|
593
|
+
entry.timestamp = endTime;
|
|
594
|
+
entry.endTime = endTime;
|
|
595
|
+
entry.pending = false;
|
|
575
596
|
return new Response(recordedBody, {
|
|
576
597
|
status: response.status,
|
|
577
598
|
statusText: response.statusText,
|
|
@@ -930,8 +951,13 @@ function redactBlobData(blobData) {
|
|
|
930
951
|
responseBody: scrub(call.responseBody)
|
|
931
952
|
})
|
|
932
953
|
);
|
|
954
|
+
const redactedHandlerResponse = blobData.handlerResponse ? {
|
|
955
|
+
...blobData.handlerResponse,
|
|
956
|
+
body: scrub(blobData.handlerResponse.body)
|
|
957
|
+
} : blobData.handlerResponse;
|
|
933
958
|
return {
|
|
934
959
|
...blobData,
|
|
960
|
+
handlerResponse: redactedHandlerResponse,
|
|
935
961
|
requestInfo: redactedRequestInfo,
|
|
936
962
|
capturedData: {
|
|
937
963
|
networkCalls: redactedNetworkCalls,
|
|
@@ -984,7 +1010,7 @@ async function finishRequest(requestContext, callbacks, response, options) {
|
|
|
984
1010
|
body: typeof response.body === "string" ? response.body : void 0
|
|
985
1011
|
},
|
|
986
1012
|
originalRequestId: options?.originalRequestId,
|
|
987
|
-
packageVersion: "0.
|
|
1013
|
+
packageVersion: "0.61.0"
|
|
988
1014
|
};
|
|
989
1015
|
const blobData = redactBlobData(rawBlobData);
|
|
990
1016
|
const blobContent = JSON.stringify(blobData);
|
|
@@ -1384,11 +1410,13 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo, p
|
|
|
1384
1410
|
}
|
|
1385
1411
|
if (precedingBlobs && precedingBlobs.length > 0) {
|
|
1386
1412
|
console.log(` [warm-up] Replaying ${precedingBlobs.length} preceding request(s) to populate module-level state\u2026`);
|
|
1413
|
+
const warmupStartNs = process.hrtime.bigint();
|
|
1387
1414
|
for (let i = 0; i < precedingBlobs.length; i++) {
|
|
1388
1415
|
const pb = precedingBlobs[i];
|
|
1389
1416
|
const netH = installNetworkInterceptor("replay", pb.capturedData.networkCalls, { silent: true });
|
|
1390
1417
|
const envH = installEnvironmentInterceptor("replay", pb.capturedData.envReads);
|
|
1391
1418
|
let warmupError;
|
|
1419
|
+
const itemStartNs = process.hrtime.bigint();
|
|
1392
1420
|
try {
|
|
1393
1421
|
if (isV2) {
|
|
1394
1422
|
const url = pb.requestInfo.rawUrl ?? `https://localhost${pb.requestInfo.url}`;
|
|
@@ -1420,20 +1448,23 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo, p
|
|
|
1420
1448
|
} catch (err) {
|
|
1421
1449
|
warmupError = err instanceof Error ? err.message : String(err);
|
|
1422
1450
|
}
|
|
1451
|
+
const itemMs = Math.round(Number(process.hrtime.bigint() - itemStartNs) / 1e6);
|
|
1423
1452
|
const consumed = netH.consumedCount();
|
|
1424
1453
|
const total = netH.totalCount();
|
|
1425
1454
|
netH.restore();
|
|
1426
1455
|
envH.restore();
|
|
1427
1456
|
if (warmupError) {
|
|
1428
|
-
console.error(` [warm-up ${i + 1}/${precedingBlobs.length}] ${pb.requestInfo.method} ${pb.requestInfo.url} \u2014 ERROR: ${warmupError} (${consumed}/${total} calls)`);
|
|
1457
|
+
console.error(` [warm-up ${i + 1}/${precedingBlobs.length}] ${pb.requestInfo.method} ${pb.requestInfo.url} \u2014 ERROR: ${warmupError} (${consumed}/${total} calls, ${itemMs}ms real)`);
|
|
1429
1458
|
} else {
|
|
1430
|
-
console.log(` [warm-up ${i + 1}/${precedingBlobs.length}] ${pb.requestInfo.method} ${pb.requestInfo.url} \u2014 OK (${consumed}/${total} network calls)`);
|
|
1459
|
+
console.log(` [warm-up ${i + 1}/${precedingBlobs.length}] ${pb.requestInfo.method} ${pb.requestInfo.url} \u2014 OK (${consumed}/${total} network calls, ${itemMs}ms real)`);
|
|
1431
1460
|
}
|
|
1432
1461
|
}
|
|
1433
|
-
|
|
1462
|
+
const warmupMs = Math.round(Number(process.hrtime.bigint() - warmupStartNs) / 1e6);
|
|
1463
|
+
console.log(` [warm-up] Done. (${precedingBlobs.length} request(s) in ${warmupMs}ms real wall-clock)`);
|
|
1434
1464
|
}
|
|
1435
1465
|
try {
|
|
1436
1466
|
let rawResult;
|
|
1467
|
+
const targetStartNs = process.hrtime.bigint();
|
|
1437
1468
|
try {
|
|
1438
1469
|
if (isV2) {
|
|
1439
1470
|
const url = requestInfo.rawUrl ?? `https://localhost${requestInfo.url}`;
|
|
@@ -1485,6 +1516,8 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo, p
|
|
|
1485
1516
|
body: JSON.stringify({ error: errorMessage })
|
|
1486
1517
|
};
|
|
1487
1518
|
}
|
|
1519
|
+
const targetMs = Math.round(Number(process.hrtime.bigint() - targetStartNs) / 1e6);
|
|
1520
|
+
console.log(` [replay] Target handler executed in ${targetMs}ms real wall-clock (${blobData.capturedData.networkCalls.length} network call(s)).`);
|
|
1488
1521
|
if (blobData.handlerResponse) {
|
|
1489
1522
|
const replayRes = rawResult;
|
|
1490
1523
|
const replayResponse = {
|