@replayio-app-building/netlify-recorder 0.60.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 +102 -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,
|
|
@@ -989,7 +1010,7 @@ async function finishRequest(requestContext, callbacks, response, options) {
|
|
|
989
1010
|
body: typeof response.body === "string" ? response.body : void 0
|
|
990
1011
|
},
|
|
991
1012
|
originalRequestId: options?.originalRequestId,
|
|
992
|
-
packageVersion: "0.
|
|
1013
|
+
packageVersion: "0.61.0"
|
|
993
1014
|
};
|
|
994
1015
|
const blobData = redactBlobData(rawBlobData);
|
|
995
1016
|
const blobContent = JSON.stringify(blobData);
|
|
@@ -1389,11 +1410,13 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo, p
|
|
|
1389
1410
|
}
|
|
1390
1411
|
if (precedingBlobs && precedingBlobs.length > 0) {
|
|
1391
1412
|
console.log(` [warm-up] Replaying ${precedingBlobs.length} preceding request(s) to populate module-level state\u2026`);
|
|
1413
|
+
const warmupStartNs = process.hrtime.bigint();
|
|
1392
1414
|
for (let i = 0; i < precedingBlobs.length; i++) {
|
|
1393
1415
|
const pb = precedingBlobs[i];
|
|
1394
1416
|
const netH = installNetworkInterceptor("replay", pb.capturedData.networkCalls, { silent: true });
|
|
1395
1417
|
const envH = installEnvironmentInterceptor("replay", pb.capturedData.envReads);
|
|
1396
1418
|
let warmupError;
|
|
1419
|
+
const itemStartNs = process.hrtime.bigint();
|
|
1397
1420
|
try {
|
|
1398
1421
|
if (isV2) {
|
|
1399
1422
|
const url = pb.requestInfo.rawUrl ?? `https://localhost${pb.requestInfo.url}`;
|
|
@@ -1425,20 +1448,23 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo, p
|
|
|
1425
1448
|
} catch (err) {
|
|
1426
1449
|
warmupError = err instanceof Error ? err.message : String(err);
|
|
1427
1450
|
}
|
|
1451
|
+
const itemMs = Math.round(Number(process.hrtime.bigint() - itemStartNs) / 1e6);
|
|
1428
1452
|
const consumed = netH.consumedCount();
|
|
1429
1453
|
const total = netH.totalCount();
|
|
1430
1454
|
netH.restore();
|
|
1431
1455
|
envH.restore();
|
|
1432
1456
|
if (warmupError) {
|
|
1433
|
-
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)`);
|
|
1434
1458
|
} else {
|
|
1435
|
-
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)`);
|
|
1436
1460
|
}
|
|
1437
1461
|
}
|
|
1438
|
-
|
|
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)`);
|
|
1439
1464
|
}
|
|
1440
1465
|
try {
|
|
1441
1466
|
let rawResult;
|
|
1467
|
+
const targetStartNs = process.hrtime.bigint();
|
|
1442
1468
|
try {
|
|
1443
1469
|
if (isV2) {
|
|
1444
1470
|
const url = requestInfo.rawUrl ?? `https://localhost${requestInfo.url}`;
|
|
@@ -1490,6 +1516,8 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo, p
|
|
|
1490
1516
|
body: JSON.stringify({ error: errorMessage })
|
|
1491
1517
|
};
|
|
1492
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)).`);
|
|
1493
1521
|
if (blobData.handlerResponse) {
|
|
1494
1522
|
const replayRes = rawResult;
|
|
1495
1523
|
const replayResponse = {
|