@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.
Files changed (2) hide show
  1. package/dist/index.js +102 -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,
@@ -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.60.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
- 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)`);
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 = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.60.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": {