@replayio-app-building/netlify-recorder 0.33.0 → 0.35.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/README.md CHANGED
@@ -113,7 +113,63 @@ export default createRecordingRequestHandler(
113
113
 
114
114
  > **Note:** Always use the response returned by the wrapper (or `finishRequest`), not your original response object. The wrapper adds the `X-Replay-Request-Id` header to the response it returns.
115
115
 
116
- ### 4. Create recordings via the Netlify Recorder service
116
+ ### 4. Expose a recording endpoint for other services
117
+
118
+ Use `createRecordingEndpoint` to create a standalone Netlify function that other services can call to trigger recording creation or check recording status. This is the simplest way to let external services interact with the `backend_requests` table without importing the full package.
119
+
120
+ ```typescript
121
+ // netlify/functions/ensure-recording.ts
122
+ import { createRecordingEndpoint } from "@replayio-app-building/netlify-recorder";
123
+ import { neon } from "@neondatabase/serverless";
124
+
125
+ const sql = neon(process.env.DATABASE_URL!);
126
+
127
+ export default createRecordingEndpoint({
128
+ sql,
129
+ recorderUrl: "https://netlify-recorder-bm4wmw.netlify.app",
130
+ // Optional: require callers to authenticate with a shared secret
131
+ secret: process.env.RECORDER_ENDPOINT_SECRET,
132
+ // Optional: receive a webhook when the recording completes
133
+ webhookUrl: "https://my-app.netlify.app/.netlify/functions/recording-webhook",
134
+ });
135
+ ```
136
+
137
+ **Calling the endpoint from another service:**
138
+
139
+ ```typescript
140
+ // Trigger recording creation (POST)
141
+ const res = await fetch("https://my-app.netlify.app/.netlify/functions/ensure-recording", {
142
+ method: "POST",
143
+ headers: {
144
+ "Content-Type": "application/json",
145
+ // Include if `secret` is configured:
146
+ "Authorization": "Bearer my-shared-secret",
147
+ },
148
+ body: JSON.stringify({ requestId: "a1b2c3d4-..." }),
149
+ });
150
+ const result = await res.json();
151
+ // { status: "queued", requestId: "a1b2c3d4-..." }
152
+ // or { status: "recorded", recordingId: "...", requestId: "..." }
153
+ // or { status: "pending", requestId: "..." }
154
+
155
+ // Check status without triggering (GET)
156
+ const status = await fetch(
157
+ "https://my-app.netlify.app/.netlify/functions/ensure-recording?requestId=a1b2c3d4-...",
158
+ { headers: { "Authorization": "Bearer my-shared-secret" } },
159
+ );
160
+ ```
161
+
162
+ **Response statuses:**
163
+
164
+ | Status | HTTP Code | Meaning |
165
+ |--------|-----------|---------|
166
+ | `recorded` | 200 | Recording exists — `recordingId` is included |
167
+ | `pending` | 200 | Recording is queued or processing — check back later |
168
+ | `queued` | 202 | Recording was just queued by this POST call |
169
+ | `not_found` | 404 | Request ID not found in `backend_requests` |
170
+ | `error` | 4xx/5xx | Validation error, auth failure, or recording failure |
171
+
172
+ ### 5. Create recordings programmatically
117
173
 
118
174
  Use `ensureRequestRecording` to turn a captured request into a Replay recording. It checks the `backend_requests` table first — if a recording already exists, it returns the recording ID immediately without calling the service. Otherwise it passes the stored blob data URL to the Netlify Recorder service and updates the row status to `"queued"`.
119
175
 
@@ -150,7 +206,7 @@ On failure:
150
206
  { "status": "failed", "error": "Error message" }
151
207
  ```
152
208
 
153
- ### 5. Manage stored requests
209
+ ### 6. Manage stored requests
154
210
 
155
211
  Use the `backendRequests*` helpers to query and manage captured requests in your database:
156
212
 
@@ -410,6 +466,30 @@ Ensures a Replay recording exists (or is being created) for a backend request. L
410
466
 
411
467
  **Throws:** If the request ID is not found in `backend_requests`, or if the service call fails.
412
468
 
469
+ ### `createRecordingEndpoint(options): (req: Request) => Promise<Response>`
470
+
471
+ Creates a Netlify Function v2 handler that other services can call to trigger recording creation or check recording status for entries in the `backend_requests` table.
472
+
473
+ Supports **POST** (trigger recording) and **GET** (check status). When `secret` is provided, all requests must include an `Authorization: Bearer <secret>` header.
474
+
475
+ **Parameters:**
476
+ - `options.sql` — A Neon SQL tagged-template function
477
+ - `options.recorderUrl` — Base URL of the Netlify Recorder service
478
+ - `options.secret` — Shared secret for authentication (optional — when omitted, the endpoint is open)
479
+ - `options.webhookUrl` — URL to POST the recording result to when complete (optional)
480
+
481
+ **Returns:** An async function `(req: Request) => Promise<Response>` suitable as a Netlify Functions v2 default export.
482
+
483
+ **POST body:** `{ "requestId": "<uuid>" }` — triggers recording if needed.
484
+
485
+ **GET query:** `?requestId=<uuid>` — returns current status without triggering.
486
+
487
+ **Response body** (`RecordingEndpointResponse`):
488
+ - `status` — `"recorded"`, `"pending"`, `"queued"`, `"not_found"`, or `"error"`
489
+ - `recordingId` — Present when `status` is `"recorded"`
490
+ - `requestId` — The request ID echoed back
491
+ - `error` — Error message when `status` is `"not_found"` or `"error"`
492
+
413
493
  ### `createRequestRecording(blobUrlOrData, handlerPath, requestInfo): Promise<RecordingResult>`
414
494
 
415
495
  Called inside a recording container running under `replay-node`. Downloads the captured data blob (or accepts pre-parsed `BlobData`), installs replay-mode interceptors that return pre-recorded responses instead of making real calls, and executes the original handler so `replay-node` can record the execution.
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
@@ -231,7 +235,7 @@ interface RecordingResult {
231
235
  */
232
236
  declare function createRequestRecording(blobUrlOrData: string | BlobData, handlerPath: string, requestInfo: RequestInfo): Promise<RecordingResult>;
233
237
 
234
- type SqlFunction$1 = (...args: any[]) => Promise<any[]>;
238
+ type SqlFunction$2 = (...args: any[]) => Promise<any[]>;
235
239
  interface BackendRequest {
236
240
  id: string;
237
241
  blob_data_url: string;
@@ -252,8 +256,8 @@ interface BackendRequest {
252
256
  * using this table. The blob data (captured network calls, env reads, etc.)
253
257
  * is uploaded to UploadThing at capture time and only the URL is stored.
254
258
  */
255
- declare function backendRequestsEnsureTable(sql: SqlFunction$1): Promise<void>;
256
- declare function backendRequestsInsert(sql: SqlFunction$1, data: {
259
+ declare function backendRequestsEnsureTable(sql: SqlFunction$2): Promise<void>;
260
+ declare function backendRequestsInsert(sql: SqlFunction$2, data: {
257
261
  id?: string;
258
262
  blobDataUrl: string;
259
263
  handlerPath: string;
@@ -261,13 +265,13 @@ declare function backendRequestsInsert(sql: SqlFunction$1, data: {
261
265
  branchName: string;
262
266
  repositoryUrl?: string | null;
263
267
  }): Promise<string>;
264
- declare function backendRequestsGet(sql: SqlFunction$1, id: string): Promise<BackendRequest | null>;
265
- declare function backendRequestsGetBlobUrl(sql: SqlFunction$1, id: string): Promise<string | null>;
266
- declare function backendRequestsList(sql: SqlFunction$1, filters?: {
268
+ declare function backendRequestsGet(sql: SqlFunction$2, id: string): Promise<BackendRequest | null>;
269
+ declare function backendRequestsGetBlobUrl(sql: SqlFunction$2, id: string): Promise<string | null>;
270
+ declare function backendRequestsList(sql: SqlFunction$2, filters?: {
267
271
  status?: string;
268
272
  limit?: number;
269
273
  }): Promise<BackendRequest[]>;
270
- declare function backendRequestsUpdateStatus(sql: SqlFunction$1, id: string, status: string, recordingId?: string, errorMessage?: string): Promise<void>;
274
+ declare function backendRequestsUpdateStatus(sql: SqlFunction$2, id: string, status: string, recordingId?: string, errorMessage?: string): Promise<void>;
271
275
  /**
272
276
  * Convenience helper: creates `FinishRequestCallbacks` that upload
273
277
  * captured request data to UploadThing and store the URL in the
@@ -275,7 +279,15 @@ declare function backendRequestsUpdateStatus(sql: SqlFunction$1, id: string, sta
275
279
  *
276
280
  * Requires `UPLOADTHING_TOKEN` environment variable and the `uploadthing` package.
277
281
  */
278
- declare function databaseCallbacks(sql: SqlFunction$1): FinishRequestCallbacks;
282
+ declare function databaseCallbacks(sql: SqlFunction$2): FinishRequestCallbacks;
283
+ /**
284
+ * Creates `FinishRequestCallbacks` that POST captured request data to a
285
+ * remote Netlify Recorder service's `/api/store-request` endpoint.
286
+ *
287
+ * Use this instead of `databaseCallbacks` when the app does not own the
288
+ * recorder database — the hosted service handles blob upload and storage.
289
+ */
290
+ declare function remoteCallbacks(recorderUrl: string): FinishRequestCallbacks;
279
291
  interface EnsureRequestRecordingOptions {
280
292
  /** Base URL of the Netlify Recorder service (e.g. "https://netlify-recorder-bm4wmw.netlify.app"). */
281
293
  recorderUrl: string;
@@ -295,9 +307,9 @@ interface EnsureRequestRecordingOptions {
295
307
  * This function is idempotent — calling it multiple times for the same request
296
308
  * is safe. Once the recording completes, subsequent calls return the recording ID.
297
309
  */
298
- declare function ensureRequestRecording(sql: SqlFunction$1, requestId: string, options: EnsureRequestRecordingOptions): Promise<string | null>;
310
+ declare function ensureRequestRecording(sql: SqlFunction$2, requestId: string, options: EnsureRequestRecordingOptions): Promise<string | null>;
299
311
 
300
- type SqlFunction = (...args: any[]) => Promise<any[]>;
312
+ type SqlFunction$1 = (...args: any[]) => Promise<any[]>;
301
313
  /**
302
314
  * Creates the `audit_log` table and a generic PL/pgSQL trigger function
303
315
  * (`audit_trigger_function`) that records INSERT, UPDATE, and DELETE
@@ -305,19 +317,56 @@ type SqlFunction = (...args: any[]) => Promise<any[]>;
305
317
  *
306
318
  * Call this once during schema initialization.
307
319
  */
308
- declare function databaseAuditEnsureLogTable(sql: SqlFunction): Promise<void>;
320
+ declare function databaseAuditEnsureLogTable(sql: SqlFunction$1): Promise<void>;
309
321
  /**
310
322
  * Creates a trigger on the specified table that calls
311
323
  * `audit_trigger_function` for INSERT, UPDATE, and DELETE operations.
312
324
  *
313
325
  * Throws if `tableName` is `'audit_log'` (cannot monitor itself).
314
326
  */
315
- declare function databaseAuditMonitorTable(sql: SqlFunction, tableName: string, primaryKeyColumn?: string): Promise<void>;
327
+ declare function databaseAuditMonitorTable(sql: SqlFunction$1, tableName: string, primaryKeyColumn?: string): Promise<void>;
316
328
  /**
317
329
  * Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
318
330
  */
319
- declare function databaseAuditDumpLogTable(sql: SqlFunction): Promise<Record<string, unknown>[]>;
331
+ declare function databaseAuditDumpLogTable(sql: SqlFunction$1): Promise<Record<string, unknown>[]>;
332
+
333
+ type SqlFunction = (...args: any[]) => Promise<any[]>;
334
+ interface CreateRecordingEndpointOptions {
335
+ sql: SqlFunction;
336
+ recorderUrl: string;
337
+ secret?: string;
338
+ webhookUrl?: string;
339
+ }
340
+ interface RecordingEndpointResponse {
341
+ status: "recorded" | "pending" | "queued" | "not_found" | "error";
342
+ recordingId?: string;
343
+ requestId?: string;
344
+ error?: string;
345
+ }
346
+ /**
347
+ * Creates a Netlify Function handler that other services can call to trigger
348
+ * recording creation for a backend request (or retrieve its current status).
349
+ *
350
+ * **POST** with `{ "requestId": "<uuid>" }` — triggers recording creation if
351
+ * needed and returns the current status. Idempotent: re-posting the same
352
+ * request ID will not re-queue an already-queued or recorded request.
353
+ *
354
+ * **GET** with `?requestId=<uuid>` — returns the current recording status
355
+ * without triggering any recording.
356
+ *
357
+ * When `secret` is provided, every request must include an
358
+ * `Authorization: Bearer <secret>` header or receive a 401 response.
359
+ *
360
+ * Response body shape (`RecordingEndpointResponse`):
361
+ * - `{ status: "recorded", recordingId, requestId }` — recording exists
362
+ * - `{ status: "pending", requestId }` — recording is queued or processing
363
+ * - `{ status: "queued", requestId }` — recording was just queued by this call
364
+ * - `{ status: "not_found", error }` — request ID not in backend_requests
365
+ * - `{ status: "error", requestId?, error }` — recording failed or server error
366
+ */
367
+ declare function createRecordingEndpoint(options: CreateRecordingEndpointOptions): (req: Request) => Promise<Response>;
320
368
 
369
+ declare function runInRequestContext<T>(requestId: string | null, fn: () => Promise<T>): Promise<T>;
321
370
  declare function getCurrentRequestId(): string | null;
322
371
 
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 };
372
+ export { type BackendRequest, type BlobData, type CapturedData, type CreateRecordingEndpointOptions, type CreateRecordingRequestHandlerOptions, type EnsureRequestRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingEndpointResponse, type RecordingResult, type RequestContext, type RequestInfo, backendRequestsEnsureTable, backendRequestsGet, backendRequestsGetBlobUrl, backendRequestsInsert, backendRequestsList, backendRequestsUpdateStatus, createRecordingEndpoint, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, databaseCallbacks, ensureRequestRecording, finishRequest, getCurrentRequestId, redactBlobData, remoteCallbacks, 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
+ };
277
405
  }
406
+ const readMap = /* @__PURE__ */ new Map();
407
+ for (const read of reads) {
408
+ readMap.set(read.key, read.value);
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
 
@@ -820,6 +974,9 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
820
974
  headers["content-type"] = "application/json";
821
975
  return new ResponseShim(body, { ...init, headers });
822
976
  }
977
+ get body() {
978
+ return this._body;
979
+ }
823
980
  async text() {
824
981
  return this._body ?? "";
825
982
  }
@@ -1126,6 +1283,33 @@ function databaseCallbacks(sql) {
1126
1283
  }
1127
1284
  };
1128
1285
  }
1286
+ function remoteCallbacks(recorderUrl) {
1287
+ const baseUrl = recorderUrl.replace(/\/+$/, "");
1288
+ return {
1289
+ storeRequest: async (data) => {
1290
+ const res = await fetch(`${baseUrl}/api/store-request`, {
1291
+ method: "POST",
1292
+ headers: { "Content-Type": "application/json" },
1293
+ body: JSON.stringify({
1294
+ blobData: data.blobData,
1295
+ commitSha: data.commitSha,
1296
+ branchName: data.branchName,
1297
+ repositoryUrl: data.repositoryUrl,
1298
+ handlerPath: data.handlerPath,
1299
+ requestId: data.requestId
1300
+ })
1301
+ });
1302
+ if (!res.ok) {
1303
+ const errBody = await res.text().catch(() => "(unreadable)");
1304
+ throw new Error(
1305
+ `netlify-recorder: remote store-request failed: ${res.status} ${errBody}`
1306
+ );
1307
+ }
1308
+ const result = await res.json();
1309
+ return result.requestId;
1310
+ }
1311
+ };
1312
+ }
1129
1313
  async function ensureRequestRecording(sql, requestId, options) {
1130
1314
  const request = await backendRequestsGet(sql, requestId);
1131
1315
  if (!request) {
@@ -1251,6 +1435,92 @@ async function databaseAuditDumpLogTable(sql) {
1251
1435
  const rows = await sql`SELECT * FROM audit_log ORDER BY performed_at DESC`;
1252
1436
  return rows;
1253
1437
  }
1438
+
1439
+ // src/createRecordingEndpoint.ts
1440
+ function jsonResponse(body, status) {
1441
+ return new Response(JSON.stringify(body), {
1442
+ status,
1443
+ headers: { "Content-Type": "application/json" }
1444
+ });
1445
+ }
1446
+ function formatStatus(request) {
1447
+ if (request.status === "recorded" && request.recording_id) {
1448
+ return { status: "recorded", recordingId: request.recording_id, requestId: request.id };
1449
+ }
1450
+ if (request.status === "queued" || request.status === "processing") {
1451
+ return { status: "pending", requestId: request.id };
1452
+ }
1453
+ if (request.status === "failed") {
1454
+ return { status: "error", requestId: request.id, error: request.error_message ?? "Recording failed" };
1455
+ }
1456
+ return { status: "pending", requestId: request.id };
1457
+ }
1458
+ function createRecordingEndpoint(options) {
1459
+ const { sql, recorderUrl, secret, webhookUrl } = options;
1460
+ return async (req) => {
1461
+ if (secret) {
1462
+ const auth = req.headers.get("authorization");
1463
+ if (auth !== `Bearer ${secret}`) {
1464
+ return jsonResponse({ status: "error", error: "Unauthorized" }, 401);
1465
+ }
1466
+ }
1467
+ try {
1468
+ if (req.method === "GET") {
1469
+ const url = new URL(req.url);
1470
+ const requestId = url.searchParams.get("requestId");
1471
+ if (!requestId) {
1472
+ return jsonResponse({ status: "error", error: "Missing requestId query parameter" }, 400);
1473
+ }
1474
+ const request = await backendRequestsGet(sql, requestId);
1475
+ if (!request) {
1476
+ return jsonResponse({ status: "not_found", error: `Request ${requestId} not found` }, 404);
1477
+ }
1478
+ return jsonResponse(formatStatus(request), 200);
1479
+ }
1480
+ if (req.method === "POST") {
1481
+ const body = await req.json();
1482
+ const requestId = body.requestId;
1483
+ if (!requestId) {
1484
+ return jsonResponse({ status: "error", error: "Missing requestId in request body" }, 400);
1485
+ }
1486
+ const request = await backendRequestsGet(sql, requestId);
1487
+ if (!request) {
1488
+ return jsonResponse({ status: "not_found", error: `Request ${requestId} not found` }, 404);
1489
+ }
1490
+ if (request.status === "recorded" && request.recording_id) {
1491
+ return jsonResponse(
1492
+ { status: "recorded", recordingId: request.recording_id, requestId },
1493
+ 200
1494
+ );
1495
+ }
1496
+ if (request.status === "queued" || request.status === "processing") {
1497
+ return jsonResponse({ status: "pending", requestId }, 200);
1498
+ }
1499
+ try {
1500
+ const recordingId = await ensureRequestRecording(sql, requestId, {
1501
+ recorderUrl,
1502
+ webhookUrl
1503
+ });
1504
+ if (recordingId) {
1505
+ return jsonResponse({ status: "recorded", recordingId, requestId }, 200);
1506
+ }
1507
+ return jsonResponse({ status: "queued", requestId }, 202);
1508
+ } catch (err) {
1509
+ const message = err instanceof Error ? err.message : String(err);
1510
+ await backendRequestsUpdateStatus(sql, requestId, "failed", void 0, message).catch(
1511
+ () => {
1512
+ }
1513
+ );
1514
+ return jsonResponse({ status: "error", requestId, error: message }, 502);
1515
+ }
1516
+ }
1517
+ return jsonResponse({ status: "error", error: "Method not allowed" }, 405);
1518
+ } catch (err) {
1519
+ const message = err instanceof Error ? err.message : String(err);
1520
+ return jsonResponse({ status: "error", error: message }, 500);
1521
+ }
1522
+ };
1523
+ }
1254
1524
  export {
1255
1525
  backendRequestsEnsureTable,
1256
1526
  backendRequestsGet,
@@ -1258,6 +1528,7 @@ export {
1258
1528
  backendRequestsInsert,
1259
1529
  backendRequestsList,
1260
1530
  backendRequestsUpdateStatus,
1531
+ createRecordingEndpoint,
1261
1532
  createRecordingRequestHandler,
1262
1533
  createRequestRecording,
1263
1534
  databaseAuditDumpLogTable,
@@ -1268,5 +1539,7 @@ export {
1268
1539
  finishRequest,
1269
1540
  getCurrentRequestId,
1270
1541
  redactBlobData,
1542
+ remoteCallbacks,
1543
+ runInRequestContext,
1271
1544
  startRequest
1272
1545
  };
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.35.0",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {