@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.
Files changed (2) hide show
  1. package/dist/index.js +107 -74
  2. 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 replayHttpRequest(url, _method, calls, consumed, callback, silent) {
176
- let matchIdx = -1;
177
- for (let i = 0; i < calls.length; i++) {
178
- if (consumed.has(i)) continue;
179
- const c = calls[i];
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
- if (matchIdx === -1) {
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
- if (!consumed.has(i)) {
188
- matchIdx = i;
189
- break;
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
- let matchIdx = -1;
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
- calls.push({
564
- url,
565
- method,
566
- requestHeaders,
567
- requestBody,
568
- responseStatus: response.status,
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.59.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
- console.log(` [warm-up] Done.`);
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 = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.59.0",
3
+ "version": "0.61.0",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {