@replayio-app-building/netlify-recorder 0.33.0 → 0.34.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 CHANGED
@@ -198,6 +198,10 @@ interface CreateRecordingRequestHandlerOptions extends FinishRequestOptions {
198
198
  * after, capturing all outbound network calls and environment variable reads.
199
199
  * The captured data is stored via the provided callbacks.
200
200
  *
201
+ * Each request runs inside its own AsyncLocalStorage context so that
202
+ * concurrent requests in local dev (Netlify Dev) do not bleed into each
203
+ * other's captured network calls, environment reads, or audit trail tags.
204
+ *
201
205
  * **Response timing:** When the Netlify Functions v2 `context` object is
202
206
  * available (with `waitUntil`), the response is returned to the client
203
207
  * **immediately** with a pre-generated `X-Replay-Request-Id` header. The
@@ -318,6 +322,7 @@ declare function databaseAuditMonitorTable(sql: SqlFunction, tableName: string,
318
322
  */
319
323
  declare function databaseAuditDumpLogTable(sql: SqlFunction): Promise<Record<string, unknown>[]>;
320
324
 
325
+ declare function runInRequestContext<T>(requestId: string | null, fn: () => Promise<T>): Promise<T>;
321
326
  declare function getCurrentRequestId(): string | null;
322
327
 
323
- export { type BackendRequest, type BlobData, type CapturedData, type CreateRecordingRequestHandlerOptions, type EnsureRequestRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, backendRequestsEnsureTable, backendRequestsGet, backendRequestsGetBlobUrl, backendRequestsInsert, backendRequestsList, backendRequestsUpdateStatus, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, databaseCallbacks, ensureRequestRecording, finishRequest, getCurrentRequestId, redactBlobData, startRequest };
328
+ export { type BackendRequest, type BlobData, type CapturedData, type CreateRecordingRequestHandlerOptions, type EnsureRequestRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, backendRequestsEnsureTable, backendRequestsGet, backendRequestsGetBlobUrl, backendRequestsInsert, backendRequestsList, backendRequestsUpdateStatus, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, databaseCallbacks, ensureRequestRecording, finishRequest, getCurrentRequestId, redactBlobData, runInRequestContext, startRequest };
package/dist/index.js CHANGED
@@ -6,17 +6,29 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
6
6
  });
7
7
 
8
8
  // src/requestState.ts
9
- var _currentRequestId = null;
10
- var _callIndex = 0;
11
- function setCurrentRequestId(id) {
12
- _currentRequestId = id;
13
- _callIndex = 0;
9
+ import { AsyncLocalStorage } from "async_hooks";
10
+ var als = new AsyncLocalStorage();
11
+ var _fallbackRequestId = null;
12
+ var _fallbackCallIndex = 0;
13
+ function runInRequestContext(requestId, fn) {
14
+ return als.run(
15
+ { requestId, callIndex: 0, networkCalls: null, envReads: null },
16
+ fn
17
+ );
18
+ }
19
+ function getRequestStore() {
20
+ return als.getStore();
14
21
  }
15
22
  function getCurrentRequestId() {
16
- return _currentRequestId;
23
+ const store = als.getStore();
24
+ return store ? store.requestId : _fallbackRequestId;
17
25
  }
18
26
  function incrementCallIndex() {
19
- return ++_callIndex;
27
+ const store = als.getStore();
28
+ if (store) {
29
+ return ++store.callIndex;
30
+ }
31
+ return ++_fallbackCallIndex;
20
32
  }
21
33
 
22
34
  // src/interceptors/network.ts
@@ -40,10 +52,84 @@ function buildSetConfigQueries(requestId, callIndex) {
40
52
  { query: "SELECT set_config('app.replay_call_index', $1, true)", params: [String(callIndex)] }
41
53
  ];
42
54
  }
55
+ var _realOriginalFetch = null;
56
+ var _captureInterceptorInstalled = false;
57
+ function ensureCaptureInterceptor() {
58
+ if (_captureInterceptorInstalled) return;
59
+ _captureInterceptorInstalled = true;
60
+ _realOriginalFetch = globalThis.fetch;
61
+ const captureFetch = async (input, init) => {
62
+ const store = getRequestStore();
63
+ const calls = store?.networkCalls;
64
+ if (!calls) {
65
+ return _realOriginalFetch(input, init);
66
+ }
67
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
68
+ const method = init?.method ?? (input instanceof Request ? input.method : "GET");
69
+ const requestHeaders = {};
70
+ if (init?.headers) {
71
+ new Headers(init.headers).forEach((v, k) => {
72
+ requestHeaders[k] = v;
73
+ });
74
+ }
75
+ const requestBody = typeof init?.body === "string" ? init.body : void 0;
76
+ const requestId = store.requestId;
77
+ if (requestId && method.toUpperCase() === "POST" && isNeonSqlRequest(url, requestBody)) {
78
+ return await handleNeonSqlRequest(
79
+ _realOriginalFetch,
80
+ input,
81
+ init,
82
+ url,
83
+ method,
84
+ requestHeaders,
85
+ requestBody,
86
+ requestId,
87
+ calls
88
+ );
89
+ }
90
+ const response = await _realOriginalFetch(input, init);
91
+ const responseBody = await response.clone().text();
92
+ const responseHeaders = {};
93
+ response.headers.forEach((v, k) => {
94
+ responseHeaders[k] = v;
95
+ });
96
+ calls.push({
97
+ url,
98
+ method,
99
+ requestHeaders,
100
+ requestBody,
101
+ responseStatus: response.status,
102
+ responseHeaders,
103
+ responseBody,
104
+ timestamp: Date.now()
105
+ });
106
+ return response;
107
+ };
108
+ globalThis.fetch = captureFetch;
109
+ }
43
110
  function installNetworkInterceptor(mode, calls) {
44
- const originalFetch = globalThis.fetch;
45
- const consumed = /* @__PURE__ */ new Set();
46
111
  if (mode === "capture") {
112
+ const store = getRequestStore();
113
+ if (store) {
114
+ ensureCaptureInterceptor();
115
+ store.networkCalls = calls;
116
+ return {
117
+ restore() {
118
+ const s = getRequestStore();
119
+ if (s) s.networkCalls = null;
120
+ },
121
+ consumedCount() {
122
+ return 0;
123
+ },
124
+ totalCount() {
125
+ return calls.length;
126
+ },
127
+ unconsumedIndices() {
128
+ return [];
129
+ }
130
+ };
131
+ }
132
+ const originalFetch2 = globalThis.fetch;
47
133
  const captureFetch = async (input, init) => {
48
134
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
49
135
  const method = init?.method ?? (input instanceof Request ? input.method : "GET");
@@ -57,7 +143,7 @@ function installNetworkInterceptor(mode, calls) {
57
143
  const requestId = getCurrentRequestId();
58
144
  if (requestId && method.toUpperCase() === "POST" && isNeonSqlRequest(url, requestBody)) {
59
145
  return await handleNeonSqlRequest(
60
- originalFetch,
146
+ originalFetch2,
61
147
  input,
62
148
  init,
63
149
  url,
@@ -68,7 +154,7 @@ function installNetworkInterceptor(mode, calls) {
68
154
  calls
69
155
  );
70
156
  }
71
- const response = await originalFetch(input, init);
157
+ const response = await originalFetch2(input, init);
72
158
  const responseBody = await response.clone().text();
73
159
  const responseHeaders = {};
74
160
  response.headers.forEach((v, k) => {
@@ -87,69 +173,84 @@ function installNetworkInterceptor(mode, calls) {
87
173
  return response;
88
174
  };
89
175
  globalThis.fetch = captureFetch;
90
- } else {
91
- const replayFetch = async (input, init) => {
92
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : typeof input === "object" && input !== null && "url" in input ? input.url : String(input);
93
- const requestBody = typeof init?.body === "string" ? init.body : void 0;
94
- let matchIdx = -1;
176
+ return {
177
+ restore() {
178
+ globalThis.fetch = originalFetch2;
179
+ },
180
+ consumedCount() {
181
+ return 0;
182
+ },
183
+ totalCount() {
184
+ return calls.length;
185
+ },
186
+ unconsumedIndices() {
187
+ return [];
188
+ }
189
+ };
190
+ }
191
+ const originalFetch = globalThis.fetch;
192
+ const consumed = /* @__PURE__ */ new Set();
193
+ const replayFetch = async (input, init) => {
194
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : typeof input === "object" && input !== null && "url" in input ? input.url : String(input);
195
+ const requestBody = typeof init?.body === "string" ? init.body : void 0;
196
+ let matchIdx = -1;
197
+ for (let i = 0; i < calls.length; i++) {
198
+ if (consumed.has(i)) continue;
199
+ const c = calls[i];
200
+ if (c && c.url === url && c.requestBody === requestBody) {
201
+ matchIdx = i;
202
+ break;
203
+ }
204
+ }
205
+ if (matchIdx === -1) {
95
206
  for (let i = 0; i < calls.length; i++) {
96
- if (consumed.has(i)) continue;
97
- const c = calls[i];
98
- if (c && c.url === url && c.requestBody === requestBody) {
207
+ if (!consumed.has(i)) {
99
208
  matchIdx = i;
100
209
  break;
101
210
  }
102
211
  }
103
- if (matchIdx === -1) {
104
- for (let i = 0; i < calls.length; i++) {
105
- if (!consumed.has(i)) {
106
- matchIdx = i;
107
- break;
108
- }
109
- }
110
- }
111
- const call = calls[matchIdx];
112
- if (matchIdx === -1 || !call) {
113
- throw new Error(
114
- `No more recorded network calls to replay (exhausted ${calls.length} calls)`
115
- );
116
- }
117
- consumed.add(matchIdx);
118
- console.log(
119
- ` [network-replay] Consumed call ${consumed.size}/${calls.length}: ${call.method} ${call.url} => ${call.responseStatus}`
212
+ }
213
+ const call = calls[matchIdx];
214
+ if (matchIdx === -1 || !call) {
215
+ throw new Error(
216
+ `No more recorded network calls to replay (exhausted ${calls.length} calls)`
120
217
  );
121
- const body = call.responseBody ?? "";
122
- const status = call.responseStatus;
123
- return {
124
- ok: status >= 200 && status < 300,
125
- status,
126
- statusText: "",
127
- headers: {
128
- get: (name) => (call.responseHeaders ?? {})[name.toLowerCase()] ?? null,
129
- has: (name) => name.toLowerCase() in (call.responseHeaders ?? {}),
130
- forEach: (cb) => {
131
- for (const [k, v] of Object.entries(call.responseHeaders ?? {})) cb(v, k);
132
- }
133
- },
134
- text: async () => body,
135
- json: async () => JSON.parse(body),
136
- clone: () => ({ text: async () => body, json: async () => JSON.parse(body) }),
137
- body: null,
138
- bodyUsed: false,
139
- redirected: false,
140
- type: "basic",
141
- url: call.url,
142
- arrayBuffer: async () => new ArrayBuffer(0),
143
- blob: async () => {
144
- throw new Error("blob() not supported in replay");
145
- },
146
- formData: async () => {
147
- throw new Error("formData() not supported in replay");
218
+ }
219
+ consumed.add(matchIdx);
220
+ console.log(
221
+ ` [network-replay] Consumed call ${consumed.size}/${calls.length}: ${call.method} ${call.url} => ${call.responseStatus}`
222
+ );
223
+ const body = call.responseBody ?? "";
224
+ const status = call.responseStatus;
225
+ return {
226
+ ok: status >= 200 && status < 300,
227
+ status,
228
+ statusText: "",
229
+ headers: {
230
+ get: (name) => (call.responseHeaders ?? {})[name.toLowerCase()] ?? null,
231
+ has: (name) => name.toLowerCase() in (call.responseHeaders ?? {}),
232
+ forEach: (cb) => {
233
+ for (const [k, v] of Object.entries(call.responseHeaders ?? {})) cb(v, k);
148
234
  }
149
- };
235
+ },
236
+ text: async () => body,
237
+ json: async () => JSON.parse(body),
238
+ clone: () => ({ text: async () => body, json: async () => JSON.parse(body) }),
239
+ body: null,
240
+ bodyUsed: false,
241
+ redirected: false,
242
+ type: "basic",
243
+ url: call.url,
244
+ arrayBuffer: async () => new ArrayBuffer(0),
245
+ blob: async () => {
246
+ throw new Error("blob() not supported in replay");
247
+ },
248
+ formData: async () => {
249
+ throw new Error("formData() not supported in replay");
250
+ }
150
251
  };
151
- globalThis.fetch = replayFetch;
152
- }
252
+ };
253
+ globalThis.fetch = replayFetch;
153
254
  return {
154
255
  restore() {
155
256
  globalThis.fetch = originalFetch;
@@ -215,9 +316,59 @@ async function handleNeonSqlRequest(originalFetch, input, init, url, method, req
215
316
  }
216
317
 
217
318
  // src/interceptors/environment.ts
319
+ var _realOriginalEnv = null;
320
+ var _captureProxyInstalled = false;
321
+ function ensureCaptureProxy() {
322
+ if (_captureProxyInstalled) return;
323
+ _captureProxyInstalled = true;
324
+ _realOriginalEnv = process.env;
325
+ process.env = new Proxy(_realOriginalEnv, {
326
+ get(target, prop) {
327
+ const value = target[prop];
328
+ if (typeof prop === "string" && prop !== "toJSON") {
329
+ const store = getRequestStore();
330
+ const reads = store?.envReads;
331
+ if (reads) {
332
+ reads.push({ key: prop, value, timestamp: Date.now() });
333
+ }
334
+ }
335
+ return value;
336
+ },
337
+ set(target, prop, value) {
338
+ target[prop] = value;
339
+ return true;
340
+ },
341
+ defineProperty(target, prop, descriptor) {
342
+ if ("value" in descriptor) {
343
+ target[prop] = descriptor.value;
344
+ return true;
345
+ }
346
+ return Reflect.defineProperty(target, prop, descriptor);
347
+ },
348
+ deleteProperty(target, prop) {
349
+ delete target[prop];
350
+ return true;
351
+ }
352
+ });
353
+ }
218
354
  function installEnvironmentInterceptor(mode, reads) {
219
355
  const originalEnv = process.env;
220
356
  if (mode === "capture") {
357
+ const store = getRequestStore();
358
+ if (store) {
359
+ ensureCaptureProxy();
360
+ const now2 = Date.now();
361
+ for (const key of Object.keys(_realOriginalEnv)) {
362
+ reads.push({ key, value: _realOriginalEnv[key], timestamp: now2 });
363
+ }
364
+ store.envReads = reads;
365
+ return {
366
+ restore() {
367
+ const s = getRequestStore();
368
+ if (s) s.envReads = null;
369
+ }
370
+ };
371
+ }
221
372
  const now = Date.now();
222
373
  for (const key of Object.keys(originalEnv)) {
223
374
  reads.push({ key, value: originalEnv[key], timestamp: now });
@@ -246,35 +397,39 @@ function installEnvironmentInterceptor(mode, reads) {
246
397
  return true;
247
398
  }
248
399
  });
249
- } else {
250
- const readMap = /* @__PURE__ */ new Map();
251
- for (const read of reads) {
252
- readMap.set(read.key, read.value);
253
- }
254
- process.env = new Proxy(originalEnv, {
255
- get(target, prop) {
256
- if (typeof prop === "string" && readMap.has(prop)) {
257
- return readMap.get(prop);
258
- }
259
- return target[prop];
260
- },
261
- set(target, prop, value) {
262
- target[prop] = value;
263
- return true;
264
- },
265
- defineProperty(target, prop, descriptor) {
266
- if ("value" in descriptor) {
267
- target[prop] = descriptor.value;
268
- return true;
269
- }
270
- return Reflect.defineProperty(target, prop, descriptor);
271
- },
272
- deleteProperty(target, prop) {
273
- delete target[prop];
274
- return true;
400
+ return {
401
+ restore() {
402
+ process.env = originalEnv;
275
403
  }
276
- });
404
+ };
405
+ }
406
+ const readMap = /* @__PURE__ */ new Map();
407
+ for (const read of reads) {
408
+ readMap.set(read.key, read.value);
277
409
  }
410
+ process.env = new Proxy(originalEnv, {
411
+ get(target, prop) {
412
+ if (typeof prop === "string" && readMap.has(prop)) {
413
+ return readMap.get(prop);
414
+ }
415
+ return target[prop];
416
+ },
417
+ set(target, prop, value) {
418
+ target[prop] = value;
419
+ return true;
420
+ },
421
+ defineProperty(target, prop, descriptor) {
422
+ if ("value" in descriptor) {
423
+ target[prop] = descriptor.value;
424
+ return true;
425
+ }
426
+ return Reflect.defineProperty(target, prop, descriptor);
427
+ },
428
+ deleteProperty(target, prop) {
429
+ delete target[prop];
430
+ return true;
431
+ }
432
+ });
278
433
  return {
279
434
  restore() {
280
435
  process.env = originalEnv;
@@ -597,82 +752,81 @@ import crypto2 from "crypto";
597
752
  function createRecordingRequestHandler(handler, options) {
598
753
  return async (event, context) => {
599
754
  const requestId = crypto2.randomUUID();
600
- setCurrentRequestId(requestId);
601
- const reqContext = startRequest(event);
602
- let response;
603
- try {
604
- response = await handler(event, context);
605
- } catch (handlerErr) {
606
- const errorMessage = handlerErr instanceof Error ? handlerErr.message : String(handlerErr);
607
- const errorResponse = {
608
- statusCode: 500,
609
- body: JSON.stringify({ error: errorMessage })
610
- };
611
- const finishOpts2 = { ...options, requestId };
612
- const ctx2 = context;
613
- if (ctx2 && typeof ctx2.waitUntil === "function") {
614
- ctx2.waitUntil(
615
- finishRequest(reqContext, options.callbacks, errorResponse, finishOpts2).catch(
755
+ return runInRequestContext(requestId, async () => {
756
+ const reqContext = startRequest(event);
757
+ let response;
758
+ try {
759
+ response = await handler(event, context);
760
+ } catch (handlerErr) {
761
+ const errorMessage = handlerErr instanceof Error ? handlerErr.message : String(handlerErr);
762
+ const errorResponse = {
763
+ statusCode: 500,
764
+ body: JSON.stringify({ error: errorMessage })
765
+ };
766
+ const finishOpts2 = { ...options, requestId };
767
+ const ctx2 = context;
768
+ if (ctx2 && typeof ctx2.waitUntil === "function") {
769
+ ctx2.waitUntil(
770
+ finishRequest(reqContext, options.callbacks, errorResponse, finishOpts2).catch(
771
+ (finishErr) => {
772
+ console.error(
773
+ `netlify-recorder: background finishRequest failed after handler error (handler: ${options.handlerPath ?? "unknown"})`,
774
+ finishErr
775
+ );
776
+ }
777
+ )
778
+ );
779
+ } else {
780
+ await finishRequest(reqContext, options.callbacks, errorResponse, finishOpts2).catch(
616
781
  (finishErr) => {
617
782
  console.error(
618
- `netlify-recorder: background finishRequest failed after handler error (handler: ${options.handlerPath ?? "unknown"})`,
783
+ `netlify-recorder: finishRequest failed after handler error (handler: ${options.handlerPath ?? "unknown"})`,
619
784
  finishErr
620
785
  );
621
786
  }
622
- )
623
- );
624
- } else {
625
- await finishRequest(reqContext, options.callbacks, errorResponse, finishOpts2).catch(
626
- (finishErr) => {
627
- console.error(
628
- `netlify-recorder: finishRequest failed after handler error (handler: ${options.handlerPath ?? "unknown"})`,
629
- finishErr
630
- );
787
+ );
788
+ }
789
+ return {
790
+ ...errorResponse,
791
+ headers: {
792
+ ...errorResponse.headers,
793
+ "X-Replay-Request-Id": requestId
631
794
  }
632
- );
795
+ };
633
796
  }
634
- setCurrentRequestId(null);
635
- return {
636
- ...errorResponse,
797
+ reqContext.cleanup();
798
+ const responseWithHeader = {
799
+ ...response,
637
800
  headers: {
638
- ...errorResponse.headers,
801
+ ...response.headers,
639
802
  "X-Replay-Request-Id": requestId
640
803
  }
641
804
  };
642
- }
643
- reqContext.cleanup();
644
- setCurrentRequestId(null);
645
- const responseWithHeader = {
646
- ...response,
647
- headers: {
648
- ...response.headers,
649
- "X-Replay-Request-Id": requestId
805
+ const finishOpts = { ...options, requestId };
806
+ const ctx = context;
807
+ if (ctx && typeof ctx.waitUntil === "function") {
808
+ ctx.waitUntil(
809
+ finishRequest(reqContext, options.callbacks, response, finishOpts).catch(
810
+ (err) => {
811
+ console.error(
812
+ `netlify-recorder: background finishRequest failed (handler: ${options.handlerPath ?? "unknown"})`,
813
+ err
814
+ );
815
+ }
816
+ )
817
+ );
818
+ return responseWithHeader;
819
+ }
820
+ try {
821
+ await finishRequest(reqContext, options.callbacks, response, finishOpts);
822
+ } catch (err) {
823
+ console.error(
824
+ `netlify-recorder: finishRequest failed (handler: ${options.handlerPath ?? "unknown"})`,
825
+ err
826
+ );
650
827
  }
651
- };
652
- const finishOpts = { ...options, requestId };
653
- const ctx = context;
654
- if (ctx && typeof ctx.waitUntil === "function") {
655
- ctx.waitUntil(
656
- finishRequest(reqContext, options.callbacks, response, finishOpts).catch(
657
- (err) => {
658
- console.error(
659
- `netlify-recorder: background finishRequest failed (handler: ${options.handlerPath ?? "unknown"})`,
660
- err
661
- );
662
- }
663
- )
664
- );
665
828
  return responseWithHeader;
666
- }
667
- try {
668
- await finishRequest(reqContext, options.callbacks, response, finishOpts);
669
- } catch (err) {
670
- console.error(
671
- `netlify-recorder: finishRequest failed (handler: ${options.handlerPath ?? "unknown"})`,
672
- err
673
- );
674
- }
675
- return responseWithHeader;
829
+ });
676
830
  };
677
831
  }
678
832
 
@@ -1268,5 +1422,6 @@ export {
1268
1422
  finishRequest,
1269
1423
  getCurrentRequestId,
1270
1424
  redactBlobData,
1425
+ runInRequestContext,
1271
1426
  startRequest
1272
1427
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {